Compare commits
@@ -1,7 +1,7 @@
|
||||
# CLAUDE.md — Règles du projet
|
||||
|
||||
Ce fichier est lu par Claude Code au début de chaque session. Il fixe le cadre du projet.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, l'étape en cours dans `docs/etape-0.md`.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, le travail en cours dans `docs/feuille-de-route.md`.
|
||||
|
||||
**En début de session**, lire `docs/organisation.md`, `docs/feuille-de-route.md`, la fin
|
||||
de `docs/journal.md`, l'index des ADR (`docs/adr/README.md`) et les plans en cours dans `docs/plans/`, puis reprendre là où en
|
||||
|
||||
@@ -21,6 +21,22 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
|
||||
https://github.com/clavierhaus/gnubg-android ; son fichier
|
||||
`jni-bridge/src/stubs.c` a servi de repère pour isoler le cœur de gnubg.
|
||||
|
||||
## Identité visuelle
|
||||
|
||||
- **Alegreya Sans**, Copyright 2013 The Alegreya Sans Project Authors
|
||||
(Juan Pablo del Peral, Huerta Tipográfica), licence
|
||||
SIL Open Font License 1.1 — https://github.com/google/fonts/tree/main/ofl/alegreyasans
|
||||
Ses tracés (graisse Bold) composent le logotype, dans `docs/identite/logo/`.
|
||||
Cinq graisses (Regular, Italic, Medium, Bold, ExtraBold) sont embarquées
|
||||
dans l'application (`web/fonts/`, avec la licence `OFL.txt`), réduites aux
|
||||
caractères latins et converties en woff2 par fontTools (143 Ko en tout).
|
||||
- **Logo et icônes d'Ustagammon** (`docs/identite/logo/`), licence
|
||||
CC BY-SA 4.0 ; publiés dans `icons/` du site (favicon, icônes de la PWA,
|
||||
logo de la barre haute).
|
||||
- **fontTools** (MIT), utilisé par `docs/identite/generer-logo.py` pour
|
||||
convertir le texte du logotype en tracés, et pour produire les fichiers
|
||||
woff2 de la police ; avec **Brotli** (MIT), qui les compresse.
|
||||
|
||||
## Outils de développement
|
||||
|
||||
- **ShellCheck** (GPLv3), analyse des scripts shell, utilisé via Docker.
|
||||
@@ -41,7 +57,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 +73,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,59 @@ 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 "À 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)
|
||||
|
||||
# Fichiers de docs/identite/logo/ publiés dans icons/ (manifeste, favicon,
|
||||
# barre haute).
|
||||
SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone-maskable-512.png \
|
||||
apple-touch-icon-180.png favicon.svg favicon-32.png \
|
||||
logo-horizontal-couleur.svg logo-horizontal-sombre.svg symbole-couleur.svg symbole-sombre.svg
|
||||
|
||||
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
|
||||
# - web/ à la racine : la page de l'application, son style, sa police, le
|
||||
# manifeste et le Web Worker du moteur ;
|
||||
# - sous icons/, le logo et les icônes de la charte, copiés depuis
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - 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/
|
||||
mkdir -p $(BUILD_DIR)/site/icons
|
||||
cp $(addprefix docs/identite/logo/,$(SITE_ICONS)) $(BUILD_DIR)/site/icons/
|
||||
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.",
|
||||
|
||||
@@ -9,25 +9,25 @@
|
||||
// l'ordinateur, videau, fin de partie et points.
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { Engine, EngineCube, Ply } from "./engine.js";
|
||||
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
EVENT_VERSION,
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
type NewGameEvent,
|
||||
} from "./events.js";
|
||||
import { type HalfMove, type PositionId, START_POSITION } from "./position.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
import { type GameState, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. C'est le niveau mesuré à
|
||||
* l'étape 0b (au plus 0,3 s par coup sous Chromium et 2,3 s sous Firefox sur
|
||||
* le PC de 2011) ; il reste à confirmer sur le téléphone.
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
|
||||
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const STANDARD_PLY: Ply = 2;
|
||||
|
||||
@@ -40,6 +40,27 @@ export interface GameSetup {
|
||||
readonly cube?: Cube;
|
||||
}
|
||||
|
||||
/** Le plateau vu de chaque camp, chacun avec sa propre numérotation des
|
||||
* cases (sa case 1 est la plus proche de sa sortie). */
|
||||
export interface SideBoards {
|
||||
readonly player: BoardSide;
|
||||
readonly computer: BoardSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
|
||||
* successifs, tels que le moteur les donne.
|
||||
*/
|
||||
export interface MoveSteps {
|
||||
readonly side: Side;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
|
||||
readonly boards: readonly SideBoards[];
|
||||
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
|
||||
* (lu sur les plateaux du moteur, pas déduit des règles). */
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
@@ -58,9 +79,10 @@ export class Game {
|
||||
readonly #dice: DiceSource;
|
||||
readonly #ply: Ply;
|
||||
#journal: GameJournal = EMPTY_JOURNAL;
|
||||
// Coup du joueur en cours de saisie : pas encore dans le journal.
|
||||
#pending: HalfMove[] = [];
|
||||
#pendingPosition: PositionId | null = null;
|
||||
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
|
||||
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
|
||||
// position après lui, et si le coup est alors complet, selon le moteur.
|
||||
#pending: PendingHalfMove[] = [];
|
||||
|
||||
private constructor(engine: Engine, options: GameOptions) {
|
||||
this.#engine = engine;
|
||||
@@ -85,6 +107,25 @@ export class Game {
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
|
||||
* journal est d'un autre format ou incohérent.
|
||||
*/
|
||||
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = journal.events[0];
|
||||
if (first?.type !== "GameStarted" || journal.head === null) {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
|
||||
throw new Error("journal de partie d'une autre version");
|
||||
}
|
||||
const game = new Game(engine, options);
|
||||
game.#journal = journal;
|
||||
// Vérifie que chaque événement de la ligne courante existe.
|
||||
currentLine(journal);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Tous les événements, branches comprises, et l'événement courant. */
|
||||
get journal(): GameJournal {
|
||||
return this.#journal;
|
||||
@@ -101,57 +142,209 @@ export class Game {
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending;
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
}
|
||||
|
||||
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
|
||||
* n'attend plus que d'être validé (confirmMove) ou défait
|
||||
* (undoLastHalfMove). */
|
||||
get moveComplete(): boolean {
|
||||
return this.#pending.at(-1)?.complete === true;
|
||||
}
|
||||
|
||||
/** La position à afficher : après les demi-coups déjà saisis. */
|
||||
get displayedPosition(): PositionId {
|
||||
return this.#pendingPosition ?? this.state.position;
|
||||
return this.#pending.at(-1)?.position ?? this.state.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est
|
||||
* déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un
|
||||
* coup du joueur. Un demi-coup utilise le dé qui l'a produit dans
|
||||
* nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune
|
||||
* règle n'est jugée ici, la légalité vient du moteur.
|
||||
*/
|
||||
get diceToPlay(): DieToPlay[] {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) {
|
||||
return [];
|
||||
}
|
||||
const [a, b] = state.roll;
|
||||
const dice = a === b ? [a, a, a, a] : [a, b];
|
||||
const used = assignDice(dice, this.pendingHalfMoves);
|
||||
return dice.map((value, i) => ({ value, used: used[i] === true }));
|
||||
}
|
||||
|
||||
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
|
||||
* moteur. */
|
||||
async displayedBoard(): Promise<SideBoards> {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
|
||||
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
);
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux
|
||||
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
|
||||
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
|
||||
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
|
||||
* un coup légal. Aucune règle n'est jugée ici.
|
||||
*/
|
||||
async nextHalfMoves(): Promise<HalfMove[]> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
|
||||
return [];
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
const { player } = await this.displayedBoard();
|
||||
const candidates = new Map<string, HalfMove>();
|
||||
for (const from of occupiedPoints(player)) {
|
||||
for (const die of roll) {
|
||||
const to = Math.max(from - die, OFF);
|
||||
candidates.set(`${from}/${to}`, { from, to });
|
||||
}
|
||||
}
|
||||
const moves = [...candidates.values()];
|
||||
const checks = await Promise.all(
|
||||
moves.map((h) =>
|
||||
this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]),
|
||||
),
|
||||
);
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
}
|
||||
|
||||
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
|
||||
* permet.) */
|
||||
async canDouble(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "rolling") {
|
||||
return false;
|
||||
}
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
0,
|
||||
);
|
||||
return decision.available;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Joueur
|
||||
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
|
||||
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
|
||||
async roll(): Promise<void> {
|
||||
const state = this.#expect("player", "rolling");
|
||||
const roll = rollDice(this.#dice);
|
||||
this.#append({ type: "DiceRolled", side: "player", roll });
|
||||
const moves = await this.#engine.legalMoves(state.position, roll);
|
||||
if (moves.length === 0) {
|
||||
await this.#playMove("player", [], null);
|
||||
this.#expect("player", "rolling");
|
||||
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
|
||||
}
|
||||
|
||||
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
|
||||
* légal : il ne peut que laisser passer son tour. */
|
||||
async cannotMove(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return false;
|
||||
}
|
||||
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
|
||||
return moves.length === 0;
|
||||
}
|
||||
|
||||
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
|
||||
* Erreur s'il a un coup possible. */
|
||||
async skipTurn(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
if (!(await this.cannotMove())) {
|
||||
throw new Error("vous avez un coup possible : jouez-le");
|
||||
}
|
||||
await this.#playMove("player", [], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
|
||||
* mène à aucun coup légal : il est refusé et rien ne change. Quand les
|
||||
* demi-coups forment un coup complet, le coup est joué.
|
||||
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
|
||||
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
|
||||
* complet (moveComplete), il attend d'être validé (confirmMove).
|
||||
*/
|
||||
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
|
||||
const state = this.#expect("player", "moving");
|
||||
const halfMoves = [...this.#pending, halfMove];
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
state.position,
|
||||
rollToPlay(state),
|
||||
halfMoves,
|
||||
);
|
||||
if (this.moveComplete) {
|
||||
return false;
|
||||
}
|
||||
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
|
||||
...this.pendingHalfMoves,
|
||||
halfMove,
|
||||
]);
|
||||
if (!check.valid || check.position === null) {
|
||||
return false;
|
||||
}
|
||||
if (check.complete) {
|
||||
this.cancelHalfMoves();
|
||||
await this.#playMove("player", halfMoves, check.position);
|
||||
} else {
|
||||
this.#pending = halfMoves;
|
||||
this.#pendingPosition = check.position;
|
||||
}
|
||||
this.#pending = [
|
||||
...this.#pending,
|
||||
{ halfMove, position: check.position, complete: check.complete },
|
||||
];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Oublie les demi-coups saisis du coup en cours. */
|
||||
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
|
||||
* pas) : il n'a jamais été dans le journal. */
|
||||
undoLastHalfMove(): void {
|
||||
this.#pending = this.#pending.slice(0, -1);
|
||||
}
|
||||
|
||||
/** Oublie tous les demi-coups saisis du coup en cours. */
|
||||
cancelHalfMoves(): void {
|
||||
this.#pending = [];
|
||||
this.#pendingPosition = null;
|
||||
}
|
||||
|
||||
/** Le joueur valide son coup complet : il entre dans le journal
|
||||
* (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */
|
||||
async confirmMove(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
const last = this.#pending.at(-1);
|
||||
if (last?.complete !== true) {
|
||||
throw new Error("le coup n'est pas complet : jouez encore");
|
||||
}
|
||||
const halfMoves = this.pendingHalfMoves;
|
||||
this.cancelHalfMoves();
|
||||
await this.#playMove("player", halfMoves, last.position);
|
||||
}
|
||||
|
||||
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
|
||||
@@ -237,6 +430,14 @@ export class Game {
|
||||
this.#journal = appendEvent(this.#journal, event);
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(cube, onRoll));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
}
|
||||
|
||||
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
|
||||
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
|
||||
const state = this.state;
|
||||
@@ -296,6 +497,52 @@ export class Game {
|
||||
}
|
||||
}
|
||||
|
||||
/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */
|
||||
export interface DieToPlay {
|
||||
readonly value: number;
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Un demi-coup saisi, pas encore validé. */
|
||||
interface PendingHalfMove {
|
||||
readonly halfMove: HalfMove;
|
||||
/** La position après lui, vue du joueur. */
|
||||
readonly position: PositionId;
|
||||
/** Les demi-coups saisis jusqu'à lui forment un coup complet. */
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé
|
||||
* différent qui le produit (de from vers from - dé, ou vers la sortie si le
|
||||
* dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du
|
||||
* plus petit au plus grand : un pion sorti l'est avec le plus petit dé
|
||||
* suffisant. Si aucune répartition ne convient, les premiers dés sont
|
||||
* marqués, un par demi-coup.
|
||||
*/
|
||||
function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] {
|
||||
const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0));
|
||||
const used = dice.map(() => false);
|
||||
const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to;
|
||||
const place = (k: number): boolean => {
|
||||
const h = halfMoves[k];
|
||||
if (h === undefined) {
|
||||
return true;
|
||||
}
|
||||
for (const i of order) {
|
||||
if (!used[i] && fits(h, dice[i] ?? 0)) {
|
||||
used[i] = true;
|
||||
if (place(k + 1)) {
|
||||
return true;
|
||||
}
|
||||
used[i] = false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return place(0) ? used : dice.map((_, i) => i < halfMoves.length);
|
||||
}
|
||||
|
||||
/** Le jet à jouer, en phase moving. */
|
||||
function rollToPlay(state: GameState): Roll {
|
||||
if (state.roll === null) {
|
||||
@@ -304,6 +551,17 @@ function rollToPlay(state: GameState): Roll {
|
||||
return state.roll;
|
||||
}
|
||||
|
||||
/** Les cases d'où un camp a des pions à jouer : la barre, puis 24 à 1. */
|
||||
function occupiedPoints(side: BoardSide): number[] {
|
||||
const points = side.bar > 0 ? [BAR] : [];
|
||||
for (let point = 24; point >= 1; point--) {
|
||||
if ((side.points[point - 1] ?? 0) > 0) {
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait, pour le moteur. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
if (cube.owner === null) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": [],
|
||||
"noEmit": true
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Les commandes du moteur exécutées dans un Web Worker (web/engine-worker.mjs),
|
||||
// pour que le calcul ne bloque jamais l'affichage.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Messages du Worker (voir engine-worker.mjs) : une fois au chargement
|
||||
// { type: "ready" } ou { type: "failed", message } ; puis, pour chaque
|
||||
// commande { id, args } envoyée, { id, result } ou { id, error }. Le Worker
|
||||
// traite les commandes une à une, dans l'ordre d'arrivée.
|
||||
|
||||
import type { RunCommand } from "./wasm-engine.js";
|
||||
|
||||
export interface EngineWorker {
|
||||
/** Résolue quand le moteur est chargé ; rejetée s'il ne peut pas l'être. */
|
||||
readonly ready: Promise<void>;
|
||||
readonly run: RunCommand;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve(result: unknown): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
export function startEngineWorker(url: URL): EngineWorker {
|
||||
const worker = new Worker(url, { type: "module" });
|
||||
const pending = new Map<number, Pending>();
|
||||
let nextId = 1;
|
||||
let failure: Error | null = null;
|
||||
let markReady: () => void = () => {};
|
||||
let markFailed: (error: Error) => void = () => {};
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
markReady = resolve;
|
||||
markFailed = reject;
|
||||
});
|
||||
|
||||
// Le moteur est inutilisable : toutes les commandes échouent.
|
||||
function fail(error: Error): void {
|
||||
failure = error;
|
||||
markFailed(error);
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === "ready") {
|
||||
markReady();
|
||||
} else if (message.type === "failed") {
|
||||
fail(new Error(message.message));
|
||||
} else {
|
||||
const request = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error !== undefined) {
|
||||
request?.reject(new Error(message.error));
|
||||
} else {
|
||||
request?.resolve(message.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Le Worker lui-même n'a pas démarré (fichier absent, navigateur trop
|
||||
// ancien pour les Workers en module ES…).
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
fail(new Error(event.message || "le Web Worker du moteur n'a pas démarré"));
|
||||
};
|
||||
|
||||
function run(args: string[]): Promise<unknown> {
|
||||
if (failure !== null) {
|
||||
return Promise.reject(failure);
|
||||
}
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, args });
|
||||
});
|
||||
}
|
||||
|
||||
return { ready, run };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Point d'entrée de l'application dans le navigateur (index.html) : le seul
|
||||
// endroit qui assemble le domaine, le moteur, le stockage et l'interface
|
||||
// (ADR 0007, vérifié par dependency-cruiser).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => Game.start(engine),
|
||||
savedGame: () => {
|
||||
const journal = store.load();
|
||||
if (journal === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
saveGame: (journal) => store.save(journal),
|
||||
confirm: (question) => window.confirm(question),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
|
||||
// fait échouer l'installation (« Cache.add() encountered a network error »),
|
||||
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
|
||||
// télécharger deux fois ces données à la première visite.
|
||||
if ("serviceWorker" in navigator) {
|
||||
const sw = new URL("../sw.js", import.meta.url);
|
||||
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
|
||||
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
|
||||
// (localStorage dans le navigateur).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
|
||||
load(): GameJournal | null;
|
||||
save(journal: GameJournal): void;
|
||||
}
|
||||
|
||||
const KEY = "ustagammon.current-game";
|
||||
|
||||
export function createGameStore(storage: KeyValueStorage): GameStore {
|
||||
return {
|
||||
load() {
|
||||
try {
|
||||
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
|
||||
const valid =
|
||||
journal !== null &&
|
||||
Array.isArray(journal.events) &&
|
||||
(journal.head === null || typeof journal.head === "string");
|
||||
return valid ? journal : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save(journal) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(journal));
|
||||
} catch {
|
||||
// Stockage plein ou interdit (navigation privée) : la partie
|
||||
// continue, elle ne pourra seulement pas être reprise.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// 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, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
type Place,
|
||||
placeOf,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { showHelp } from "./help.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Durée du glissement d'un pion, pour chaque demi-coup de l'ordinateur. */
|
||||
const SLIDE_MS = 400;
|
||||
|
||||
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"),
|
||||
confirm: byId<HTMLButtonElement>("confirm"),
|
||||
cancel: byId<HTMLButtonElement>("cancel"),
|
||||
skip: byId<HTMLButtonElement>("skip"),
|
||||
replay: byId<HTMLButtonElement>("replay"),
|
||||
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"),
|
||||
doubleDialog: byId<HTMLDialogElement>("double-dialog"),
|
||||
doubleQuestion: byId("double-question"),
|
||||
doubleConfirm: byId<HTMLButtonElement>("double-confirm"),
|
||||
doubleCancel: byId<HTMLButtonElement>("double-cancel"),
|
||||
};
|
||||
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.confirm.textContent = TEXTS.confirm;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
ui.cancel.textContent = TEXTS.cancel;
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.replay.textContent = TEXTS.replay;
|
||||
ui.replay.title = TEXTS.replayTitle;
|
||||
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;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit continuer.
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info = "";
|
||||
// Le dernier coup de l'ordinateur, montré (traces, bouton « Revoir »)
|
||||
// jusqu'à la prochaine action du joueur ; null : rien à montrer.
|
||||
let shownMove: MoveSteps | null = null;
|
||||
// Pendant l'animation d'un coup, render() laisse le plateau à l'animation.
|
||||
let animating = false;
|
||||
|
||||
// ------------------------------------------------------------ Dessin
|
||||
|
||||
/** Recalcule ce qui demande le moteur, sans redessiner. */
|
||||
async function update(): Promise<void> {
|
||||
if (game === null) {
|
||||
return;
|
||||
}
|
||||
sides = await game.displayedBoard();
|
||||
targets = moveTargets(await game.nextHalfMoves());
|
||||
canDouble = await game.canDouble();
|
||||
stuck = await game.cannotMove();
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
async function refresh(): Promise<void> {
|
||||
await update();
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
if (game === null || sides === null) {
|
||||
return;
|
||||
}
|
||||
const state = game.state;
|
||||
const model = boardModel(sides);
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
shownMove === null
|
||||
? null
|
||||
: moveTrace(shownMove.side, shownMove.halfMoves, shownMove.hits),
|
||||
// Mêmes conditions que les boutons « Lancer les dés »,
|
||||
// « Valider » et « Doubler ».
|
||||
diceAction: busy ? null : diceAction(game),
|
||||
canDouble: !busy && canDouble,
|
||||
});
|
||||
}
|
||||
ui.pips.textContent = TEXTS.pipCounts(model.pipCount.player, model.pipCount.computer);
|
||||
// Un jet sans coup possible reste annoncé jusqu'à l'action suivante,
|
||||
// même après un rechargement de la page.
|
||||
if (stuck && state.roll !== null) {
|
||||
ui.info.textContent = TEXTS.youCannotMove(state.roll);
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
}
|
||||
ui.prompt.textContent = stuck
|
||||
? TEXTS.pressContinue
|
||||
: 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.confirm, player && state.phase === "moving" && game.moveComplete);
|
||||
show(ui.cancel, player && state.phase === "moving" && !stuck);
|
||||
show(ui.skip, stuck);
|
||||
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");
|
||||
show(ui.replay, shownMove !== null);
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.take,
|
||||
ui.drop,
|
||||
ui.confirm,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
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).
|
||||
* Son dernier coup, s'il est la dernière chose jouée, devient le coup à
|
||||
* montrer. Renvoie vrai si l'ordinateur a joué. */
|
||||
async function computerTurns(): Promise<boolean> {
|
||||
let played = false;
|
||||
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();
|
||||
played = true;
|
||||
}
|
||||
if (game !== null) {
|
||||
shownMove = await computerMoveToShow(game);
|
||||
}
|
||||
return played;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser les pions du coup de l'ordinateur, un demi-coup après
|
||||
* l'autre, à partir des plateaux intermédiaires donnés par le domaine. Rien
|
||||
* si le système demande moins d'animations : les traces suffisent.
|
||||
*/
|
||||
async function animateMove(steps: MoveSteps): Promise<void> {
|
||||
if (game === null || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
return;
|
||||
}
|
||||
const decor = { dice: shownDice(game, false), cube: game.state.cube, ...NO_MARKS };
|
||||
const draw = (i: number) => {
|
||||
const boards = steps.boards[i];
|
||||
if (boards !== undefined) {
|
||||
renderBoard(ui.board, boardModel(boards), decor);
|
||||
}
|
||||
};
|
||||
animating = true;
|
||||
render(); // messages et boutons ; le plateau est à l'animation
|
||||
try {
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
const from = placeOf(steps.side, halfMove.from);
|
||||
const to = placeOf(steps.side, halfMove.to);
|
||||
draw(i);
|
||||
const start = checkerSpot(ui.board, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(ui.board, to) : null;
|
||||
draw(i + 1);
|
||||
await Promise.all([
|
||||
start && slideChecker(ui.board, to, start, SLIDE_MS),
|
||||
victim &&
|
||||
slideChecker(ui.board, `bar-${otherSide(steps.side)}`, victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rend la main au joueur : recalcule, anime le coup à montrer (si
|
||||
* animate), puis redessine avec ses traces. */
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
shownMove = null;
|
||||
render();
|
||||
try {
|
||||
await action(game);
|
||||
save();
|
||||
await computerTurns();
|
||||
} catch (error) {
|
||||
info = TEXTS.actionFailed(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
await settle(true);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
ui.skip.onclick = () =>
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.skipTurn();
|
||||
});
|
||||
|
||||
// Doubler demande confirmation : un double proposé ne se reprend pas.
|
||||
ui.double.onclick = () => {
|
||||
if (busy || game === null || !canDouble) {
|
||||
return;
|
||||
}
|
||||
ui.doubleQuestion.textContent = TEXTS.confirmDouble(2 * game.state.cube.value);
|
||||
ui.doubleDialog.showModal();
|
||||
};
|
||||
ui.doubleCancel.onclick = () => ui.doubleDialog.close();
|
||||
ui.doubleConfirm.onclick = () => {
|
||||
ui.doubleDialog.close();
|
||||
void 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 = "";
|
||||
});
|
||||
|
||||
// Le coup saisi, complet, entre dans la partie : le tour passe.
|
||||
ui.confirm.onclick = () =>
|
||||
act(async (g) => {
|
||||
info = "";
|
||||
await g.confirmMove();
|
||||
});
|
||||
|
||||
// Annuler défait le dernier demi-coup saisi, un par un. L'écran est
|
||||
// occupé le temps que le moteur recalcule les coups possibles.
|
||||
ui.cancel.onclick = () => {
|
||||
if (!busy && game !== null) {
|
||||
game.undoLastHalfMove();
|
||||
selected = null;
|
||||
shownMove = null;
|
||||
busy = true;
|
||||
render();
|
||||
void settle(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
|
||||
* validation. */
|
||||
function playHalfMove(halfMove: HalfMove): void {
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
await g.playHalfMove(halfMove);
|
||||
});
|
||||
}
|
||||
|
||||
// Toucher le plateau : un pion jouable, puis une de ses cases d'arrivée
|
||||
// (ou aucune, s'il n'en a qu'une : il y va tout de suite).
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
const target = event.target as Element | null;
|
||||
// Toucher les dés (d'un côté ou de l'autre du plateau) ou le videau :
|
||||
// comme leurs boutons.
|
||||
const action = target?.closest("[data-action]")?.getAttribute("data-action");
|
||||
if (action === "roll" && !ui.roll.hidden && !ui.roll.disabled) {
|
||||
ui.roll.click();
|
||||
return;
|
||||
}
|
||||
if (action === "confirm" && !ui.confirm.hidden && !ui.confirm.disabled) {
|
||||
ui.confirm.click();
|
||||
return;
|
||||
}
|
||||
if (action === "double" && !ui.double.hidden && !ui.double.disabled) {
|
||||
ui.double.click();
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
playHalfMove(halfMove);
|
||||
return;
|
||||
}
|
||||
const only = place === selected ? null : onlyHalfMove(targets, place);
|
||||
if (only !== null) {
|
||||
playHalfMove(only);
|
||||
return;
|
||||
}
|
||||
selected = targets.has(place) && place !== selected ? place : null;
|
||||
if (selected !== null) {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// Revoir le dernier coup de l'ordinateur, puis ses traces.
|
||||
ui.replay.onclick = async () => {
|
||||
if (busy || shownMove === null) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
await settle(true);
|
||||
};
|
||||
|
||||
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.confirm,
|
||||
ui.cancel,
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
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). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
let played = false;
|
||||
try {
|
||||
played = await computerTurns();
|
||||
} finally {
|
||||
await settle(played);
|
||||
}
|
||||
},
|
||||
(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 : [];
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer quand le joueur peut lancer,
|
||||
* valider quand son coup est complet. */
|
||||
function diceAction(game: Game): DiceAction | null {
|
||||
const state = game.state;
|
||||
if (state.turn !== "player") {
|
||||
return null;
|
||||
}
|
||||
if (state.phase === "rolling") {
|
||||
return "roll";
|
||||
}
|
||||
return state.phase === "moving" && game.moveComplete ? "confirm" : null;
|
||||
}
|
||||
|
||||
/** Les dés à montrer : le dernier jet, de la couleur de qui l'a lancé (au
|
||||
* jet d'ouverture, un dé chacun ; quatre pour un double). Pendant que le
|
||||
* joueur saisit son coup, chaque dé déjà utilisé est estompé. Ils sont
|
||||
* barrés si le jet ne permet aucun coup : le joueur est bloqué (stuck), ou
|
||||
* l'ordinateur vient de passer son tour sans bouger. */
|
||||
function shownDice(game: Game, stuck: boolean): ShownDie[] {
|
||||
const toPlay = game.diceToPlay;
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
return [];
|
||||
}
|
||||
// Au jet d'ouverture, un dé chacun.
|
||||
const sideOf = (i: number): Side => rolled.side ?? (i === 0 ? "player" : "computer");
|
||||
if (toPlay.length > 0) {
|
||||
return toPlay.map((die, i) => ({
|
||||
value: die.value,
|
||||
side: sideOf(i),
|
||||
blocked: stuck,
|
||||
used: die.used,
|
||||
}));
|
||||
}
|
||||
const blocked = computerCouldNotMove(game);
|
||||
const [a, b] = rolled.roll;
|
||||
const values = a === b ? [a, a, a, a] : [a, b];
|
||||
return values.map((value, i) => ({ value, side: sideOf(i), blocked, used: false }));
|
||||
}
|
||||
|
||||
/** La dernière chose jouée (fin de partie mise à part) est un tour de
|
||||
* l'ordinateur qui n'a pas pu jouer son jet. */
|
||||
function computerCouldNotMove(game: Game): boolean {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0;
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, demi-coup par demi-coup, s'il est la
|
||||
* dernière chose jouée (fin de partie mise à part) et qu'il a déplacé des
|
||||
* pions ; sinon null. */
|
||||
async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
const last = game.events.findLast((e) => e.type !== "GameEnded");
|
||||
if (last?.type !== "MovePlayed" || last.side !== "computer" || last.halfMoves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** 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" && game.moveComplete) {
|
||||
return TEXTS.confirmOrUndo;
|
||||
}
|
||||
if (state.phase === "moving" && state.roll !== null) {
|
||||
return selecting ? TEXTS.pickTarget : TEXTS.yourMove(state.roll);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** Le seul demi-coup possible depuis place, s'il n'a qu'une case d'arrivée
|
||||
* (le pion y va dès qu'on le touche) ; null sinon. */
|
||||
export function onlyHalfMove(
|
||||
targets: ReadonlyMap<Place, ReadonlyMap<Place, HalfMove>>,
|
||||
place: Place,
|
||||
): HalfMove | null {
|
||||
const tos = targets.get(place);
|
||||
if (tos === undefined || tos.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
return [...tos.values()][0] ?? null;
|
||||
}
|
||||
|
||||
/** Ce qui reste dessiné d'un coup une fois joué. */
|
||||
export interface MoveTrace {
|
||||
/** Le camp qui a joué. */
|
||||
readonly side: Side;
|
||||
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
|
||||
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
|
||||
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
|
||||
readonly landed: ReadonlyMap<Place, number>;
|
||||
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
|
||||
readonly left: ReadonlyMap<Place, number>;
|
||||
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
|
||||
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
|
||||
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
|
||||
*/
|
||||
export function moveTrace(
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
hits: readonly boolean[],
|
||||
): MoveTrace {
|
||||
const balance = new Map<Place, number>();
|
||||
const arrows = halfMoves.map((h) => {
|
||||
const from = placeOf(side, h.from);
|
||||
const to = placeOf(side, h.to);
|
||||
balance.set(from, (balance.get(from) ?? 0) - 1);
|
||||
balance.set(to, (balance.get(to) ?? 0) + 1);
|
||||
return { from, to };
|
||||
});
|
||||
const landed = new Map<Place, number>();
|
||||
const left = new Map<Place, number>();
|
||||
for (const [place, n] of balance) {
|
||||
if (n > 0) {
|
||||
landed.set(place, n);
|
||||
} else if (n < 0) {
|
||||
left.set(place, -n);
|
||||
}
|
||||
}
|
||||
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
|
||||
}
|
||||
|
||||
/** La couleur d'une flèche (case), d'après son numéro affiché (1 à 24,
|
||||
* pour le joueur) : impaire en point-a, paire en point-b (charte). Jamais
|
||||
* d'après sa colonne : les cases 12 et 13, l'une au-dessus de l'autre,
|
||||
* n'ont pas la même couleur. */
|
||||
export function pointColor(number: number): "point-a" | "point-b" {
|
||||
return number % 2 === 1 ? "point-a" : "point-b";
|
||||
}
|
||||
|
||||
/** 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,619 @@
|
||||
// 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 MoveTrace,
|
||||
type Place,
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "./board-model.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
export interface ShownDie {
|
||||
readonly value: number;
|
||||
readonly side: Side;
|
||||
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
|
||||
readonly blocked: boolean;
|
||||
/** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
|
||||
export type DiceAction = "roll" | "confirm";
|
||||
|
||||
/** 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 traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
|
||||
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
|
||||
* sont dessinés. */
|
||||
readonly diceAction: DiceAction | null;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
export const NO_MARKS = {
|
||||
movable: new Set<Place>(),
|
||||
selected: null,
|
||||
targets: new Set<Place>(),
|
||||
trace: null,
|
||||
diceAction: null,
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
|
||||
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
|
||||
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
|
||||
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
|
||||
*/
|
||||
function slotCenter(place: Place, index: number): Spot {
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
};
|
||||
}
|
||||
if (place === "off-player" || place === "off-computer") {
|
||||
const y =
|
||||
place === "off-computer"
|
||||
? FRAME + index * OFF_SLAB
|
||||
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
|
||||
}
|
||||
const { top, column } = pointSlot(Number(place.slice(1)));
|
||||
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: columnLeft(column) + POINT_WIDTH / 2,
|
||||
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
|
||||
};
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
if (place === `bar-${side}`) {
|
||||
return board.bar[side];
|
||||
}
|
||||
if (place === `off-${side}`) {
|
||||
return board.off[side];
|
||||
}
|
||||
const point = board.points.find((p) => p.place === place);
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const cube = drawCube(svg, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTrace(svg, board, decor.trace);
|
||||
}
|
||||
if (decor.diceAction !== null) {
|
||||
if (decor.diceAction === "roll") {
|
||||
waitingDice(svg);
|
||||
}
|
||||
// Une zone sur chaque moitié du plateau : les dés en attente (à
|
||||
// gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
|
||||
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
|
||||
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
|
||||
}
|
||||
// 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: pointColor(number),
|
||||
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) {
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
y: top ? 0 : MIDDLE,
|
||||
width: POINT_WIDTH,
|
||||
height: MIDDLE,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
|
||||
* pour les montrer tous. Le pion du dessus porte son endroit dans
|
||||
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
|
||||
function drawStack(
|
||||
svg: SVGSVGElement,
|
||||
side: Side,
|
||||
count: 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 { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 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,
|
||||
});
|
||||
// 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) {
|
||||
drawStack(svg, side, board.bar[side], `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) {
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH,
|
||||
height: MIDDLE,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
|
||||
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
|
||||
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
|
||||
* (sur la barre), et une flèche par demi-coup.
|
||||
*/
|
||||
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
|
||||
const side = trace.side;
|
||||
for (const [place, n] of trace.left) {
|
||||
// Au-dessus des pions restés sur la case.
|
||||
const remaining = countAt(board, side, place);
|
||||
for (let k = 0; k < n; k++) {
|
||||
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
|
||||
}
|
||||
const defs = add(svg, "defs", {});
|
||||
const marker = add(defs, "marker", {
|
||||
id: "trace-head",
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 8,
|
||||
refY: 5,
|
||||
markerWidth: 3.5,
|
||||
markerHeight: 3.5,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
const departures = new Map<Place, number>();
|
||||
for (const { from, to } of trace.arrows) {
|
||||
// De la place libre laissée par le pion (son fantôme) à son pion
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
const start = slotCenter(from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
|
||||
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
|
||||
function topIndex(board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
|
||||
}
|
||||
|
||||
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
|
||||
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
|
||||
add(svg, "line", {
|
||||
class: "trace-arrow",
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
|
||||
* dessiné (null s'il n'y a pas de pion). */
|
||||
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
* Résolue à la fin du glissement.
|
||||
*/
|
||||
export async function slideChecker(
|
||||
svg: SVGSVGElement,
|
||||
place: Place,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
const to = checkerSpot(svg, place);
|
||||
if (checker === null || to === null || typeof checker.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
// Devant les autres pions pendant le trajet, mais derrière les zones à
|
||||
// toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(checker, svg.lastChild);
|
||||
const animation = checker.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
// 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", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
"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,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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): SVGElement {
|
||||
const center = cubeCenter(cube);
|
||||
const x = center.x - CUBE / 2;
|
||||
const y = center.y - 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: center.x, y: center.y + 6 },
|
||||
String(cube.owner === null ? 64 : cube.value),
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Le centre du videau dans le plateau de sortie. */
|
||||
function cubeCenter(cube: Cube): Spot {
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
|
||||
}
|
||||
|
||||
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement): void {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: MIDDLE - DIE / 2,
|
||||
width: DIE,
|
||||
height: DIE,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce
|
||||
* qu'on touche, avec un rôle et un nom accessibles. Les boutons restent
|
||||
* pour le clavier.
|
||||
*/
|
||||
function addAction(
|
||||
hits: Element,
|
||||
action: string,
|
||||
label: string,
|
||||
center: Spot,
|
||||
width: number,
|
||||
): void {
|
||||
add(hits, "rect", {
|
||||
class: "hit action",
|
||||
"data-action": action,
|
||||
role: "button",
|
||||
"aria-label": label,
|
||||
// Sans déborder du dessin à droite (le videau est contre le bord).
|
||||
x: Math.min(center.x - width / 2, WIDTH - width),
|
||||
y: center.y - MIN_TARGET / 2,
|
||||
width,
|
||||
height: MIN_TARGET,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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,100 @@
|
||||
// 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";
|
||||
container.dataset.loaded = "true";
|
||||
} catch {
|
||||
status.textContent = TEXTS.helpFailed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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",
|
||||
confirm: "Valider",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
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.",
|
||||
confirmOrUndo:
|
||||
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
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)}.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
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.",
|
||||
} as const;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Le moteur WebAssembly répond indéfiniment : une partie en navigateur
|
||||
// envoie des centaines de commandes au même moteur chargé une fois.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Régression : callMain (Emscripten) laisse ses arguments sur la pile du
|
||||
// WebAssembly (64 Ko). Sans remise en place après chaque commande
|
||||
// (web/engine-wasm.mjs), la pile débordait vers la 500e commande : erreur
|
||||
// « memory access out of bounds » sous Node, moteur muet dans Firefox,
|
||||
// onglet planté dans Chromium.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus
|
||||
// haut.
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
|
||||
interface RawEngine {
|
||||
runText(args: string[]): { text: string; exitCode: number };
|
||||
}
|
||||
|
||||
test("le moteur répond à 3000 commandes à la suite", async () => {
|
||||
const module = await import(new URL("build/wasm/engine.mjs", root).href);
|
||||
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
|
||||
const engine: RawEngine = await wrapper.loadEngine(
|
||||
module.default,
|
||||
fileURLToPath(new URL("build/wasm/", root)),
|
||||
);
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const { text, exitCode } = engine.runText(["position", "4HPwATDgc/ABMA", "AQEAAAAAAAAA"]);
|
||||
assert.equal(exitCode, 0, `commande ${i}`);
|
||||
assert.match(text, /"pip_count": 167/, `commande ${i}`);
|
||||
}
|
||||
});
|
||||
@@ -36,10 +36,17 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
} else {
|
||||
assert.ok(state.roll !== null);
|
||||
const [first] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
|
||||
if (first === undefined) {
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
continue;
|
||||
}
|
||||
for (const halfMove of first.halfMoves) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
await game.confirmMove();
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
|
||||
@@ -78,27 +78,145 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
|
||||
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
|
||||
|
||||
// Coup complet : il attend la validation, hors du journal.
|
||||
assert.equal(game.moveComplete, true);
|
||||
assert.equal(game.displayedPosition, "AFTER");
|
||||
assert.equal(game.events.length, 2);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 7 }), false);
|
||||
|
||||
await game.confirmMove();
|
||||
const last = game.events.at(-1);
|
||||
assert.equal(last?.type, "MovePlayed");
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
assert.equal(game.state.position, "SWAPPED");
|
||||
});
|
||||
|
||||
test("sans coup possible, le tour passe", async () => {
|
||||
test("un coup incomplet ne se valide pas", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await assert.rejects(game.confirmMove(), /pas complet/);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await assert.rejects(game.confirmMove(), /pas complet/);
|
||||
assert.equal(game.events.length, 2);
|
||||
});
|
||||
|
||||
test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
const journal = game.journal;
|
||||
|
||||
game.undoLastHalfMove();
|
||||
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(game.displayedPosition, "HALF");
|
||||
|
||||
game.undoLastHalfMove();
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.displayedPosition, START_POSITION);
|
||||
game.undoLastHalfMove(); // rien à défaire : sans effet
|
||||
assert.equal(game.journal, journal);
|
||||
|
||||
// Le coup se ressaisit ensuite normalement.
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
|
||||
assert.equal(game.moveComplete, true);
|
||||
});
|
||||
|
||||
test("les dés utilisés par les demi-coups saisis sont marqués", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
assert.deepEqual(game.diceToPlay, [
|
||||
{ value: 3, used: false },
|
||||
{ value: 1, used: false },
|
||||
]);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
assert.deepEqual(game.diceToPlay, [
|
||||
{ value: 3, used: true },
|
||||
{ value: 1, used: false },
|
||||
]);
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
assert.deepEqual(
|
||||
game.diceToPlay.map((d) => d.used),
|
||||
[true, true],
|
||||
);
|
||||
await game.confirmMove();
|
||||
assert.deepEqual(game.diceToPlay, []);
|
||||
});
|
||||
|
||||
test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => {
|
||||
const engine = fakeEngine({
|
||||
checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }),
|
||||
});
|
||||
const double = Game.start(engine, {
|
||||
dice: fixedDice([2, 2]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await double.roll();
|
||||
assert.equal(double.diceToPlay.length, 4);
|
||||
await double.playHalfMove({ from: 13, to: 11 });
|
||||
await double.playHalfMove({ from: 11, to: 9 });
|
||||
assert.deepEqual(
|
||||
double.diceToPlay.map((d) => d.used),
|
||||
[true, true, false, false],
|
||||
);
|
||||
|
||||
const bearOff = Game.start(engine, {
|
||||
dice: fixedDice([6, 4]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await bearOff.roll();
|
||||
// 3/off : le 4 suffit, le 6 reste.
|
||||
await bearOff.playHalfMove({ from: 3, to: 0 });
|
||||
assert.deepEqual(bearOff.diceToPlay, [
|
||||
{ value: 6, used: false },
|
||||
{ value: 4, used: true },
|
||||
]);
|
||||
// 6/2 demande le 4 : la sortie a donc pris le 6.
|
||||
await bearOff.playHalfMove({ from: 6, to: 2 });
|
||||
assert.deepEqual(
|
||||
bearOff.diceToPlay.map((d) => d.used),
|
||||
[true, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
|
||||
const engine = fakeEngine({ legalMoves: async () => [] });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([6, 6]),
|
||||
setup: { position: "BLOCKED", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed");
|
||||
assert.deepEqual(last.halfMoves, []);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
|
||||
const engine = fakeEngine({
|
||||
legalMoves: async () => [
|
||||
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
|
||||
],
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await game.cannotMove(), false);
|
||||
await assert.rejects(game.skipTurn(), /coup possible/);
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: true }),
|
||||
@@ -171,6 +289,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const played = game.events.at(-1);
|
||||
assert.equal(played?.type, "MovePlayed");
|
||||
|
||||
@@ -179,6 +298,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
|
||||
assert.equal(game.state.turn, "player");
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
|
||||
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
|
||||
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
|
||||
@@ -218,3 +338,58 @@ test("lecture et écriture des demi-coups", () => {
|
||||
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
|
||||
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
|
||||
});
|
||||
|
||||
test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const saved = JSON.parse(JSON.stringify(game.journal));
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), saved);
|
||||
assert.deepEqual(resumed.state, game.state);
|
||||
assert.deepEqual(resumed.events, game.events);
|
||||
|
||||
const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) };
|
||||
assert.throws(() => Game.resume(fakeEngine(), other), /version/);
|
||||
assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/);
|
||||
assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/);
|
||||
});
|
||||
|
||||
test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => {
|
||||
let available = true;
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available, double: false, take: true }),
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
assert.equal(await game.canDouble(), true);
|
||||
available = false;
|
||||
assert.equal(await game.canDouble(), false);
|
||||
available = true;
|
||||
await game.roll();
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(await game.canDouble(), false);
|
||||
});
|
||||
|
||||
test("le plateau affiché est donné du point de vue de chaque camp", async () => {
|
||||
const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount });
|
||||
const engine = fakeEngine({
|
||||
board: async () => ({
|
||||
onRoll: side(1),
|
||||
opponent: side(2),
|
||||
swappedPosition: "SWAPPED",
|
||||
result: null,
|
||||
}),
|
||||
});
|
||||
const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
|
||||
const sides = await playerTurn.displayedBoard();
|
||||
assert.equal(sides.player.pipCount, 1);
|
||||
assert.equal(sides.computer.pipCount, 2);
|
||||
const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
|
||||
const swapped = await computerTurn.displayedBoard();
|
||||
assert.equal(swapped.player.pipCount, 2);
|
||||
assert.equal(swapped.computer.pipCount, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Un coup demi-coup par demi-coup (Game.lastMoveSteps), sur le vrai moteur
|
||||
// WebAssembly : les plateaux intermédiaires que l'interface anime, et les
|
||||
// pions frappés qu'elle signale.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
formatHalfMoves,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
} from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 13, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 10 du joueur (sa case 15), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_CAN_HIT = "4P8HEADg/wcEAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 21 du joueur (sa case 4), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_ON_BAR = "yP8PAADg/wcAQA";
|
||||
|
||||
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
/** Une partie où le joueur, au trait, joue le jet dice à la position. */
|
||||
async function playerMove(position: PositionId, dice: [number, number], move: string) {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice(dice),
|
||||
setup: { position, firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
|
||||
}
|
||||
await game.confirmMove();
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Le nombre de pions d'un camp sur une case (1 à 24), sur la barre ou sortis. */
|
||||
function at(side: BoardSide, point: number | "bar" | "off"): number {
|
||||
if (point === "bar") {
|
||||
return side.bar;
|
||||
}
|
||||
return point === "off" ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.confirmMove();
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.equal(steps.side, "player");
|
||||
assert.equal(formatHalfMoves(steps.halfMoves), "8/5 6/5");
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
const counts = steps.boards.map((b) => [at(b.player, 8), at(b.player, 6), at(b.player, 5)]);
|
||||
assert.deepEqual(counts, [
|
||||
[3, 5, 0],
|
||||
[2, 5, 1],
|
||||
[2, 4, 2],
|
||||
]);
|
||||
// Le dernier plateau est celui de la partie après le coup.
|
||||
assert.deepEqual(steps.boards[2], await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("un pion frappé part sur la barre au demi-coup qui le frappe", async () => {
|
||||
const game = await playerMove(PLAYER_CAN_HIT, [3, 1], "13/10 10/9");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
const bar = steps.boards.map((b) => at(b.computer, "bar"));
|
||||
assert.deepEqual(bar, [0, 1, 1]);
|
||||
// Le blot était sur la case 10 du joueur, la case 15 de l'ordinateur.
|
||||
assert.equal(at(steps.boards[0]?.computer as BoardSide, 15), 1);
|
||||
assert.equal(at(steps.boards[1]?.computer as BoardSide, 15), 0);
|
||||
assert.equal(at(steps.boards[2]?.player as BoardSide, 9), 1);
|
||||
});
|
||||
|
||||
test("un coup depuis la barre, qui frappe en rentrant", async () => {
|
||||
const game = await playerMove(PLAYER_ON_BAR, [4, 2], "bar/21 6/4");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => [at(b.player, "bar"), at(b.player, 21), at(b.computer, "bar")]),
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, 1, 1],
|
||||
[0, 1, 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("une sortie de pions, jusqu'à la fin de la partie", async () => {
|
||||
const game = await playerMove(PLAYER_ALMOST_DONE, [6, 5], "6/off 5/off");
|
||||
assert.equal(game.state.phase, "over");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => at(b.player, "off")),
|
||||
[13, 14, 15],
|
||||
);
|
||||
});
|
||||
|
||||
test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Vous commencez avec 3-1, puis l'ordinateur tire 6-4.
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.equal(steps.side, "computer");
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1);
|
||||
// Chaque demi-coup déplace un pion de l'ordinateur de sa case de départ.
|
||||
for (const [i, h] of steps.halfMoves.entries()) {
|
||||
const from = h.from === BAR ? "bar" : h.from;
|
||||
const before = steps.boards[i]?.computer as BoardSide;
|
||||
const after = steps.boards[i + 1]?.computer as BoardSide;
|
||||
assert.equal(at(after, from), at(before, from) - 1);
|
||||
}
|
||||
assert.deepEqual(steps.boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("avant le premier coup, il n'y a rien à montrer", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
assert.equal(await game.lastMoveSteps(), null);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Ce que l'interface propose au joueur, sur le vrai moteur WebAssembly : les
|
||||
// demi-coups possibles (mis en évidence quand il touche un pion) et le
|
||||
// plateau affiché pendant la saisie d'un coup.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5
|
||||
// (même position que dans les étapes du scénario).
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
/** Les demi-coups en notation, triés : « 13/10 13/12… ». */
|
||||
function text(halfMoves: readonly HalfMove[]): string {
|
||||
return halfMoves
|
||||
.map((h) => formatHalfMoves([h]))
|
||||
.sort()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
test("au départ avec 3-1 : chaque pion jouable, avec chaque dé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
assert.equal(text(await game.nextHalfMoves()), "13/10 24/21 24/23 6/3 6/5 8/5 8/7");
|
||||
// 13/12 manque : votre case 12 est tenue par l'ordinateur. Après 8/5,
|
||||
// il ne reste que le 1.
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
|
||||
assert.equal(text(await game.nextHalfMoves()), "24/23 5/4 6/5 8/7");
|
||||
});
|
||||
|
||||
test("en fin de partie, les demi-coups vers la sortie viennent du moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([6, 5]),
|
||||
setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(text(await game.nextHalfMoves()), "5/off 6/1 6/off");
|
||||
});
|
||||
|
||||
test("hors du tour de jeu du joueur, rien n'est proposé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([1, 3]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
test("le plateau affiché suit les demi-coups saisis", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
const before = await game.displayedBoard();
|
||||
assert.equal(before.player.points[7], 3);
|
||||
assert.equal(before.computer.points[23], 2);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
const after = await game.displayedBoard();
|
||||
assert.equal(after.player.points[7], 2);
|
||||
assert.equal(after.player.points[4], 1);
|
||||
assert.equal(after.player.pipCount, before.player.pipCount - 3);
|
||||
});
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { GameEvent } from "../../src/domain/game/events.js";
|
||||
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
OFF,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
START_POSITION,
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
@@ -27,11 +33,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
|
||||
// l'ordinateur n'en a sorti aucun.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
|
||||
// pion devrait rentrer.
|
||||
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
|
||||
|
||||
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
|
||||
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
|
||||
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
|
||||
// est 13/10* 10/9 : il frappe votre blot.
|
||||
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let accepted: boolean;
|
||||
let positionBefore: PositionId;
|
||||
let computerTurn: { before: GameState; ms: number };
|
||||
let computerMove: MoveSteps;
|
||||
|
||||
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
|
||||
* 1 à 24, dans sa numérotation). */
|
||||
function checkersAt(side: BoardSide, point: number): number {
|
||||
if (point === BAR) {
|
||||
return side.bar;
|
||||
}
|
||||
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
@@ -53,6 +80,15 @@ async function play(text: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Joue les demi-coups puis valide le coup, complet. */
|
||||
async function playAndConfirm(text: string): Promise<boolean> {
|
||||
if (!(await play(text))) {
|
||||
return false;
|
||||
}
|
||||
await game.confirmMove();
|
||||
return true;
|
||||
}
|
||||
|
||||
function lastEvent(): GameEvent {
|
||||
const event = game.events.at(-1);
|
||||
assert.ok(event !== undefined, "aucun événement");
|
||||
@@ -133,12 +169,62 @@ Then("la position n'a pas changé", () => {
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
// ------------------------------------------ Valider ou reprendre son coup
|
||||
|
||||
Then("votre coup attend votre validation", async () => {
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(game.moveComplete, true);
|
||||
// Rien n'est encore dans le journal, et plus aucun pion ne bouge.
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("vos dés sont tous marqués comme joués", () => {
|
||||
assert.ok(game.diceToPlay.length > 0);
|
||||
assert.ok(game.diceToPlay.every((die) => die.used));
|
||||
});
|
||||
|
||||
When("vous validez votre coup", async () => {
|
||||
await game.confirmMove();
|
||||
});
|
||||
|
||||
Then("seul votre {int} est marqué comme joué", (value: number) => {
|
||||
const used = game.diceToPlay.filter((die) => die.used);
|
||||
assert.deepEqual(
|
||||
used.map((die) => die.value),
|
||||
[value],
|
||||
);
|
||||
});
|
||||
|
||||
Then("chacun de vos pions jouables n'a plus qu'une case d'arrivée", async () => {
|
||||
const halfMoves = await game.nextHalfMoves();
|
||||
assert.ok(halfMoves.length > 0);
|
||||
const starts = halfMoves.map((h) => h.from);
|
||||
assert.equal(new Set(starts).size, starts.length);
|
||||
});
|
||||
|
||||
When("vous annulez le dernier demi-coup", () => {
|
||||
game.undoLastHalfMove();
|
||||
});
|
||||
|
||||
Then("il ne reste de joué que {word}", (text: string) => {
|
||||
assert.deepEqual(game.pendingHalfMoves, parseHalfMoves(text));
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
Then("aucun de vos dés n'est marqué comme joué", () => {
|
||||
assert.ok(game.diceToPlay.length > 0);
|
||||
assert.ok(game.diceToPlay.every((die) => !die.used));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- L'ordinateur joue
|
||||
|
||||
Given("c'est au tour de l'ordinateur", async () => {
|
||||
// Vous commencez avec 3-1, puis l'ordinateur tirera 6-4.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
|
||||
assert.equal(await play("8/5 6/5"), true);
|
||||
assert.equal(await playAndConfirm("8/5 6/5"), true);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
@@ -171,8 +257,141 @@ Then(
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------- Aucun coup possible
|
||||
|
||||
Given("votre pion sur la barre ne peut pas rentrer", () => {
|
||||
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("vous lancez les dés", async () => {
|
||||
await game.roll();
|
||||
});
|
||||
|
||||
Then("aucun coup n'est possible avec votre jet", async () => {
|
||||
assert.deepEqual(game.state.roll, [6, 5]);
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("c'est toujours à vous", () => {
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
assert.deepEqual(event.halfMoves, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------- Voir le coup de l'ordinateur
|
||||
|
||||
Given("un de vos pions est seul à portée de l'ordinateur", () => {
|
||||
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
|
||||
// doubler, il joue.
|
||||
game = Game.start(engine, {
|
||||
setup: {
|
||||
position: COMPUTER_MUST_HIT,
|
||||
firstTurn: "computer",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
dice: fixedDice([3, 1]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur a joué", async () => {
|
||||
await game.playComputerTurn();
|
||||
const played = lastEvent();
|
||||
assert.ok(played.type === "MovePlayed" && played.side === "computer");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
computerMove = steps;
|
||||
});
|
||||
|
||||
Then("son coup vous est montré case par case", async () => {
|
||||
const { side, halfMoves, boards } = computerMove;
|
||||
assert.equal(side, "computer");
|
||||
assert.ok(halfMoves.length > 0);
|
||||
assert.equal(boards.length, halfMoves.length + 1);
|
||||
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
|
||||
// parti de la case de départ du demi-coup pour sa case d'arrivée.
|
||||
for (const [i, h] of halfMoves.entries()) {
|
||||
const before = boards[i]?.computer;
|
||||
const after = boards[i + 1]?.computer;
|
||||
assert.ok(before !== undefined && after !== undefined);
|
||||
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
|
||||
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
|
||||
}
|
||||
// Le dernier est le plateau de la partie.
|
||||
assert.deepEqual(boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
Then("votre pion frappé part sur la barre", () => {
|
||||
const { boards, hits } = computerMove;
|
||||
const hit = hits.indexOf(true);
|
||||
assert.ok(hit >= 0, "aucun pion frappé");
|
||||
assert.equal(boards[hit]?.player.bar, 0);
|
||||
assert.equal(boards[hit + 1]?.player.bar, 1);
|
||||
});
|
||||
|
||||
Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
// La confirmation elle-même est une question de l'interface (une fenêtre
|
||||
// « Doubler à 2 ? ») : ces étapes vérifient que rien n'est proposé tant que
|
||||
// vous n'avez pas confirmé, et ce qui suit votre confirmation.
|
||||
|
||||
Given("vous pouvez doubler", async () => {
|
||||
game = Game.start(engine, { setup: { position: START_POSITION, firstTurn: "player" } });
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
When("vous demandez à doubler", async () => {
|
||||
assert.equal(await game.canDouble(), true);
|
||||
});
|
||||
|
||||
When("vous changez d'avis", () => {
|
||||
// Vous refermez la question : la partie n'est pas touchée.
|
||||
});
|
||||
|
||||
Then("rien n'est proposé à l'ordinateur", () => {
|
||||
assert.ok(!game.events.some((e) => e.type === "CubeOffered"));
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(game.state.cube, { value: 1, owner: null });
|
||||
});
|
||||
|
||||
When("vous confirmez", async () => {
|
||||
await game.double();
|
||||
});
|
||||
|
||||
Then("l'ordinateur prend ou passe, comme le recommande le moteur", async () => {
|
||||
const decision = await engine.cubeDecision(
|
||||
START_POSITION,
|
||||
{ value: 1, owner: "centered" },
|
||||
STANDARD_PLY,
|
||||
);
|
||||
const offered = game.events.findIndex((e) => e.type === "CubeOffered");
|
||||
const answer = game.events[offered + 1];
|
||||
assert.ok(offered >= 0 && answer !== undefined);
|
||||
if (decision.take) {
|
||||
assert.equal(answer.type, "CubeTaken");
|
||||
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
|
||||
} else {
|
||||
assert.equal(answer.type, "CubeDropped");
|
||||
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
Given("l'ordinateur vous propose de doubler", async () => {
|
||||
game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } });
|
||||
await game.playComputerTurn();
|
||||
@@ -232,7 +451,7 @@ Given("un joueur a sorti tous ses pions", async () => {
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await play("6/off 5/off"), true);
|
||||
assert.equal(await playAndConfirm("6/off 5/off"), true);
|
||||
});
|
||||
|
||||
Then("le résultat indique le gagnant et les points gagnés", () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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 { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
placeOf,
|
||||
pointColor,
|
||||
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("couleur des flèches : impaires en point-a, paires en point-b, quelle que soit la colonne", () => {
|
||||
for (const number of [1, 13, 23]) {
|
||||
assert.equal(pointColor(number), "point-a", `flèche ${number}`);
|
||||
}
|
||||
for (const number of [2, 12, 24]) {
|
||||
assert.equal(pointColor(number), "point-b", `flèche ${number}`);
|
||||
}
|
||||
// 12 et 13 partagent la colonne 0, mais pas la couleur.
|
||||
assert.equal(pointSlot(12).column, pointSlot(13).column);
|
||||
assert.notEqual(pointColor(12), pointColor(13));
|
||||
});
|
||||
|
||||
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 });
|
||||
// Un seul toucher suffit pour un pion à destination unique.
|
||||
assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 });
|
||||
assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 });
|
||||
assert.equal(onlyHalfMove(targets, "p8"), null);
|
||||
assert.equal(onlyHalfMove(targets, "p13"), null);
|
||||
});
|
||||
|
||||
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
|
||||
// L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et
|
||||
// 7 sont les cases 12, 16 et 18 du joueur.
|
||||
const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]);
|
||||
assert.deepEqual(trace.arrows, [
|
||||
{ from: "p12", to: "p16" },
|
||||
{ from: "p12", to: "p18" },
|
||||
]);
|
||||
assert.deepEqual([...trace.left], [["p12", 2]]);
|
||||
assert.deepEqual(
|
||||
[...trace.landed],
|
||||
[
|
||||
["p16", 1],
|
||||
["p18", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(trace.hits, 0);
|
||||
});
|
||||
|
||||
test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => {
|
||||
const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]);
|
||||
assert.equal(trace.arrows.length, 2);
|
||||
assert.deepEqual([...trace.left], [["p1", 1]]);
|
||||
assert.deepEqual([...trace.landed], [["p11", 1]]);
|
||||
assert.equal(trace.hits, 1);
|
||||
});
|
||||
|
||||
test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => {
|
||||
const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]);
|
||||
assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" });
|
||||
assert.deepEqual(
|
||||
[...entering.left],
|
||||
[
|
||||
["bar-computer", 1],
|
||||
["p19", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(entering.hits, 1);
|
||||
const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]);
|
||||
assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]);
|
||||
assert.deepEqual(
|
||||
[...bearingOff.left],
|
||||
[
|
||||
["p19", 1],
|
||||
["p20", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
|
||||
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
|
||||
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
|
||||
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
|
||||
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
|
||||
assert.deepEqual(feature.scenarios[0]?.steps, [
|
||||
"Quand vous commencez une nouvelle partie",
|
||||
"Alors les pions sont en position de départ",
|
||||
"Et le premier jet de dés désigne qui commence",
|
||||
]);
|
||||
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
|
||||
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
|
||||
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
|
||||
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
|
||||
for (const scenario of feature.scenarios) {
|
||||
assert.ok(scenario.steps.length >= 2, scenario.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
|
||||
assert.ok(files.includes("videau.md"));
|
||||
assert.ok(!files.includes("README.md"));
|
||||
for (const file of files) {
|
||||
const entry = parseGlossaryEntry(read(`docs/glossaire/${file}`));
|
||||
assert.ok(entry !== null, file);
|
||||
assert.ok(entry.brief.length > 10, file);
|
||||
}
|
||||
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
|
||||
term: "Videau",
|
||||
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
});
|
||||
assert.equal(parseGlossaryEntry("pas une fiche"), null);
|
||||
});
|
||||
|
||||
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
};
|
||||
const store = createGameStore(storage);
|
||||
assert.equal(store.load(), null);
|
||||
const journal = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
store.save(journal);
|
||||
assert.deepEqual(store.load(), journal);
|
||||
for (const key of data.keys()) {
|
||||
data.set(key, "{pas du JSON");
|
||||
}
|
||||
assert.equal(store.load(), null);
|
||||
// Un stockage plein ou interdit ne casse pas la partie.
|
||||
const full = createGameStore({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.save(journal));
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
|
||||
assert.equal(
|
||||
TEXTS.youLose("single", 1),
|
||||
"L'ordinateur gagne la partie simple : 1 point pour lui.",
|
||||
);
|
||||
assert.equal(
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
|
||||
@@ -30,10 +30,22 @@
|
||||
header X-Content-Type-Options "nosniff"
|
||||
header Referrer-Policy "no-referrer"
|
||||
|
||||
# Politique de sécurité du contenu : tout vient du site lui-même, sans
|
||||
# script ni style en ligne. 'wasm-unsafe-eval' autorise la compilation du
|
||||
# moteur WebAssembly (et rien d'autre : ni eval ni new Function). L'en-tête
|
||||
# s'applique aussi au Web Worker du moteur et au service worker.
|
||||
header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
|
||||
# Données du moteur (Emscripten) : extension inconnue de Caddy, qui
|
||||
# devinerait text/xml d'après le début du fichier.
|
||||
@engine_data path *.data
|
||||
header @engine_data Content-Type "application/octet-stream"
|
||||
|
||||
# Textes de l'aide (copies de docs/), lus par l'application.
|
||||
@markdown path *.md
|
||||
header @markdown Content-Type "text/markdown; charset=utf-8"
|
||||
@gherkin path *.feature
|
||||
header @gherkin Content-Type "text/plain; charset=utf-8"
|
||||
|
||||
file_server
|
||||
}
|
||||
|
||||
@@ -52,3 +52,9 @@ construire ou tester ; rien n'est embarqué dans l'application.
|
||||
Validés par Pierre avec l'ADR 0009, dans Docker, jamais embarqués :
|
||||
TypeScript (`tsc`, Apache-2.0), Biome (MIT ou Apache-2.0),
|
||||
`@cucumber/cucumber` (MIT), `dependency-cruiser` (MIT).
|
||||
|
||||
### 2026-11-03 — Playwright pour les tests dans le navigateur
|
||||
|
||||
Validé par Pierre : Playwright 1.48.2 (Apache-2.0), image
|
||||
`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisé seulement par
|
||||
`make test-browser` (Chromium et Firefox sans écran). Rien n'est embarqué.
|
||||
|
||||
@@ -68,3 +68,24 @@ vérification des dépendances.
|
||||
standard ») est tenu même au niveau fort (2 plis) : c'est le niveau standard
|
||||
de l'ordinateur à l'étape 0d.
|
||||
- Firefox sur le téléphone reste à vérifier, sans bloquer.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Pages de test du moteur retirées du site
|
||||
|
||||
Décidé par Pierre après son premier test sur le téléphone : « supprimer la
|
||||
partie test du moteur totalement ».
|
||||
|
||||
- Les pages de test et de mesure de l'étape 0b (`web/moteur/` : `index.html`,
|
||||
`bench.html` et leurs scripts) sont supprimées. Le site publié n'a plus rien
|
||||
sous `/moteur/`, ni dans le cache du service worker ; `make deploy`
|
||||
n'annonce plus que l'application, et l'aide ne renvoie plus vers le test du
|
||||
moteur.
|
||||
- Les mesures du Fairphone 4 ci-dessus, faites avec `bench.html`, restent la
|
||||
référence. Pour les refaire, la mesure se lance sous Node sur le PC
|
||||
(`make bench`) : son code partagé est déplacé de `web/moteur/benchmark.mjs`
|
||||
vers `engine-wasm/benchmark.mjs`. Une mesure sur le téléphone demanderait de
|
||||
remettre une page de mesure, à décider le moment venu.
|
||||
- Le moteur reste vérifié par la suite de contrat sur WebAssembly
|
||||
(`make test`) et, dans l'application, par la partie jouée dans un navigateur
|
||||
(`make test-browser`).
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 0010. Identité visuelle : symbole, logotype, palette
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Le brief créatif (https://claude.ai/artifact/36WMwZWqvMxXvSSVBJTgwd) fixe la
|
||||
personnalité d'Ustagammon (transmission, chaleur, maîtrise), une palette de
|
||||
départ (bois sombre, ivoire, un accent unique à choisir entre rouge brique et
|
||||
turquoise) et quatre pistes de symbole, avec une contrainte : un 8 caché, clin
|
||||
d'œil à l'Atelier du Huit, qui se découvre au second regard.
|
||||
|
||||
Les propositions ont été faites en trois tours sur une planche de travail
|
||||
(https://claude.ai/artifact/Cck1SZCrKeYWbqJLhbwWNp), chacune testée en
|
||||
couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
|
||||
|
||||
## Décision
|
||||
|
||||
- **Accent** : rouge brique, `#A8432B` en thème clair, `#D0694B` en thème
|
||||
sombre. La turquoise est écartée.
|
||||
- **Symbole** (variante « P3d ») : deux pions vus de trois quarts, avec leur
|
||||
tranche, posés sur une flèche du plateau en brique. Le maître (noyer) est
|
||||
devant, l'apprenti (ivoire, filet brique) derrière ; leurs silhouettes
|
||||
dessinent le 8.
|
||||
- **Logotype** (variante « L3 ») : « Ustagammon » en Alegreya Sans Bold, dont
|
||||
les deux « m » sont redessinés en quatre flèches au trait qui alternent
|
||||
noyer et brique, comme les flèches d'un plateau. En monochrome, les quatre
|
||||
flèches prennent la couleur du texte.
|
||||
- **Icône d'application** : symbole sur fond ivoire `#F3EAD8`.
|
||||
- **Police** : Alegreya Sans (SIL Open Font License 1.1), pour le logotype et
|
||||
pour l'interface. Pierre a validé cette dépendance, ainsi que fontTools
|
||||
(MIT), outil de développement qui sert à régénérer le logotype, le
|
||||
2026-11-03.
|
||||
- Les fichiers de référence sont dans `docs/identite/logo/`, produits par
|
||||
`docs/identite/generer-logo.py` ; ils sont sous licence CC BY-SA 4.0.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Accent turquoise** : plus froid ; la brique s'accorde mieux au bois et au
|
||||
café.
|
||||
- **Piste B (le videau)** : écartée au premier tour, au profit des pions.
|
||||
- **Pions vus de face (A1) ou en léger relief sans flèche (P1)** : appréciés,
|
||||
mais la flèche rattache le symbole au plateau et fait écho au logotype.
|
||||
- **Filets brique sur les deux pions (P3c)** : trop de brique dans un petit
|
||||
symbole.
|
||||
- **Logotype aux flèches pleines (L2)** : les « mm » s'y lisaient moins vite ;
|
||||
le trait (L1) garde la lisibilité, et l'alternance de couleur (L3) apporte
|
||||
le rappel du plateau.
|
||||
- **Fond d'icône noyer** : moins lumineux sur un écran d'accueil.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le design system (couleurs des deux thèmes, typographie, composants) part de
|
||||
cette palette et de cette police.
|
||||
- La police Alegreya Sans devra être embarquée dans la PWA (fonctionnement hors
|
||||
ligne) ; sa licence OFL le permet, avec sa notice.
|
||||
- Le favicon utilise un dessin simplifié du symbole, sans filets, illisibles
|
||||
en dessous de 32 px.
|
||||
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
|
||||
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 0011. Leçons numérotées, conseil du maître après le coup, bilan de fin de partie
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Le cadrage (`docs/cadrage.md`) prévoit pour la V1 un coach en direct, un écran
|
||||
de fin de partie et des leçons par concept, sans en fixer la forme. Les
|
||||
maquettes des écrans (https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe),
|
||||
faites avec la charte graphique (ADR 0010), en ont proposé une. Pierre en a
|
||||
retenu trois éléments le 2026-11-03.
|
||||
|
||||
Un point des maquettes contredisait le cadrage : le conseil apparaissait avant
|
||||
que le joueur ne joue, en donnant la réponse. Pierre a tranché pour un
|
||||
commentaire après le coup.
|
||||
|
||||
## Décision
|
||||
|
||||
- **Leçons numérotées** : un parcours de leçons dans un ordre fixe
|
||||
(« Leçon 1 », « Leçon 2 »…). Chaque leçon affiche son état (à commencer,
|
||||
« 3 exercices sur 5 », terminée) et sa progression. L'accueil propose de
|
||||
continuer la leçon en cours.
|
||||
- **Conseil du maître** : c'est la forme du coach en direct du cadrage. Il
|
||||
commente le coup **après** que le joueur l'a joué, jamais avant. Il donne
|
||||
l'appréciation du moteur avec un mot et une icône (bon coup, imprécision,
|
||||
erreur), puis une phrase du maître qui dit quel coup il aurait joué et
|
||||
pourquoi, avec un lien pour en savoir plus. L'écart d'équité n'apparaît que
|
||||
sur demande. Il reste désactivable, comme le prévoit le cadrage.
|
||||
- **Bilan de fin de partie** : le résultat, le décompte des coups par
|
||||
appréciation (bons coups, imprécisions, erreurs), « le coup à revoir »
|
||||
(la plus grosse erreur, avec sa position, le coup joué et le meilleur coup)
|
||||
et le prompt prêt à copier pour une IA.
|
||||
|
||||
Les maquettes de référence sont les écrans « Plateau de jeu », « Leçon et
|
||||
aide » et « Fin de partie » du canevas ci-dessus.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Conseil avant le coup, avec la réponse** : le joueur n'essaie plus par
|
||||
lui-même, et on n'apprend pas en recopiant.
|
||||
- **Indice avant le coup, sans la réponse, en plus du commentaire après** :
|
||||
écarté pour l'instant au profit d'un seul mécanisme simple. L'« indice » de
|
||||
la liste des fonctions du cadrage reste à concevoir séparément.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le commentaire après le coup demande une analyse du coup joué par le moteur
|
||||
(classement des coups et écart avec le meilleur). Elle doit tenir dans le
|
||||
budget de temps du téléphone, en arrière-plan, sans retarder le tour
|
||||
suivant.
|
||||
- Les seuils entre « bon coup », « imprécision » et « erreur » sont à fixer,
|
||||
en s'appuyant sur ceux de GNU Backgammon (doubtful, bad, very bad).
|
||||
- Les phrases du maître doivent rester exactes : elles s'appuient sur les
|
||||
coups et les chiffres du moteur, jamais sur une explication inventée.
|
||||
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
|
||||
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
|
||||
progression est stockée localement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 0012. Mode immersif : le plateau en paysage, sur demande
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Sur l'écran de jeu en portrait, le plateau n'occupe qu'environ 35 % de l'écran
|
||||
d'un téléphone (pions de 24 px sur un écran de 390 px de large). Pierre le
|
||||
trouve peu immersif et a demandé un mode où le plateau prend le maximum de
|
||||
l'écran.
|
||||
|
||||
Deux pistes ont été maquettées
|
||||
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Mode immersif ») :
|
||||
le téléphone en paysage, plateau sur toute la hauteur ; ou le portrait avec le
|
||||
plateau tourné d'un quart de tour. Toutes deux donnent environ 57 % de l'écran
|
||||
au plateau et des pions de 30 px : le petit côté de l'écran limite la taille
|
||||
des pions dans les deux cas.
|
||||
|
||||
## Décision
|
||||
|
||||
- Un **mode immersif en paysage** : le plateau occupe toute la hauteur, dans
|
||||
son orientation habituelle. À gauche : le menu, les comptes de points et la
|
||||
consigne. À droite : l'aide et les actions. Le fond est en noyer jusqu'aux
|
||||
bords de l'écran.
|
||||
- On y entre **par un geste explicite** : un bouton « Plein écran » sur l'écran
|
||||
de jeu en portrait. On en sort par le menu ou par le retour du téléphone.
|
||||
- Le bouton force la vue paysage même si le téléphone est tenu droit ou si sa
|
||||
rotation automatique est verrouillée : passage en plein écran
|
||||
(`requestFullscreen`), puis verrouillage de l'orientation
|
||||
(`screen.orientation.lock("landscape")`).
|
||||
- Si le verrouillage est refusé (navigateur ou système qui ne le permet pas,
|
||||
iPhone), l'app affiche la vue paysage tournée d'un quart de tour dans l'écran
|
||||
portrait. Le résultat est le même en main.
|
||||
- L'écran de jeu en portrait reste la vue par défaut, pour jouer d'une main.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Plateau tourné en portrait** : il change la lecture du plateau (numéros de
|
||||
côté, camps à gauche et à droite) et risque de dérouter les débutants habitués
|
||||
à une vraie table.
|
||||
- **Bascule automatique quand on tourne le téléphone** : elle ne marche pas si
|
||||
la rotation est verrouillée, et elle surprend en cours de partie. Elle reste
|
||||
possible en plus du bouton, plus tard.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le rendu du plateau doit s'adapter à deux mises en page (portrait et paysage)
|
||||
sans changer le domaine ni le moteur : seule la vue change (ADR 0007).
|
||||
- `screen.orientation.lock` n'est accepté qu'en plein écran, et pas partout :
|
||||
la solution de repli par rotation CSS est obligatoire, et elle doit être
|
||||
testée (`make test-browser`).
|
||||
- Le plein écran masque la barre d'état du téléphone, ce qui libère encore un
|
||||
peu de place.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Vue paysage dès la rotation, plateau = écran
|
||||
|
||||
Décidé par Pierre après son premier essai sur le téléphone, où la vue paysage
|
||||
à deux colonnes (plateau limité à 70 % de la largeur, texte à droite) lui a
|
||||
paru « vraiment pas optimale ».
|
||||
|
||||
- Dès que le téléphone est en paysage, même sans plein écran, l'app applique
|
||||
la mise en page « plateau = écran » sur la hauteur réelle (`100dvh`, barre
|
||||
d'adresse comprise). La mise en page à deux colonnes disparaît.
|
||||
- Dans cette vue, les contrôles sont dans le plateau : comptes de points sur la
|
||||
barre, videau au milieu de la barre, dés et consigne dans la bande centrale,
|
||||
pictogrammes (menu, annuler, plein écran) dans la colonne des sorties
|
||||
(maquette « Immersif : plein écran, contrôles dans le plateau » du canevas
|
||||
ci-dessus). La géométrie du plateau suit les proportions de l'écran ; les
|
||||
numéros des flèches font au moins 12 px.
|
||||
- Le bouton « Plein écran » garde son rôle : plein écran, verrouillage en
|
||||
paysage, repli par rotation CSS.
|
||||
|
||||
La bascule automatique écartée plus haut était celle qui forçait
|
||||
l'orientation ; ici, seule la mise en page suit l'orientation choisie par le
|
||||
joueur.
|
||||
|
||||
### 2026-11-03 — Placement des contrôles en paysage, après la revue UX
|
||||
|
||||
La revue UX de l'étape 0e.2 a montré deux conflits dans le placement décrit
|
||||
par l'amendement précédent : la moitié basse de la colonne des sorties est une
|
||||
cible de jeu (toucher fait sortir un pion), et les pions frappés s'empilent
|
||||
depuis le milieu de la barre. Arbitrage de la session design, sur délégation
|
||||
de Pierre :
|
||||
|
||||
- une colonne de commandes à gauche, dans le cadre : menu en haut, videau au
|
||||
milieu (il monte ou descend selon son propriétaire), « Annuler le
|
||||
demi-coup » en bas, remplacé par « Revoir » quand le coup de l'ordinateur
|
||||
est montré ; « Quitter le plein écran » passe dans le menu ;
|
||||
- la colonne des sorties reste réservée aux pions sortis ;
|
||||
- les comptes de points sont des pastilles aux deux bouts de la barre, aux
|
||||
couleurs du camp, en 13 px.
|
||||
|
||||
Maquettes : rangée « Révision UX (étape 0e) : paysage » du canevas
|
||||
(variantes thème sombre, barre d'adresse visible et fin de partie). Règles de
|
||||
comportement : `docs/identite/charte/interaction.md`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 0013. Une position du plateau dans l'adresse de la page
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre (besoin), Claude Code (format)
|
||||
|
||||
## Contexte
|
||||
|
||||
Pierre a demandé, au premier test sur le téléphone (2026-11-03), qu'avec la
|
||||
seule adresse on arrive à une position du plateau : chaque position devient un
|
||||
lien direct, à partager, à mettre dans une leçon ou dans un prompt pour une IA.
|
||||
GNU Backgammon décrit déjà toute position par deux identifiants publics et
|
||||
documentés : le Position ID (14 caractères, les pions) et le Match ID
|
||||
(12 caractères : videau, score, longueur du match, joueur au trait, dés). Le
|
||||
moteur sait les lire et les écrire (`engine.h`).
|
||||
|
||||
## Décision
|
||||
|
||||
- L'adresse porte la position dans le fragment (après `#`) :
|
||||
`https://ustagammon.sans.pub/#position=<Position ID>&match=<Match ID>`,
|
||||
chaque identifiant encodé avec `encodeURIComponent` (le base64 de gnubg
|
||||
contient `+` et `/`). `match` est facultatif : sans lui, partie libre, videau
|
||||
au centre, joueur du bas au trait, sans dés.
|
||||
- Ouvrir un tel lien affiche la position, non jouable, avec deux actions :
|
||||
« Jouer à partir d'ici » (nouvelle partie contre l'ordinateur depuis cette
|
||||
position, sans toucher à la partie en cours) et « Retour à ma partie ».
|
||||
- Toute position affichée (partie, coup de l'ordinateur, analyse, leçon) peut
|
||||
être copiée en lien (« Copier le lien de la position »).
|
||||
- Un identifiant illisible pour le moteur donne un message clair et le retour
|
||||
à la partie, jamais une page cassée.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Paramètres de requête (`?position=`)** : ils changent l'adresse demandée
|
||||
au serveur et au service worker ; hors ligne, la page en cache ne
|
||||
correspondrait plus. Le fragment ne quitte jamais le navigateur.
|
||||
- **XGID (format d'eXtreme Gammon)** : répandu, mais un seul identifiant de
|
||||
plus à décoder ; gnubg l'importe, son import pourra s'ajouter plus tard
|
||||
(outil « import d'un identifiant GNU ou XGID » du cadrage).
|
||||
- **Format maison plus lisible** : il faudrait le décoder sans le moteur, donc
|
||||
réimplémenter une règle de représentation (ADR 0007).
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le fragment est lu au chargement et à chaque changement (`hashchange`) ; le
|
||||
domaine reçoit une position et un Match ID, le moteur les valide.
|
||||
- Les liens restent valables tant que GNU Backgammon garde ces formats, stables
|
||||
depuis des années.
|
||||
- Les liens contiennent la position, rien d'autre : pas de donnée personnelle.
|
||||
@@ -16,3 +16,7 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
|
||||
| [0007](0007-architecture-et-conventions-de-code.md) | Architecture et conventions de code | accepté |
|
||||
| [0008](0008-glossaire-et-scenarios-source-unique.md) | Glossaire et scénarios : une source unique pour le code, l'aide et les tests | accepté |
|
||||
| [0009](0009-socle-pwa-typescript.md) | Socle : PWA en TypeScript, moteur en WebAssembly | accepté |
|
||||
| [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté |
|
||||
| [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté |
|
||||
| [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté |
|
||||
| [0013](0013-position-dans-l-adresse.md) | Une position du plateau dans l'adresse de la page | accepté |
|
||||
|
||||
@@ -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 ont été retirées (ADR
|
||||
0009, amendement du 2026-11-03). Il est publié sur
|
||||
CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app
|
||||
`ustagammon`, sur https://ustagammon.sans.pub.
|
||||
|
||||
@@ -46,31 +48,71 @@ make deploy-prod`.
|
||||
|
||||
## Ce qui est publié
|
||||
|
||||
- `make site` assemble `build/site/` : le contenu de `web/` à la racine, le
|
||||
moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`) sous
|
||||
`wasm/`, et `version.json` avec le commit publié.
|
||||
- `make site` assemble `build/site/` (9 Mo) :
|
||||
- le contenu de `web/` à la racine : la page de l'application
|
||||
(`index.html`, `app.css`), le manifeste (`manifest.webmanifest`), les
|
||||
icônes (`icons/`), le service worker (`sw.js`), le Web Worker du moteur
|
||||
(`engine-worker.mjs`, `engine-wasm.mjs`) et les pages de test sous
|
||||
`moteur/` ;
|
||||
- l'application TypeScript compilée (`build/web/src/`) sous `app/` ;
|
||||
- le moteur (`engine.mjs`, `engine.wasm`, `engine.data`, `positions.json`)
|
||||
sous `wasm/` ;
|
||||
- des copies de `docs/glossaire/` et `docs/scenarios/` sous `docs/`, lues
|
||||
par l'aide de l'application (ADR 0008) ;
|
||||
- `version.json` avec le commit publié ;
|
||||
- enfin, `scripts/service-worker.sh` écrit en tête de `sw.js` la liste de
|
||||
tous ces fichiers et une empreinte de leur contenu.
|
||||
- `make package` ajoute `deploy/` et produit `build/ustagammon.tar` (environ
|
||||
9 Mo).
|
||||
9 Mo, 7,3 Mo transférés grâce à la compression).
|
||||
- `tests/test_site.sh` (dans `make test`) construit l'image de production en
|
||||
local, la lance et vérifie ce que reçoit un navigateur : pages, types MIME
|
||||
(`application/wasm` pour le moteur), compression gzip des données, en-têtes,
|
||||
et commit de `version.json`.
|
||||
local, la lance et vérifie ce que reçoit un navigateur : application, pages
|
||||
de test, aide, manifeste et icônes, liste du service worker, types MIME
|
||||
(`application/wasm` pour le moteur), compression gzip des données,
|
||||
en-têtes dont la politique de sécurité, et commit de `version.json`.
|
||||
- `make test-browser` (hors de `make test`, plusieurs minutes) joue une
|
||||
partie complète par l'interface dans Chromium et Firefox sans écran, sur
|
||||
la même image, puis vérifie la reprise, l'aide, le hors ligne (Chromium
|
||||
seulement : Firefox sous Playwright refuse toute navigation « hors
|
||||
ligne », on y vérifie que le cache est complet et que la page rechargée
|
||||
est servie par le service worker) et la politique de sécurité. Captures
|
||||
d'écran dans `build/browser-shots/`.
|
||||
|
||||
**Hors ligne et mises à jour.** Le service worker met tous les fichiers du
|
||||
site en cache à la première visite (environ 9 Mo), puis sert tout depuis ce
|
||||
cache, avec ou sans réseau. À chaque publication, l'empreinte écrite dans
|
||||
`sw.js` change dès qu'un fichier a changé : le navigateur, qui revérifie
|
||||
`sw.js` à chaque ouverture, installe la nouvelle version (tout le site est
|
||||
retéléchargé), qui prend la main et efface l'ancien cache. La page déjà
|
||||
ouverte continue sur l'ancienne version ; le lancement suivant utilise la
|
||||
nouvelle. Une publication sans changement de fichier garde le même `sw.js` :
|
||||
rien n'est retéléchargé. Le service worker exige https (ou `localhost`) :
|
||||
avec `make deploy`, en http sur le réseau local, l'application fonctionne,
|
||||
mais pas hors ligne.
|
||||
|
||||
Réglages du serveur (`deploy/Caddyfile`) : compression zstd ou gzip, y compris
|
||||
pour les données du moteur ; `Cache-Control: no-cache` (les noms de fichiers ne
|
||||
changent pas d'une version à l'autre, le navigateur revalide chaque fichier) ;
|
||||
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer`.
|
||||
`X-Content-Type-Options: nosniff` ; `Referrer-Policy: no-referrer` ; et une
|
||||
politique de sécurité du contenu (`Content-Security-Policy`) : tout vient du
|
||||
site lui-même, aucun script ni style en ligne, aucun `eval`, seule la
|
||||
compilation du WebAssembly est permise (`'wasm-unsafe-eval'`). Elle est
|
||||
vérifiée par `tests/test_site.sh` (en-tête présent) et par
|
||||
`make test-browser` : aucune violation signalée par Chromium ni Firefox
|
||||
pendant toute une partie (moteur, Web Worker, service worker, aide), et un
|
||||
script en ligne ajouté exprès à la page est bien bloqué.
|
||||
|
||||
## Vérifier
|
||||
|
||||
```bash
|
||||
curl -s https://ustagammon.sans.pub/version.json # commit publié
|
||||
curl -sI https://ustagammon.sans.pub/wasm/engine.wasm # application/wasm
|
||||
curl -sI https://ustagammon.sans.pub/ | grep -i content-security # CSP
|
||||
curl -s https://ustagammon.sans.pub/sw.js | head -2 # version du cache
|
||||
ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}} {{.CurrentState}}" | head -2'
|
||||
```
|
||||
|
||||
Puis ouvrir le site sur le téléphone : la page de test doit afficher « Moteur
|
||||
prêt ».
|
||||
Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne
|
||||
remplace « Chargement du moteur… ».
|
||||
|
||||
## Retour arrière
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Étape 0 — Prototype technique
|
||||
|
||||
**But** : prouver que la chaîne technique fonctionne avant d'investir dans le design, et trancher entre deux socles : PWA (moteur en WebAssembly) ou Android natif.
|
||||
|
||||
**Règle** : chaque sous-étape se termine par une démonstration que le développeur peut lancer lui-même avec une commande `make`.
|
||||
|
||||
---
|
||||
|
||||
## 0a. Le moteur en ligne de commande
|
||||
|
||||
**Objectif** : interroger le moteur GNU Backgammon sur une position, depuis le PC.
|
||||
|
||||
À faire :
|
||||
1. Récupérer les sources du moteur (choisir entre gnubg, gnubg-nn ou le moteur extrait par CBG ; justifier le choix dans un ADR, `docs/adr/`).
|
||||
2. Définir une **interface C minimale** du moteur (`engine/include/engine.h`, identifiants en anglais : ADR 0007), indépendante de toute interface graphique :
|
||||
- initialiser le moteur (charger réseaux, base de fin de partie, table d'équité de match) ;
|
||||
- lister les coups légaux pour une position et des dés ;
|
||||
- donner le meilleur coup et le classement des coups avec leur équité, pour un niveau d'évaluation donné ;
|
||||
- donner la décision de videau (pas de double, double/prend, double/passe, avec équités) ;
|
||||
- évaluer une position (probabilités de gain, gammon, backgammon).
|
||||
Les positions entrent et sortent au format **GNU Position ID + Match ID**.
|
||||
3. Écrire `engine-cli`, un petit programme qui expose ces fonctions en ligne de commande, avec une sortie JSON.
|
||||
4. Générer un jeu de **positions de référence** (au moins 30 : ouvertures, contact, course, sortie, décisions de videau) avec le GNU Backgammon de bureau dans Docker, et stocker les résultats attendus dans `tests/reference/`.
|
||||
5. `make test` compare `engine-cli` aux références (même meilleur coup, équités à une tolérance près, à fixer).
|
||||
|
||||
**Démo** : `make engine && ./build/engine-cli bestmove <position_id> <match_id> 31` affiche le meilleur coup en JSON ; `make test` passe.
|
||||
|
||||
---
|
||||
|
||||
## 0b. Le test WebAssembly (décisif)
|
||||
|
||||
**Objectif** : savoir si le moteur tourne assez vite et léger dans le navigateur du téléphone.
|
||||
|
||||
À faire :
|
||||
1. Compiler le même moteur et la même interface C en WebAssembly avec Emscripten (dans Docker).
|
||||
2. Charger le moteur dans un **Web Worker** (jamais sur le fil d'affichage).
|
||||
3. Page HTML minimale : un champ Position ID, des dés, un bouton « meilleur coup », l'affichage du résultat et du temps de calcul.
|
||||
4. Rejouer les positions de référence dans le navigateur : les résultats doivent concorder avec 0a.
|
||||
5. `make deploy-web` sert la page sur le réseau local pour l'ouvrir depuis le Fairphone 4.
|
||||
6. `make bench` (ou une page de mesure) produit un petit rapport : temps par coup selon le niveau d'évaluation, taille téléchargée, mémoire utilisée.
|
||||
|
||||
**Critères de réussite (à ajuster après mesure)** :
|
||||
|
||||
| Mesure sur le Fairphone 4 | Seuil |
|
||||
| --- | --- |
|
||||
| Meilleur coup, niveau rapide (0-ply GNU) | moins de 0,3 s |
|
||||
| Meilleur coup, niveau fort (2-ply GNU) | moins de 3 s |
|
||||
| Décision de videau, niveau fort | moins de 3 s |
|
||||
| Taille totale à télécharger (wasm + données, compressé) | moins de 40 Mo |
|
||||
| Résultats identiques aux références de 0a | 100 % |
|
||||
|
||||
**Démo** : la page ouverte sur le Fairphone donne le meilleur coup d'une position de référence, avec le temps de calcul affiché.
|
||||
|
||||
---
|
||||
|
||||
## 0c. Android natif (seulement si 0b échoue)
|
||||
|
||||
**Objectif** : la même démonstration en application Android.
|
||||
|
||||
À faire :
|
||||
1. Compiler le moteur pour arm64 avec le NDK (dans Docker), en s'appuyant sur la configuration de CBG.
|
||||
2. Application Android minimale, construite en ligne de commande (Gradle sans Android Studio, `-j2`, démon Gradle limité en mémoire).
|
||||
3. Même écran de test et mêmes mesures qu'en 0b.
|
||||
4. `make deploy` installe l'APK sur le Fairphone via adb.
|
||||
|
||||
---
|
||||
|
||||
## 0d. Plateau minimal et décision
|
||||
|
||||
1. Sur le socle retenu, un **plateau minimal** : une partie complète jouable contre le moteur, coups et videau décidés par le moteur, dés tirés par l'app.
|
||||
2. Rédiger un ADR (`docs/adr/`) : socle retenu (PWA ou Android natif), mesures obtenues, raisons, risques restants.
|
||||
|
||||
**Critère de fin de l'étape 0** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Étape 1 — V1 « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il décrit l'étape en cours et sera supprimé à sa clôture,
|
||||
après report de ce qui est durable dans les ADR et le journal.
|
||||
|
||||
**Critère de fin** (cadrage) : un débutant apprend et joue seul ; un joueur de
|
||||
club comprend ses erreurs. Livré avec un plan de test (`docs/guide-test-etape-1.md`)
|
||||
que Pierre déroule sur le Fairphone 4.
|
||||
|
||||
**Préalable** : les sous-étapes 0e (retours du premier test) de la feuille de
|
||||
route, dont la revue UX validée par Pierre.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 0010,
|
||||
0011 et 0012. Ce qui n'y figure pas est hors de l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| 1a. Moteur pour l'analyse | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
- **Un coach bienveillant, mis en avant mais discret** (2026-11-03) : le
|
||||
conseil du maître et les conseils sont une signature de l'app, présents à
|
||||
chaque partie, sans jamais interrompre le jeu ni culpabiliser le joueur. Le
|
||||
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
|
||||
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
|
||||
|
||||
## Questions pour Pierre (ne bloquent pas le démarrage)
|
||||
|
||||
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
|
||||
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
|
||||
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
|
||||
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
|
||||
les règles » qui oriente vers le tutoriel ou la partie.
|
||||
- **Seuils des appréciations** : proposés d'après ceux de GNU Backgammon
|
||||
(douteux 0,04, mauvais 0,08, très mauvais 0,16 d'équité), à calibrer.
|
||||
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
|
||||
|
||||
## Ordre de réalisation
|
||||
|
||||
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
|
||||
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étape a son plan dans `docs/plans/` et sa merge request.
|
||||
@@ -3,33 +3,35 @@
|
||||
Le travail restant, dans l'ordre. Une ligne réalisée est retirée : ce qui a été
|
||||
fait se lit dans les merge requests et le journal, ce qui a été décidé dans les
|
||||
ADR (`docs/adr/`). Les versions et leur contenu sont définis dans
|
||||
`docs/cadrage.md` ; le détail de l'étape en cours est dans `docs/etape-0.md`.
|
||||
`docs/cadrage.md`.
|
||||
|
||||
Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
|
||||
|
||||
## Étape 0 — Prototype technique
|
||||
## Étape 0 — Prototype technique : terminée
|
||||
|
||||
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
|
||||
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
|
||||
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
|
||||
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
|
||||
page de test, page de mesure et guide de test (0b), mesures sur le Fairphone 4 et
|
||||
socle PWA en TypeScript (ADR 0009 ; 0c Android natif abandonnée), domaine
|
||||
« partie » en journal d'événements avec scénario exécuté sur le vrai moteur et
|
||||
garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub.
|
||||
Close le 2026-11-03 : partie complète jouée par Pierre sur le Fairphone 4, coups
|
||||
de l'ordinateur rapides, tous les tests au vert. Acquis : moteur gnubg officiel
|
||||
(ADR 0005) comparé à 32 positions de référence, moteur en WebAssembly, socle
|
||||
PWA en TypeScript (ADR 0009 ; Android natif abandonné), domaine « partie » en
|
||||
journal d'événements, plateau jouable hors ligne avec la charte graphique,
|
||||
publié sur https://ustagammon.sans.pub. Le détail est dans le journal et les
|
||||
merge requests.
|
||||
|
||||
## Retours du premier test sur téléphone (avant l'étape 1)
|
||||
|
||||
Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0d.2 | Plateau minimal jouable (PWA hors ligne) ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | |
|
||||
|
||||
**Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s
|
||||
par coup de l'IA en niveau standard, tous les tests au vert.
|
||||
| 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` |
|
||||
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
| Étape | Contenu | État |
|
||||
| --- | --- | --- |
|
||||
| 1. V1 Jouer et comprendre | Plateau soigné, niveaux, coach, analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
|
||||
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
|
||||
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# Guide de test : le moteur sur le Fairphone 4 (étape 0b)
|
||||
|
||||
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
|
||||
|
||||
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
|
||||
calcule, puis lancer une mesure et coller son rapport dans la conversation.
|
||||
Comptez un quart d'heure.
|
||||
|
||||
## 1. Préparer le PC
|
||||
|
||||
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
|
||||
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
|
||||
outils. La première fois, c'est long ; ensuite, c'est immédiat.
|
||||
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
|
||||
|
||||
## 2. Lancer la page
|
||||
|
||||
1. Tapez `make deploy-web` puis Entrée.
|
||||
2. La première fois, le moteur se compile : quelques minutes.
|
||||
3. Le terminal affiche deux adresses, par exemple :
|
||||
|
||||
```
|
||||
Page de test : http://192.168.1.21:8080/
|
||||
Page de mesure : http://192.168.1.21:8080/bench.html
|
||||
```
|
||||
|
||||
Vos chiffres peuvent être différents : notez ceux de votre écran.
|
||||
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
|
||||
fin : Ctrl+C dans le terminal.
|
||||
|
||||
## 3. Essayer un meilleur coup
|
||||
|
||||
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
|
||||
2. Tapez l'adresse de la **page de test** dans la barre d'adresse, exactement
|
||||
comme affichée (avec `http://` et `:8080`).
|
||||
3. Attendez le message « Moteur prêt, chargé en … ms ».
|
||||
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
|
||||
5. Dans « Niveau », choisissez « fort (2 plis) ».
|
||||
6. Touchez « Meilleur coup ».
|
||||
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
|
||||
si vous voulez, il sera aussi dans le rapport.
|
||||
|
||||
## 4. Lancer la mesure
|
||||
|
||||
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
|
||||
page de mesure).
|
||||
2. Touchez « Lancer la mesure ».
|
||||
3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
|
||||
gardez l'écran allumé et la page ouverte, sans changer d'application.
|
||||
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
|
||||
« dépassé » sinon.
|
||||
|
||||
## 5. Envoyer le rapport
|
||||
|
||||
1. Touchez « Copier les résultats ».
|
||||
2. Si la page dit « Rapport copié », c'est bon.
|
||||
3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
|
||||
puis « Copier ».
|
||||
4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
|
||||
ou envoyez-le vous sur le PC).
|
||||
|
||||
Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
|
||||
page de mesure) et collez aussi ce second rapport : cela montre si les temps
|
||||
sont stables. Si les deux navigateurs sont installés, faites la mesure dans
|
||||
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
|
||||
lent, et le rapport indique le navigateur utilisé.
|
||||
|
||||
## Si la page ne s'ouvre pas
|
||||
|
||||
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
|
||||
et `:8080` à la fin.
|
||||
- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
|
||||
pas sur la 4G ni sur un Wi-Fi invité.
|
||||
- **Vérifiez que la page tourne** : sur le PC, ouvrez
|
||||
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
|
||||
plus, relancez `make deploy-web` et lisez le message d'erreur.
|
||||
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
|
||||
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
|
||||
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
|
||||
8080/tcp`.
|
||||
- **« Le moteur n'a pas pu se charger »** : notez le message exact et
|
||||
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
|
||||
Firefox), à jour.
|
||||
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
|
||||
`make deploy-web WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Identité visuelle d'Ustagammon
|
||||
|
||||
Décision et historique des choix : ADR 0010 (`docs/adr/0010-identite-visuelle.md`).
|
||||
Brief créatif : https://claude.ai/artifact/36WMwZWqvMxXvSSVBJTgwd
|
||||
|
||||
Les dessins de ce dossier sont sous licence **CC BY-SA 4.0**. Le logotype
|
||||
utilise des tracés de la police **Alegreya Sans Bold** (Juan Pablo del Peral,
|
||||
Huerta Tipográfica), sous licence SIL Open Font License 1.1.
|
||||
|
||||
## Fichiers (`logo/`)
|
||||
|
||||
| Fichier | Usage |
|
||||
| --- | --- |
|
||||
| `symbole-couleur.svg` | Symbole seul, sur fond clair |
|
||||
| `symbole-sombre.svg` | Symbole seul, sur fond sombre |
|
||||
| `symbole-noir.svg`, `symbole-blanc.svg` | Monochromes, fond transparent |
|
||||
| `logotype-*.svg` | Le nom seul, mêmes quatre déclinaisons |
|
||||
| `logo-horizontal-*.svg` | Symbole et nom côte à côte, mêmes déclinaisons |
|
||||
| `icone.svg`, `icone-192.png`, `icone-512.png` | Icône de la PWA (`purpose: any`) |
|
||||
| `icone-maskable.svg`, `icone-maskable-192.png`, `icone-maskable-512.png` | Icône de la PWA (`purpose: maskable`), symbole dans la zone de sécurité |
|
||||
| `apple-touch-icon-180.png` | Ajout à l'écran d'accueil sur iPhone (via le navigateur) |
|
||||
| `favicon.svg`, `favicon-16.png`, `favicon-32.png`, `favicon-48.png` | Onglet du navigateur ; dessin simplifié, sans filets |
|
||||
| `android-premier-plan.svg`, `android-arriere-plan.svg`, `android-monochrome.svg` | Couches de l'icône adaptative Android (canevas 108 dp), pour plus tard |
|
||||
|
||||
## Couleurs du logo
|
||||
|
||||
| Rôle | Clair | Sombre |
|
||||
| --- | --- | --- |
|
||||
| Noyer (pion maître) | `#4A2E1F` | idem |
|
||||
| Ivoire (pion apprenti, fond d'icône) | `#F3EAD8` | idem |
|
||||
| Brique (flèche, filet, « mm ») | `#A8432B` | `#D0694B` (flèches du logotype), `#C9583C` (flèche du symbole) |
|
||||
| Texte du logotype | `#2B1D15` | `#F3EAD8` |
|
||||
|
||||
## Régénérer
|
||||
|
||||
Les fichiers sont versionnés ; il n'est utile de les régénérer qu'en cas de
|
||||
retouche du dessin. Il faut Python 3 avec fontTools (licence MIT) et la police
|
||||
Alegreya Sans Bold :
|
||||
|
||||
```sh
|
||||
mkdir -p build/police
|
||||
curl -sSLf -o build/police/AlegreyaSans-Bold.ttf \
|
||||
https://github.com/google/fonts/raw/main/ofl/alegreyasans/AlegreyaSans-Bold.ttf
|
||||
python3 -I docs/identite/generer-logo.py build/police/AlegreyaSans-Bold.ttf docs/identite/logo
|
||||
```
|
||||
|
||||
Les PNG sont des rendus des SVG par Chrome sans interface
|
||||
(`google-chrome --headless --screenshot`), à la taille indiquée dans leur nom.
|
||||
|
||||
## Police de l'application
|
||||
|
||||
`web/fonts/` contient Alegreya Sans en cinq graisses, réduite aux caractères
|
||||
latins (français compris) et convertie en woff2, soit 143 Ko au lieu de
|
||||
1,3 Mo en TTF. Pour la refaire, depuis les TTF de
|
||||
https://github.com/google/fonts/tree/main/ofl/alegreyasans (Python 3,
|
||||
fontTools et brotli, par exemple dans l'image `emscripten/emsdk`) :
|
||||
|
||||
```sh
|
||||
pip install fonttools brotli
|
||||
for f in Regular Italic Medium Bold ExtraBold; do
|
||||
python3 -m fontTools.subset AlegreyaSans-$f.ttf \
|
||||
--output-file=web/fonts/AlegreyaSans-$f.woff2 --flavor=woff2 \
|
||||
--unicodes="U+0020-007E,U+00A0-00FF,U+0152-0153,U+0178,U+02BC,U+02C6,U+02DC,U+2009,U+2011,U+2013-2014,U+2018-201E,U+2022,U+2026,U+202F,U+2039-203A,U+20AC,U+2192,U+2212" \
|
||||
--layout-features='*' --name-IDs='*' --name-languages='*'
|
||||
done
|
||||
```
|
||||
|
||||
## Charte graphique
|
||||
|
||||
La charte complète (couleurs des deux thèmes, typographie, composants) est dans
|
||||
`charte/`, avec les liens vers le design system et les maquettes d'écrans.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Charte graphique d'Ustagammon
|
||||
|
||||
Copie de travail du design system, pour l'appliquer à la PWA sans dépendre
|
||||
d'un accès à claude.ai. La référence vivante, avec aperçus dans les deux thèmes :
|
||||
|
||||
- Design system : https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M
|
||||
- Maquettes des écrans (accueil, plateau, leçon, fin de partie, thème sombre) :
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe
|
||||
|
||||
Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
|
||||
|
||||
## Fichiers
|
||||
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
|
||||
| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone |
|
||||
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
|
||||
| `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur |
|
||||
| `bundle.css` | Styles des composants ; mêmes noms de classes que `web/app.css` |
|
||||
| `board.js` | Dessin de référence du plateau (repère 640 × 520), pour comparaison |
|
||||
|
||||
## Appliquer la charte à `web/`
|
||||
|
||||
1. **Couleurs** : les variables du plateau de `web/app.css` (`--frame`, `--bed`,
|
||||
`--point-a`, `--checker-player`…) ont les mêmes noms dans `tokens.json` ;
|
||||
reprendre leurs valeurs, thème clair dans `:root`, thème sombre dans le bloc
|
||||
`prefers-color-scheme: dark`. Remplacer l'accent vert provisoire par la
|
||||
brique (`--accent`, `--highlight`, `--target`) et ajouter les couleurs
|
||||
nouvelles (`surface`, `accent-soft`, `verdict-*`, `focus`, `last-move` en laiton).
|
||||
2. **Police** : embarquer Alegreya Sans (Regular, Italic, Medium, Bold,
|
||||
ExtraBold) dans `web/`, avec sa licence OFL, en `@font-face`, et l'ajouter
|
||||
au cache du service worker (hors ligne).
|
||||
3. **Composants** : reprendre de `bundle.css` les boutons ronds
|
||||
(`radius-pill`), le filet intérieur des pions, l'anneau de focus et la zone
|
||||
de consigne.
|
||||
4. **Icônes et logo** : remplacer `web/icons/` par les fichiers de
|
||||
`../logo/` (icônes any et maskable, favicon), mettre le logo horizontal
|
||||
dans la barre haute, et la couleur `theme-color` du manifeste à `#4A2E1F`.
|
||||
|
||||
Les contrastes de tous les textes ont été vérifiés par calcul (4,5:1 au moins
|
||||
dans les deux thèmes) ; les marques du plateau, à 3:1 contre au moins un de
|
||||
leurs voisins.
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Dessin de référence du plateau Ustagammon, en SVG, avec les classes de bundle.css.
|
||||
* Repère : 640 × 520, comme web/app.css (.board, aspect-ratio 640 / 520).
|
||||
* Numérotation du point de vue du joueur : 1 à 12 en bas (de droite à gauche),
|
||||
* 13 à 24 en haut (de gauche à droite). Le joueur a les pions ivoire. */
|
||||
window.UstaBoard = (function () {
|
||||
var W = 640, H = 520, SIDE = 16, TB = 28, PW = 43, BAR = 40, PH = 190, R = 20;
|
||||
var TRAY_X = 580, TRAY_W = 44;
|
||||
|
||||
function colX(col) { return SIDE + col * PW + (col >= 6 ? BAR : 0); }
|
||||
function place(point) { // point 1..24 → {col, top}
|
||||
return point <= 12 ? { col: 12 - point, top: false } : { col: point - 13, top: true };
|
||||
}
|
||||
function checker(cx, cy, side, extra) {
|
||||
var c = side === "player" ? "player" : "computer";
|
||||
return '<circle class="checker checker-' + c + (extra ? " " + extra : "") + '" cx="' + cx + '" cy="' + cy + '" r="' + R + '"/>' +
|
||||
'<circle class="checker-ring ring-' + c + '" cx="' + cx + '" cy="' + cy + '" r="12"/>';
|
||||
}
|
||||
function die(x, y, n, side) {
|
||||
var s = 36, p = { 1: [[.5, .5]], 2: [[.27, .27], [.73, .73]], 3: [[.27, .27], [.5, .5], [.73, .73]],
|
||||
4: [[.27, .27], [.73, .27], [.27, .73], [.73, .73]], 5: [[.27, .27], [.73, .27], [.5, .5], [.27, .73], [.73, .73]],
|
||||
6: [[.27, .25], [.73, .25], [.27, .5], [.73, .5], [.27, .75], [.73, .75]] }[n];
|
||||
var out = '<rect class="die-' + side + '" x="' + x + '" y="' + y + '" width="' + s + '" height="' + s + '" rx="6"/>';
|
||||
p.forEach(function (q) { out += '<circle class="pip-' + side + '" cx="' + (x + q[0] * s) + '" cy="' + (y + q[1] * s) + '" r="3.6"/>'; });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* state = { checkers: {point: [side, n]}, selected: point, movable: [points],
|
||||
* targets: [points], lastMove: [points], dice: [a, b], diceSide, cube } */
|
||||
function svg(state) {
|
||||
var o = '<svg class="board" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Plateau de backgammon" xmlns="http://www.w3.org/2000/svg">';
|
||||
o += '<rect class="board-frame" x="0" y="0" width="' + W + '" height="' + H + '" rx="10"/>';
|
||||
o += '<rect class="board-bed" x="' + SIDE + '" y="' + TB + '" width="' + (TRAY_X - 8 - SIDE) + '" height="' + (H - 2 * TB) + '"/>';
|
||||
o += '<rect class="board-bar" x="' + (SIDE + 6 * PW) + '" y="' + TB + '" width="' + BAR + '" height="' + (H - 2 * TB) + '"/>';
|
||||
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + TB + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
|
||||
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + (H / 2 + 4) + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
|
||||
for (var point = 1; point <= 24; point++) {
|
||||
var pl = place(point), x = colX(pl.col), cls = point % 2 === 1 ? "point-a" : "point-b"; // impairs en noyer, pairs en chêne
|
||||
var base = pl.top ? TB : H - TB, apex = pl.top ? TB + PH : H - TB - PH;
|
||||
o += '<polygon class="' + cls + '" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
|
||||
if ((state.targets || []).indexOf(point) >= 0)
|
||||
o += '<polygon class="target-zone" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
|
||||
if ((state.lastMove || []).indexOf(point) >= 0)
|
||||
o += '<rect class="last-move" x="' + (x + 6) + '" y="' + (pl.top ? TB : H - TB - 5) + '" width="' + (PW - 12) + '" height="5" rx="2.5"/>';
|
||||
o += '<text class="point-number" x="' + (x + PW / 2) + '" y="' + (pl.top ? 19 : H - 9) + '">' + point + '</text>';
|
||||
var c = (state.checkers || {})[point];
|
||||
if (c) {
|
||||
var shown = Math.min(c[1], 5);
|
||||
for (var i = 0; i < shown; i++) {
|
||||
var cy = pl.top ? TB + R + 1 + i * (2 * R + 1) : H - TB - R - 1 - i * (2 * R + 1);
|
||||
var extra = "";
|
||||
if (i === shown - 1 && state.selected === point) extra = "selected";
|
||||
else if (i === shown - 1 && (state.movable || []).indexOf(point) >= 0) extra = "movable";
|
||||
o += checker(x + PW / 2, cy, c[0], extra);
|
||||
if (i === shown - 1 && c[1] > 5)
|
||||
o += '<text class="checker-count count-' + c[0] + '" x="' + (x + PW / 2) + '" y="' + (cy + 6) + '">' + c[1] + '</text>';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.dice) {
|
||||
var dx = colX(8) + 6, dy = H / 2 - 18, side = state.diceSide || "player";
|
||||
o += die(dx, dy, state.dice[0], side) + die(dx + 46, dy, state.dice[1], side);
|
||||
}
|
||||
if (state.cube) {
|
||||
var bx = SIDE + 6 * PW + 2;
|
||||
o += '<rect class="cube-face" x="' + bx + '" y="' + (H / 2 - 18) + '" width="36" height="36" rx="6"/>';
|
||||
o += '<text class="cube-value" x="' + (bx + 18) + '" y="' + (H / 2 + 6) + '">' + state.cube + '</text>';
|
||||
}
|
||||
return o + '</svg>';
|
||||
}
|
||||
|
||||
var OPENING = { 24: ["player", 2], 13: ["player", 5], 8: ["player", 3], 6: ["player", 5],
|
||||
1: ["computer", 2], 12: ["computer", 5], 17: ["computer", 3], 19: ["computer", 5] };
|
||||
return { svg: svg, OPENING: OPENING };
|
||||
})();
|
||||
@@ -0,0 +1,165 @@
|
||||
/* Ustagammon — styles des composants.
|
||||
* Toutes les valeurs viennent de tokens.json (variables CSS du même nom).
|
||||
* Les noms de classes reprennent ceux de web/app.css : ce fichier peut être
|
||||
* recopié dans l'application presque tel quel. */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-underline-offset: 3px; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Barre haute */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
.topbar .brand { flex: 1; display: flex; align-items: center; min-width: 0; }
|
||||
.topbar .brand img { height: 32px; width: auto; display: block; }
|
||||
.topbar h1 { flex: 1; margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
|
||||
|
||||
/* ---------------------------------------------------------------- Boutons */
|
||||
|
||||
button, .button {
|
||||
min-height: var(--button-height);
|
||||
padding: 0 20px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary, .button.secondary {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
button.quiet, .button.quiet {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.topbar button { min-height: 44px; padding: 0 14px; font-size: 15px; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.actions button { flex: 1; }
|
||||
|
||||
/* ------------------------------------------------------- Zone de consigne */
|
||||
|
||||
.pips { margin: var(--space-1) 0 0; font-size: 15px; line-height: 20px; color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.info, .prompt { min-height: 25px; margin: 6px 0; }
|
||||
.info { color: var(--muted); }
|
||||
.prompt { font-weight: 700; }
|
||||
.prompt.thinking::after { content: ""; animation: thinking 1.2s steps(4, end) infinite; }
|
||||
@keyframes thinking { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }
|
||||
@media (prefers-reduced-motion: reduce) { .prompt.thinking::after { animation: none; content: "…"; } }
|
||||
|
||||
/* ---------------------------------------------------------------- Plateau */
|
||||
|
||||
.board { display: block; width: 100%; height: auto; 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(--point-number); font-size: 13px; font-weight: 500; text-anchor: middle; font-family: var(--font-sans); }
|
||||
.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-ring { fill: none; stroke-width: 1.5; }
|
||||
.checker-player + .checker-ring, .ring-player { stroke: var(--checker-player-edge); }
|
||||
.ring-computer { stroke: var(--checker-computer-edge); }
|
||||
.checker.movable { stroke: var(--highlight); stroke-width: 3; }
|
||||
.checker.selected { stroke: var(--highlight); stroke-width: 6; }
|
||||
.checker-count { font-size: 18px; font-weight: 700; text-anchor: middle; font-family: var(--font-sans); }
|
||||
.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); stroke-width: 1.5; }
|
||||
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
|
||||
.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: 800; text-anchor: middle; font-family: var(--font-sans); }
|
||||
|
||||
/* ------------------------------------------------------------------ Cartes */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.overline { font-size: 13px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
||||
|
||||
/* --------------------------------------------------- Appréciation d'un coup */
|
||||
|
||||
.verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.verdict svg { width: 18px; height: 18px; flex: none; }
|
||||
.verdict-good { color: var(--verdict-good); }
|
||||
.verdict-doubtful { color: var(--verdict-doubtful); }
|
||||
.verdict-bad { color: var(--verdict-bad); }
|
||||
|
||||
/* -------------------------------------------------------- Fiche de glossaire */
|
||||
|
||||
.glossary-card h3 { margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
|
||||
.glossary-card .en { color: var(--muted); font-size: 15px; line-height: 20px; font-style: italic; }
|
||||
.glossary-card p { margin: 0; }
|
||||
.glossary-card .see { font-size: 15px; line-height: 20px; color: var(--muted); }
|
||||
|
||||
/* ------------------------------------------------ Demander à une IA (prompt) */
|
||||
|
||||
.ask-ai { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.ask-ai pre {
|
||||
margin: 0;
|
||||
max-height: 7.5em;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
font: 15px/20px var(--font-sans);
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.ask-ai .done { color: var(--verdict-good); font-weight: 700; font-size: 15px; }
|
||||
|
||||
/* ---------------------------------------------------------- Carte de leçon */
|
||||
|
||||
.lesson-card { flex-direction: row; align-items: center; gap: var(--space-3); }
|
||||
.lesson-card .body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.lesson-card h3 { margin: 0; font-size: 17px; line-height: 25px; font-weight: 700; }
|
||||
.lesson-card .state { font-size: 15px; line-height: 20px; color: var(--muted); }
|
||||
.progress { height: 6px; border-radius: var(--radius-pill); background: var(--surface-sunken); overflow: hidden; }
|
||||
.progress > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||
@@ -0,0 +1,65 @@
|
||||
Ustagammon apprend le backgammon comme on l'apprend à une table de café : quelqu'un qui sait montre à quelqu'un qui découvre. L'interface est **chaleureuse, artisanale, claire et intemporelle**, discrètement turque par la matière (le bois, l'ivoire, la brique), jamais par le folklore. Elle doit donner envie à un débutant sans paraître naïve à un joueur confirmé : un maître bienveillant, pas un logiciel d'analyse, pas un casino.
|
||||
|
||||
Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont exactement ceux des variables de `web/app.css` ; `components/bundle.css` reprend ses noms de classes et peut y être recopié.
|
||||
|
||||
## Contenus et ton
|
||||
|
||||
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
|
||||
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
|
||||
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
|
||||
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
|
||||
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
|
||||
- **Ni émoji, ni point d'exclamation en série, ni majuscules de réclame.** Les capitales sont réservées au style `overline`.
|
||||
- Le nom s'écrit **Ustagammon**, en un mot, majuscule initiale.
|
||||
|
||||
## Couleurs
|
||||
|
||||
La palette, c'est le bois, les pions et le café, avec **un seul accent : le rouge brique** (`accent`).
|
||||
|
||||
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
|
||||
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
|
||||
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
|
||||
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
|
||||
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
|
||||
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
|
||||
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
|
||||
|
||||
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
|
||||
|
||||
## Typographie
|
||||
|
||||
Une seule famille, **Alegreya Sans** (licence SIL OFL, fichiers dans `fonts/`), sans empattement humaniste, chaleureuse et lisible en petit. À embarquer dans l'app : elle doit fonctionner hors ligne.
|
||||
|
||||
- `display` (32px, 800) : un seul par écran, accueil et fin de partie.
|
||||
- `title` (24px) : titre d'écran ; `heading` (20px) : titre de section ou de carte.
|
||||
- `body` (17px) : tout le texte à lire, jamais plus petit. `body-strong` pour la consigne du moment et les boutons. `body-italic` pour la parole du maître ou un exemple.
|
||||
- `caption` (15px) : compte de points, légendes. `overline` (13px, capitales espacées) : étiquette au-dessus d'un titre, avec parcimonie.
|
||||
- Chiffres alignés (`font-variant-numeric: tabular-nums`) dès que des nombres se comparent (compte de points, scores).
|
||||
|
||||
## Formes, espace et profondeur
|
||||
|
||||
- **Boutons ronds comme des pions** : `radius-pill`, hauteur `button-height` (48px). Aucune cible tactile sous 44px.
|
||||
- Cartes et fiches : `radius-md`, contour `border`, ombre `shadow-raised` et rien de plus profond. Dés et videau : `radius-sm`.
|
||||
- Espacements : `space-1` à `space-8`. Gouttière de page `space-4` sur téléphone ; écart entre blocs `space-6`. Contenu limité à `page-width` sur grand écran.
|
||||
- Le plateau occupe toute la largeur sur téléphone ; c'est lui le héros de l'écran de jeu, pas les panneaux autour.
|
||||
- Focus clavier : anneau `focus` plein de 3px, décalé de 2px, sur tous les éléments interactifs.
|
||||
|
||||
## Mouvement
|
||||
|
||||
Sobre et utile : un pion glisse vers sa flèche (150 à 250 ms, sans rebond), les dés roulent brièvement. Les trois points de « l'ordinateur réfléchit » sont la seule animation en boucle. Tout s'arrête avec `prefers-reduced-motion`.
|
||||
|
||||
## Iconographie
|
||||
|
||||
- Le **symbole** (deux pions sur une flèche, le 8 caché) et le **logotype** (les « mm » en flèches) sont dans le groupe Logos ; versions couleur, sombre, noire et blanche. Ne jamais les redessiner, déformer ou recolorer.
|
||||
- Barre haute : `logo-horizontal-couleur.svg` en thème clair, `logo-horizontal-sombre.svg` en thème sombre, 32px de haut.
|
||||
- Icônes d'interface : traits simples de 2px à bouts ronds, dessinés en SVG dans le code (pas de police d'icônes, pas d'émoji). Les seules icônes du système pour l'instant : les trois appréciations de coups (coche, point d'interrogation, croix) et la copie (deux feuillets).
|
||||
- Dés, pions et videau sont dessinés en SVG avec les couleurs du plateau, jamais en images.
|
||||
|
||||
## Interaction
|
||||
|
||||
Règles de comportement (réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l’écran de jeu) : section « Interaction ».
|
||||
|
||||
## Composants
|
||||
|
||||
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d’un coup (`MoveVerdict`), pictogrammes (`Icons`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Interaction
|
||||
|
||||
Ustagammon doit se sentir **net et rapide**, comme un outil bien réglé : chaque toucher répond tout de suite, rien ne saute, rien n'interrompt la partie sans raison. La chaleur vient de la matière et du ton ; la précision vient du comportement.
|
||||
|
||||
## Principes
|
||||
|
||||
1. **Réponse immédiate.** Un toucher produit un effet visible en moins de 100 ms : le pion part, le bouton s'enfonce. Le moteur calcule ensuite, en arrière-plan ; l'interface ne l'attend jamais pour réagir.
|
||||
2. **Rien ne bouge sous le doigt.** Les zones de l'écran de jeu ont une hauteur fixe : seuls leurs contenus changent. Une ligne vide garde sa place.
|
||||
3. **Annuler plutôt que confirmer.** Une action se fait tout de suite et se rattrape pendant 10 s par un bandeau « Reprendre » (nouvelle partie, abandon). Une boîte de confirmation n'est permise que pour une décision de jeu irréversible qui engage la partie : prendre ou passer un double, doubler.
|
||||
4. **Une seule action principale par moment**, en brique, à droite, sous le pouce.
|
||||
5. **Le joueur garde la main.** L'app ne joue jamais à sa place : quand aucun coup n'est possible, les dés se barrent et le joueur passe en les touchant (demande explicite de Pierre).
|
||||
6. **Le maître parle après le coup, discrètement** (ADR 0011) : une ligne réservée sous le plateau, qui s'ouvre sur l'explication si on la touche. Jamais de fenêtre qui s'impose.
|
||||
7. **Le sens passe toujours par deux canaux** : couleur et mot, vibration et marque visible, raccourci et bouton.
|
||||
|
||||
## Mouvement
|
||||
|
||||
| Jeton | Valeur | Usage |
|
||||
| --- | --- | --- |
|
||||
| `duration-instant` | 90ms | Retour d'appui d'un bouton, apparition du contour d'un pion jouable. |
|
||||
| `duration-fast` | 160ms | Pion qui glisse d'une flèche à l'autre, dés qui se posent, bandeaux. |
|
||||
| `duration-moderate` | 240ms | Feuille du menu qui monte, panneau de fin de partie, bascule de mise en page. |
|
||||
| `easing-standard` | cubic-bezier(0.2, 0, 0, 1) | Tout ce qui se déplace : départ franc, arrivée douce. |
|
||||
| `easing-exit` | cubic-bezier(0.4, 0, 1, 1) | Ce qui disparaît. |
|
||||
|
||||
- Pas de rebond, pas d'élastique, pas de fondu de plus de 240 ms.
|
||||
- Les coups de l'ordinateur se jouent demi-coup par demi-coup, 160 ms par pion, 120 ms de pause entre deux : on suit le coup sans l'attendre.
|
||||
- Le bouton enfoncé descend de 1 px et s'assombrit de 6 %, sans ombre.
|
||||
- `prefers-reduced-motion` : les déplacements deviennent des apparitions instantanées ; les dés ne roulent pas.
|
||||
|
||||
## Vibrations
|
||||
|
||||
Activées par défaut, désactivables dans le menu (« Vibrations »). Elles doublent un événement visible, jamais seules. Rien ne vibre pour un toucher de bouton ni pour le lancer des dés.
|
||||
|
||||
| Moment | Motif (ms) |
|
||||
| --- | --- |
|
||||
| Votre pion se pose | `12` |
|
||||
| Un pion est frappé | `25, 40, 25` |
|
||||
| L'ordinateur propose de doubler | `40, 60, 40` |
|
||||
| Fin de partie gagnée | `30, 50, 30, 50, 60` |
|
||||
| Fin de partie perdue | `60` |
|
||||
|
||||
Les durées se règlent en main sur le Fairphone 4. Une seule vibration à la fois : la dernière annule la précédente.
|
||||
|
||||
## Clavier (ordinateur)
|
||||
|
||||
Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) quand un clavier est détecté. `?` ouvre la liste.
|
||||
|
||||
| Touche | Action |
|
||||
| --- | --- |
|
||||
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
|
||||
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
|
||||
| `↑` `↓` | Choisir sa case d'arrivée |
|
||||
| `Entrée` | Poser le pion |
|
||||
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
|
||||
| `D` | Doubler |
|
||||
| `P` / `N` | Prendre / passer, quand l'ordinateur double |
|
||||
| `F` | Plein écran |
|
||||
| `M` | Menu |
|
||||
| `?` | Aide et raccourcis |
|
||||
| `Échap` | Fermer le menu, l'aide ou la bulle ouverte ; ne touche jamais à la partie |
|
||||
|
||||
Le plateau entier se joue au clavier : c'est aussi ce qui le rend accessible aux lecteurs d'écran.
|
||||
|
||||
## Pictogrammes
|
||||
|
||||
Trait de 2 px à bouts ronds, sur une grille de 20 × 20, `currentColor`, dessinés en SVG dans le code. Le jeu complet : menu, aide, plein écran, quitter le plein écran, annuler (flèche de retour), revoir (flèche circulaire), fermer, chevron, copier, et les trois appréciations (coche, point d'interrogation, croix dans un cercle). En portrait, la barre haute affiche un libellé de 13 px sous chaque pictogramme ; en paysage et sur ordinateur, le pictogramme seul avec son `aria-label`.
|
||||
|
||||
## Mises en page de l'écran de jeu
|
||||
|
||||
- **Portrait** : barre haute, bandeau de l'ordinateur, plateau, bandeau du joueur (teinté `accent-soft` quand c'est à lui), ligne du maître ou du dernier coup, consigne, et barre d'actions collée en bas. Hauteurs fixes.
|
||||
- **Paysage** (dès que le téléphone est tourné ; ADR 0012) : le plateau est l'écran. Colonne de commandes à gauche dans le cadre (menu, videau, annuler ou revoir), sorties à droite, comptes de points en pastilles aux deux bouts de la barre, consigne et dés dans la bande centrale. Numéros des flèches à 12 px au moins.
|
||||
- **Ordinateur** : plateau à gauche, colonne à droite avec les bandeaux des joueurs, la liste des coups, la consigne, les actions et leurs raccourcis.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Recette design de l'écran de jeu
|
||||
|
||||
Critères vérifiables sur les captures de `make test-browser` (412×915,
|
||||
915×412, 844×320, thèmes clair et sombre) et à la main sur le Fairphone 4.
|
||||
Chaque ligne se coche ou se refuse ; une ligne refusée dit pourquoi.
|
||||
Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
|
||||
## Toutes tailles
|
||||
|
||||
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
|
||||
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
|
||||
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
|
||||
- [ ] `overscroll-behavior: none` : tirer vers le bas ne recharge pas la page.
|
||||
|
||||
## Portrait (412 × 915)
|
||||
|
||||
- [ ] Ordre de haut en bas : barre haute, bandeau ordinateur, plateau, bandeau joueur, ligne du maître ou du dernier coup, consigne, barre d'actions collée en bas.
|
||||
- [ ] Stabilité : sur une partie complète, la position verticale du plateau et de la barre d'actions ne varie pas d'un pixel (comparer les boîtes entre deux captures successives).
|
||||
- [ ] Barre haute : pictogrammes avec libellé de 13 px (Aide, Plein écran, Menu) ; pas de bouton « Nouvelle partie ».
|
||||
- [ ] Comptes de points en 20 px gras, chiffres alignés ; le bandeau du joueur qui a la main est teinté `accent-soft` ; l'écart s'écrit « mène de N ».
|
||||
- [ ] Plus de durée de calcul « (0,5 s) » dans les messages.
|
||||
- [ ] Aucun coup possible : dés barrés et message ; le tour ne passe qu'au toucher des dés.
|
||||
- [ ] Conseil du maître : une ligne, après le coup du joueur seulement ; toucher l'ouvre.
|
||||
|
||||
## Paysage (915 × 412 et 844 × 320)
|
||||
|
||||
- [ ] Le cadre noyer touche les quatre bords ; aucune marge de page, aucune colonne de texte.
|
||||
- [ ] Flèches d'au moins 44 px de large ; pions d'au moins 22 px de diamètre à 844 × 320.
|
||||
- [ ] Colonne de commandes à gauche : menu en haut, videau au milieu, annuler (ou revoir) en bas ; colonne des sorties libre.
|
||||
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
|
||||
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
|
||||
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
|
||||
|
||||
## Comportement
|
||||
|
||||
- [ ] Un toucher sur un pion produit un effet visible en moins de 100 ms (trace de performance de Playwright).
|
||||
- [ ] Déplacements à 160 ms en `easing-standard`, sans rebond ; coups de l'ordinateur pion par pion ; tout est instantané avec `prefers-reduced-motion`.
|
||||
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
|
||||
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
|
||||
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"name": "Ustagammon",
|
||||
"version": 1,
|
||||
"color": {
|
||||
"themes": [
|
||||
{
|
||||
"id": "light",
|
||||
"name": "Clair"
|
||||
},
|
||||
{
|
||||
"id": "dark",
|
||||
"name": "Sombre"
|
||||
}
|
||||
],
|
||||
"tokens": [
|
||||
{
|
||||
"name": "background",
|
||||
"value": {
|
||||
"light": "#F5EFE5",
|
||||
"dark": "#17110D"
|
||||
},
|
||||
"usage": "Fond de page (papier ivoire / café torréfié)."
|
||||
},
|
||||
{
|
||||
"name": "surface",
|
||||
"value": {
|
||||
"light": "#FFFCF6",
|
||||
"dark": "#221913"
|
||||
},
|
||||
"usage": "Fond des cartes, fiches de glossaire, panneaux posés sur `background`."
|
||||
},
|
||||
{
|
||||
"name": "surface-sunken",
|
||||
"value": {
|
||||
"light": "#ECE3D4",
|
||||
"dark": "#2C211A"
|
||||
},
|
||||
"usage": "Zones en retrait : bloc de prompt à copier, piste de progression."
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#EFE5D5"
|
||||
},
|
||||
"usage": "Texte courant et titres, sur `background`, `surface` et `surface-sunken`."
|
||||
},
|
||||
{
|
||||
"name": "muted",
|
||||
"value": {
|
||||
"light": "#6B5C50",
|
||||
"dark": "#A99886"
|
||||
},
|
||||
"usage": "Texte secondaire (compte de points, légendes, aide), sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "border",
|
||||
"value": {
|
||||
"light": "#DED2C0",
|
||||
"dark": "#3A2D24"
|
||||
},
|
||||
"usage": "Séparateurs et contours décoratifs de cartes (ne porte aucun sens)."
|
||||
},
|
||||
{
|
||||
"name": "border-strong",
|
||||
"value": {
|
||||
"light": "#8E7D6A",
|
||||
"dark": "#7D6B5B"
|
||||
},
|
||||
"usage": "Contour des boutons secondaires et des champs : 3:1 sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "accent",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#D0694B"
|
||||
},
|
||||
"usage": "Rouge brique, l'accent unique : bouton principal, liens, case où le pion peut aller, focus. Texte en brique sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "on-accent",
|
||||
"value": {
|
||||
"light": "#FFFFFF",
|
||||
"dark": "#1A110C"
|
||||
},
|
||||
"usage": "Texte et icônes posés sur un aplat `accent`."
|
||||
},
|
||||
{
|
||||
"name": "accent-soft",
|
||||
"value": {
|
||||
"light": "#F1D9CF",
|
||||
"dark": "#4A2519"
|
||||
},
|
||||
"usage": "Fond léger d'accent : puce « à toi de jouer », sélection dans une liste. Texte `text` dessus."
|
||||
},
|
||||
{
|
||||
"name": "focus",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#E58A6E"
|
||||
},
|
||||
"usage": "Anneau de focus clavier, 3 px plein, décalé de 2 px : 3:1 sur tous les fonds."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"value": {
|
||||
"light": "#8C1C3A",
|
||||
"dark": "#F08FA6"
|
||||
},
|
||||
"usage": "Message d'erreur technique (texte), toujours accompagné d'un mot. Lie-de-vin, distinct de la brique."
|
||||
},
|
||||
{
|
||||
"name": "verdict-good",
|
||||
"value": {
|
||||
"light": "#2F6B4F",
|
||||
"dark": "#7CCBA4"
|
||||
},
|
||||
"usage": "« Bon coup » : texte et icône, toujours avec le mot. Sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "verdict-doubtful",
|
||||
"value": {
|
||||
"light": "#7E5A0F",
|
||||
"dark": "#E0B04F"
|
||||
},
|
||||
"usage": "« Imprécision » (doubtful) : texte et icône, toujours avec le mot."
|
||||
},
|
||||
{
|
||||
"name": "verdict-bad",
|
||||
"value": {
|
||||
"light": "#8C1C3A",
|
||||
"dark": "#F08FA6"
|
||||
},
|
||||
"usage": "« Erreur » et « grosse erreur » (bad, very bad) : texte et icône, toujours avec le mot."
|
||||
},
|
||||
{
|
||||
"name": "brand-noyer",
|
||||
"value": {
|
||||
"light": "#4A2E1F",
|
||||
"dark": "#4A2E1F"
|
||||
},
|
||||
"usage": "Noyer du logo : pion maître. Ne sert pas au texte de l'interface."
|
||||
},
|
||||
{
|
||||
"name": "brand-ivoire",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
},
|
||||
"usage": "Ivoire du logo : pion apprenti, fond de l'icône d'application."
|
||||
},
|
||||
{
|
||||
"name": "brand-laiton",
|
||||
"value": {
|
||||
"light": "#8F6A1C",
|
||||
"dark": "#D6A64A"
|
||||
},
|
||||
"usage": "Laiton, comme les charnières d'un plateau de tavla : marque du dernier coup joué, rien d'autre."
|
||||
},
|
||||
{
|
||||
"name": "frame",
|
||||
"value": {
|
||||
"light": "#4A2E1F",
|
||||
"dark": "#2A1A10"
|
||||
},
|
||||
"usage": "Cadre du plateau (noyer)."
|
||||
},
|
||||
{
|
||||
"name": "bed",
|
||||
"value": {
|
||||
"light": "#E9DCC4",
|
||||
"dark": "#4A3626"
|
||||
},
|
||||
"usage": "Fond du plateau, entre les flèches."
|
||||
},
|
||||
{
|
||||
"name": "bar",
|
||||
"value": {
|
||||
"light": "#3F2619",
|
||||
"dark": "#24170E"
|
||||
},
|
||||
"usage": "Barre centrale."
|
||||
},
|
||||
{
|
||||
"name": "tray",
|
||||
"value": {
|
||||
"light": "#3A2418",
|
||||
"dark": "#22150D"
|
||||
},
|
||||
"usage": "Plateaux de sortie, à droite."
|
||||
},
|
||||
{
|
||||
"name": "point-a",
|
||||
"value": {
|
||||
"light": "#6B4430",
|
||||
"dark": "#3A2519"
|
||||
},
|
||||
"usage": "Flèches impaires (1, 3, 5…), noyer clair."
|
||||
},
|
||||
{
|
||||
"name": "point-b",
|
||||
"value": {
|
||||
"light": "#C9AE88",
|
||||
"dark": "#7E6142"
|
||||
},
|
||||
"usage": "Flèches paires (2, 4, 6…), chêne."
|
||||
},
|
||||
{
|
||||
"name": "point-number",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#CDBFA8"
|
||||
},
|
||||
"usage": "Numéros des flèches, posés sur `frame`."
|
||||
},
|
||||
{
|
||||
"name": "checker-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Pions du joueur : ivoire, l'apprenti."
|
||||
},
|
||||
{
|
||||
"name": "checker-player-edge",
|
||||
"value": {
|
||||
"light": "#8C7A62",
|
||||
"dark": "#8E7C64"
|
||||
},
|
||||
"usage": "Contour et filet des pions du joueur."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
},
|
||||
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer-edge",
|
||||
"value": {
|
||||
"light": "#120A05",
|
||||
"dark": "#A8957D"
|
||||
},
|
||||
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
|
||||
},
|
||||
{
|
||||
"name": "count-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions du joueur."
|
||||
},
|
||||
{
|
||||
"name": "count-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "die-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "pip-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Points des dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "die-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
},
|
||||
"usage": "Dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "pip-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Points des dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "cube",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Face du videau."
|
||||
},
|
||||
{
|
||||
"name": "cube-text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Valeur du videau (2, 4, 8…)."
|
||||
},
|
||||
{
|
||||
"name": "highlight",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#E07A5A"
|
||||
},
|
||||
"usage": "Contour d'un pion jouable ou sélectionné du joueur : la brique. Il contraste à 3:1 au moins avec le pion ivoire (clair) ou avec le fond du plateau (sombre)."
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"value": {
|
||||
"light": "rgba(168,67,43,0.32)",
|
||||
"dark": "rgba(224,122,90,0.38)"
|
||||
},
|
||||
"usage": "Voile sur la flèche où le pion sélectionné peut aller."
|
||||
},
|
||||
{
|
||||
"name": "last-move",
|
||||
"value": {
|
||||
"light": "{brand-laiton}",
|
||||
"dark": "{brand-laiton}"
|
||||
},
|
||||
"usage": "Marque du dernier coup joué (laiton)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"fonts": [
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Regular.ttf",
|
||||
"weight": "400"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Italic.ttf",
|
||||
"weight": "400",
|
||||
"style": "italic"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Medium.ttf",
|
||||
"weight": "500"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Bold.ttf",
|
||||
"weight": "700"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-ExtraBold.ttf",
|
||||
"weight": "800"
|
||||
}
|
||||
],
|
||||
"families": {
|
||||
"sans": "\"Alegreya Sans\", \"Gill Sans\", \"Segoe UI\", system-ui, sans-serif"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"name": "Titres",
|
||||
"family": "sans",
|
||||
"styles": [
|
||||
{
|
||||
"name": "display",
|
||||
"fontSize": "32px",
|
||||
"lineHeight": "36px",
|
||||
"fontWeight": 800,
|
||||
"letterSpacing": "-0.01em",
|
||||
"sample": "Une partie contre le maître",
|
||||
"usage": "Titre d'accueil et de fin de partie, une fois par écran."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"fontSize": "24px",
|
||||
"lineHeight": "30px",
|
||||
"fontWeight": 700,
|
||||
"sample": "Le point 8",
|
||||
"usage": "Titre d'écran (leçon, aide)."
|
||||
},
|
||||
{
|
||||
"name": "heading",
|
||||
"fontSize": "20px",
|
||||
"lineHeight": "26px",
|
||||
"fontWeight": 700,
|
||||
"sample": "Les ouvertures",
|
||||
"usage": "Sous-titre de section, titre de carte."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Texte",
|
||||
"family": "sans",
|
||||
"styles": [
|
||||
{
|
||||
"name": "body",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 400,
|
||||
"sample": "Ce pion peut rejoindre votre point 8.",
|
||||
"usage": "Texte courant ; jamais en dessous de 17px pour le contenu à lire."
|
||||
},
|
||||
{
|
||||
"name": "body-strong",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 700,
|
||||
"sample": "À vous de jouer : 6 et 1.",
|
||||
"usage": "Consigne du moment (zone prompt), boutons."
|
||||
},
|
||||
{
|
||||
"name": "body-italic",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 400,
|
||||
"fontStyle": "italic",
|
||||
"sample": "Philippe aurait joué 13/7 8/7.",
|
||||
"usage": "Parole du maître, citation, exemple."
|
||||
},
|
||||
{
|
||||
"name": "caption",
|
||||
"fontSize": "15px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 400,
|
||||
"sample": "Vous : 167 · Ordinateur : 152",
|
||||
"usage": "Compte de points, légendes, mentions."
|
||||
},
|
||||
{
|
||||
"name": "overline",
|
||||
"fontSize": "13px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 700,
|
||||
"letterSpacing": "0.08em",
|
||||
"sample": "LEÇON 3 SUR 12",
|
||||
"usage": "Étiquette en capitales au-dessus d'un titre ; avec parcimonie."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"spacing": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "space-1",
|
||||
"value": "4px",
|
||||
"usage": "Écart entre une icône et son texte."
|
||||
},
|
||||
{
|
||||
"name": "space-2",
|
||||
"value": "8px",
|
||||
"usage": "Écart entre boutons voisins, marges internes serrées."
|
||||
},
|
||||
{
|
||||
"name": "space-3",
|
||||
"value": "12px",
|
||||
"usage": "Marge interne des puces et du bloc prompt."
|
||||
},
|
||||
{
|
||||
"name": "space-4",
|
||||
"value": "16px",
|
||||
"usage": "Gouttière latérale de page sur téléphone, marge interne des cartes."
|
||||
},
|
||||
{
|
||||
"name": "space-6",
|
||||
"value": "24px",
|
||||
"usage": "Écart entre blocs d'un écran."
|
||||
},
|
||||
{
|
||||
"name": "space-8",
|
||||
"value": "32px",
|
||||
"usage": "Écart entre sections."
|
||||
},
|
||||
{
|
||||
"name": "button-height",
|
||||
"value": "48px",
|
||||
"usage": "Hauteur des boutons ; aucune cible tactile sous 44px."
|
||||
},
|
||||
{
|
||||
"name": "page-width",
|
||||
"value": "40rem",
|
||||
"usage": "Largeur maximale du contenu sur grand écran."
|
||||
}
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "radius-sm",
|
||||
"value": "6px",
|
||||
"usage": "Dés, videau, puces de verdict."
|
||||
},
|
||||
{
|
||||
"name": "radius-md",
|
||||
"value": "12px",
|
||||
"usage": "Cartes, fiches, bloc prompt."
|
||||
},
|
||||
{
|
||||
"name": "radius-pill",
|
||||
"value": "999px",
|
||||
"usage": "Boutons : ronds comme des pions."
|
||||
}
|
||||
]
|
||||
},
|
||||
"shadow": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "shadow-raised",
|
||||
"value": {
|
||||
"light": "0 1px 2px rgba(43,29,21,0.10)",
|
||||
"dark": "0 1px 2px rgba(0,0,0,0.40)"
|
||||
},
|
||||
"usage": "Seule ombre permise : cartes posées sur le fond. Rien de plus profond."
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"source": "Brief créatif + ADR 0010 + web/app.css (noms des variables du plateau)"
|
||||
},
|
||||
"duration": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "duration-instant",
|
||||
"value": "90ms",
|
||||
"usage": "Retour d'appui d'un bouton, contour d'un pion jouable."
|
||||
},
|
||||
{
|
||||
"name": "duration-fast",
|
||||
"value": "160ms",
|
||||
"usage": "Pion qui glisse, dés qui se posent, bandeaux."
|
||||
},
|
||||
{
|
||||
"name": "duration-moderate",
|
||||
"value": "240ms",
|
||||
"usage": "Feuille du menu, panneau de fin de partie, bascule de mise en page. Jamais plus long."
|
||||
}
|
||||
]
|
||||
},
|
||||
"easing": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "easing-standard",
|
||||
"value": "cubic-bezier(0.2, 0, 0, 1)",
|
||||
"usage": "Tout ce qui se déplace : départ franc, arrivée douce."
|
||||
},
|
||||
{
|
||||
"name": "easing-exit",
|
||||
"value": "cubic-bezier(0.4, 0, 1, 1)",
|
||||
"usage": "Ce qui disparaît."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Génère les fichiers SVG de l'identité Ustagammon : symbole P3d, logotype L3.
|
||||
|
||||
Le symbole : deux pions vus de trois quarts (le maître en noyer devant,
|
||||
l'apprenti en ivoire derrière) posés sur une flèche brique ; leurs deux
|
||||
silhouettes dessinent un 8, clin d'œil à l'Atelier du Huit.
|
||||
Le logotype : « Ustagammon » en Alegreya Sans Bold (SIL OFL), dont les deux
|
||||
« m » sont redessinés en quatre flèches au trait, alternant noyer et brique.
|
||||
|
||||
Usage : python3 -I generer-logo.py <AlegreyaSans-Bold.ttf> <dossier_sortie>
|
||||
Licence des dessins produits : CC BY-SA 4.0.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fontTools.pens.svgPathPen import SVGPathPen
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
FONT, OUT = sys.argv[1], sys.argv[2]
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# ---------- Palettes ----------
|
||||
# d/ds/de : face, tranche et filet du pion noyer ; l/ls : face et tranche du pion ivoire ;
|
||||
# o : contour du pion ivoire ; a : flèche et filet brique ; sep : liseré du pion noyer ;
|
||||
# txt / acc : texte du logotype et flèches brique des « mm ».
|
||||
COULEUR = dict(d="#4A2E1F", ds="#2B1A10", de="#7A5038", l="#F3EAD8", ls="#CDB898",
|
||||
o="#2B1A10", a="#A8432B", sep="#F3EAD8", txt="#2B1D15", acc="#A8432B")
|
||||
SOMBRE = dict(COULEUR, a="#C9583C", txt="#F3EAD8", acc="#D0694B")
|
||||
# Monochromes : I = encre, H = trou (transparent)
|
||||
MONO = dict(d="I", ds="I", de="H", l="H", ls="I", o="I", a="I", sep="H", txt="I", acc="I")
|
||||
|
||||
|
||||
def symbol(p, simple=False):
|
||||
"""Symbole P3d dans une boîte de 64 × 64. `simple` : sans filets, pour le favicon."""
|
||||
s = [f'<polygon points="6,62 58,62 32,1" fill="{p["a"]}"/>']
|
||||
ow = 3 if simple else 2.2
|
||||
s.append(f'<path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="{p["ls"]}" stroke="{p["o"]}" stroke-width="{ow}" stroke-linejoin="round"/>')
|
||||
s.append(f'<ellipse cx="32" cy="21" rx="10.5" ry="7" fill="{p["l"]}" stroke="{p["o"]}" stroke-width="{ow}"/>')
|
||||
if not simple:
|
||||
s.append(f'<ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="{p["a"]}" stroke-width="2"/>')
|
||||
sw = 2.6 if simple else 2
|
||||
s.append(f'<path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="{p["ds"]}" stroke="{p["sep"]}" stroke-width="{sw}" stroke-linejoin="round"/>')
|
||||
s.append(f'<ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="{p["d"]}" stroke="{p["sep"]}" stroke-width="{sw}"/>')
|
||||
if not simple:
|
||||
s.append(f'<ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="{p["de"]}" stroke-width="1.6"/>')
|
||||
return "".join(s)
|
||||
|
||||
|
||||
# ---------- Logotype ----------
|
||||
font = TTFont(FONT)
|
||||
gs = font.getGlyphSet()
|
||||
cmap = font.getBestCmap()
|
||||
hmtx = font["hmtx"]
|
||||
XH = font["OS/2"].sxHeight # hauteur d'x (465 pour Alegreya Sans Bold)
|
||||
STROKE = 0.21 * XH # épaisseur du trait des flèches, proche du fût des lettres
|
||||
MM_W = 3.0 * XH # largeur du bloc « mm »
|
||||
GAP = 20
|
||||
|
||||
|
||||
def glyph(ch, x, fill):
|
||||
g = cmap[ord(ch)]
|
||||
pen = SVGPathPen(gs)
|
||||
gs[g].draw(pen)
|
||||
return f'<path transform="translate({x:.1f},0) scale(1,-1)" d="{pen.getCommands()}" fill="{fill}"/>', hmtx[g][0]
|
||||
|
||||
|
||||
def inter(p1, d1, p2, d2):
|
||||
"""Intersection de deux droites données par un point et une direction."""
|
||||
(x1, y1), (dx1, dy1), (x2, y2), (dx2, dy2) = p1, d1, p2, d2
|
||||
det = dx1 * -dy2 - dy1 * -dx2
|
||||
t = ((x2 - x1) * -dy2 - (y2 - y1) * -dx2) / det
|
||||
return (x1 + t * dx1, y1 + t * dy1)
|
||||
|
||||
|
||||
def chevron(x0, x1, top):
|
||||
"""Flèche « ^ » au trait : pieds coupés sur la ligne de base (y = 0), sommet en onglet."""
|
||||
peak = ((x0 + x1) / 2, -top)
|
||||
h = STROKE / 2
|
||||
lines = []
|
||||
for a, b in (((x0, 0), peak), (peak, (x1, 0))):
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
n = (dx * dx + dy * dy) ** 0.5
|
||||
nx, ny = -dy / n, dx / n
|
||||
lines.append(((a[0] + nx * h, a[1] + ny * h), (dx, dy), (a[0] - nx * h, a[1] - ny * h)))
|
||||
(lo, ld, li), (ro, rd, ri) = lines
|
||||
base = ((0, 0), (1, 0))
|
||||
l_ext, l_int = (li, lo) if li[1] < lo[1] else (lo, li)
|
||||
r_ext, r_int = (ri, ro) if ri[1] < ro[1] else (ro, ri)
|
||||
pts = [inter(l_ext, ld, *base), inter(l_ext, ld, r_ext, rd), inter(r_ext, rd, *base),
|
||||
inter(r_int, rd, *base), inter(l_int, ld, r_int, rd), inter(l_int, ld, *base)]
|
||||
return " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
||||
|
||||
|
||||
def logotype(p, x=0.0):
|
||||
parts = []
|
||||
for ch in "Ustaga":
|
||||
s, adv = glyph(ch, x, p["txt"])
|
||||
parts.append(s)
|
||||
x += adv
|
||||
x += GAP
|
||||
unit = MM_W / 300 # coordonnées de la maquette : boîte de 300 × 100
|
||||
top = XH * 1.06 # léger dépassement de la hauteur d'x, comme une lettre pointue
|
||||
for i, (a, b) in enumerate(((8, 74), (74, 140), (160, 226), (226, 292))):
|
||||
fill = p["txt"] if i % 2 == 0 else p["acc"]
|
||||
parts.append(f'<polygon points="{chevron(x + a * unit, x + b * unit, top)}" fill="{fill}"/>')
|
||||
x += MM_W + GAP
|
||||
for ch in "on":
|
||||
s, adv = glyph(ch, x, p["txt"])
|
||||
parts.append(s)
|
||||
x += adv
|
||||
return "".join(parts), x
|
||||
|
||||
|
||||
ASC, DESC = 680, 240 # marges verticales autour de la ligne de base
|
||||
|
||||
|
||||
def mono_wrap(body, vb, ink, mid):
|
||||
"""Transforme un dessin encre/trou en dessin d'une seule couleur, trous transparents."""
|
||||
body = body.replace('"I"', '"#fff"').replace('"H"', '"#000"')
|
||||
x, y, w, h = vb
|
||||
return (f'<mask id="{mid}" maskUnits="userSpaceOnUse" x="{x}" y="{y}" width="{w}" height="{h}">'
|
||||
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="#000"/>{body}</mask>'
|
||||
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{ink}" mask="url(#{mid})"/>')
|
||||
|
||||
|
||||
def svg(vb, body, title):
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{" ".join(f"{v:g}" for v in vb)}" '
|
||||
f'width="{vb[2]:g}" height="{vb[3]:g}" role="img" aria-label="{title}"><title>{title}</title>{body}</svg>\n')
|
||||
|
||||
|
||||
def write(name, content):
|
||||
with open(os.path.join(OUT, name), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
VARIANTS = {"couleur": COULEUR, "sombre": SOMBRE, "noir": "#000000", "blanc": "#FFFFFF"}
|
||||
|
||||
|
||||
def palette(v):
|
||||
return VARIANTS[v] if isinstance(VARIANTS[v], dict) else MONO
|
||||
|
||||
|
||||
def ink(body, v):
|
||||
return body if isinstance(VARIANTS[v], dict) else body.replace('"I"', f'"{VARIANTS[v]}"')
|
||||
|
||||
|
||||
# ---------- Symbole ----------
|
||||
for v in VARIANTS:
|
||||
vb = (0, 0, 64, 64)
|
||||
body = symbol(palette(v))
|
||||
if not isinstance(VARIANTS[v], dict):
|
||||
body = mono_wrap(body, vb, VARIANTS[v], "m")
|
||||
write(f"symbole-{v}.svg", svg(vb, body, "Ustagammon"))
|
||||
|
||||
# ---------- Logotype seul ----------
|
||||
for v in VARIANTS:
|
||||
body, w = logotype(palette(v))
|
||||
write(f"logotype-{v}.svg", svg((-10, -ASC, round(w + 20), ASC + DESC), ink(body, v), "Ustagammon"))
|
||||
|
||||
# ---------- Logo horizontal ----------
|
||||
SYM = 1450 # hauteur du symbole, pour un corps de 1000
|
||||
CENTER = -322 # centre optique : mi-hauteur des capitales
|
||||
for v in VARIANTS:
|
||||
p = palette(v)
|
||||
sym = f'<g transform="translate(0,{CENTER - SYM / 2}) scale({SYM / 64:.4f})">{symbol(p)}</g>'
|
||||
text, w = logotype(p, x=SYM + 280)
|
||||
vb = (-20, CENTER - SYM / 2 - 20, round(w + 40), SYM + 40)
|
||||
if not isinstance(VARIANTS[v], dict):
|
||||
sym = mono_wrap(sym, vb, VARIANTS[v], "m")
|
||||
write(f"logo-horizontal-{v}.svg", svg(vb, sym + ink(text, v), "Ustagammon"))
|
||||
|
||||
# ---------- Icônes ----------
|
||||
IVOIRE = "#F3EAD8"
|
||||
|
||||
|
||||
def placed(frac, size, pal=COULEUR, simple=False):
|
||||
"""Symbole centré dans un carré de `size`, occupant la fraction `frac` du côté."""
|
||||
s = size * frac / 64
|
||||
off = (size - 64 * s) / 2
|
||||
return f'<g transform="translate({off:.2f},{off:.2f}) scale({s:.4f})">{symbol(pal, simple)}</g>'
|
||||
|
||||
|
||||
# Icône « any » : tuile ivoire aux coins arrondis, transparente autour
|
||||
write("icone.svg", svg((0, 0, 512, 512), f'<rect width="512" height="512" rx="112" fill="{IVOIRE}"/>' + placed(0.70, 512), "Ustagammon"))
|
||||
# Icône « maskable » : fond plein, symbole dans la zone de sécurité (cercle de 80 %)
|
||||
write("icone-maskable.svg", svg((0, 0, 512, 512), f'<rect width="512" height="512" fill="{IVOIRE}"/>' + placed(0.56, 512), "Ustagammon"))
|
||||
# Favicon : dessin simplifié (sans filets), traits épaissis, fond transparent
|
||||
write("favicon.svg", svg((0, 0, 64, 64), symbol(COULEUR, simple=True), "Ustagammon"))
|
||||
# Android, icône adaptative : canevas de 108 × 108 dp, zone sûre de 66 dp au centre
|
||||
write("android-premier-plan.svg", svg((0, 0, 108, 108), placed(0.58, 108), "Ustagammon, premier plan"))
|
||||
write("android-arriere-plan.svg", svg((0, 0, 108, 108), f'<rect width="108" height="108" fill="{IVOIRE}"/>', "Ustagammon, arrière-plan"))
|
||||
write("android-monochrome.svg", svg((0, 0, 108, 108), mono_wrap(placed(0.58, 108, MONO), (0, 0, 108, 108), "#000000", "m"), "Ustagammon, monochrome"))
|
||||
print("\n".join(sorted(os.listdir(OUT))))
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, arrière-plan"><title>Ustagammon, arrière-plan</title><rect width="108" height="108" fill="#F3EAD8"/></svg>
|
||||
|
After Width: | Height: | Size: 231 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, monochrome"><title>Ustagammon, monochrome</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="108" height="108"><rect x="0" y="0" width="108" height="108" fill="#000"/><g transform="translate(22.68,22.68) scale(0.9787)"><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></g></mask><rect x="0" y="0" width="108" height="108" fill="#000000" mask="url(#m)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 108" width="108" height="108" role="img" aria-label="Ustagammon, premier plan"><title>Ustagammon, premier plan</title><g transform="translate(22.68,22.68) scale(0.9787)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>
|
||||
|
After Width: | Height: | Size: 912 B |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="3" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="3"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2.6" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2.6"/></svg>
|
||||
|
After Width: | Height: | Size: 642 B |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Ustagammon"><title>Ustagammon</title><rect width="512" height="512" fill="#F3EAD8"/><g transform="translate(112.64,112.64) scale(4.4800)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>
|
||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Ustagammon"><title>Ustagammon</title><rect width="512" height="512" rx="112" fill="#F3EAD8"/><g transform="translate(76.80,76.80) scale(5.6000)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g></svg>
|
||||
|
After Width: | Height: | Size: 940 B |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -1067 7050 1490" width="7050" height="1490" role="img" aria-label="Ustagammon"><title>Ustagammon</title><g transform="translate(0,-1047.0) scale(22.6562)"><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g><path transform="translate(1730.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#2B1D15"/><path transform="translate(2368.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#2B1D15"/><path transform="translate(2752.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#2B1D15"/><path transform="translate(3090.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><path transform="translate(3571.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#2B1D15"/><path transform="translate(4071.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><polygon points="4558.1,0.0 4762.6,-657.2 4967.2,0.0 4865.0,0.0 4762.6,-328.6 4660.3,0.0" fill="#2B1D15"/><polygon points="4865.0,0.0 5069.6,-657.2 5274.1,0.0 5171.9,0.0 5069.6,-328.6 4967.2,0.0" fill="#A8432B"/><polygon points="5264.9,0.0 5469.4,-657.2 5674.0,0.0 5571.8,0.0 5469.4,-328.6 5367.1,0.0" fill="#2B1D15"/><polygon points="5571.8,0.0 5776.4,-657.2 5980.9,0.0 5878.7,0.0 5776.4,-328.6 5674.0,0.0" fill="#A8432B"/><path transform="translate(5987.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#2B1D15"/><path transform="translate(6481.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#2B1D15"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -1067 7050 1490" width="7050" height="1490" role="img" aria-label="Ustagammon"><title>Ustagammon</title><g transform="translate(0,-1047.0) scale(22.6562)"><polygon points="6,62 58,62 32,1" fill="#C9583C"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#C9583C" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></g><path transform="translate(1730.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#F3EAD8"/><path transform="translate(2368.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#F3EAD8"/><path transform="translate(2752.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#F3EAD8"/><path transform="translate(3090.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><path transform="translate(3571.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#F3EAD8"/><path transform="translate(4071.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><polygon points="4558.1,0.0 4762.6,-657.2 4967.2,0.0 4865.0,0.0 4762.6,-328.6 4660.3,0.0" fill="#F3EAD8"/><polygon points="4865.0,0.0 5069.6,-657.2 5274.1,0.0 5171.9,0.0 5069.6,-328.6 4967.2,0.0" fill="#D0694B"/><polygon points="5264.9,0.0 5469.4,-657.2 5674.0,0.0 5571.8,0.0 5469.4,-328.6 5367.1,0.0" fill="#F3EAD8"/><polygon points="5571.8,0.0 5776.4,-657.2 5980.9,0.0 5878.7,0.0 5776.4,-328.6 5674.0,0.0" fill="#D0694B"/><path transform="translate(5987.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#F3EAD8"/><path transform="translate(6481.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#F3EAD8"/></svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#FFFFFF"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#FFFFFF"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#FFFFFF"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#FFFFFF"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#FFFFFF"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#FFFFFF"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#FFFFFF"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#FFFFFF"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#FFFFFF"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#FFFFFF"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#FFFFFF"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#FFFFFF"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#2B1D15"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#2B1D15"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#2B1D15"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#2B1D15"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#2B1D15"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#2B1D15"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#A8432B"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#2B1D15"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#A8432B"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#2B1D15"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#2B1D15"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#000000"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#000000"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#000000"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#000000"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#000000"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#000000"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#000000"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#000000"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#000000"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#000000"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#000000"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#000000"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -680 5300 920" width="5300" height="920" role="img" aria-label="Ustagammon"><title>Ustagammon</title><path transform="translate(0.0,0) scale(1,-1)" d="M573 317 572 232Q573 117 505.0 54.0Q437 -9 310 -9Q188 -9 124.0 52.0Q60 113 63 227L64 320L61 644H197L190 245Q189 172 221.0 137.0Q253 102 318 102Q448 102 447 238L440 644H578Z" fill="#F3EAD8"/><path transform="translate(638.0,0) scale(1,-1)" d="M345 450 327 352 311 347Q282 366 253.0 376.0Q224 386 202 386Q178 386 164.0 373.5Q150 361 150 343Q150 322 168.5 309.0Q187 296 227 280Q267 264 293.0 249.0Q319 234 337.5 207.5Q356 181 356 142Q356 102 335.5 67.5Q315 33 273.0 11.5Q231 -10 170 -10Q98 -10 21 20L36 124L49 132Q80 106 118.0 90.5Q156 75 185 75Q212 75 227.0 88.5Q242 102 242 121Q242 142 223.5 155.0Q205 168 165 184Q124 202 98.0 216.5Q72 231 53.5 258.0Q35 285 35 325Q35 390 84.0 431.5Q133 473 217 473Q285 473 345 450Z" fill="#F3EAD8"/><path transform="translate(1022.0,0) scale(1,-1)" d="M316 115 327 105 314 26Q263 0 210 -6Q75 8 75 126L80 209L79 381H17L12 388L19 461H78V538L199 577L208 569L205 461H324L328 454L322 381H203L197 157Q197 125 209.0 111.5Q221 98 249 98Q281 98 316 115Z" fill="#F3EAD8"/><path transform="translate(1360.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><path transform="translate(1841.0,0) scale(1,-1)" d="M431 387 429 383Q444 353 444 316Q444 241 389.5 196.5Q335 152 242 152Q214 152 184 157Q175 148 172.0 143.0Q169 138 169 131Q169 113 193.0 106.0Q217 99 269 92Q328 85 367.5 75.0Q407 65 435.5 40.0Q464 15 464 -31Q464 -78 434.5 -115.5Q405 -153 353.0 -174.0Q301 -195 235 -195Q144 -195 95.0 -158.0Q46 -121 46 -60Q77 -17 129 15V20Q95 32 76.0 53.5Q57 75 57 111Q84 144 125 172V177Q89 197 70.5 229.5Q52 262 52 306Q52 384 106.0 428.0Q160 472 253 472Q307 472 351 454Q423 467 498 494L508 485V402Q481 395 431 387ZM329 314Q329 390 251 390Q167 390 167 313Q167 273 187.0 252.5Q207 232 246 232Q287 232 308.0 253.0Q329 274 329 314ZM236 -1 202 4Q178 -12 166.0 -27.0Q154 -42 154 -59Q154 -85 178.0 -101.0Q202 -117 248 -117Q295 -117 323.5 -100.5Q352 -84 352 -55Q352 -28 323.5 -18.0Q295 -8 236 -1Z" fill="#F3EAD8"/><path transform="translate(2341.0,0) scale(1,-1)" d="M408 343Q408 325 402 229Q401 201 399.0 171.5Q397 142 397 121Q397 104 402.0 97.5Q407 91 420 91Q430 91 446 95L456 87L444 17Q412 -4 371 -8Q341 -5 321.0 13.5Q301 32 294 65H287Q237 12 184 -9Q112 -8 71.0 29.0Q30 66 30 130Q30 180 55.5 208.5Q81 237 133 247L286 276V317Q286 382 222 382Q191 382 156.5 368.0Q122 354 79 324L69 330L54 418Q97 445 146.0 459.5Q195 474 244 474Q321 474 364.5 439.5Q408 405 408 343ZM179 186Q163 182 155.5 172.5Q148 163 148 144Q148 118 162.0 103.5Q176 89 201 89Q240 89 283 135L285 209Z" fill="#F3EAD8"/><polygon points="2828.1,0.0 3032.6,-657.2 3237.2,0.0 3135.0,0.0 3032.6,-328.6 2930.3,0.0" fill="#F3EAD8"/><polygon points="3135.0,0.0 3339.6,-657.2 3544.1,0.0 3441.9,0.0 3339.6,-328.6 3237.2,0.0" fill="#D0694B"/><polygon points="3534.9,0.0 3739.4,-657.2 3944.0,0.0 3841.8,0.0 3739.4,-328.6 3637.1,0.0" fill="#F3EAD8"/><polygon points="3841.8,0.0 4046.4,-657.2 4250.9,0.0 4148.7,0.0 4046.4,-328.6 3944.0,0.0" fill="#D0694B"/><path transform="translate(4257.0,0) scale(1,-1)" d="M464 241Q464 119 406.0 54.5Q348 -10 239 -10Q137 -10 83.0 50.0Q29 110 29 223Q29 345 87.0 409.5Q145 474 254 474Q356 474 410.0 414.0Q464 354 464 241ZM154 249Q154 157 176.0 118.0Q198 79 248 79Q296 79 317.5 110.5Q339 142 339 214Q339 305 316.5 344.5Q294 384 245 384Q197 384 175.5 352.0Q154 320 154 249Z" fill="#F3EAD8"/><path transform="translate(4751.0,0) scale(1,-1)" d="M347 290Q348 325 331.5 344.5Q315 364 284 364Q239 364 188 321V205L194 0H61L68 192L63 456L189 467V399H196Q251 449 316 471Q392 471 432.0 436.5Q472 402 470 337L467 204L473 0H339Z" fill="#F3EAD8"/></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="0" y="0" width="64" height="64" fill="#000"/><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></mask><rect x="0" y="0" width="64" height="64" fill="#FFFFFF" mask="url(#m)"/></svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#A8432B"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#A8432B" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></svg>
|
||||
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64"><rect x="0" y="0" width="64" height="64" fill="#000"/><polygon points="6,62 58,62 32,1" fill="#fff"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#000" stroke="#fff" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#fff" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#fff" stroke="#000" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#fff" stroke="#000" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#000" stroke-width="1.6"/></mask><rect x="0" y="0" width="64" height="64" fill="#000000" mask="url(#m)"/></svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Ustagammon"><title>Ustagammon</title><polygon points="6,62 58,62 32,1" fill="#C9583C"/><path d="M21.5,21 v4.5 a10.5,7 0 0 0 21,0 v-4.5 z" fill="#CDB898" stroke="#2B1A10" stroke-width="2.2" stroke-linejoin="round"/><ellipse cx="32" cy="21" rx="10.5" ry="7" fill="#F3EAD8" stroke="#2B1A10" stroke-width="2.2"/><ellipse cx="32" cy="21" rx="5.5" ry="3.6" fill="none" stroke="#C9583C" stroke-width="2"/><path d="M17.5,43 v6.5 a14.5,9.5 0 0 0 29,0 v-6.5 z" fill="#2B1A10" stroke="#F3EAD8" stroke-width="2" stroke-linejoin="round"/><ellipse cx="32" cy="43" rx="14.5" ry="9.5" fill="#4A2E1F" stroke="#F3EAD8" stroke-width="2"/><ellipse cx="32" cy="43" rx="8.3" ry="5.4" fill="none" stroke="#7A5038" stroke-width="1.6"/></svg>
|
||||
|
After Width: | Height: | Size: 824 B |
@@ -64,3 +64,102 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
- Étape 0d : plateau minimal jouable sur le socle retenu.
|
||||
- `adb` n'est pas installé sur le PC : `sudo apt install adb` avant les tests sur
|
||||
le téléphone.
|
||||
|
||||
## 2026-11-02 (soir) et 2026-11-03
|
||||
|
||||
**Fait**
|
||||
- Mesures sur le Fairphone 4 (Chrome 156) : 2 plis en 348 ms au pire, seuil
|
||||
3 s ; ADR 0009 accepté (socle PWA en TypeScript), 0c abandonnée.
|
||||
- Déploiement CapRover (MR !10) : app `ustagammon` créée, site publié sur
|
||||
https://ustagammon.sans.pub (HTTPS forcé).
|
||||
- Étape 0d.1 (MR !11) : domaine « partie » en journal d'événements, scénario
|
||||
exécuté par Cucumber sur le vrai moteur, garde-fous d'architecture.
|
||||
- Étape 0d.2 : plateau jouable au toucher en PWA hors ligne, aide intégrée
|
||||
(glossaire et scénarios lus depuis leurs sources), CSP, `make test-browser`
|
||||
(parties complètes dans Chromium et Firefox). Deux bugs trouvés et corrigés :
|
||||
la pile du moteur WebAssembly débordait après quelques centaines de commandes
|
||||
(plantage en cours de partie), et le service worker échouait dans Chromium en
|
||||
téléchargeant les données en même temps que le moteur.
|
||||
- Interruptions de quota : reprise programmée à 23h50 ; l'agent de 0d.2 arrêté a
|
||||
été remplacé par un agent de reprise sur le même espace de travail.
|
||||
|
||||
**Reste à faire**
|
||||
- Vérification finale de l'étape 0 avec Pierre : une partie complète sur le
|
||||
Fairphone 4, en suivant le guide de test (section « Étape 0d »).
|
||||
- Sous Firefox sur PC, un tour de l'ordinateur a pris jusqu'à 5,1 s
|
||||
(Chromium : 0,4 s) ; Firefox sur le téléphone reste à mesurer.
|
||||
- Validation par Pierre de Playwright (Apache-2.0), outil de `make test-browser`.
|
||||
|
||||
## 2026-11-03 (nuit) : identité visuelle
|
||||
|
||||
**Fait**
|
||||
- Identité choisie par Pierre en trois tours sur une planche de travail :
|
||||
accent brique, symbole « P3d » (deux pions sur une flèche, le 8 caché),
|
||||
logotype « L3 » (les « mm » en flèches), icône sur fond ivoire. ADR 0010,
|
||||
fichiers du logo et des icônes dans `docs/identite/logo/` (MR !12).
|
||||
- Design system publié (https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M) :
|
||||
couleurs des deux thèmes reprenant les noms de variables de `web/app.css`,
|
||||
Alegreya Sans, onze composants, contrastes vérifiés par calcul. Copie dans
|
||||
`docs/identite/charte/`.
|
||||
- Maquettes des quatre écrans et du thème sombre
|
||||
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe).
|
||||
|
||||
**Reste à faire**
|
||||
- Appliquer la charte à la PWA (couleurs, police embarquée, icônes, logo) :
|
||||
à planifier pour l'agent développeur, voir `docs/identite/charte/README.md`.
|
||||
- Les maquettes proposent des éléments de produit qui ne sont pas encore
|
||||
décidés (leçons, « Conseil du maître », bilan de fin de partie) : à valider
|
||||
par Pierre avant toute mise en œuvre.
|
||||
|
||||
## 2026-11-03 (matin) : fin de l'étape 0, retours du test sur téléphone
|
||||
|
||||
**Fait**
|
||||
- Pierre a joué une partie complète sur le Fairphone 4 : « très rapide de la
|
||||
part de l'ordinateur ». Avec les tests au vert, le critère de fin de l'étape 0
|
||||
est atteint. Le guide de test et `docs/etape-0.md`, temporaires, sont
|
||||
supprimés ; leurs décisions sont dans les ADR 0005 et 0009.
|
||||
- Playwright validé par Pierre (amendement de l'ADR 0004, MR !20).
|
||||
- Douze retours de Pierre après l'essai, plus l'installation en un toucher et
|
||||
les retours haptiques, rangés en sous-étapes 0e dans la feuille de route
|
||||
(MR !20). Lancés : la saisie du coup (0e.1) et une revue UX par un designer
|
||||
UI (0e.2).
|
||||
- Coordination avec la session « identité » : elle propose et maquette le
|
||||
design (mode immersif, compte de points, accueil, tutoriel), Pierre tranche,
|
||||
la session technique implémente. Numéros des flèches à 12 px minimum.
|
||||
- Décision de Pierre : la vue paysage « plateau = écran » s'applique dès la
|
||||
rotation du téléphone, sans plein écran (amendement de l'ADR 0012).
|
||||
- Mode avion vérifié par Pierre sur le téléphone : l'application fonctionne
|
||||
hors ligne.
|
||||
- Étape 1 découpée dans `docs/etape-1.md` (sous-étapes 1a à 1j).
|
||||
- Nouvel objectif fixé par Pierre : traiter les retours 0e (les siens et ceux
|
||||
du designer), puis réaliser l'étape 1, livrée avec un plan de test.
|
||||
|
||||
**Reste à faire**
|
||||
- Firefox sur le téléphone reste à mesurer.
|
||||
|
||||
## 2026-11-03 (jour) : coordination design et développement
|
||||
|
||||
**Fait**
|
||||
- Leçons numérotées, conseil du maître après le coup et bilan de fin de partie
|
||||
retenus par Pierre (ADR 0011) ; mode immersif en paysage (ADR 0012), puis
|
||||
vue paysage dès la rotation, avec le plateau qui occupe tout l'écran.
|
||||
- Revue UX du designer de la session de développement (étape 0e.2), arbitrée
|
||||
par la session design sur délégation de Pierre : portrait à zones fixes,
|
||||
compte de points en bandeaux (pastilles en paysage), menu en feuille,
|
||||
« annuler plutôt que confirmer », pas de passage automatique du tour,
|
||||
vibrations, pictogrammes, clavier sur ordinateur. Règles dans
|
||||
`docs/identite/charte/interaction.md` (MR !23), second amendement de
|
||||
l'ADR 0012 (placement des contrôles en paysage).
|
||||
- Maquettes des variantes sur le canevas (paysage clair, sombre, avec barre
|
||||
d'adresse, fin de partie ; portrait révisé, conseil, nouvelle partie
|
||||
annulable ; menu ; ordinateur avec raccourcis).
|
||||
- Couleur des flèches corrigée dans la charte (impaires en noyer clair, paires
|
||||
en chêne ; MR !25). La correction dans l'app est faite par la session de
|
||||
développement, dans la MR de saisie du coup.
|
||||
|
||||
**Reste à faire**
|
||||
- Implémentation par la session de développement (plan
|
||||
`docs/plans/2026-11-03-revision-ux.md`), puis revue design sur ses captures
|
||||
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
|
||||
Pierre sur le téléphone.
|
||||
- Réglage en main des vibrations sur le Fairphone 4.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Moteur pour l'analyse et les niveaux (1a)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1a (`docs/etape-1.md`)
|
||||
- **Statut** : en cours
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Donner au domaine tout ce dont l'étape 1 a besoin du moteur : niveaux de jeu de
|
||||
l'ordinateur, analyse d'un coup joué et d'une décision de videau, chance d'un
|
||||
jet. Tout vient de GNU Backgammon, comparé à gnubg de bureau.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0005, 0007 et la méthode de comparaison de `tests/reference/` (README).
|
||||
Ne pas modifier `engine/gnubg/` (copie fidèle). Pas de nouvelle dépendance.
|
||||
|
||||
1. **Niveaux de jeu** : les niveaux de GNU Backgammon (débutant, amateur,
|
||||
intermédiaire, avancé, expert, champion du monde…), avec leurs plis, filtres
|
||||
et bruit, repris des réglages prédéfinis de gnubg (pas inventés). Le bruit
|
||||
doit être déterministe (option « deterministic » de gnubg) pour que les
|
||||
tests soient reproductibles. API C (`engine.h`), `engine-cli`, adaptateur
|
||||
WebAssembly et interface `Engine` du domaine (ajout seulement : ne pas
|
||||
toucher au reste de `app/src/domain/game/`, qu'un autre agent modifie).
|
||||
2. **Analyse d'un coup joué** : pour une position, un jet et un coup joué,
|
||||
l'équité du coup joué, celle du meilleur coup, l'écart, et le rang du coup
|
||||
joué, au niveau d'analyse demandé (comme l'analyse de partie de gnubg).
|
||||
3. **Analyse d'une décision de videau** : équités sans double, double/prend,
|
||||
double/passe, et l'écart de la décision prise (double, pas de double,
|
||||
prendre, passer), comme gnubg.
|
||||
4. **Chance d'un jet** : la chance (luck) d'un jet au sens de gnubg, et son
|
||||
classement (très chanceux… très malchanceux) avec les seuils de gnubg.
|
||||
5. **Seuils d'appréciation** : exposer les seuils de gnubg (douteux, mauvais,
|
||||
très mauvais) comme constantes documentées ; l'interface les traduira.
|
||||
6. **Références** : ajouter des positions de référence évaluées par gnubg de
|
||||
bureau (Docker existant, `make reference`) pour l'analyse d'un coup, d'une
|
||||
décision de videau et la chance, comparées par `make test` (natif et
|
||||
WebAssembly) avec la tolérance existante. Les niveaux avec bruit : vérifier
|
||||
au moins que le coup choisi est reproductible et identique à celui de gnubg
|
||||
de bureau sur quelques positions.
|
||||
7. Glossaire : fiches pour les nouveaux termes (niveau, chance, appréciation,
|
||||
équité si absente…), reliées au code.
|
||||
8. Mesurer sous Node (`make bench` ou équivalent) le temps d'une analyse de
|
||||
coup au niveau prévu pour le conseil du maître (2 plis), pour vérifier
|
||||
qu'elle tient en arrière-plan sur le téléphone (repère : ADR 0009).
|
||||
|
||||
Machine ancienne : `-j2`, ne recompiler le moteur que si nécessaire.
|
||||
`make lint` et `make test` doivent passer. Commits fréquents ; `git add` avec
|
||||
chemins explicites ; ne pas pousser, pas de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- `engine-cli` répond aux nouvelles commandes en JSON.
|
||||
- Les nouvelles références concordent avec gnubg de bureau, natif et WebAssembly.
|
||||
- `make lint` et `make test` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Parties enregistrées, branches et export SGF (1b)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1b (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (lancé après la fusion de 0e.1)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et
|
||||
les exporter au format SGF de GNU Backgammon.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios
|
||||
et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur
|
||||
directement.
|
||||
|
||||
1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une
|
||||
liste de parties (identifiant, date de début et de dernière action,
|
||||
adversaire et niveau, score, état en cours ou finie, résultat) et le
|
||||
journal d'événements de chacune. Migration transparente de la partie
|
||||
en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est
|
||||
indisponible (navigation privée), repli sur le comportement actuel.
|
||||
2. **Branches** : le journal est déjà un arbre (événements avec parent,
|
||||
`goBackTo`). Exposer dans le domaine la liste des branches d'une partie
|
||||
(feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à
|
||||
l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la
|
||||
branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici
|
||||
(1e) : un accès minimal suffit pour le test (menu « Mes parties » :
|
||||
reprendre une partie, voir ses branches).
|
||||
3. **Nouvelle partie annulable** : l'arbitrage UX (plan
|
||||
`2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne »
|
||||
pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie
|
||||
par son identifiant) ; l'interface viendra avec la révision UX.
|
||||
4. **Export SGF** : une partie (et ses branches en variantes) au format SGF
|
||||
de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par
|
||||
le gnubg de bureau de référence (Docker existant) : le fichier exporté se
|
||||
charge sans erreur et ses positions successives sont les mêmes que celles
|
||||
du journal, sur des parties générées par les tests.
|
||||
5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici
|
||||
sans perdre l'original, exporter une partie. Fiches du glossaire pour les
|
||||
nouveaux termes (branche, SGF…).
|
||||
|
||||
`make lint`, `make test` et `make test-browser` passent (ajouter au test
|
||||
navigateur : deux parties enregistrées, reprise de la première après
|
||||
rechargement). Commits fréquents ; pas de push ni de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après rechargement, la liste des parties et chaque branche sont intactes.
|
||||
- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions.
|
||||
- Les trois commandes de test passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Position dans l'adresse (0e.4)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.4 (feuille de route)
|
||||
- **Statut** : prévu (après la fusion de la révision UX, qui touche les mêmes fichiers)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer l'ADR 0013 : chaque position du plateau a un lien direct.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
|
||||
|
||||
1. Lecture du fragment `#position=…&match=…` au chargement et sur
|
||||
`hashchange` ; validation par le moteur ; message clair si illisible.
|
||||
2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle
|
||||
partie depuis la position, la partie en cours restant reprenable) et
|
||||
« Retour à ma partie ».
|
||||
3. Domaine : démarrer une partie depuis une position et un Match ID donnés
|
||||
(événement GameStarted étendu de façon rétrocompatible, ou équivalent
|
||||
justifié), sans règle réimplémentée.
|
||||
4. « Copier le lien de la position » depuis le menu de la partie et depuis la
|
||||
vue du coup de l'ordinateur ; le lien copié rouvre exactement la même
|
||||
position (test aller-retour).
|
||||
5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la
|
||||
position »), fiches du glossaire (Position ID, Match ID si absentes).
|
||||
6. `make lint`, `make test`, `make test-browser` (ouvrir un lien, jouer à
|
||||
partir d'ici, revenir, lien invalide).
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même
|
||||
position ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Révision UX : portrait stable, compte de points, menu, paysage « plateau = écran »
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.3 (feuille de route)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer la revue UX (0e.2) telle qu'arbitrée par la session « identité » sur
|
||||
délégation de Pierre le 2026-11-03, et l'amendement de l'ADR 0012.
|
||||
|
||||
## Références
|
||||
|
||||
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
|
||||
« Révision UX » en bas du canevas.
|
||||
- Règles : section « Interaction » du design system, copiée dans
|
||||
`docs/identite/charte/interaction.md` (durées, courbes, motifs de vibration,
|
||||
touches) et composant Icons (10 pictogrammes).
|
||||
- Rapport du designer UI : conservé dans la description de la MR qui clôt ce
|
||||
plan.
|
||||
|
||||
## Décisions à appliquer
|
||||
|
||||
1. **Portrait stable** (maquette « Portrait révisé ») :
|
||||
- une barre haute avec le symbole et trois pictogrammes, libellés de 13 px dessous : Aide, Plein écran, Menu ;
|
||||
- le bandeau de l'ordinateur, puis le plateau, puis le bandeau du joueur ;
|
||||
- une ligne réservée de 44 px : le dernier coup de l'ordinateur avec ↻, ou la ligne du maître ;
|
||||
- la consigne ;
|
||||
- une barre d'actions collée en bas, l'action secondaire à gauche et la principale à droite (« Jouer ce coup »). Toucher les dés valide aussi ;
|
||||
- la durée de calcul affichée disparaît.
|
||||
2. **Compte de points** :
|
||||
- en portrait, des bandeaux. Les chiffres sont en 20 px gras, alignés. Le bandeau de celui qui a la main est en accent-soft (« Vous · à vous de jouer »). L'écart s'écrit « mène de 8 », en muted, du côté de celui qui mène. Toucher le chiffre ouvre une bulle avec le texte de la fiche du glossaire (source unique, ADR 0008) ;
|
||||
- en paysage, des pastilles aux bouts de la barre.
|
||||
3. **Menu en feuille** qui monte du bas :
|
||||
- il contient Nouvelle partie, Plein écran, Aide et glossaire, Thème, et les interrupteurs Vibrations et « Écran allumé pendant la partie » ;
|
||||
- « Nouvelle partie » agit sans confirmation. Un bandeau « Nouvelle partie commencée. Reprendre l'ancienne » reste 10 s avec une barre de temps, et l'ancienne partie se restaure depuis son journal ;
|
||||
- plus de `window.confirm`.
|
||||
4. **Pas de passage automatique du tour** quand aucun coup n'est possible : les dés sont barrés avec le message, et le joueur passe en touchant les dés (ou Espace). Ajouter `overscroll-behavior: none` et `user-select: none`. L'écran reste allumé pendant une partie (Wake Lock), activé par défaut, avec un interrupteur.
|
||||
5. **Paysage, « le plateau est l'écran »** (ADR 0012 amendé) :
|
||||
- la vue s'applique dès la rotation, sur `100dvh` ;
|
||||
- la géométrie suit l'écran, par une fonction pure testée unitairement, d'après les formules du composant Board des maquettes ;
|
||||
- une colonne de commandes à gauche dans le cadre : menu en haut, videau au milieu, ↶ en bas, remplacé par ↻ quand le coup de l'ordinateur est montré ;
|
||||
- les sorties restent libres ;
|
||||
- numéros des flèches à 12 px au moins ;
|
||||
- la fin de partie s'affiche dans un panneau centré sur un voile ;
|
||||
- le plein écran s'ouvre sur un geste (pictogramme ou menu), avec `requestFullscreen` puis `screen.orientation.lock` et un repli par rotation CSS. Ce n'est pas le réglage par défaut de l'app installée.
|
||||
6. **Vibrations** (`navigator.vibrate`, si disponible) aux quatre moments, avec les motifs d'`interaction.md` : pion posé, pion frappé, double proposé par l'ordinateur, fin de partie. Elles sont activées par défaut, rien au lancer des dés, et elles doublent toujours un signal visible.
|
||||
7. **Pictogrammes** du composant Icons, en SVG dans le site (CSP : pas de style en ligne).
|
||||
8. **Consigne** : forme longue pendant les trois premières parties, puis forme courte.
|
||||
9. **Mouvement** :
|
||||
- durées de 90, 160 et 240 ms, avec la courbe cubic-bezier(0.2, 0, 0, 1) ;
|
||||
- l'ordinateur joue pion par pion : 160 ms par pion, 120 ms de pause ;
|
||||
- `prefers-reduced-motion` est respecté.
|
||||
10. **Clavier** (priorité basse) : prévoir dans l'architecture une table d'actions commune au toucher et au clavier. Les touches d'`interaction.md` peuvent venir dans une MR ultérieure.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 : ni le domaine ni le moteur ne changent pour la mise en page. ADR 0008 : scénarios Gherkin pour les comportements visibles nouveaux (nouvelle partie annulable, menu). Aucune nouvelle dépendance. `tests/browser/play.mjs` étendu aux deux orientations, avec des captures Playwright en 412×915, 915×412 et 844×320, en thème clair et sombre, rangées dans un dossier indiqué par la session principale pour la revue design. `make lint`, `make test` et `make test-browser` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -5,6 +5,11 @@ joueur, écrit pour lui. Ces fichiers sont la seule source (ADR 0008) :
|
||||
l'application les met en forme comme pages d'aide, et ils sont exécutés comme
|
||||
tests d'intégration sur le domaine et le moteur réel.
|
||||
|
||||
`make site` copie ce dossier dans le site publié (`docs/scenarios/`) ; l'aide
|
||||
de l'application (bouton « Aide ») lit ces copies à l'exécution
|
||||
(`app/src/ui/help.ts`). Les fichiers affichés dans l'aide sont ceux de la
|
||||
liste `FEATURES` de `help.ts`.
|
||||
|
||||
`make test` les exécute avec Cucumber : les étapes, définies dans
|
||||
`app/tests/steps/`, pilotent le domaine branché sur le vrai moteur
|
||||
WebAssembly, avec des dés imposés pour que chaque exécution soit identique. Une
|
||||
|
||||
@@ -11,11 +11,40 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Et le premier jet de dés désigne qui commence
|
||||
|
||||
Scénario: Jouer un coup permis
|
||||
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
|
||||
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
|
||||
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
|
||||
ou de l'autre du plateau, ou « Valider ».
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5 6/5
|
||||
Alors votre coup attend votre validation
|
||||
Et vos dés sont tous marqués comme joués
|
||||
Quand vous validez votre coup
|
||||
Alors votre coup est accepté
|
||||
Et c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Un pion qui n'a qu'une case d'arrivée
|
||||
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
|
||||
qu'une case d'arrivée, et il y va dès que vous le touchez.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
Scénario: Reprendre un pion avant de valider
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5 6/5
|
||||
Et que vous annulez le dernier demi-coup
|
||||
Alors il ne reste de joué que 8/5
|
||||
Quand vous annulez le dernier demi-coup
|
||||
Alors la position n'a pas changé
|
||||
Et aucun de vos dés n'est marqué comme joué
|
||||
|
||||
Scénario: Un coup interdit est refusé
|
||||
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
|
||||
pouvez pas vous y poser.
|
||||
@@ -30,6 +59,43 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand ses dés sont tirés
|
||||
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
|
||||
que vous avez touché « Continuer ».
|
||||
|
||||
Étant donné que votre pion sur la barre ne peut pas rentrer
|
||||
Quand vous lancez les dés
|
||||
Alors aucun coup n'est possible avec votre jet
|
||||
Et c'est toujours à vous
|
||||
Quand vous continuez
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
étaient. Le bouton « Revoir » rejoue son coup.
|
||||
|
||||
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
|
||||
Quand l'ordinateur a joué
|
||||
Alors son coup vous est montré case par case
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Doubler, après confirmation
|
||||
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
|
||||
« Doubler ». L'application vous demande de confirmer : une fois proposé,
|
||||
un double ne se reprend pas.
|
||||
|
||||
Étant donné que vous pouvez doubler
|
||||
Quand vous demandez à doubler
|
||||
Mais que vous changez d'avis
|
||||
Alors rien n'est proposé à l'ordinateur
|
||||
Quand vous demandez à doubler
|
||||
Et que vous confirmez
|
||||
Alors l'ordinateur prend ou passe, comme le recommande le moteur
|
||||
|
||||
Scénario: Prendre le double de l'ordinateur
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous prenez
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC
|
||||
// bench.mjs -- mesure du moteur WebAssembly sous Node, sur le PC
|
||||
// (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte
|
||||
// est celui du téléphone.
|
||||
//
|
||||
@@ -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 "./benchmark.mjs";
|
||||
import { commands, loadEngine } from "../web/engine-wasm.mjs";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de
|
||||
// mesure (bench.html, dans le navigateur) et `make bench` (sous Node).
|
||||
// benchmark.mjs -- mesure du moteur WebAssembly sous Node, pour `make bench`
|
||||
// (engine-wasm/bench.mjs). Elle servait aussi à la page de mesure du site,
|
||||
// retirée avec les pages de test du moteur (ADR 0009, amendement du
|
||||
// 2026-11-03).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -7,7 +9,7 @@
|
||||
// Sur les positions de référence (tests/reference/positions.json) : temps du
|
||||
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
|
||||
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
|
||||
// de l'étape 0b (docs/etape-0.md).
|
||||
// de l'étape 0b (ADR 0009).
|
||||
|
||||
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
|
||||
// toutes les positions, est en dessous (la médiane est donnée en plus).
|
||||
@@ -20,13 +22,12 @@ 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
|
||||
// Fichiers téléchargés pour faire tourner le moteur dans l'application
|
||||
// (hors code de l'application elle-même, quelques dizaines de Ko), chemins
|
||||
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
|
||||
export const DOWNLOADED_FILES = [
|
||||
"index.html",
|
||||
"style.css",
|
||||
"page.mjs",
|
||||
"engine-client.mjs",
|
||||
"app.css",
|
||||
"engine-worker.mjs",
|
||||
"engine-wasm.mjs",
|
||||
"wasm/engine.mjs",
|
||||
@@ -33,14 +33,18 @@ ENGINE_INCLUDES := -I$(COMPAT) -I$(COMPAT)/glib-shim -I$(GNUBG) -I$(GNUBG)/lib -
|
||||
# - main n'est pas lancé au chargement, et le programme ne s'arrête pas à sa
|
||||
# sortie : le moteur reste chargé entre deux commandes ;
|
||||
# - mémoire extensible : les bases de fin de partie occupent plusieurs
|
||||
# mégaoctets.
|
||||
# mégaoctets ;
|
||||
# - stackSave et stackRestore : callMain laisse ses arguments sur la pile
|
||||
# (64 Ko) ; sans restauration après chaque commande, elle déborde au bout
|
||||
# de quelques centaines de commandes et corrompt la mémoire du moteur
|
||||
# (voir web/engine-wasm.mjs).
|
||||
LINK_FLAGS := -O3 \
|
||||
-sMODULARIZE -sEXPORT_ES6 -sEXPORT_NAME=createEngineModule \
|
||||
-sENVIRONMENT=web,worker,node \
|
||||
-sINVOKE_RUN=0 -sEXIT_RUNTIME=0 \
|
||||
-sALLOW_MEMORY_GROWTH \
|
||||
-sEXPORTED_FUNCTIONS=_main,_engine_init \
|
||||
-sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8 \
|
||||
-sEXPORTED_RUNTIME_METHODS=callMain,ccall,HEAPU8,stackSave,stackRestore \
|
||||
--preload-file $(BUILD)/data@/data
|
||||
|
||||
DATA := $(BUILD)/data/gnubg.wd $(BUILD)/data/gnubg_os0.bd $(BUILD)/data/gnubg_ts0.bd \
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Joue une partie complète par l'interface dans Chromium et Firefox sans
|
||||
# écran (tests/browser/play.mjs), sur le site servi par l'image de production
|
||||
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
|
||||
# dans build/browser-shots/.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
# première fois). Lent (plusieurs minutes) : lancé par make test-browser,
|
||||
# pas par make test.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
|
||||
site_image="ustagammon/site-test:local"
|
||||
docker build -q -t "$site_image" build/deploy >/dev/null
|
||||
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
|
||||
trap 'docker stop "$container" >/dev/null' EXIT
|
||||
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
|
||||
# Le paquet playwright, à la version des navigateurs de l'image.
|
||||
tools=build/browser-tools
|
||||
version="${image##*:v}"
|
||||
version="${version%%-*}"
|
||||
if [ ! -f "$tools/node_modules/playwright/package.json" ]; then
|
||||
mkdir -p "$tools"
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/$tools:/tools" -w /tools \
|
||||
"$image" npm install --no-audit --no-fund --save-exact "playwright@$version" >/dev/null
|
||||
fi
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
# demande un contexte sûr, ce qu'est localhost.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
done
|
||||
exit "$status"
|
||||
@@ -0,0 +1,600 @@
|
||||
// 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 (validés, annulés), coups de
|
||||
// l'ordinateur, videau (avec sa confirmation), reprise
|
||||
// après rechargement, aide, puis hors ligne (service worker).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node play.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh, dans l'image de Playwright. Sort en erreur au
|
||||
// premier problème, ou si la page signale une erreur (dont une violation de
|
||||
// la politique de sécurité du contenu).
|
||||
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node play.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 360, height: 740 },
|
||||
deviceScaleFactor: 2,
|
||||
locale: "fr-FR",
|
||||
});
|
||||
// Pour le diagnostic d'un blocage : la dernière commande envoyée au moteur
|
||||
// (Web Worker), et si elle a reçu sa réponse ; et, pour vérifier la
|
||||
// politique de sécurité du contenu, chaque violation signalée par la page.
|
||||
await context.addInitScript(() => {
|
||||
window.__engine = { sent: 0, received: 0, last: null };
|
||||
const post = Worker.prototype.postMessage;
|
||||
Worker.prototype.postMessage = function (message, ...rest) {
|
||||
window.__engine.sent++;
|
||||
window.__engine.last = message?.args ?? null;
|
||||
if (!this.__counted) {
|
||||
this.__counted = true;
|
||||
this.addEventListener("message", () => window.__engine.received++);
|
||||
}
|
||||
return post.call(this, message, ...rest);
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const problems = [];
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("crash", () => {
|
||||
console.error(`[${browserName}] la page a planté`);
|
||||
process.exit(1);
|
||||
});
|
||||
// Vrai pendant le contrôle final de la politique de sécurité, qui provoque
|
||||
// exprès une violation.
|
||||
let cspProbe = false;
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && !(cspProbe && /Content.Security.Policy/i.test(message.text()))) {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const shot = (name) => page.screenshot({ path: `${shots}/${browserName}-${name}.png` });
|
||||
|
||||
/** Relève les violations de la CSP signalées par la page (avant chaque
|
||||
* rechargement, qui les efface). */
|
||||
const violations = [];
|
||||
async function collectViolations() {
|
||||
violations.push(...(await page.evaluate(() => window.__csp)));
|
||||
}
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les coups joués de chaque camp, d'après le journal enregistré. */
|
||||
async function savedJournal() {
|
||||
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
|
||||
}
|
||||
|
||||
function countMoves(journal, side) {
|
||||
return journal.events.filter((e) => e.type === "MovePlayed" && e.side === side).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attend que le joueur ait quelque chose à faire, et dit quoi. (Pas de
|
||||
* page.waitForFunction : Playwright y passe par eval, que la politique de
|
||||
* sécurité du site interdit, à raison.)
|
||||
*/
|
||||
async function waitForPlayer() {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const todo = await page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["again", "take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||||
});
|
||||
if (todo !== null) {
|
||||
return todo;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await shot("bloque").catch(() => {});
|
||||
const screen = await page
|
||||
.evaluate(() => {
|
||||
const text = (id) => document.getElementById(id)?.textContent;
|
||||
const busy = document.body.classList.contains("busy") ? "occupé" : "libre";
|
||||
const engine = window.__engine;
|
||||
return (
|
||||
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
|
||||
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
|
||||
`${JSON.stringify(engine.last)} ; journal ` +
|
||||
localStorage.getItem("ustagammon.current-game")
|
||||
);
|
||||
})
|
||||
.catch((error) => `page illisible (${error.message})`);
|
||||
throw new Error(`rien à faire pour le joueur depuis 60 s : ${screen}`);
|
||||
}
|
||||
|
||||
/** Le dernier coup de l'ordinateur, s'il est la dernière chose jouée (fin de
|
||||
* partie mise à part) et qu'il a déplacé des pions ; sinon null. */
|
||||
function computerMoveToShow(journal) {
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
return last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length > 0
|
||||
? last
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Ce que montre le plateau du coup de l'ordinateur. */
|
||||
async function shownTraces() {
|
||||
return page.evaluate(() => ({
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
landed: document.querySelectorAll(".trace-landed").length,
|
||||
ghosts: document.querySelectorAll(".trace-ghost").length,
|
||||
hits: document.querySelectorAll(".trace-hit").length,
|
||||
replay: !document.getElementById("replay").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Après un tour de l'ordinateur : ses traces sont là si son coup est la
|
||||
* dernière chose jouée, et rien sinon. */
|
||||
async function checkTraces() {
|
||||
const journal = await savedJournal();
|
||||
const move = computerMoveToShow(journal);
|
||||
const traces = await shownTraces();
|
||||
const last = journal.events.filter((e) => e.type !== "GameEnded").at(-1);
|
||||
if (last?.type === "MovePlayed" && last.side === "computer" && last.halfMoves.length === 0) {
|
||||
// L'ordinateur n'a pas pu jouer : son jet reste barré, avec un message.
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^L'ordinateur ne peut pas jouer \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
|
||||
blocked === (await shownDice()).dice,
|
||||
`jet de l'ordinateur sans coup mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
log(`l'ordinateur ne peut pas jouer : « ${info} »`);
|
||||
}
|
||||
if (move === null) {
|
||||
check(traces.arrows + traces.landed + traces.ghosts === 0 && !traces.replay,
|
||||
`traces sans coup de l'ordinateur à montrer : ${JSON.stringify(traces)}`);
|
||||
return null;
|
||||
}
|
||||
check(traces.arrows === move.halfMoves.length && traces.landed > 0 && traces.ghosts > 0 &&
|
||||
traces.replay, `coup de l'ordinateur mal montré : ${JSON.stringify(traces)}`);
|
||||
return traces;
|
||||
}
|
||||
|
||||
/** Une zone à toucher d'une action du plateau (dés ou videau) : count zones
|
||||
* (les dés en ont une de chaque côté du plateau), chacune un bouton
|
||||
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
|
||||
async function touchTarget(action, label, count = 1, index = 0) {
|
||||
const zones = page.locator(`[data-action="${action}"]`);
|
||||
check((await zones.count()) === count,
|
||||
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
|
||||
for (const zone of await zones.all()) {
|
||||
check((await zone.getAttribute("role")) === "button" &&
|
||||
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
|
||||
const box = await zone.boundingBox();
|
||||
check(box.width >= 44 && box.height >= 44,
|
||||
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
|
||||
}
|
||||
return zones.nth(index);
|
||||
}
|
||||
|
||||
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
|
||||
async function shownDice() {
|
||||
return {
|
||||
dice: await page.locator("g.die[data-value]").count(),
|
||||
used: await page.locator("g.die-used").count(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le dernier jet du joueur, d'après le journal. */
|
||||
function lastPlayerRoll(journal) {
|
||||
const rolled = journal.events.filter((e) => e.type === "DiceRolled").at(-1);
|
||||
return rolled?.roll ?? [];
|
||||
}
|
||||
|
||||
/** Observe la page pendant ms millisecondes : le plus grand nombre
|
||||
* d'animations en cours à la fois, et le plus petit nombre de flèches. */
|
||||
async function watchReplay(ms) {
|
||||
let animations = 0;
|
||||
let arrows = Infinity;
|
||||
for (const end = Date.now() + ms; Date.now() < end; ) {
|
||||
const seen = await page.evaluate(() => ({
|
||||
animations: document.getAnimations().length,
|
||||
arrows: document.querySelectorAll(".trace-arrow").length,
|
||||
}));
|
||||
animations = Math.max(animations, seen.animations);
|
||||
arrows = Math.min(arrows, seen.arrows);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
return { animations, arrows };
|
||||
}
|
||||
|
||||
/** « Revoir » : rejoue le coup (sans animation en mouvement réduit), puis
|
||||
* remet les mêmes traces. */
|
||||
async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
||||
await page.click("#replay");
|
||||
const seen = await watchReplay(300);
|
||||
if (reducedMotion) {
|
||||
check(seen.animations === 0, "Revoir en mouvement réduit : des pions glissent quand même");
|
||||
} else {
|
||||
check(seen.animations > 0 && seen.arrows === 0,
|
||||
`Revoir : pas d'animation sans traces (${JSON.stringify(seen)})`);
|
||||
}
|
||||
await waitForPlayer();
|
||||
const after = await shownTraces();
|
||||
check(JSON.stringify(after) === JSON.stringify(traces),
|
||||
`traces différentes après Revoir : ${JSON.stringify(after)}`);
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
|
||||
// Garde-fou : une étape bloquée plus de deux minutes arrête tout, avec une
|
||||
// capture et l'état de l'écran.
|
||||
let lastStep = Date.now();
|
||||
let stepName = "chargement";
|
||||
function step(name) {
|
||||
lastStep = Date.now();
|
||||
stepName = name;
|
||||
}
|
||||
const watchdog = setInterval(async () => {
|
||||
if (Date.now() - lastStep > 120_000) {
|
||||
clearInterval(watchdog);
|
||||
console.error(`[${browserName}] bloqué depuis 2 minutes (étape : ${stepName})`);
|
||||
await shot("bloque").catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
}, 5_000);
|
||||
|
||||
const start = Date.now();
|
||||
await page.goto(base);
|
||||
const first = await waitForPlayer();
|
||||
log(`moteur chargé, partie prête en ${Date.now() - start} ms (${first})`);
|
||||
await shot("1-debut");
|
||||
|
||||
let doubled = false;
|
||||
let selectionShot = false;
|
||||
let reloaded = false;
|
||||
let computerMs = [];
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
let rolls = 0;
|
||||
// Saisie du coup : demi-coups du coup en cours, pions avancés d'un seul
|
||||
// toucher ou en deux (pion, puis case), coups validés et annulations.
|
||||
let halfMoves = 0;
|
||||
let singleTouch = 0;
|
||||
let twoTouches = 0;
|
||||
let confirms = 0;
|
||||
let undone = 0;
|
||||
let doubles = 0;
|
||||
// Vrai dès que le joueur a touché un pion ou « Annuler » pendant son coup :
|
||||
// le coup de l'ordinateur n'est plus montré.
|
||||
let touched = false;
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 1500, "la partie ne finit pas");
|
||||
const todo = await waitForPlayer();
|
||||
step(`partie, action ${actions}`);
|
||||
// Le coup de l'ordinateur est montré jusqu'à l'action suivante du
|
||||
// joueur ; deux fois, on le revoit (la seconde en mouvement réduit).
|
||||
const traces = touched ? null : await checkTraces();
|
||||
if (traces !== null) {
|
||||
tracesSeen++;
|
||||
if (replays < 2) {
|
||||
replays++;
|
||||
await checkReplay(traces, replays === 2);
|
||||
if (replays === 1) {
|
||||
await shot("coup-adverse");
|
||||
}
|
||||
log(`coup de l'ordinateur revu${replays === 2 ? " en mouvement réduit" : ""} : ` +
|
||||
`${traces.arrows} flèches, ${traces.landed} pions entourés, ` +
|
||||
`${traces.ghosts} fantômes, ${traces.hits} pions frappés signalés`);
|
||||
}
|
||||
}
|
||||
if (process.env.TRACE) {
|
||||
log(`${todo} : ${await page.textContent("#info")} / ${await page.textContent("#prompt")}`);
|
||||
}
|
||||
const info = await page.textContent("#info");
|
||||
const timing = /\((\d+),(\d) s\)/.exec(info ?? "");
|
||||
if (timing) {
|
||||
computerMs.push(Number(timing[1]) * 1000 + Number(timing[2]) * 100);
|
||||
}
|
||||
if (todo === "again") {
|
||||
break;
|
||||
}
|
||||
if (todo === "skip") {
|
||||
// Aucun coup possible : le jet reste affiché, barré, avec un
|
||||
// message, et le tour ne passe qu'en touchant « Continuer ».
|
||||
const info = await page.textContent("#info");
|
||||
const blocked = await page.locator(".die-blocked").count();
|
||||
check(/^Aucun coup possible avec \d-\d\.$/.test(info ?? "") && blocked >= 2 &&
|
||||
blocked === (await shownDice()).dice,
|
||||
`aucun coup possible mal montré : « ${info} », ${blocked} dés barrés`);
|
||||
skips++;
|
||||
log(`aucun coup possible : « ${info} », vous continuez`);
|
||||
await page.click("#skip");
|
||||
} else 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;
|
||||
// Toucher le videau demande confirmation ; « Annuler » ne
|
||||
// propose rien, puis « Doubler » (le bouton) et confirmer.
|
||||
const dialog = page.locator("#double-dialog");
|
||||
const question = "Doubler à 2 ? L'ordinateur pourra prendre ou passer.";
|
||||
await (await touchTarget("double", "Doubler")).click();
|
||||
check(await dialog.isVisible(), "videau touché : pas de confirmation");
|
||||
check((await page.textContent("#double-question")) === question,
|
||||
`confirmation du videau : « ${await page.textContent("#double-question")} »`);
|
||||
await shot("videau-confirmation");
|
||||
await page.click("#double-cancel");
|
||||
check(!(await dialog.isVisible()), "Annuler ne ferme pas la confirmation");
|
||||
const offered = (await savedJournal()).events.some((e) => e.type === "CubeOffered");
|
||||
check(!offered && (await page.isVisible("#roll")), "double proposé malgré Annuler");
|
||||
await page.click("#double");
|
||||
check(await dialog.isVisible(), "« Doubler » : pas de confirmation");
|
||||
await page.click("#double-confirm");
|
||||
await waitForPlayer();
|
||||
const answer = (await page.textContent("#info")) || (await page.textContent("#prompt"));
|
||||
log(`videau touché : confirmation demandée, annulée, puis double confirmé ; réponse : ${answer}`);
|
||||
continue;
|
||||
}
|
||||
// Une fois sur deux, en touchant les dés plutôt que le bouton, d'un
|
||||
// côté puis de l'autre du plateau.
|
||||
rolls++;
|
||||
if (rolls % 2 === 0) {
|
||||
await (await touchTarget("roll", "Lancer les dés", 2, (rolls / 2) % 2)).click();
|
||||
} else {
|
||||
await page.click("#roll");
|
||||
}
|
||||
} else if (todo === "confirm") {
|
||||
// Coup complet : rien n'est joué tant qu'il n'est pas validé.
|
||||
const journal = await savedJournal();
|
||||
const last = journal.events.at(-1);
|
||||
check(last?.type === "DiceRolled" && (await page.isEnabled("#cancel")),
|
||||
`coup complet déjà joué ou non annulable (dernier événement ${last?.type})`);
|
||||
check(/Valider/.test((await page.textContent("#prompt")) ?? ""),
|
||||
`consigne de validation absente : « ${await page.textContent("#prompt")} »`);
|
||||
const dice = await shownDice();
|
||||
check(dice.used === halfMoves && dice.used > 0,
|
||||
`dés estompés : ${dice.used} pour ${halfMoves} demi-coups`);
|
||||
if (undone === 0 && halfMoves >= 2) {
|
||||
// Une fois : « Annuler » défait les demi-coups un par un.
|
||||
if (confirms === 0) {
|
||||
await shot("2b-coup-a-valider");
|
||||
}
|
||||
for (let left = halfMoves - 1; left >= 0; left--) {
|
||||
await page.click("#cancel");
|
||||
await waitForPlayer();
|
||||
const after = await shownDice();
|
||||
check(after.used === left && (await page.isHidden("#confirm")),
|
||||
`Annuler : ${after.used} dés estompés, ${left} attendus`);
|
||||
undone++;
|
||||
}
|
||||
check(await page.isDisabled("#cancel"), "Annuler reste actif sans demi-coup à défaire");
|
||||
check((await savedJournal()).head === journal.head, "Annuler a touché au journal");
|
||||
log(`Annuler : ${undone} demi-coups défaits un par un, journal intact`);
|
||||
halfMoves = 0;
|
||||
continue;
|
||||
}
|
||||
confirms++;
|
||||
// Une fois sur trois avec le bouton, sinon en touchant les dés
|
||||
// (d'un côté ou de l'autre).
|
||||
if (confirms % 3 === 0) {
|
||||
await page.click("#confirm");
|
||||
} else {
|
||||
await (await touchTarget("confirm", "Valider", 2, confirms % 2)).click();
|
||||
}
|
||||
await waitForPlayer();
|
||||
const played = await savedJournal();
|
||||
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
|
||||
"le coup validé n'est pas dans le journal");
|
||||
halfMoves = 0;
|
||||
touched = false;
|
||||
} else {
|
||||
check((await page.locator("[data-action]").count()) === 0,
|
||||
"dés ou videau touchables pendant que vous jouez votre coup");
|
||||
if (halfMoves === 0) {
|
||||
// Quatre dés pour un double, deux sinon.
|
||||
const [a, b] = lastPlayerRoll(await savedJournal());
|
||||
const dice = await shownDice();
|
||||
check(dice.dice === (a === b ? 4 : 2) && dice.used === 0,
|
||||
`jet ${a}-${b} : ${dice.dice} dés montrés, ${dice.used} estompés`);
|
||||
if (a === b) {
|
||||
doubles++;
|
||||
}
|
||||
}
|
||||
touched = true;
|
||||
// Un pion à destination unique avance dès qu'on le touche ; sinon,
|
||||
// ses cases d'arrivée s'allument.
|
||||
await page.click(".hit.can-move");
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
twoTouches++;
|
||||
if (!selectionShot) {
|
||||
selectionShot = true;
|
||||
await shot("2-pion-touche");
|
||||
}
|
||||
await page.click(".hit.can-land");
|
||||
} else {
|
||||
singleTouch++;
|
||||
}
|
||||
halfMoves++;
|
||||
await waitForPlayer();
|
||||
const dice = await shownDice();
|
||||
check(dice.used === halfMoves, `${dice.used} dés estompés après ${halfMoves} demi-coups`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Une fois, au milieu de la partie, entre deux coups : fermer et rouvrir
|
||||
// la page.
|
||||
const journal = await savedJournal();
|
||||
if (!reloaded && countMoves(journal, "computer") >= 3 && countMoves(journal, "player") >= 3) {
|
||||
reloaded = true;
|
||||
await waitForPlayer();
|
||||
await shot("3-milieu-de-partie");
|
||||
const before = await savedJournal();
|
||||
const pips = await page.textContent("#pips");
|
||||
await collectViolations();
|
||||
await page.reload();
|
||||
await waitForPlayer();
|
||||
const after = await savedJournal();
|
||||
check(after.head === before.head, "la partie n'a pas été reprise après rechargement");
|
||||
check((await page.textContent("#pips")) === pips, "plateau différent après rechargement");
|
||||
log(`partie reprise après rechargement (événement ${after.head})`);
|
||||
}
|
||||
}
|
||||
|
||||
const journal = await savedJournal();
|
||||
const result = await page.textContent("#prompt");
|
||||
log(
|
||||
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
|
||||
`${countMoves(journal, "computer")} de l'ordinateur, ${journal.events.length} événements)`,
|
||||
);
|
||||
check(countMoves(journal, "player") >= 3 && countMoves(journal, "computer") >= 3, "trop peu de coups");
|
||||
check(reloaded, "la reprise après rechargement n'a pas été essayée");
|
||||
check(replays === 2, "le coup de l'ordinateur n'a pas été revu");
|
||||
check(doubled && rolls >= 2, "dés ou videau jamais touchés sur le plateau");
|
||||
check(singleTouch > 0 && twoTouches > 0,
|
||||
`saisie : ${singleTouch} pions avancés d'un toucher, ${twoTouches} en deux touchers`);
|
||||
check(undone >= 2, "Annuler n'a pas été essayé sur un coup complet");
|
||||
check(confirms >= 3, "trop peu de coups validés pour essayer dés et bouton");
|
||||
log(`dés lancés ${Math.floor(rolls / 2)} fois en les touchant, des deux côtés du plateau`);
|
||||
log(`demi-coups : ${singleTouch} d'un seul toucher (destination unique), ${twoTouches} en deux ` +
|
||||
`touchers ; ${confirms} coups validés (${Math.floor(confirms / 3)} par le bouton, les autres ` +
|
||||
`en touchant les dés) ; ${doubles} doubles montrés en quatre dés`);
|
||||
log(`coup de l'ordinateur montré ${tracesSeen} fois, et effacé à chaque action suivante`);
|
||||
log(skips > 0 ? `« Continuer » demandé ${skips} fois` : "aucun jet sans coup possible dans cette partie");
|
||||
if (computerMs.length > 0) {
|
||||
log(`temps affichés des tours de l'ordinateur : max ${Math.max(...computerMs)} ms, ` +
|
||||
`${computerMs.length} tours`);
|
||||
}
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
step("aide");
|
||||
// Aide : scénario et glossaire lus dans les copies de docs/.
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
const terms = await page.locator(".glossary dt").count();
|
||||
const scenarios = await page.locator("#help-content h3").count();
|
||||
check(terms >= 20 && scenarios >= 5, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
||||
log(`aide : ${scenarios} scénarios, ${terms} termes du glossaire`);
|
||||
await shot("5-aide");
|
||||
await page.click("#help-back");
|
||||
|
||||
step("thème sombre");
|
||||
// Thème sombre.
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
step("paysage");
|
||||
// Téléphone tourné : le plateau et les boutons tiennent dans l'écran.
|
||||
for (const [width, height] of [[915, 412], [740, 360]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
const board = await page.locator("#board").boundingBox();
|
||||
const actions = await page.locator(".actions").boundingBox();
|
||||
check(board.y + board.height <= height && board.x + board.width <= width,
|
||||
`paysage ${width}x${height} : plateau hors de l'écran`);
|
||||
check(actions.y + actions.height <= height, `paysage ${width}x${height} : boutons hors de l'écran`);
|
||||
}
|
||||
log("paysage : plateau et boutons visibles en entier");
|
||||
await shot("6-paysage");
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
|
||||
step("mise en cache");
|
||||
// Hors ligne : une fois le cache du service worker rempli, la page se
|
||||
// recharge sans réseau, et le moteur répond.
|
||||
const cached = await page.evaluate(async () => {
|
||||
// Le service worker est enregistré une fois le moteur chargé ; s'il ne
|
||||
// s'installe pas, l'attente s'arrête au bout d'une minute.
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const names = await caches.keys();
|
||||
if (names.length === 1) {
|
||||
const cache = await caches.open(names[0]);
|
||||
const keys = await cache.keys();
|
||||
const active = (await navigator.serviceWorker.getRegistration())?.active;
|
||||
if (active && keys.some((r) => r.url.endsWith("/wasm/engine.data"))) {
|
||||
return keys.length;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
check(cached > 0, "le service worker n'a pas mis le site en cache");
|
||||
// Firefox sous Playwright refuse toute navigation une fois le contexte
|
||||
// « hors ligne », même servie par le service worker (NS_ERROR_OFFLINE) : on
|
||||
// y vérifie seulement que la page rechargée est contrôlée par le service
|
||||
// worker, dont le cache est complet.
|
||||
const offlineMode = browserName === "chromium";
|
||||
if (offlineMode) {
|
||||
await context.setOffline(true);
|
||||
}
|
||||
step("rechargement hors ligne");
|
||||
await collectViolations();
|
||||
await page.reload();
|
||||
const controlled = await page.evaluate(() => navigator.serviceWorker.controller !== null);
|
||||
check(controlled, "la page rechargée n'est pas contrôlée par le service worker");
|
||||
const offline = await waitForPlayer();
|
||||
if (offline === "roll") {
|
||||
await page.click("#roll");
|
||||
await waitForPlayer();
|
||||
}
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
await page.click("#help-back");
|
||||
log(
|
||||
offlineMode
|
||||
? `hors ligne : page rechargée sans réseau depuis le cache (${cached} adresses), moteur et aide disponibles`
|
||||
: `cache du service worker rempli (${cached} adresses), page rechargée sous son contrôle ` +
|
||||
"(le hors ligne lui-même n'est pas simulable dans Firefox sous Playwright)",
|
||||
);
|
||||
await shot("7-hors-ligne");
|
||||
|
||||
step("CSP");
|
||||
// Politique de sécurité du contenu : aucune violation pendant toute la
|
||||
// partie (moteur WebAssembly, Web Worker, service worker, aide compris) ; et
|
||||
// elle est bien appliquée : un script en ligne ajouté à la page est bloqué.
|
||||
await collectViolations();
|
||||
check(violations.length === 0, `violations de la CSP : ${violations.join(", ")}`);
|
||||
cspProbe = true;
|
||||
const probe = await page.evaluate(async () => {
|
||||
const script = document.createElement("script");
|
||||
script.textContent = "window.__inline = true;";
|
||||
document.head.append(script);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return { ran: window.__inline === true, violations: window.__csp };
|
||||
});
|
||||
check(!probe.ran, "CSP non appliquée : un script en ligne a été exécuté");
|
||||
check(
|
||||
probe.violations.some((v) => v.startsWith("script-src")),
|
||||
"CSP : le script en ligne bloqué n'a pas été signalé",
|
||||
);
|
||||
log("CSP : aucune violation pendant la partie, script en ligne bloqué");
|
||||
|
||||
clearInterval(watchdog);
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log("aucune erreur dans la page");
|
||||
@@ -29,10 +29,11 @@ for f in "${fichiers[@]}"; do
|
||||
if grep -q $'\r' "$f"; then
|
||||
signaler "$f : fins de ligne Windows (CRLF)"
|
||||
fi
|
||||
# Copies fidèles de GNU Backgammon (sources et données) : on ne
|
||||
# reformate pas leurs espaces, pour rester identique à l'original.
|
||||
# Copies fidèles de GNU Backgammon (sources et données) et de la licence
|
||||
# de la police : on ne reformate pas leurs espaces, pour rester
|
||||
# identique à l'original.
|
||||
case "$f" in
|
||||
engine/gnubg/* | engine/data/*) continue ;;
|
||||
engine/gnubg/* | engine/data/* | web/fonts/OFL.txt) continue ;;
|
||||
esac
|
||||
if grep -nE '[[:blank:]]+$' "$f" >/dev/null; then
|
||||
signaler "$f : espaces en fin de ligne (lignes $(grep -nE '[[:blank:]]+$' "$f" | cut -d: -f1 | paste -sd, -))"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/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, aide, manifeste, service worker, types MIME,
|
||||
# compression, politique de sécurité, version), et l'absence des anciennes
|
||||
# pages de test du moteur.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -37,12 +39,85 @@ 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"
|
||||
# Charte graphique (ADR 0010) : icônes et logo copiés de docs/identite/logo/,
|
||||
# police Alegreya Sans et sa licence.
|
||||
check "icons/favicon.svg" "image/svg\+xml"
|
||||
check "icons/favicon-32.png" "image/png"
|
||||
check "icons/apple-touch-icon-180.png" "image/png"
|
||||
check "icons/logo-horizontal-couleur.svg" "image/svg\+xml"
|
||||
check "icons/logo-horizontal-sombre.svg" "image/svg\+xml"
|
||||
check "icons/symbole-couleur.svg" "image/svg\+xml"
|
||||
check "icons/symbole-sombre.svg" "image/svg\+xml"
|
||||
cmp -s <(curl -s "$base/icons/icone-512.png") docs/identite/logo/icone-512.png \
|
||||
|| report "/icons/icone-512.png diffère de docs/identite/logo/"
|
||||
for weight in Regular Italic Medium Bold ExtraBold; do
|
||||
check "fonts/AlegreyaSans-$weight.woff2" "font/woff2"
|
||||
done
|
||||
check "fonts/OFL.txt" "text/plain"
|
||||
# Chaque fichier désigné par la page (icônes, police, style) est servi.
|
||||
page="$(curl -s "$base/")"
|
||||
while read -r ref; do
|
||||
curl -sf -o /dev/null "$base/$ref" || report "index.html : $ref introuvable"
|
||||
done < <(grep -oE '(href|src|srcset)="[^"]*"' <<<"$page" | sed -E 's/^[a-z]+="(.*)"$/\1/')
|
||||
grep -qF "url(\"fonts/AlegreyaSans-Regular.woff2\")" <(curl -s "$base/app.css") \
|
||||
|| report "app.css : police Alegreya Sans non déclarée"
|
||||
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"
|
||||
# Les pages de test du moteur (étape 0b) sont retirées (ADR 0009,
|
||||
# amendement du 2026-11-03) : plus rien sous /moteur/.
|
||||
[ ! -e build/site/moteur ] || report "build/site/moteur/ existe encore"
|
||||
for gone in moteur/ moteur/bench.html; do
|
||||
curl -s -o /dev/null -w "%{http_code}" "$base/$gone" | grep -qx 404 || report "/$gone : 404 attendu"
|
||||
done
|
||||
# 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"
|
||||
grep -q '"theme_color": "#4A2E1F"' <<<"$manifest" || report "manifeste : theme_color autre que le noyer"
|
||||
grep -q '"purpose": "maskable"' <<<"$manifest" || report "manifeste : pas d'icône maskable"
|
||||
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 \
|
||||
docs/glossaire/videau.md version.json fonts/AlegreyaSans-Bold.woff2 icons/favicon.svg; 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 « ./ » pour index.html.
|
||||
[ "$listed" -eq "$((present + 1))" ] \
|
||||
|| report "sw.js liste $listed adresses pour $present fichiers (plus « ./ »)"
|
||||
grep -q '"moteur/' <<<"$sw" && report "sw.js met encore en cache des pages de test du moteur"
|
||||
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"
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
/* Style de l'application, selon la charte graphique (ADR 0010,
|
||||
* docs/identite/charte/) : les couleurs, tailles et rayons sont les
|
||||
* variables de tokens.json, sous le même nom, redéfinies pour le thème
|
||||
* sombre ; les composants reprennent bundle.css. */
|
||||
|
||||
/* ------------------------------------------------------------ Police */
|
||||
|
||||
/* Alegreya Sans (SIL Open Font License 1.1, voir fonts/OFL.txt), réduite
|
||||
* aux caractères latins et convertie en woff2 (voir docs/identite/README.md).
|
||||
* Embarquée dans le site, donc dans le cache du service worker : elle
|
||||
* fonctionne hors ligne. */
|
||||
@font-face {
|
||||
font-family: "Alegreya Sans";
|
||||
src: url("fonts/AlegreyaSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Alegreya Sans";
|
||||
src: url("fonts/AlegreyaSans-Italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Alegreya Sans";
|
||||
src: url("fonts/AlegreyaSans-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Alegreya Sans";
|
||||
src: url("fonts/AlegreyaSans-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Alegreya Sans";
|
||||
src: url("fonts/AlegreyaSans-ExtraBold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Couleurs */
|
||||
|
||||
:root {
|
||||
--background: #f5efe5;
|
||||
--surface: #fffcf6;
|
||||
--surface-sunken: #ece3d4;
|
||||
--text: #2b1d15;
|
||||
--muted: #6b5c50;
|
||||
--border: #ded2c0;
|
||||
--border-strong: #8e7d6a;
|
||||
--accent: #a8432b;
|
||||
--on-accent: #ffffff;
|
||||
--accent-soft: #f1d9cf;
|
||||
--focus: #a8432b;
|
||||
--error: #8c1c3a;
|
||||
--verdict-good: #2f6b4f;
|
||||
--verdict-doubtful: #7e5a0f;
|
||||
--verdict-bad: #8c1c3a;
|
||||
--brand-noyer: #4a2e1f;
|
||||
--brand-ivoire: #f3ead8;
|
||||
--brand-laiton: #8f6a1c;
|
||||
|
||||
--frame: #4a2e1f;
|
||||
--bed: #e9dcc4;
|
||||
--bar: #3f2619;
|
||||
--tray: #3a2418;
|
||||
--point-a: #6b4430;
|
||||
--point-b: #c9ae88;
|
||||
--point-number: #f3ead8;
|
||||
--checker-player: #f7f0e2;
|
||||
--checker-player-edge: #8c7a62;
|
||||
--checker-computer: #2e1c12;
|
||||
--checker-computer-edge: #120a05;
|
||||
--count-player: #2b1d15;
|
||||
--count-computer: #f3ead8;
|
||||
--die-player: #f7f0e2;
|
||||
--pip-player: #2b1d15;
|
||||
--die-computer: #2e1c12;
|
||||
--pip-computer: #f3ead8;
|
||||
--cube: #f7f0e2;
|
||||
--cube-text: #2b1d15;
|
||||
--highlight: #a8432b;
|
||||
--target: rgba(168, 67, 43, 0.32);
|
||||
/* Dernier coup joué (marques et traces) : le laiton, rien d'autre. */
|
||||
--last-move: var(--brand-laiton);
|
||||
|
||||
/* Numéros des flèches. La charte les pose sur le cadre (point-number,
|
||||
* ivoire) ; notre plateau les pose sur le fond, où l'ivoire ne se lirait
|
||||
* pas : texte secondaire en thème clair (4,7:1 sur bed), point-number en
|
||||
* thème sombre (6,3:1). */
|
||||
--point-label: var(--muted);
|
||||
|
||||
--shadow-raised: 0 1px 2px rgba(43, 29, 21, 0.1);
|
||||
|
||||
--font-sans: "Alegreya Sans", "Gill Sans", "Segoe UI", system-ui, sans-serif;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--button-height: 48px;
|
||||
--page-width: 40rem;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* Hauteur de la barre haute : boutons de 44 px et marges. */
|
||||
--topbar-height: 60px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #17110d;
|
||||
--surface: #221913;
|
||||
--surface-sunken: #2c211a;
|
||||
--text: #efe5d5;
|
||||
--muted: #a99886;
|
||||
--border: #3a2d24;
|
||||
--border-strong: #7d6b5b;
|
||||
--accent: #d0694b;
|
||||
--on-accent: #1a110c;
|
||||
--accent-soft: #4a2519;
|
||||
--focus: #e58a6e;
|
||||
--error: #f08fa6;
|
||||
--verdict-good: #7ccba4;
|
||||
--verdict-doubtful: #e0b04f;
|
||||
--verdict-bad: #f08fa6;
|
||||
--brand-laiton: #d6a64a;
|
||||
|
||||
--frame: #2a1a10;
|
||||
--bed: #4a3626;
|
||||
--bar: #24170e;
|
||||
--tray: #22150d;
|
||||
--point-a: #3a2519;
|
||||
--point-b: #7e6142;
|
||||
--point-number: #cdbfa8;
|
||||
--checker-player: #ede3cf;
|
||||
--checker-player-edge: #8e7c64;
|
||||
--checker-computer: #140d09;
|
||||
--checker-computer-edge: #a8957d;
|
||||
--count-player: #2b1d15;
|
||||
--count-computer: #f3ead8;
|
||||
--die-player: #ede3cf;
|
||||
--pip-player: #2b1d15;
|
||||
--die-computer: #140d09;
|
||||
--pip-computer: #ede3cf;
|
||||
--cube: #ede3cf;
|
||||
--cube-text: #2b1d15;
|
||||
--highlight: #e07a5a;
|
||||
--target: rgba(224, 122, 90, 0.38);
|
||||
|
||||
--point-label: var(--point-number);
|
||||
|
||||
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Page */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: var(--page-width);
|
||||
padding: 0 var(--space-4) var(--space-6);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier, sur tout élément interactif. */
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Barre haute */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: var(--topbar-height);
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar .brand img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Boutons */
|
||||
|
||||
/* Ronds comme des pions ; un seul bouton principal (brique) par écran,
|
||||
* les autres en contour. */
|
||||
button {
|
||||
min-height: var(--button-height);
|
||||
padding: 0 20px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
/* Pas de délai ni de zoom sur un double toucher. */
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
}
|
||||
|
||||
/* Retour visuel au toucher. */
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.topbar button {
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- Messages sous le plateau */
|
||||
|
||||
/* Compte de points : information secondaire, discrète. */
|
||||
.pips {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
/* Ce qui vient de se passer (coup de l'ordinateur, jet d'ouverture) :
|
||||
* secondaire, mais lisible. */
|
||||
.info {
|
||||
min-height: 25px;
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.info:empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* La consigne du moment : ce qui doit ressortir de l'écran. Fond d'accent
|
||||
* léger quand c'est au joueur, neutre pendant que l'ordinateur réfléchit. */
|
||||
.prompt {
|
||||
min-height: calc(25px + 2 * var(--space-3));
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-weight: 700;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.prompt.thinking,
|
||||
body.busy .prompt {
|
||||
font-weight: 400;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Fin de partie : le résultat en titre. */
|
||||
#game:has(#again:not([hidden])) .prompt {
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
/* L'ordinateur réfléchit : trois points qui apparaissent l'un après
|
||||
* l'autre (la seule animation en boucle). */
|
||||
.prompt.thinking::after {
|
||||
content: "";
|
||||
animation: thinking 1.2s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes thinking {
|
||||
0% { content: ""; }
|
||||
25% { content: "."; }
|
||||
50% { content: ".."; }
|
||||
75% { content: "..."; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.prompt.thinking::after {
|
||||
animation: none;
|
||||
content: "…";
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Plateau */
|
||||
|
||||
.board {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 640 / 520;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-family: var(--font-sans);
|
||||
/* Alegreya Sans dessine par défaut des chiffres elzéviriens (de hauteurs
|
||||
* variables) : sur le plateau, des chiffres alignés se lisent mieux. */
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
/* Téléphone en portrait : le plateau, héros de l'écran, prend la largeur
|
||||
* presque entière (8 px de marge au lieu de 16) ; chaque pixel compte pour
|
||||
* toucher un pion. */
|
||||
@media (max-width: 40rem) and (orientation: portrait) {
|
||||
.board {
|
||||
width: calc(100% + 2 * (var(--space-4) - var(--space-2)));
|
||||
margin-inline: calc(var(--space-2) - var(--space-4));
|
||||
}
|
||||
}
|
||||
|
||||
.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(--point-label);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
|
||||
* le dessine. */
|
||||
.checker-ring {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.ring-player { stroke: var(--checker-player-edge); }
|
||||
.ring-computer { stroke: var(--checker-computer-edge); }
|
||||
|
||||
.checker.movable {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.checker.selected {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.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); }
|
||||
/* Le dernier coup de l'ordinateur (couleur laiton, charte) : une flèche par
|
||||
* demi-coup, les pions arrivés et frappés entourés, un pion fantôme là où
|
||||
* un pion est parti. */
|
||||
.trace-arrow {
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.trace-head { fill: var(--last-move); }
|
||||
|
||||
.trace-landed,
|
||||
.trace-hit {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.trace-hit { stroke-dasharray: 6 4; }
|
||||
|
||||
.trace-ghost {
|
||||
fill: none;
|
||||
stroke: var(--last-move);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
|
||||
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
|
||||
.pip-player { fill: var(--pip-player); }
|
||||
.pip-computer { fill: var(--pip-computer); }
|
||||
|
||||
/* Dés du joueur en attente d'être lancés, et videau quand il peut doubler :
|
||||
* ils se touchent comme les boutons. */
|
||||
.die-waiting rect {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.cube.can-double .cube-face {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Jet sans coup possible : dés estompés et barrés. */
|
||||
.die-blocked { opacity: 0.5; }
|
||||
|
||||
.die-strike {
|
||||
stroke: var(--text);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Dé déjà utilisé par un demi-coup du coup en cours : estompé, sans être
|
||||
* barré (il n'est pas perdu : « Annuler » le rend). */
|
||||
.die-used { opacity: 0.35; }
|
||||
|
||||
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
|
||||
|
||||
.cube-value {
|
||||
fill: var(--cube-text);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.hit {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ Confirmation (videau) */
|
||||
|
||||
/* Une carte au milieu de l'écran, comme le glossaire ; le fond de la page
|
||||
* s'assombrit derrière. */
|
||||
.confirm {
|
||||
width: min(22rem, calc(100vw - 2 * var(--space-4)));
|
||||
padding: var(--space-4);
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.confirm::backdrop {
|
||||
background: rgba(43, 29, 21, 0.5);
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.confirm-actions button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Aide */
|
||||
|
||||
.help {
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.help h2 {
|
||||
margin: var(--space-8) 0 var(--space-2);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help h3 {
|
||||
margin: var(--space-6) 0 var(--space-1);
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.help p {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin: var(--space-2) 0;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.steps li + li {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.steps li::marker {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Glossaire : une liste de définitions, sur une carte. */
|
||||
.glossary {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-1) var(--space-4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.glossary dt {
|
||||
padding-top: var(--space-3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glossary dd {
|
||||
margin: 0;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.glossary dd + dt {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.about {
|
||||
margin-top: var(--space-8);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Mode paysage */
|
||||
|
||||
/* Téléphone tourné : le plateau prend toute la hauteur de l'écran, à
|
||||
* gauche ; la barre haute, les messages et les boutons passent dans une
|
||||
* colonne à sa droite. */
|
||||
@media (orientation: landscape) and (max-height: 40rem) {
|
||||
body {
|
||||
max-width: none;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
/* Le jeu en deux colonnes : #game laisse ses enfants dans la grille de
|
||||
* la page. Quand l'aide est affichée, #game est caché, la première
|
||||
* colonne n'a plus de largeur et l'aide prend toute la place. */
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(13rem, 1fr);
|
||||
grid-template-rows: auto auto auto auto auto 1fr;
|
||||
column-gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#game {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
padding-block: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / -1;
|
||||
width: auto;
|
||||
height: calc(100vh - 2 * var(--space-2));
|
||||
height: calc(100dvh - 2 * var(--space-2));
|
||||
max-width: 70vw;
|
||||
margin-block: var(--space-2);
|
||||
}
|
||||
|
||||
.pips,
|
||||
.info,
|
||||
.prompt,
|
||||
.actions {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.pips {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.help {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// bench-page.mjs -- page de mesure (bench.html) : chronomètre le moteur
|
||||
// dans un Web Worker et produit le rapport à copier.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { DOWNLOADED_FILES, DOWNLOAD_LIMIT_BYTES, formatReport, measureTimes, summarize }
|
||||
from "./benchmark.mjs";
|
||||
import { startEngine } from "./engine-client.mjs";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// Taille d'un fichier du site, brute et compressée gzip (comme le ferait un
|
||||
// serveur qui compresse ses réponses).
|
||||
async function fileSize(name) {
|
||||
const blob = await (await fetch(name, { cache: "no-store" })).blob();
|
||||
const gzipped = blob.stream().pipeThrough(new CompressionStream("gzip"));
|
||||
const gzipBytes = (await new Response(gzipped).arrayBuffer()).byteLength;
|
||||
return { name, bytes: blob.size, gzipBytes };
|
||||
}
|
||||
|
||||
function addRow(cells) {
|
||||
const row = $("table").insertRow();
|
||||
for (const text of cells) {
|
||||
row.insertCell().textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
async function measure() {
|
||||
$("start").disabled = true;
|
||||
$("results").hidden = true;
|
||||
$("table").replaceChildren();
|
||||
|
||||
$("status").textContent = "Chargement du moteur…";
|
||||
const start = performance.now();
|
||||
const engine = startEngine();
|
||||
await engine.ready;
|
||||
const loadMs = performance.now() - start;
|
||||
|
||||
const { positions } = await (await fetch("wasm/positions.json")).json();
|
||||
let memoryBytes = 0;
|
||||
const times = await measureTimes({
|
||||
bestMove: (...args) => engine.bestMove(...args).then(keepMemory),
|
||||
cubeDecision: (...args) => engine.cubeDecision(...args).then(keepMemory),
|
||||
}, positions, (done, total) => {
|
||||
$("status").textContent = "Calcul " + done + " sur " + total + "…";
|
||||
});
|
||||
function keepMemory(answer) {
|
||||
memoryBytes = answer.memoryBytes;
|
||||
return answer;
|
||||
}
|
||||
|
||||
$("status").textContent = "Mesure de la taille à télécharger…";
|
||||
const files = await Promise.all(DOWNLOADED_FILES.map(fileSize));
|
||||
|
||||
for (const s of summarize(times)) {
|
||||
addRow([s.label, Math.round(s.medianMs) + " ms", Math.round(s.maxMs) + " ms",
|
||||
s.limitMs + " ms", s.ok ? "OK" : "dépassé"]);
|
||||
}
|
||||
const gzipTotal = files.reduce((sum, f) => sum + f.gzipBytes, 0);
|
||||
addRow(["Taille compressée", "", (gzipTotal / 1e6).toFixed(1).replace(".", ",") + " Mo",
|
||||
DOWNLOAD_LIMIT_BYTES / 1e6 + " Mo", gzipTotal < DOWNLOAD_LIMIT_BYTES ? "OK" : "dépassé"]);
|
||||
|
||||
$("report").value = formatReport({
|
||||
environment: navigator.userAgent, files, loadMs, memoryBytes, times,
|
||||
});
|
||||
$("results").hidden = false;
|
||||
$("status").textContent = "Mesure terminée.";
|
||||
}
|
||||
|
||||
// Copie dans le presse-papiers. L'API moderne n'existe que sur les pages
|
||||
// sécurisées (https ou localhost) : sur le réseau local en http, on
|
||||
// sélectionne le texte et on utilise l'ancienne commande de copie.
|
||||
async function copyReport() {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText($("report").value);
|
||||
} else {
|
||||
$("report").select();
|
||||
if (!document.execCommand("copy")) {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
$("copy-status").textContent = "Rapport copié : collez-le dans la conversation.";
|
||||
} catch {
|
||||
$("report").select();
|
||||
$("copy-status").textContent = "Copie impossible : le texte est sélectionné, "
|
||||
+ "copiez-le à la main (appui long, puis Copier).";
|
||||
}
|
||||
}
|
||||
|
||||
$("start").onclick = () => measure().catch((error) => {
|
||||
$("status").textContent = "La mesure a échoué : " + error.message;
|
||||
$("status").classList.add("error");
|
||||
$("start").disabled = false;
|
||||
});
|
||||
$("copy").onclick = copyReport;
|
||||
@@ -1,35 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ustagammon : mesure du moteur</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="bench-page.mjs"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Mesure du moteur</h1>
|
||||
<p>Cette page chronomètre le moteur sur les positions de référence : meilleur coup
|
||||
aux niveaux rapide et fort, décision de videau au niveau fort. Elle mesure aussi
|
||||
la taille à télécharger et la mémoire utilisée. Comptez une à quelques minutes ;
|
||||
gardez l'écran allumé et la page au premier plan.</p>
|
||||
<button id="start" type="button">Lancer la mesure</button>
|
||||
<p id="status" class="status"></p>
|
||||
|
||||
<section id="results" hidden>
|
||||
<h2>Résultats</h2>
|
||||
<table>
|
||||
<thead><tr><th>Mesure</th><th>Médiane</th><th>Maximum</th><th>Seuil</th><th></th></tr></thead>
|
||||
<tbody id="table"></tbody>
|
||||
</table>
|
||||
<h2>Rapport</h2>
|
||||
<button id="copy" type="button">Copier les résultats</button>
|
||||
<p id="copy-status" class="status"></p>
|
||||
<textarea id="report" readonly></textarea>
|
||||
</section>
|
||||
|
||||
<p><a href="./">Page de test</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
// engine-client.mjs -- le moteur vu depuis la page : démarre le Web Worker
|
||||
// (engine-worker.mjs) et lui envoie les commandes.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Toutes les fonctions renvoient une promesse de { result, ms, memoryBytes } :
|
||||
// result est l'objet JSON d'engine-cli, ms le temps de calcul mesuré dans le
|
||||
// Worker. La promesse est rejetée si le moteur signale une erreur.
|
||||
|
||||
import { commands } from "./engine-wasm.mjs";
|
||||
|
||||
export function startEngine() {
|
||||
const worker = new Worker(new URL("engine-worker.mjs", import.meta.url), { type: "module" });
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let resolveReady, rejectReady;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
const message = event.data;
|
||||
if (message.type === "ready") {
|
||||
resolveReady(message);
|
||||
} else if (message.type === "failed") {
|
||||
rejectReady(new Error(message.message));
|
||||
} else {
|
||||
const { resolve, reject } = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Erreur au chargement du Worker lui-même (fichier absent, navigateur
|
||||
// trop ancien pour les Workers en module ES…).
|
||||
worker.onerror = (event) => {
|
||||
rejectReady(new Error(event.message || "le Web Worker du moteur n'a pas démarré"));
|
||||
};
|
||||
|
||||
function run(args) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, args: args.map(String) });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// Promesse de { loadMs, memoryBytes } une fois le moteur chargé.
|
||||
ready,
|
||||
bestMove: (positionId, matchId, dice, ply) =>
|
||||
run(commands.bestMove(positionId, matchId, dice, ply)),
|
||||
cubeDecision: (positionId, matchId, ply) =>
|
||||
run(commands.cubeDecision(positionId, matchId, ply)),
|
||||
evaluate: (positionId, matchId, ply) =>
|
||||
run(commands.evaluate(positionId, matchId, ply)),
|
||||
reference: (positionId, matchId, dice, ply) =>
|
||||
run(commands.reference(positionId, matchId, dice, ply)),
|
||||
};
|
||||
}
|
||||
@@ -36,10 +36,19 @@ export async function loadEngine(createEngineModule, wasmDir) {
|
||||
throw new Error("le moteur n'a pas pu charger ses données (code " + status + ")");
|
||||
}
|
||||
|
||||
// callMain copie les arguments sur la pile du WebAssembly sans jamais
|
||||
// les retirer : on remet la pile en place après chaque commande, sinon
|
||||
// elle déborde au bout de quelques centaines de commandes (environ 540
|
||||
// pendant une partie) et le moteur plante ou ne répond plus.
|
||||
function runText(args) {
|
||||
output = [];
|
||||
const exitCode = module.callMain(args.map(String));
|
||||
return { text: output.join("\n"), exitCode };
|
||||
const stack = module.stackSave();
|
||||
try {
|
||||
const exitCode = module.callMain(args.map(String));
|
||||
return { text: output.join("\n"), exitCode };
|
||||
} finally {
|
||||
module.stackRestore(stack);
|
||||
}
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2013 The Alegreya Sans Project Authors (https://github.com/huertatipografica/Alegreya-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -3,46 +3,66 @@
|
||||
<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="#4A2E1F">
|
||||
<title>Ustagammon</title>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="icons/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="icons/favicon-32.png" sizes="32x32" type="image/png">
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon-180.png">
|
||||
<link rel="preload" href="fonts/AlegreyaSans-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="fonts/AlegreyaSans-Bold.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<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>
|
||||
<body class="busy">
|
||||
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
|
||||
<header class="topbar">
|
||||
<!-- Logo de la charte (docs/identite/logo/, copié dans icons/ par make
|
||||
site) : horizontal, ou symbole seul quand la place manque (téléphone
|
||||
étroit, colonne du mode paysage) ; version sombre en thème sombre. -->
|
||||
<h1 class="brand">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark) and (max-width: 24.5rem), (prefers-color-scheme: dark) and (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-sombre.svg">
|
||||
<source media="(max-width: 24.5rem), (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-couleur.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="icons/logo-horizontal-sombre.svg">
|
||||
<img src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
</picture>
|
||||
</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>
|
||||
<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="confirm" type="button" hidden></button>
|
||||
<button id="cancel" type="button" class="secondary" hidden></button>
|
||||
<button id="skip" type="button" hidden></button>
|
||||
<button id="replay" type="button" class="secondary" hidden></button>
|
||||
<button id="again" type="button" hidden></button>
|
||||
</div>
|
||||
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
|
||||
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
|
||||
<p id="double-question"></p>
|
||||
<div class="confirm-actions">
|
||||
<button id="double-confirm" type="button"></button>
|
||||
<button id="double-cancel" type="button" class="secondary"></button>
|
||||
</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>
|
||||
</dialog>
|
||||
<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,19 @@
|
||||
{
|
||||
"name": "Ustagammon",
|
||||
"short_name": "Ustagammon",
|
||||
"description": "Jouer au backgammon contre l'ordinateur, hors ligne.",
|
||||
"lang": "fr",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#F5EFE5",
|
||||
"theme_color": "#4A2E1F",
|
||||
"icons": [
|
||||
{ "src": "icons/icone-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icone-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icone-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "icons/icone-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "icons/icone.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||
]
|
||||
}
|
||||