Étape 0d.1 : domaine « partie » en TypeScript, scénario exécuté ; ADR 0009 accepté #11

Merged
pierre merged 11 commits from plateau into main 2026-11-02 20:14:20 +00:00
46 changed files with 2591 additions and 21 deletions
+8
View File
@@ -49,6 +49,14 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
inclus dans le moteur WebAssembly, licences compatibles GPLv3. L'image
fournit aussi **Node.js** (MIT), qui rejoue la suite de contrat sur le
moteur WebAssembly et lance `make bench`.
- **Image `ustagammon/web`** (`docker/web/Dockerfile`), construite sur
`node:22.11.0-alpine3.20` : **TypeScript** 6.0.3 (Apache-2.0), qui compile
`app/` ; **@types/node** 22.20.5 (MIT), types de Node pour les tests ;
**Biome** 2.5.15 (MIT ou Apache-2.0), lint et formatage ;
**@cucumber/cucumber** 12.9.0 (MIT), qui exécute les scénarios de
`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.
## Serveur de production
+19 -3
View File
@@ -20,19 +20,23 @@ REFERENCE_IMAGE := ustagammon/gnubg-reference:14b6798
BUILD_C_IMAGE := ustagammon/build-c:2
# Emscripten (compilation WebAssembly ; contient aussi Node et Python 3).
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).
WEB_PORT := 8080
# Serveur statique de production (deploy/Dockerfile) : même version ici.
CADDY_IMAGE := caddy:2.8.4-alpine
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
DOCKER_C := docker run --rm --user "$$(id -u):$$(id -g)" -v "$(CURDIR):/src" -w /src $(BUILD_C_IMAGE)
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 bench test lint deploy deploy-web site package deploy-prod clean reference-image reference
.PHONY: help setup build engine engine-wasm web bench test lint deploy deploy-web site package deploy-prod clean reference-image reference
help: ## Liste les commandes disponibles
@echo "Ustagammon : commandes disponibles"
@@ -52,9 +56,11 @@ setup: ## Installe la chaîne d'outils (images Docker) ; sans effet si déjà fa
|| docker pull $(WASM_IMAGE)
@docker image inspect $(CADDY_IMAGE) >/dev/null 2>&1 \
|| docker pull $(CADDY_IMAGE)
@docker image inspect $(WEB_IMAGE) >/dev/null 2>&1 \
|| docker build -t $(WEB_IMAGE) docker/web
@echo "Chaîne d'outils prête."
build: engine ## Compile le moteur (si nécessaire) puis l'application
build: engine-wasm web ## Compile le moteur (si nécessaire) puis l'application
engine: ## Compile le moteur, ses données et build/engine-cli (dans Docker)
@docker image inspect $(BUILD_C_IMAGE) >/dev/null 2>&1 \
@@ -66,6 +72,16 @@ engine-wasm: engine ## Compile le moteur en WebAssembly dans build/wasm/ (dans D
|| { echo "Emscripten absent : lancer d'abord make setup"; exit 1; }
@$(DOCKER_WASM) make -f engine-wasm/wasm.mk -j2 --no-print-directory
# L'application est recompilée entièrement (quelques secondes) : build/web/
# ne garde ainsi aucun fichier dont la source a disparu. Le package.json
# indique à Node que les .js produits sont des modules ES.
web: ## Compile l'application TypeScript (app/) dans build/web/ (dans Docker)
@docker image inspect $(WEB_IMAGE) >/dev/null 2>&1 \
|| { echo "Outils web absents : lancer d'abord make setup"; exit 1; }
@rm -rf $(BUILD_DIR)/web
@$(DOCKER_WEB) tsc -p app/tsconfig.json
@cp app/package.json $(BUILD_DIR)/web/package.json
bench: engine-wasm ## Mesure le moteur WebAssembly sous Node, sur ce PC (rapport texte)
@$(DOCKER_WASM) node engine-wasm/bench.mjs
+43
View File
@@ -0,0 +1,43 @@
// Règles d'architecture de l'application (ADR 0007, point 2 ; ADR 0009),
// vérifiées par dependency-cruiser dans make test (tests/test_web.sh).
// Les chemins sont relatifs au dossier app/.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "domain-is-pure",
comment:
"Le domaine n'importe que le domaine : ni le moteur (src/engine), ni " +
"l'interface (src/ui), ni Node, ni bibliothèque, ni code du navigateur. " +
"Il décrit ce dont il a besoin (interface Engine) ; les adaptateurs " +
"l'implémentent.",
severity: "error",
from: { path: "^src/domain/" },
to: { pathNot: "^src/domain/" },
},
{
name: "ui-does-not-call-engine",
comment:
"Aucun écran n'appelle le moteur directement : l'interface passe par le domaine.",
severity: "error",
from: { path: "^src/ui/" },
to: { path: "^src/engine/" },
},
{
name: "no-circular",
comment: "Pas de dépendance circulaire.",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
// Les imports de types comptent aussi : ils disparaissent à la
// compilation, mais créent bien une dépendance dans le code source.
tsPreCompilationDeps: true,
// Résolution des imports comme TypeScript : « ./events.js » désigne
// le fichier events.ts. Le tsconfig.json est celui du dossier courant.
tsConfig: { fileName: "tsconfig.json" },
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"files": {
"includes": ["**", "!**/node_modules"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 4,
"lineWidth": 100
},
"linter": {
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "ustagammon-app",
"private": true,
"license": "GPL-3.0-or-later",
"type": "module"
}
+19
View File
@@ -0,0 +1,19 @@
// Le videau.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Side } from "./side.js";
/**
* Le videau : sa valeur (1, 2, 4…) et le camp qui le possède, seul à pouvoir
* redoubler (null : au centre, les deux peuvent doubler).
*
* Glossaire : docs/glossaire/videau.md
*/
export interface Cube {
readonly value: number;
readonly owner: Side | null;
}
export const CENTERED_CUBE: Cube = { value: 1, owner: null };
+119
View File
@@ -0,0 +1,119 @@
// Ce que le domaine demande au moteur (ADR 0007, point 2).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le domaine définit cette interface ; un adaptateur par plateforme
// l'implémente (src/engine/ pour le moteur WebAssembly). Toutes les règles du
// jeu viennent d'ici : le domaine n'en réimplémente aucune.
//
// Les positions sont toujours vues du joueur au trait, celui qui joue ou
// double. Les méthodes sont asynchrones : dans le navigateur, le moteur
// calcule dans un Web Worker.
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/**
* Le moteur de jeu : GNU Backgammon.
*
* Glossaire : docs/glossaire/moteur.md
*/
export interface Engine {
/** Les coups légaux d'un jet (liste vide : le joueur ne peut pas jouer). */
legalMoves(position: PositionId, roll: Roll): Promise<Move[]>;
/** Le meilleur coup au niveau ply, ou null si aucun coup n'est possible. */
bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise<Move | null>;
/** La décision de videau du joueur au trait, avant de lancer les dés. */
cubeDecision(position: PositionId, cube: EngineCube, ply: Ply): Promise<CubeDecision>;
/** Où en est un coup saisi demi-coup par demi-coup. */
checkHalfMoves(
position: PositionId,
roll: Roll,
halfMoves: readonly HalfMove[],
): Promise<HalfMovesCheck>;
/** Le plateau d'une position, et le résultat si la partie y est finie. */
board(position: PositionId, cube: EngineCube): Promise<Board>;
}
/**
* Niveau de réflexion du moteur, en plis : 0 (évaluation directe) à 4.
*
* Glossaire : docs/glossaire/pli.md
*/
export type Ply = number;
/** Le videau vu du joueur au trait. */
export interface EngineCube {
readonly value: number;
readonly owner: "centered" | "onRoll" | "opponent";
}
/**
* Un coup légal complet.
*
* Glossaire : docs/glossaire/coup.md
*/
export interface Move {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
/** Ses demi-coups, dans un ordre jouable. */
readonly halfMoves: readonly HalfMove[];
/** La position après le coup, vue du joueur qui vient de jouer. */
readonly position: PositionId;
}
export interface CubeDecision {
/** Le joueur au trait a le droit de doubler. */
readonly available: boolean;
/** Le moteur lui recommande de doubler. */
readonly double: boolean;
/** S'il double, le moteur recommande à l'adversaire de prendre. */
readonly take: boolean;
}
export interface HalfMovesCheck {
/** Les demi-coups commencent un coup légal. */
readonly valid: boolean;
/** Ils forment un coup légal complet. */
readonly complete: boolean;
/** La position après eux, vue du joueur qui joue (null si refusés). */
readonly position: PositionId | null;
}
/**
* Un plateau décodé, pour l'affichage et pour savoir si la partie est finie.
*
* Glossaire : docs/glossaire/plateau.md
*/
export interface Board {
readonly onRoll: BoardSide;
readonly opponent: BoardSide;
/** La même position vue de l'adversaire. */
readonly swappedPosition: PositionId;
/** null tant que la partie n'est pas finie. */
readonly result: BoardResult | null;
}
/** Un camp, vu de lui-même : sa case 1 est la plus proche de sa sortie. */
export interface BoardSide {
/** Nombre de pions sur les cases 1 à 24 (indices 0 à 23). */
readonly points: readonly number[];
readonly bar: number;
readonly off: number;
readonly pipCount: number;
}
/** Simple, gammon ou backgammon. */
export type ResultKind = "single" | "gammon" | "backgammon";
export interface BoardResult {
readonly winner: "onRoll" | "opponent";
readonly kind: ResultKind;
/** Points gagnés, videau et règle Jacoby compris. */
readonly points: number;
}
+148
View File
@@ -0,0 +1,148 @@
// Les événements d'une partie et leur journal (ADR 0007, point 3).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une partie est la suite de ce qui s'est passé. Un événement n'est jamais
// modifié ; chacun pointe vers le précédent (parent). Les événements forment
// donc un arbre : une branche est un nouvel événement accroché à un ancien,
// et annuler revient à repartir du parent. Le format des événements est un
// contrat durable : le changer demande une migration et un ADR.
//
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
// le moteur : l'état se recalcule par une simple boucle (state.ts).
import type { Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
/** Version du format des événements. */
export const EVENT_VERSION = 1;
interface EventBase {
/** Identifiant, unique dans la partie. */
readonly id: string;
/** L'événement précédent ; null pour le premier. */
readonly parent: string | null;
readonly version: number;
}
/** Début de partie : la position, le videau, et qui commence (null : le jet
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
* départ est la même pour les deux. */
export interface GameStarted extends EventBase {
readonly type: "GameStarted";
readonly position: PositionId;
readonly cube: Cube;
readonly firstTurn: Side | null;
}
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
* dé : roll[0] est celui du joueur, roll[1] celui de l'ordinateur. */
export interface DiceRolled extends EventBase {
readonly type: "DiceRolled";
readonly side: Side | null;
readonly roll: Roll;
}
/** Un coup joué (aucun demi-coup si le jet ne permettait pas de jouer). La
* position est celle d'après le coup, vue de l'adversaire, qui a la main. */
export interface MovePlayed extends EventBase {
readonly type: "MovePlayed";
readonly side: Side;
readonly halfMoves: readonly HalfMove[];
readonly position: PositionId;
}
/** side propose de doubler. */
export interface CubeOffered extends EventBase {
readonly type: "CubeOffered";
readonly side: Side;
}
/** side prend le double : le videau double de valeur et passe chez lui. */
export interface CubeTaken extends EventBase {
readonly type: "CubeTaken";
readonly side: Side;
}
/** side passe le double : il perd la partie (GameEnded suit). */
export interface CubeDropped extends EventBase {
readonly type: "CubeDropped";
readonly side: Side;
}
/** Fin de partie. */
export interface GameEnded extends EventBase {
readonly type: "GameEnded";
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
}
/**
* Ce qui s'est passé à un moment de la partie.
*
* Glossaire : docs/glossaire/evenement-de-partie.md
*/
export type GameEvent =
| GameStarted
| DiceRolled
| MovePlayed
| CubeOffered
| CubeTaken
| CubeDropped
| GameEnded;
/** Un événement avant son ajout au journal (sans id, parent ni version). */
export type NewGameEvent = WithoutBase<GameEvent>;
// Omit appliqué à chaque sorte d'événement séparément (et non à leur union,
// qui ne garderait que les champs communs).
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
/** Tous les événements d'une partie, branches comprises, et l'événement
* courant (head) : la partie en est là. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
/** Ajoute un événement après l'événement courant, qui devient le nouveau. */
export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJournal {
const added = Object.freeze({
...event,
id: String(journal.events.length + 1),
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
return { events: [...journal.events, added], head: added.id };
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
export function currentLine(journal: GameJournal): GameEvent[] {
const byId = new Map(journal.events.map((event) => [event.id, event]));
const line: GameEvent[] = [];
let id = journal.head;
while (id !== null) {
const event = byId.get(id);
if (event === undefined) {
throw new Error(`événement introuvable : ${id}`);
}
line.push(event);
id = event.parent;
}
return line.reverse();
}
/** Revient à un événement passé ; le prochain ajout ouvrira une branche. */
export function goBackTo(journal: GameJournal, id: string): GameJournal {
if (!journal.events.some((event) => event.id === id)) {
throw new Error(`événement introuvable : ${id}`);
}
return { events: journal.events, head: id };
}
+313
View File
@@ -0,0 +1,313 @@
// Une partie libre contre l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
// des événements au journal (events.ts), d'où l'état est recalculé
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// 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 {
appendEvent,
currentLine,
EMPTY_JOURNAL,
type GameEvent,
type GameJournal,
goBackTo,
type NewGameEvent,
} from "./events.js";
import { type HalfMove, 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.
*/
export const STANDARD_PLY: Ply = 2;
/** Une partie qui ne commence pas à la position de départ. */
export interface GameSetup {
/** La position, vue de firstTurn. */
readonly position: PositionId;
/** Le camp qui a la main ; il n'y a pas de jet d'ouverture. */
readonly firstTurn: Side;
readonly cube?: Cube;
}
export interface GameOptions {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
readonly ply?: Ply;
readonly setup?: GameSetup;
}
/**
* Une partie contre l'ordinateur.
*
* Glossaire : docs/glossaire/partie.md
*/
export class Game {
readonly #engine: Engine;
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;
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
this.#dice = options.dice ?? randomDice;
this.#ply = options.ply ?? STANDARD_PLY;
}
/** Commence une partie : à la position de départ avec le jet d'ouverture,
* ou à la position de options.setup. */
static start(engine: Engine, options: GameOptions = {}): Game {
const game = new Game(engine, options);
const setup = options.setup;
game.#append({
type: "GameStarted",
position: setup?.position ?? START_POSITION,
cube: setup?.cube ?? CENTERED_CUBE,
firstTurn: setup?.firstTurn ?? null,
});
while (game.state.phase === "opening") {
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
}
return game;
}
/** Tous les événements, branches comprises, et l'événement courant. */
get journal(): GameJournal {
return this.#journal;
}
/** Les événements du début jusqu'à maintenant. */
get events(): GameEvent[] {
return currentLine(this.#journal);
}
get state(): GameState {
return replay(this.events);
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending;
}
/** La position à afficher : après les demi-coups déjà saisis. */
get displayedPosition(): PositionId {
return this.#pendingPosition ?? this.state.position;
}
// ------------------------------------------------------------- Joueur
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
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);
}
}
/**
* 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é.
*/
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 (!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;
}
return true;
}
/** Oublie les demi-coups saisis du coup en cours. */
cancelHalfMoves(): void {
this.#pending = [];
this.#pendingPosition = null;
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
* selon le moteur. */
async double(): Promise<void> {
const state = this.#expect("player", "rolling");
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
this.#ply,
);
if (!decision.available) {
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
}
this.#append({ type: "CubeOffered", side: "player" });
this.#answerDouble("computer", decision.take);
}
/** Le joueur prend le double de l'ordinateur. */
take(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", true);
}
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
drop(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", false);
}
// --------------------------------------------------------- Ordinateur
/**
* Le tour de l'ordinateur : il double si le moteur le recommande (le
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
* les dés et joue le meilleur coup du moteur.
*/
async playComputerTurn(): Promise<void> {
let state = this.#expect("computer", "rolling", "moving");
if (state.phase === "rolling") {
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "computer"),
this.#ply,
);
if (decision.double) {
this.#append({ type: "CubeOffered", side: "computer" });
return;
}
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
state = this.state;
}
const best = await this.#engine.bestMove(
state.position,
engineCube(state.cube, "computer"),
rollToPlay(state),
this.#ply,
);
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
}
// ------------------------------------------------------------ Retour
/** Revient à un événement passé (« Rejouer d'ici ») : la suite reste dans
* le journal, et la prochaine action ouvre une nouvelle branche. */
goBackTo(eventId: string): void {
this.cancelHalfMoves();
this.#journal = goBackTo(this.#journal, eventId);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
if (last?.parent == null) {
throw new Error("rien à annuler");
}
this.goBackTo(last.parent);
}
// ------------------------------------------------------------ Interne
#append(event: NewGameEvent): void {
this.#journal = appendEvent(this.#journal, event);
}
/** 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;
if (state.turn !== side || !phases.includes(state.phase)) {
throw new Error(
`action impossible : la main est à ${state.turn ?? "personne"}, phase ${state.phase}`,
);
}
return state;
}
/**
* Enregistre un coup. after est la position après le coup, vue de celui
* qui joue (null s'il ne pouvait pas jouer). Le moteur dit si la partie
* est finie et donne la position vue de l'adversaire.
*/
async #playMove(
side: Side,
halfMoves: readonly HalfMove[],
after: PositionId | null,
): Promise<void> {
const state = this.state;
const board = await this.#engine.board(
after ?? state.position,
engineCube(state.cube, side),
);
this.#append({
type: "MovePlayed",
side,
halfMoves: halfMoves.map((h) => ({ from: h.from, to: h.to })),
position: board.swappedPosition,
});
if (board.result !== null) {
this.#append({
type: "GameEnded",
winner: board.result.winner === "onRoll" ? side : otherSide(side),
kind: board.result.kind,
points: board.result.points,
});
}
}
/** taker prend ou passe le double proposé par l'autre camp. */
#answerDouble(taker: Side, take: boolean): void {
if (take) {
this.#append({ type: "CubeTaken", side: taker });
return;
}
// Passer : on perd la partie à la valeur du videau d'avant le double.
this.#append({ type: "CubeDropped", side: taker });
this.#append({
type: "GameEnded",
winner: otherSide(taker),
kind: "single",
points: this.state.cube.value,
});
}
}
/** Le jet à jouer, en phase moving. */
function rollToPlay(state: GameState): Roll {
if (state.roll === null) {
throw new Error("aucun jet à jouer");
}
return state.roll;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
return { value: cube.value, owner: "centered" };
}
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
}
+73
View File
@@ -0,0 +1,73 @@
// Positions et demi-coups.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Identifiant d'une position (Position ID de gnubg, 14 caractères). Il code le
* plateau vu du joueur au trait. Le domaine ne le décode jamais lui-même : il
* le passe au moteur.
*
* Glossaire : docs/glossaire/identifiant-de-position.md
*/
export type PositionId = string;
/** La position de départ, identique pour les deux joueurs. */
export const START_POSITION: PositionId = "4HPwATDgc/ABMA";
/** Case de la barre et de la sortie dans un demi-coup. */
export const BAR = 25;
export const OFF = 0;
/**
* Un pion déplacé avec un seul dé, d'une case à une autre. Les cases sont
* vues du joueur qui joue : 1 à 24, BAR (25) pour la barre, OFF (0) pour la
* sortie.
*
* Glossaire : docs/glossaire/demi-coup.md
*/
export interface HalfMove {
readonly from: number;
readonly to: number;
}
/** Lit un coup noté « 8/5 6/5 », « bar/22 » ou « 6/off » (un dé par demi-coup). */
export function parseHalfMoves(text: string): HalfMove[] {
return text
.trim()
.split(/\s+/)
.filter((part) => part !== "")
.map((part) => {
const [from, to, ...rest] = part.split("/");
if (from === undefined || to === undefined || rest.length > 0) {
throw new Error(`demi-coup mal formé : ${part}`);
}
return { from: parsePoint(from), to: parsePoint(to) };
});
}
/** Écrit des demi-coups sous la forme « 8/5 6/5 ». */
export function formatHalfMoves(halfMoves: readonly HalfMove[]): string {
return halfMoves.map((h) => `${formatPoint(h.from)}/${formatPoint(h.to)}`).join(" ");
}
function parsePoint(text: string): number {
if (text === "bar") {
return BAR;
}
if (text === "off") {
return OFF;
}
const point = Number(text);
if (!Number.isInteger(point) || point < 1 || point > 24) {
throw new Error(`case invalide : ${text}`);
}
return point;
}
function formatPoint(point: number): string {
if (point === BAR) {
return "bar";
}
return point === OFF ? "off" : String(point);
}
+46
View File
@@ -0,0 +1,46 @@
// Jets de dés et source du hasard.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Un jet : les deux dés, chacun de 1 à 6.
*
* Glossaire : docs/glossaire/jet.md
*/
export type Roll = readonly [number, number];
/**
* Source de dés : chaque appel renvoie un dé, de 1 à 6. Elle est passée au
* domaine (ADR 0007, point 4) : hasard véritable dans l'application, dés
* imposés dans les tests.
*/
export type DiceSource = () => number;
/** Le hasard ordinaire. */
export const randomDice: DiceSource = () => 1 + Math.floor(Math.random() * 6);
/** Des dés imposés, dans l'ordre ; erreur s'il n'en reste plus. */
export function fixedDice(values: readonly number[]): DiceSource {
let next = 0;
return () => {
const value = values[next];
if (value === undefined) {
throw new Error("plus de dés imposés");
}
next++;
return value;
};
}
/** Tire un jet de deux dés. */
export function rollDice(dice: DiceSource): Roll {
return [checkDie(dice()), checkDie(dice())];
}
function checkDie(value: number): number {
if (!Number.isInteger(value) || value < 1 || value > 6) {
throw new Error(`dé invalide : ${value}`);
}
return value;
}
+11
View File
@@ -0,0 +1,11 @@
// Les deux camps d'une partie contre l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Le joueur humain (« vous ») ou l'ordinateur. */
export type Side = "player" | "computer";
export function otherSide(side: Side): Side {
return side === "player" ? "computer" : "player";
}
+108
View File
@@ -0,0 +1,108 @@
// L'état d'une partie, recalculé en rejouant ses événements.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { GameEvent } from "./events.js";
import { type PositionId, START_POSITION } from "./position.js";
import type { Roll } from "./roll.js";
import { otherSide, type Side } from "./side.js";
/**
* Où en est la partie :
* - opening : jet d'ouverture, chacun lance un dé ;
* - rolling : turn peut doubler, puis lance les dés ;
* - moving : turn joue le jet roll ;
* - doubled : turn a proposé de doubler, l'adversaire doit prendre ou passer ;
* - over : partie finie, voir result.
*/
export type Phase = "opening" | "rolling" | "moving" | "doubled" | "over";
export interface GameResult {
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
}
export interface GameState {
readonly phase: Phase;
/** Le camp qui a la main (null pendant le jet d'ouverture). */
readonly turn: Side | null;
/** La position, vue du camp qui a la main. */
readonly position: PositionId;
/** Le jet à jouer (phase moving). */
readonly roll: Roll | null;
readonly cube: Cube;
readonly result: GameResult | null;
}
/** Avant le premier événement. */
const NOT_STARTED: GameState = {
phase: "opening",
turn: null,
position: START_POSITION,
roll: null,
cube: CENTERED_CUBE,
result: null,
};
/** Rejoue des événements, du premier au dernier. */
export function replay(events: readonly GameEvent[]): GameState {
return events.reduce(applyEvent, NOT_STARTED);
}
/** L'état après un événement. */
export function applyEvent(state: GameState, event: GameEvent): GameState {
switch (event.type) {
case "GameStarted":
return {
...NOT_STARTED,
position: event.position,
cube: event.cube,
turn: event.firstTurn,
phase: event.firstTurn === null ? "opening" : "rolling",
};
case "DiceRolled":
if (event.side !== null) {
return { ...state, phase: "moving", roll: event.roll };
}
// Jet d'ouverture : le plus fort dé commence et joue les deux
// dés ; en cas d'égalité, on relance.
if (event.roll[0] === event.roll[1]) {
return state;
}
return {
...state,
phase: "moving",
turn: event.roll[0] > event.roll[1] ? "player" : "computer",
roll: event.roll,
};
case "MovePlayed":
return {
...state,
phase: "rolling",
turn: otherSide(event.side),
position: event.position,
roll: null,
};
case "CubeOffered":
return { ...state, phase: "doubled" };
case "CubeTaken":
return {
...state,
phase: "rolling",
cube: { value: state.cube.value * 2, owner: event.side },
};
case "CubeDropped":
return state;
case "GameEnded":
return {
...state,
phase: "over",
roll: null,
result: { winner: event.winner, kind: event.kind, points: event.points },
};
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": [],
"noEmit": true
},
"include": ["."]
}
+144
View File
@@ -0,0 +1,144 @@
// L'interface Engine du domaine, implémentée avec le moteur WebAssembly.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le moteur WebAssembly répond aux mêmes commandes qu'engine-cli, en JSON
// (web/engine-wasm.mjs). Cet adaptateur traduit chaque méthode en commande et
// chaque réponse en types du domaine. Il ne sait pas comment la commande est
// exécutée : directement sous Node, dans un Web Worker dans le navigateur.
import type {
Board,
BoardResult,
BoardSide,
CubeDecision,
Engine,
EngineCube,
HalfMovesCheck,
Move,
Ply,
} from "../domain/game/engine.js";
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
* promesse est rejetée si le moteur signale une erreur. */
export type RunCommand = (args: string[]) => Promise<unknown>;
// Les réponses d'engine-cli utilisées ici (voir engine-cli/main.c).
interface CliMove {
move: string;
position_id: string;
half_moves: [number, number][];
}
interface CliSide {
points: number[];
bar: number;
off: number;
pip_count: number;
}
interface CliBoard {
on_roll: CliSide;
opponent: CliSide;
swapped_position_id: string;
result: { winner: "on_roll" | "opponent"; kind: BoardResult["kind"]; points: number } | null;
}
export function createWasmEngine(run: RunCommand): Engine {
// Un Match ID par état du videau : le moteur les code une fois.
const matchIds = new Map<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
// trait, 1 son adversaire, -1 le centre.
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
const key = `${cube.value} ${owner}`;
let id = matchIds.get(key);
if (id === undefined) {
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
match_id: string;
};
id = answer.match_id;
matchIds.set(key, id);
}
return id;
}
return {
async legalMoves(position: PositionId, roll: Roll): Promise<Move[]> {
// Le Match ID n'a pas d'effet sur les coups légaux.
const answer = (await run(["legal", position, "MAEAAAAAAAAA", diceText(roll)])) as {
moves: CliMove[];
};
return answer.moves.map(toMove);
},
async bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "bestmove", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null };
return answer.best_move === null ? null : toMove(answer.best_move);
},
async cubeDecision(
position: PositionId,
cube: EngineCube,
ply: Ply,
): Promise<CubeDecision> {
const args = ["--ply", String(ply), "cube", position, await matchId(cube)];
const answer = (await run(args)) as CubeDecision;
return { available: answer.available, double: answer.double, take: answer.take };
},
async checkHalfMoves(
position: PositionId,
roll: Roll,
halfMoves: readonly HalfMove[],
): Promise<HalfMovesCheck> {
const args = ["halfmoves", position, diceText(roll), formatHalfMoves(halfMoves)];
const answer = (await run(args)) as {
valid: boolean;
complete: boolean;
result_position_id: string | null;
};
return {
valid: answer.valid,
complete: answer.complete,
position: answer.result_position_id,
};
},
async board(position: PositionId, cube: EngineCube): Promise<Board> {
const answer = (await run(["position", position, await matchId(cube)])) as CliBoard;
return {
onRoll: toSide(answer.on_roll),
opponent: toSide(answer.opponent),
swappedPosition: answer.swapped_position_id,
result:
answer.result === null
? null
: {
winner: answer.result.winner === "on_roll" ? "onRoll" : "opponent",
kind: answer.result.kind,
points: answer.result.points,
},
};
},
};
}
function diceText(roll: Roll): string {
return `${roll[0]}${roll[1]}`;
}
function toMove(move: CliMove): Move {
return {
notation: move.move,
halfMoves: move.half_moves.map(([from, to]) => ({ from, to })),
position: move.position_id,
};
}
function toSide(side: CliSide): BoardSide {
return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count };
}
View File
+68
View File
@@ -0,0 +1,68 @@
// Une partie complète sur le vrai moteur WebAssembly, avec des dés imposés :
// l'ordinateur contre une stratégie simple du joueur (toujours le premier
// coup légal, saisi demi-coup par demi-coup, et toujours prendre).
//
// 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 type { DiceSource } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
import { loadNodeEngine } from "./support/node-engine.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/** Joue jusqu'à la fin ; renvoie le nombre d'actions. */
async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNodeEngine>>) {
let actions = 0;
while (game.state.phase !== "over") {
assert.ok(++actions < 2000, "la partie ne finit pas");
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
game.take();
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
await game.roll();
} 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");
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
}
}
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: seededDice(seed), ply });
await playToTheEnd(game, engine);
const events = game.events;
const last = events.at(-1);
assert.equal(last?.type, "GameEnded");
const result = game.state.result;
assert.ok(result !== null);
assert.ok(result.points >= 1);
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "player"));
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "computer"));
// Rejouer le journal redonne le même état.
assert.deepEqual(replay(events), game.state);
});
}
+220
View File
@@ -0,0 +1,220 @@
// Tests unitaires du domaine « partie », avec un faux moteur (ADR 0007,
// point 7) : journal, rejeu, branches, déroulement des tours et du videau.
//
// 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 {
Board,
CubeDecision,
Engine,
HalfMovesCheck,
Move,
} from "../src/domain/game/engine.js";
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
* textes sans signification. */
function fakeEngine(overrides: Partial<Engine> = {}): Engine {
const move: Move = {
notation: "8/5 6/5",
halfMoves: parseHalfMoves("8/5 6/5"),
position: "AFTER",
};
const notOver: Board = {
onRoll: { points: [], bar: 0, off: 0, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 0 },
swappedPosition: "SWAPPED",
result: null,
};
const noDouble: CubeDecision = { available: true, double: false, take: true };
return {
legalMoves: async () => [move],
bestMove: async () => move,
cubeDecision: async () => noDouble,
checkHalfMoves: async (_position, _roll, halfMoves): Promise<HalfMovesCheck> => {
const text = formatHalfMoves(halfMoves);
if (text === "8/5") {
return { valid: true, complete: false, position: "HALF" };
}
if (text === "8/5 6/5") {
return { valid: true, complete: true, position: "AFTER" };
}
return { valid: false, complete: false, position: null };
},
board: async () => notOver,
...overrides,
};
}
test("le jet d'ouverture se relance en cas d'égalité, puis le plus fort dé commence", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([4, 4, 2, 5]) });
const rolls = game.events.filter((e) => e.type === "DiceRolled");
assert.deepEqual(
rolls.map((e) => e.roll),
[
[4, 4],
[2, 5],
],
);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "moving");
assert.deepEqual(game.state.roll, [2, 5]);
assert.equal(game.state.position, START_POSITION);
});
test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change rien", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.equal(await game.playHalfMove({ from: 13, to: 12 }), false);
assert.equal(game.events.length, 2);
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(game.displayedPosition, "HALF");
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
const last = game.events.at(-1);
assert.equal(last?.type, "MovePlayed");
assert.deepEqual(game.pendingHalfMoves, []);
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 () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
dice: fixedDice([6, 6]),
setup: { position: "BLOCKED", firstTurn: "player" },
});
await game.roll();
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed");
assert.deepEqual(last.halfMoves, []);
assert.equal(game.state.turn, "computer");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
game.take();
assert.deepEqual(game.state.cube, { value: 2, owner: "player" });
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.turn, "computer");
});
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
});
await game.playComputerTurn();
game.drop();
assert.equal(game.state.phase, "over");
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
});
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: false }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
await game.double();
assert.deepEqual(
game.events.slice(-3).map((e) => e.type),
["CubeOffered", "CubeDropped", "GameEnded"],
);
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
});
test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: false, double: false, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
});
await assert.rejects(game.double(), /videau/);
assert.equal(game.events.length, 1);
});
test("la fin de partie vient du moteur : gagnant, sorte et points", async () => {
const engine = fakeEngine({
board: async () => ({
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
swappedPosition: "SWAPPED",
result: { winner: "onRoll", kind: "gammon", points: 2 },
}),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer" },
dice: fixedDice([2, 1]),
});
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
assert.throws(() => game.take(), /action impossible/);
});
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 });
const played = game.events.at(-1);
assert.equal(played?.type, "MovePlayed");
game.undo();
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
assert.equal(moves.length, 2);
assert.equal(moves[0]?.parent, moves[1]?.parent);
assert.notEqual(game.events.at(-1)?.id, played?.id);
game.goBackTo(played?.id ?? "");
assert.equal(game.events.at(-1)?.id, played?.id);
});
test("les événements sont immuables et versionnés ; le rejeu est déterministe", () => {
let journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
const [started, rolled] = currentLine(journal);
assert.equal(started?.version, 1);
assert.equal(started?.parent, null);
assert.equal(rolled?.parent, started?.id);
assert.throws(() => {
(rolled as { parent: string | null }).parent = null;
}, TypeError);
assert.deepEqual(replay(currentLine(journal)), replay(currentLine(journal)));
assert.equal(replay(currentLine(goBackTo(journal, "1"))).phase, "opening");
});
test("lecture et écriture des demi-coups", () => {
assert.deepEqual(parseHalfMoves("bar/22 6/off"), [
{ from: 25, to: 22 },
{ from: 6, to: 0 },
]);
assert.equal(formatHalfMoves(parseHalfMoves(" 8/5 6/5 ")), "8/5 6/5");
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
});
+242
View File
@@ -0,0 +1,242 @@
// Étapes du scénario docs/scenarios/jouer-une-partie.feature (ADR 0008) :
// elles pilotent le domaine, branché sur le vrai moteur WebAssembly, avec des
// dés imposés pour que chaque exécution soit identique.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
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 { 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 { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { loadNodeEngine } from "../support/node-engine.js";
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
setDefaultTimeout(30_000);
// Course où l'ordinateur, au trait, mène nettement : le moteur lui recommande
// de doubler, et au joueur de prendre (position de référence
// « cube-double-take », tests/reference/positions.json).
const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5 ;
// l'ordinateur n'en a sorti aucun.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
let engine: Engine;
let game: Game;
let accepted: boolean;
let positionBefore: PositionId;
let computerTurn: { before: GameState; ms: number };
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Before(() => {
accepted = false;
});
/** Joue les demi-coups un par un ; au premier refus, le coup est abandonné. */
async function play(text: string): Promise<boolean> {
positionBefore = game.displayedPosition;
for (const halfMove of parseHalfMoves(text)) {
if (!(await game.playHalfMove(halfMove))) {
game.cancelHalfMoves();
return false;
}
}
return true;
}
function lastEvent(): GameEvent {
const event = game.events.at(-1);
assert.ok(event !== undefined, "aucun événement");
return event;
}
// ------------------------------------------------------- Commencer une partie
When("vous commencez une nouvelle partie", () => {
// Égalité au premier jet d'ouverture, puis 5 contre 2.
game = Game.start(engine, { dice: fixedDice([4, 4, 5, 2]) });
});
Then("les pions sont en position de départ", async () => {
assert.equal(game.state.position, START_POSITION);
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
const start = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0);
for (const side of [board.onRoll, board.opponent]) {
assert.deepEqual(side.points, start);
assert.equal(side.bar, 0);
assert.equal(side.off, 0);
}
});
Then("le premier jet de dés désigne qui commence", () => {
const opening = game.events.filter((e) => e.type === "DiceRolled" && e.side === null);
const last = opening.at(-1);
assert.ok(last?.type === "DiceRolled");
// Les jets d'ouverture égaux ont été relancés.
for (const tie of opening.slice(0, -1)) {
assert.ok(tie.type === "DiceRolled" && tie.roll[0] === tie.roll[1]);
}
const [playerDie, computerDie] = last.roll;
assert.notEqual(playerDie, computerDie);
assert.equal(game.state.turn, playerDie > computerDie ? "player" : "computer");
assert.equal(game.state.phase, "moving");
assert.deepEqual(game.state.roll, last.roll);
});
// ------------------------------------------------------------ Jouer un coup
Given("c'est à vous de jouer avec le jet {int}-{int}", (first: number, second: number) => {
// Le jet d'ouverture : votre dé, puis celui de l'ordinateur.
game = Game.start(engine, { dice: fixedDice([first, second]) });
assert.equal(game.state.turn, "player");
assert.deepEqual(game.state.roll, [first, second]);
});
When(/^vous jouez (.+)$/, async (text: string) => {
accepted = await play(text);
});
When(/^vous essayez de jouer (.+)$/, async (text: string) => {
accepted = await play(text);
});
Then("votre coup est accepté", () => {
assert.equal(accepted, true);
const event = lastEvent();
assert.ok(event.type === "MovePlayed");
assert.equal(event.side, "player");
});
Then("c'est ensuite au tour de l'ordinateur", () => {
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "rolling");
});
Then("votre coup est refusé", () => {
assert.equal(accepted, false);
});
Then("la position n'a pas changé", () => {
assert.equal(game.displayedPosition, positionBefore);
assert.equal(game.state.position, positionBefore);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
assert.equal(lastEvent().type, "DiceRolled");
});
// ------------------------------------------------------- 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(game.state.turn, "computer");
});
When("ses dés sont tirés", async () => {
const before = game.state;
const start = performance.now();
await game.playComputerTurn();
computerTurn = { before, ms: performance.now() - start };
});
Then(
"il joue le coup recommandé par le moteur en moins de {int} secondes",
async (seconds: number) => {
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
const played = lastEvent();
assert.ok(rolled?.type === "DiceRolled" && rolled.side === "computer");
assert.ok(played.type === "MovePlayed" && played.side === "computer");
const best: Move | null = await engine.bestMove(
computerTurn.before.position,
{ value: 1, owner: "centered" },
rolled.roll,
STANDARD_PLY,
);
assert.ok(best !== null);
assert.deepEqual(played.halfMoves, best.halfMoves);
assert.ok(
computerTurn.ms < seconds * 1000,
`tour de l'ordinateur en ${Math.round(computerTurn.ms)} ms`,
);
},
);
// ------------------------------------------------------------------ Videau
Given("l'ordinateur vous propose de doubler", async () => {
game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } });
await game.playComputerTurn();
const event = lastEvent();
assert.ok(event.type === "CubeOffered" && event.side === "computer");
assert.equal(game.state.phase, "doubled");
});
When("vous prenez", () => {
game.take();
});
When("vous passez", () => {
game.drop();
});
Then("la partie continue à valeur doublée", () => {
assert.equal(game.state.cube.value, 2);
assert.equal(game.state.result, null);
assert.equal(game.state.phase, "rolling");
});
Then("le videau est de votre côté : vous seul pourrez redoubler", async () => {
const { cube, position } = game.state;
assert.equal(cube.owner, "player");
// Le moteur le confirme : l'ordinateur, au trait, ne peut plus doubler…
const computer = await engine.cubeDecision(
position,
{ value: 2, owner: "opponent" },
STANDARD_PLY,
);
assert.equal(computer.available, false);
// … alors que vous, à votre tour, le pourrez.
const board = await engine.board(position, { value: 2, owner: "opponent" });
const player = await engine.cubeDecision(
board.swappedPosition,
{ value: 2, owner: "onRoll" },
STANDARD_PLY,
);
assert.equal(player.available, true);
});
Then("la partie s'arrête", () => {
assert.equal(game.state.phase, "over");
assert.equal(lastEvent().type, "GameEnded");
});
Then("vous perdez la partie à sa valeur d'avant le double", () => {
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 1 });
});
// --------------------------------------------------------- Fin de partie
Given("un joueur a sorti tous ses pions", async () => {
game = Game.start(engine, {
setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await game.roll();
assert.equal(await play("6/off 5/off"), true);
});
Then("le résultat indique le gagnant et les points gagnés", () => {
// Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement :
// règle Jacoby, le videau n'a pas bougé.
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
});
+37
View File
@@ -0,0 +1,37 @@
// Le vrai moteur WebAssembly sous Node, pour les tests et les scénarios.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Charge build/wasm/ (make engine-wasm) avec web/engine-wasm.mjs, comme la
// page de test du moteur, et le branche sur l'adaptateur du domaine. Le moteur
// n'est chargé qu'une fois par processus.
import { fileURLToPath } from "node:url";
import type { Engine } from "../../src/domain/game/engine.js";
import { createWasmEngine } from "../../src/engine/wasm-engine.js";
// Ce fichier est compilé dans build/web/tests/support/ : la racine du dépôt
// est quatre dossiers plus haut.
const root = new URL("../../../../", import.meta.url);
interface WasmEngine {
run(args: string[]): { result: unknown; ms: number };
}
let loading: Promise<Engine> | undefined;
export function loadNodeEngine(): Promise<Engine> {
loading ??= load();
return loading;
}
async function load(): Promise<Engine> {
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 wasm: WasmEngine = await wrapper.loadEngine(
module.default,
fileURLToPath(new URL("build/wasm/", root)),
);
return createWasmEngine(async (args) => wasm.run(args).result);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "../build/web"
},
"include": ["src", "tests"]
}
+25
View File
@@ -0,0 +1,25 @@
# Outils de l'application web (ADR 0009) : TypeScript, Biome, Cucumber et
# dependency-cruiser, sous Node. Rien de tout cela n'est embarqué dans
# l'application : ces outils compilent, vérifient et testent.
#
# Les modules sont installés dans /opt/tools, puis rendus visibles sous
# /node_modules, à la racine du conteneur : Node et TypeScript les trouvent en
# remontant depuis le dépôt monté sous /src, sans node_modules dans le dépôt.
# (npm refuse d'installer directement à la racine.)
#
# Versions figées, publiées depuis plus de deux semaines au 2026-11-02.
# Cucumber reste en 12.x : la 13 charge des modules ES avec require(), ce que
# Node 22.11 ne fait pas sans option (il faut Node 22.12).
FROM node:22.11.0-alpine3.20
WORKDIR /opt/tools
RUN npm install --no-audit --no-fund --save-exact \
typescript@6.0.3 \
@types/node@22.20.5 \
@biomejs/biome@2.5.15 \
@cucumber/cucumber@12.9.0 \
dependency-cruiser@18.6.0 \
&& npm cache clean --force \
&& ln -s /opt/tools/node_modules /node_modules
ENV PATH="/opt/tools/node_modules/.bin:${PATH}"
@@ -46,3 +46,9 @@ construire ou tester ; rien n'est embarqué dans l'application.
| Python 3 | PSF | `ustagammon/build-c` | Comparateur des références |
| jq | MIT | `ustagammon/build-c` | Lecture du JSON dans les tests |
| Emscripten | MIT / NCSA | `emscripten/emsdk:3.1.64` | Compilation du moteur en WebAssembly (étape 0b) |
### 2026-11-02 — Outils de l'application web (étape 0d)
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).
+70
View File
@@ -0,0 +1,70 @@
# 0009. Socle : PWA en TypeScript, moteur en WebAssembly
- **Statut** : accepté
- **Date** : 2026-11-02
- **Décideurs** : Pierre, sur proposition de Claude Code, après les mesures
sur le Fairphone 4
## Contexte
L'étape 0 doit trancher entre une PWA (moteur en WebAssembly) et une
application Android native (`docs/etape-0.md`). L'étape 0b a produit le moteur
en WebAssembly (MR !9) : même suite de contrat qu'en natif (écart maximal
9,5e-4 pour une tolérance de 0,002), 7,3 Mo compressés à télécharger (seuil
40 Mo). Sur le PC de développement (i5-2450M, 2011), le meilleur coup à 2 plis
prend au plus 0,3 s sous Chromium et 2,3 s sous Firefox (seuil sur le
téléphone : 3 s).
Mesures sur le Fairphone 4 (Chrome 156, page `bench.html`, servie par
`make deploy-web`), trois essais le 2026-11-02, maximum sur les positions de
référence :
| Mesure | Fairphone 4 (pire des 3 essais) | Seuil | Marge |
| --- | --- | --- | --- |
| Meilleur coup, 0 pli | 21 ms | 300 ms | ×14 |
| Meilleur coup, 2 plis (médiane 94 à 98 ms) | 348 ms (`race-2`) | 3 000 ms | ×8,6 |
| Décision de videau, 2 plis | 148 ms | 3 000 ms | ×20 |
| Chargement du moteur | 239 à 276 ms | – | – |
| Téléchargement, gzip | 7,3 Mo | 40 Mo | ×5 |
| Mémoire WebAssembly | 51,9 Mo | – | – |
Les trois essais diffèrent de moins de 10 %. Firefox n'a pas été mesuré sur le
téléphone.
L'ADR 0007 renvoyait au choix du socle le langage de l'interface et l'outil de
vérification des dépendances.
## Décision
- **Socle : PWA**, installable et utilisable hors ligne, moteur en WebAssembly
dans un Web Worker. L'étape 0c (Android natif) n'est pas faite.
- **Langage : TypeScript**, compilé par `tsc` en modules JavaScript standard,
sans framework d'interface ni empaqueteur.
- **Lint et formatage : Biome.**
- **Scénarios exécutés** (ADR 0008) par `@cucumber/cucumber`, sous Node, sur le
domaine branché au vrai moteur WebAssembly.
- **Règles d'architecture vérifiées** (ADR 0007) par `dependency-cruiser` :
le domaine n'importe ni l'interface, ni le stockage, ni le moteur ; aucun
écran n'importe le moteur.
- Tous ces outils tournent dans Docker et ne sont jamais embarqués dans
l'application.
## Alternatives écartées
- **Android natif (Kotlin, NDK)** : un seul système, une chaîne de build lourde
(Gradle, NDK) sur une machine de 2011, et aucun gain mesuré à ce stade sur
les temps de calcul. Reste le plan B si les mesures du téléphone échouent.
- **JavaScript annoté (JSDoc) vérifié par TypeScript** : pas d'étape de build,
mais des types moins lisibles ; Pierre a choisi TypeScript.
- **Un framework d'interface** (React, Vue…) : inutile pour un plateau et
quelques écrans, et contraire à la règle de simplicité (ADR 0007).
## Conséquences
- La distribution sur F-Droid et le Play Store demandera plus tard une
enveloppe Android minimale autour de la PWA (à décider à l'étape 1).
- L'étape 0c (Android natif) est abandonnée.
- Le critère de fin de l'étape 0 (« moins de 2 s par coup de l'IA en niveau
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.
+1
View File
@@ -15,3 +15,4 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
| [0006](0006-merge-requests-systematiques.md) | Merge requests systématiques, fusionnées par Claude Code | accepté |
| [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é |
+5 -5
View File
@@ -13,14 +13,14 @@ 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).
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.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| Mesures | Pierre relève les mesures sur le Fairphone 4, en suivant `docs/guide-test-etape-0.md` | 🔄 | |
| Décision | Socle PWA ou Android natif, consigné dans un ADR — validation de Pierre | ⏳ | |
| 0c | Android natif (NDK, Gradle en ligne de commande, `make deploy` via adb) | ⏸ si 0b échoue | |
| 0d | Plateau minimal jouable sur le socle retenu ; vérifié avec Pierre en suivant `docs/scenarios/jouer-une-partie.feature` | ⏳ | |
| 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.
+6 -1
View File
@@ -26,7 +26,10 @@ Voir aussi : [Doubler](doubler.md)
`code` est facultatif : il désigne le type du domaine qui implémente le terme,
dès qu'il existe. Dans le code, ce type porte en commentaire le chemin de sa
fiche (`Glossaire : docs/glossaire/videau.md`).
fiche (`Glossaire : docs/glossaire/videau.md`), juste avant sa déclaration
exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
`code` est reliée à ce type, chaque référence du code mène à une fiche dont le
`code` est le type qui la porte.
## Termes
@@ -38,6 +41,7 @@ fiche (`Glossaire : docs/glossaire/videau.md`).
| [Branche](branche.md) | Branch |
| [Compte de points](compte-de-points.md) | Pip count |
| [Coup](coup.md) | Move |
| [Demi-coup](demi-coup.md) | Half-move |
| [Course](course.md) | Race |
| [Doubler](doubler.md) | Double |
| [Équité](equite.md) | Equity |
@@ -51,6 +55,7 @@ fiche (`Glossaire : docs/glossaire/videau.md`).
| [Partie](partie.md) | Game |
| [Passer](passer.md) | Drop |
| [Pion](pion.md) | Checker |
| [Plateau](plateau.md) | Board |
| [Pli](pli.md) | Ply |
| [Point](point.md) | Point |
| [Prendre](prendre.md) | Take |
+1
View File
@@ -1,6 +1,7 @@
---
term: Coup
english: Move
code: Move
---
# Coup
+15
View File
@@ -0,0 +1,15 @@
---
term: Demi-coup
english: Half-move
code: HalfMove
---
# Demi-coup
**En bref** : le déplacement d'un pion avec un seul dé, par exemple 8/5 avec un 3 ; un coup en compte deux, ou quatre avec un double.
On saisit son coup demi-coup par demi-coup : l'application n'accepte que ceux
qui mènent à un coup permis. Pour un pion qui sort, la case d'arrivée se note
« off » (6/off) ; pour un pion qui rentre depuis la barre, la case de départ se
note « bar » (bar/22).
Voir aussi : [Coup](coup.md), [Jet](jet.md), [Barre](barre.md), [Sortie](sortie.md)
+1
View File
@@ -1,6 +1,7 @@
---
term: Événement de partie
english: Game event
code: GameEvent
---
# Événement de partie
@@ -1,6 +1,7 @@
---
term: Identifiant de position
english: Position ID
code: PositionId
---
# Identifiant de position
+1
View File
@@ -1,6 +1,7 @@
---
term: Jet
english: Roll
code: Roll
---
# Jet
+1
View File
@@ -1,6 +1,7 @@
---
term: Moteur
english: Engine
code: Engine
---
# Moteur
+1
View File
@@ -1,6 +1,7 @@
---
term: Partie
english: Game
code: Game
---
# Partie
+14
View File
@@ -0,0 +1,14 @@
---
term: Plateau
english: Board
code: Board
---
# Plateau
**En bref** : la surface de jeu, avec ses 24 cases (les points), la barre au milieu, et les pions de chaque joueur.
Chaque joueur numérote les cases de son point de vue : sa case 1 est la plus
proche de sa sortie, sa case 24 la plus éloignée, qui est la case 1 de
l'adversaire.
Voir aussi : [Point](point.md), [Pion](pion.md), [Barre](barre.md), [Identifiant de position](identifiant-de-position.md)
+1
View File
@@ -1,6 +1,7 @@
---
term: Pli
english: Ply
code: Ply
---
# Pli
+1
View File
@@ -1,6 +1,7 @@
---
term: Videau
english: Cube
code: Cube
---
# Videau
+4 -2
View File
@@ -5,8 +5,10 @@ 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.
Tant que le domaine n'existe pas, les scénarios se déroulent à la main : ils
servent alors de guide de test.
`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
étape sans définition fait échouer les tests.
## Format
+5 -2
View File
@@ -14,11 +14,14 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous jouez 8/5 6/5
Alors votre coup est accepté
Et c'est au tour de l'ordinateur
Et c'est ensuite au tour de l'ordinateur
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.
Étant donné que c'est à vous de jouer avec le jet 3-1
Quand vous essayez de jouer 8/4
Quand vous essayez de jouer 13/12
Alors votre coup est refusé
Et la position n'a pas changé
+202 -4
View File
@@ -13,6 +13,15 @@
* legal POSITION_ID MATCH_ID DÉS coups légaux, sans évaluation
* cube POSITION_ID MATCH_ID décision de videau
* eval POSITION_ID MATCH_ID évaluation de la position
* position POSITION_ID MATCH_ID plateau décodé, même position vue de
* l'adversaire, fin de partie éventuelle
* halfmoves POSITION_ID DÉS [DEMI-COUPS]
* vérifie des demi-coups saisis un par
* un (ex. « 8/5 6/5 », « bar/22 »,
* « 6/off »)
* matchid VIDEAU PROPRIÉTAIRE Match ID d'une partie libre avec la
* règle Jacoby, joueur 0 au trait ;
* PROPRIÉTAIRE : -1 (centre), 0 ou 1
* reference POSITION_ID MATCH_ID [DÉS]
* un niveau au format des positions de
* référence (tests/reference/README.md) :
@@ -45,6 +54,9 @@
* float32 calculé par gnubg. */
#define NUMBER "%.9g"
/* Liste des commandes, pour les messages d'erreur. */
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference"
/* Coups gardés dans le classement de la commande reference (comme hint). */
#define REFERENCE_MAX_MOVES 10
@@ -113,10 +125,24 @@ print_probabilities(const float p[ENGINE_NUM_PROBABILITIES])
p[ENGINE_LOSE_GAMMON], p[ENGINE_LOSE_BACKGAMMON]);
}
/* Demi-coups sous la forme [[8, 5], [6, 5]]. */
static void
print_half_moves(const engine_half_move *half_moves, int count)
{
int i;
printf("[");
for (i = 0; i < count; i++)
printf("%s[%d, %d]", i ? ", " : "", half_moves[i].from, half_moves[i].to);
printf("]");
}
static void
print_move(const engine_move *m, int with_evaluation)
{
printf("{\"move\": \"%s\", \"position_id\": \"%s\"", m->notation, m->position_id);
printf("{\"move\": \"%s\", \"position_id\": \"%s\", \"half_moves\": ",
m->notation, m->position_id);
print_half_moves(m->half_moves, m->half_move_count);
if (with_evaluation) {
printf(", \"ply\": %d, \"equity\": " NUMBER ", \"probabilities\": ", m->ply, m->equity);
print_probabilities(m->probabilities);
@@ -254,11 +280,177 @@ run_position_command(const options *opt)
print_probabilities(c.probabilities);
printf(", ");
print_cube_fields(&c);
printf("}\n");
printf(", \"available\": %s, \"double\": %s, \"take\": %s}\n",
c.available ? "true" : "false", c.should_double ? "true" : "false",
c.should_take ? "true" : "false");
}
return EXIT_SUCCESS;
}
static void
print_side(const engine_side *side)
{
int i;
printf("{\"points\": [");
for (i = 0; i < 24; i++)
printf("%s%d", i ? ", " : "", side->points[i]);
printf("], \"bar\": %d, \"off\": %d, \"pip_count\": %d}", side->bar, side->off,
side->pip_count);
}
/* position : plateau, même position vue de l'adversaire, fin de partie. */
static int
run_board_command(const options *opt)
{
static const char *const kinds[] = { "", "single", "gammon", "backgammon" };
const char *position_id = opt->args[0], *match_id = opt->args[1];
engine_board board;
engine_result result;
engine_status status;
if (opt->arg_count != 2)
return usage_error("arguments attendus : POSITION_ID MATCH_ID");
status = engine_decode_position(position_id, &board);
if (status == ENGINE_OK)
status = engine_game_result(position_id, match_id, &result);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"on_roll\": ", position_id,
match_id);
print_side(&board.on_roll);
printf(", \"opponent\": ");
print_side(&board.opponent);
printf(", \"swapped_position_id\": \"%s\", \"result\": ", board.swapped_position_id);
if (result.over)
printf("{\"winner\": \"%s\", \"kind\": \"%s\", \"points\": %d}",
result.winner == 0 ? "on_roll" : "opponent", kinds[result.kind], result.points);
else
printf("null");
printf("}\n");
return EXIT_SUCCESS;
}
/* Lit une case d'un demi-coup : 1 à 25, « bar » (25) ou « off » (0). */
static int
parse_point(const char *text, size_t length, int *point)
{
char buffer[4];
char *end;
long value;
if (length == 0 || length >= sizeof buffer)
return 0;
memcpy(buffer, text, length);
buffer[length] = '\0';
if (strcmp(buffer, "bar") == 0) {
*point = ENGINE_BAR;
return 1;
}
if (strcmp(buffer, "off") == 0) {
*point = ENGINE_OFF;
return 1;
}
value = strtol(buffer, &end, 10);
if (*end || value < 1 || value > ENGINE_BAR)
return 0;
*point = (int) value;
return 1;
}
/* Lit des demi-coups séparés par des espaces, chacun « DÉPART/ARRIVÉE ».
* Renvoie leur nombre, ou -1 si le texte est mal formé. */
static int
parse_half_moves(const char *text, engine_half_move half_moves[ENGINE_MAX_HALF_MOVES])
{
int count = 0;
while (*text) {
const char *start, *slash;
size_t length;
while (*text == ' ')
text++;
if (!*text)
break;
start = text;
while (*text && *text != ' ')
text++;
length = (size_t) (text - start);
slash = memchr(start, '/', length);
if (!slash || count == ENGINE_MAX_HALF_MOVES
|| !parse_point(start, (size_t) (slash - start), &half_moves[count].from)
|| !parse_point(slash + 1, length - (size_t) (slash - start) - 1, &half_moves[count].to))
return -1;
count++;
}
return count;
}
/* halfmoves : vérifie des demi-coups saisis un par un. */
static int
run_half_moves_command(const options *opt)
{
const char *position_id = opt->args[0];
engine_half_move half_moves[ENGINE_MAX_HALF_MOVES];
engine_half_moves_check check;
engine_status status;
int die1, die2, count = 0;
if (opt->arg_count != 2 && opt->arg_count != 3)
return usage_error("arguments attendus : POSITION_ID DÉS [DEMI-COUPS]");
if (!parse_dice(opt->args[1], &die1, &die2))
return usage_error("dés attendus sous la forme 31");
if (opt->arg_count == 3)
count = parse_half_moves(opt->args[2], half_moves);
if (count < 0)
return usage_error("demi-coups attendus sous la forme 8/5 6/5, bar/22 ou 6/off");
status = engine_check_half_moves(position_id, die1, die2, half_moves, count, &check);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"position_id\": \"%s\", \"dice\": [%d, %d], \"half_moves\": ", position_id,
die1, die2);
print_half_moves(half_moves, count);
printf(", \"valid\": %s, \"complete\": %s, \"result_position_id\": ",
check.valid ? "true" : "false", check.complete ? "true" : "false");
if (check.valid)
printf("\"%s\"}\n", check.position_id);
else
printf("null}\n");
return EXIT_SUCCESS;
}
/* matchid : Match ID d'une partie libre avec la règle Jacoby, joueur 0 au
* trait. */
static int
run_match_id_command(const options *opt)
{
engine_match match = { .cube_value = 1, .cube_owner = -1, .jacoby = 1 };
char match_id[13];
char *end;
engine_status status;
if (opt->arg_count != 2)
return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE");
match.cube_value = (int) strtol(opt->args[0], &end, 10);
if (*end || end == opt->args[0])
return usage_error("VIDEAU attend un nombre");
match.cube_owner = (int) strtol(opt->args[1], &end, 10);
if (*end || end == opt->args[1])
return usage_error("PROPRIÉTAIRE attend -1, 0 ou 1");
status = engine_encode_match(&match, match_id);
if (status != ENGINE_OK)
return print_engine_error(status);
printf("{\"match_id\": \"%s\", \"match\": ", match_id);
print_match(&match);
printf("}\n");
return EXIT_SUCCESS;
}
/*
* reference : un niveau d'un fichier de résultat de tests/reference/, de
* même structure que plies["<n>"] (voir tests/reference/README.md).
@@ -359,7 +551,7 @@ parse_options(int argc, char **argv, options *opt)
}
}
if (!opt->command)
return "commande attendue : bestmove, moves, legal, cube, eval ou reference";
return "commande attendue : " COMMANDS;
return NULL;
}
@@ -393,8 +585,14 @@ main(int argc, char **argv)
result = run_position_command(&opt);
else if (strcmp(opt.command, "reference") == 0)
result = run_reference_command(&opt);
else if (strcmp(opt.command, "position") == 0)
result = run_board_command(&opt);
else if (strcmp(opt.command, "halfmoves") == 0)
result = run_half_moves_command(&opt);
else if (strcmp(opt.command, "matchid") == 0)
result = run_match_id_command(&opt);
else
result = usage_error("commande inconnue : bestmove, moves, legal, cube, eval ou reference");
result = usage_error("commande inconnue : " COMMANDS);
#ifndef __EMSCRIPTEN__
engine_shutdown();
+277 -4
View File
@@ -182,6 +182,38 @@ engine_decode_match(const char *match_id, engine_match *match)
return ENGINE_OK;
}
/* Code un Match ID : voir engine.h. */
engine_status
engine_encode_match(const engine_match *match, char match_id[13])
{
const unsigned int no_dice[2] = { 0, 0 };
int value = match->cube_value, log2 = 0;
while (value > 1 && value % 2 == 0) {
value /= 2;
log2++;
}
if (value != 1 || log2 > 12 || match->cube_owner < -1 || match->cube_owner > 1
|| match->player_on_roll < 0 || match->player_on_roll > 1 || match->match_length < 0)
return ENGINE_ERROR_MATCH_ID;
snprintf(match_id, 13, "%s",
MatchID(no_dice, match->player_on_roll, 0, 0, match->player_on_roll,
match->cube_owner, match->crawford, match->match_length, match->score,
match->cube_value, match->jacoby, GAME_PLAYING));
return ENGINE_OK;
}
/* Décode un Position ID en plateau de gnubg. */
static engine_status
decode_board(const char *position_id, TanBoard board)
{
if (engine_state != READY)
return ENGINE_ERROR_NOT_INITIALISED;
if (!is_base64_id(position_id, L_POSITIONID) || !PositionFromID(board, position_id))
return ENGINE_ERROR_POSITION_ID;
return ENGINE_OK;
}
/* Décode Position ID et Match ID, et prépare le cubeinfo de gnubg. */
static engine_status
decode_position(const char *position_id, const char *match_id, position *pos)
@@ -189,10 +221,9 @@ decode_position(const char *position_id, const char *match_id, position *pos)
engine_match *m = &pos->match;
engine_status status;
if (engine_state != READY)
return ENGINE_ERROR_NOT_INITIALISED;
if (!is_base64_id(position_id, L_POSITIONID) || !PositionFromID(pos->board, position_id))
return ENGINE_ERROR_POSITION_ID;
status = decode_board(position_id, pos->board);
if (status != ENGINE_OK)
return status;
status = engine_decode_match(match_id, m);
if (status != ENGINE_OK)
return status;
@@ -237,6 +268,13 @@ fill_move(engine_move *out, const move *m, const position *pos, int evaluated)
FormatMove(out->notation, (ConstTanBoard) pos->board, m->anMove);
snprintf(out->position_id, sizeof out->position_id, "%s", PositionIDFromKey(&m->key));
/* gnubg numérote les cases de 0 à 23, la barre 24 et la sortie -1. */
out->half_move_count = 0;
for (i = 0; i < 2 * ENGINE_MAX_HALF_MOVES && m->anMove[i] >= 0; i += 2) {
out->half_moves[i / 2].from = m->anMove[i] + 1;
out->half_moves[i / 2].to = m->anMove[i + 1] + 1;
out->half_move_count++;
}
out->ply = evaluated ? (int) m->esMove.ec.nPlies : -1;
for (i = 0; i < ENGINE_NUM_PROBABILITIES; i++)
out->probabilities[i] = evaluated ? m->arEvalMove[i] : 0.0f;
@@ -381,5 +419,240 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng
cube->optimal = equities[OUTPUT_OPTIMAL];
cube->decision = cube_decision_names[decision];
cube->decision_text = GetCubeRecommendation(decision);
GetDPEq(&cube->available, NULL, &pos.cube);
cube->should_double = decision == DOUBLE_TAKE || decision == DOUBLE_PASS
|| decision == DOUBLE_BEAVER || decision == REDOUBLE_TAKE || decision == REDOUBLE_PASS;
cube->should_take = cube->double_take <= cube->double_pass;
return ENGINE_OK;
}
/* ------------------------------------------------- Plateau et fin de partie */
static void
fill_side(engine_side *side, const unsigned int checkers[25])
{
int i, on_board = 0;
side->pip_count = 0;
for (i = 0; i < 25; i++) {
on_board += (int) checkers[i];
side->pip_count += (int) checkers[i] * (i + 1);
}
for (i = 0; i < 24; i++)
side->points[i] = (int) checkers[i];
side->bar = (int) checkers[24];
side->off = anChequers[VARIATION_STANDARD] - on_board;
}
engine_status
engine_decode_position(const char *position_id, engine_board *board)
{
TanBoard b;
engine_status status = decode_board(position_id, b);
if (status != ENGINE_OK)
return status;
/* Dans gnubg, b[1] est le joueur au trait et b[0] son adversaire. */
fill_side(&board->on_roll, b[1]);
fill_side(&board->opponent, b[0]);
SwapSides(b);
snprintf(board->swapped_position_id, sizeof board->swapped_position_id, "%s", PositionID((ConstTanBoard) b));
return ENGINE_OK;
}
engine_status
engine_game_result(const char *position_id, const char *match_id, engine_result *result)
{
TanBoard b;
engine_match match;
engine_status status = decode_board(position_id, b);
int i;
if (status == ENGINE_OK)
status = engine_decode_match(match_id, &match);
if (status != ENGINE_OK)
return status;
memset(result, 0, sizeof *result);
result->kind = GameStatus((ConstTanBoard) b, VARIATION_STANDARD);
if (result->kind == 0)
return ENGINE_OK;
result->over = 1;
/* Le gagnant est celui qui n'a plus aucun pion sur le plateau. */
for (i = 0; i < 25 && b[1][i] == 0; i++)
;
result->winner = i == 25 ? 0 : 1;
/* Règle Jacoby : gammons et backgammons ne comptent qu'une fois le
* videau tourné, c'est-à-dire quand il n'est plus au centre. */
if (match.match_length == 0 && match.jacoby && match.cube_owner == -1)
result->points = match.cube_value;
else
result->points = result->kind * match.cube_value;
return ENGINE_OK;
}
/* ------------------------------------------ Saisie demi-coup par demi-coup */
/*
* La légalité de chaque demi-coup vient de gnubg : GenerateMoves avec un seul
* dé (le second à 0) donne exactement les demi-coups permis avec ce dé (barre
* d'abord, case libre, règles de sortie). Celle du coup entier aussi : les
* demi-coups doivent pouvoir se compléter en une position de la liste des
* coups légaux, qui impose de jouer le plus de dés possible.
*/
/* Un demi-coup permis et la position où il mène. */
typedef struct {
int from, to; /* numérotation de gnubg : 0 à 24, -1 sortie */
positionkey key;
} step;
/* Positions d'arrivée des coups légaux. */
typedef struct {
positionkey *keys;
int count;
} legal_results;
/* Les demi-coups permis avec un dé. Ils sont copiés : GenerateMoves réutilise
* un tableau interne, que l'appel suivant écraserait. Il y en a au plus un
* par case de départ. */
static int
single_steps(ConstTanBoard board, int die, step steps[25])
{
movelist list;
unsigned int i;
GenerateMoves(&list, board, die, 0, FALSE);
for (i = 0; i < list.cMoves && i < 25; i++) {
steps[i].from = list.amMoves[i].anMove[0];
steps[i].to = list.amMoves[i].anMove[1];
steps[i].key = list.amMoves[i].key;
}
return (int) i;
}
static int
is_legal_result(ConstTanBoard board, const legal_results *legal)
{
positionkey key;
int i;
PositionKey(board, &key);
for (i = 0; i < legal->count; i++)
if (EqualKeys(key, legal->keys[i]))
return 1;
return 0;
}
/* Les dés sans celui d'indice skip. */
static void
remove_die(const int dice[], int count, int skip, int rest[])
{
int i, j = 0;
for (i = 0; i < count; i++)
if (i != skip)
rest[j++] = dice[i];
}
/* Vrai si board peut encore mener à un coup légal avec les dés restants. */
static int
can_complete(ConstTanBoard board, const int dice[], int count, const legal_results *legal)
{
step steps[25];
int rest[ENGINE_MAX_HALF_MOVES];
TanBoard next;
int i, j, n;
if (is_legal_result(board, legal))
return 1;
for (i = 0; i < count; i++) {
if (i > 0 && dice[i] == dice[0])
continue; /* même valeur déjà essayée (double) */
remove_die(dice, count, i, rest);
n = single_steps(board, dice[i], steps);
for (j = 0; j < n; j++) {
PositionFromKey(next, &steps[j].key);
if (can_complete((ConstTanBoard) next, rest, count - 1, legal))
return 1;
}
}
return 0;
}
/* Joue les demi-coups ; en cas de succès, result reçoit la position atteinte.
* Un même demi-coup peut correspondre à plusieurs dés (une sortie avec un dé
* plus fort que nécessaire) : chacun est essayé. */
static int
play_half_moves(ConstTanBoard board, const int dice[], int count,
const engine_half_move *half_moves, int remaining,
const legal_results *legal, TanBoard result)
{
step steps[25];
int rest[ENGINE_MAX_HALF_MOVES];
TanBoard next;
int i, j, n;
if (remaining == 0) {
if (!can_complete(board, dice, count, legal))
return 0;
memcpy(result, board, sizeof(TanBoard));
return 1;
}
for (i = 0; i < count; i++) {
if (i > 0 && dice[i] == dice[0])
continue; /* même valeur déjà essayée (double) */
remove_die(dice, count, i, rest);
n = single_steps(board, dice[i], steps);
for (j = 0; j < n; j++) {
if (steps[j].from != half_moves->from - 1 || steps[j].to != half_moves->to - 1)
continue;
PositionFromKey(next, &steps[j].key);
if (play_half_moves((ConstTanBoard) next, rest, count - 1, half_moves + 1,
remaining - 1, legal, result))
return 1;
}
}
return 0;
}
engine_status
engine_check_half_moves(const char *position_id, int die1, int die2,
const engine_half_move *half_moves, int count,
engine_half_moves_check *check)
{
TanBoard board, result;
movelist list;
legal_results legal;
int dice[ENGINE_MAX_HALF_MOVES] = { die1, die2, die1, die2 };
int dice_count = die1 == die2 ? 4 : 2;
engine_status status;
unsigned int i;
status = decode_board(position_id, board);
if (status == ENGINE_OK)
status = check_dice(die1, die2);
if (status != ENGINE_OK)
return status;
memset(check, 0, sizeof *check);
if (count < 0 || count > dice_count)
return ENGINE_OK;
GenerateMoves(&list, (ConstTanBoard) board, die1, die2, FALSE);
if (list.cMoves == 0)
return ENGINE_OK; /* aucun coup possible : rien à saisir */
legal.count = (int) list.cMoves;
legal.keys = g_malloc(sizeof *legal.keys * list.cMoves);
for (i = 0; i < list.cMoves; i++)
legal.keys[i] = list.amMoves[i].key;
if (play_half_moves((ConstTanBoard) board, dice, dice_count, half_moves, count, &legal, result)) {
check->valid = 1;
check->complete = is_legal_result((ConstTanBoard) result, &legal);
snprintf(check->position_id, sizeof check->position_id, "%s",
PositionID((ConstTanBoard) result));
}
g_free(legal.keys);
return ENGINE_OK;
}
+84
View File
@@ -66,11 +66,29 @@ enum {
ENGINE_NUM_PROBABILITIES
};
/*
* Demi-coup : un pion déplacé d'une case avec un seul dé. Les cases sont
* numérotées du point de vue du joueur qui joue, comme dans la notation de
* gnubg : 1 à 24, 25 pour la barre, 0 pour la sortie.
*/
#define ENGINE_BAR 25
#define ENGINE_OFF 0
typedef struct {
int from;
int to;
} engine_half_move;
/* Un coup complet utilise au plus quatre dés (un double). */
#define ENGINE_MAX_HALF_MOVES 4
/* Un coup, avec son évaluation quand il a été classé. */
typedef struct {
char notation[32]; /* notation de gnubg, ex. « 8/5 6/5 » */
char position_id[16]; /* Position ID après le coup, plateau vu du
* joueur qui vient de jouer */
engine_half_move half_moves[ENGINE_MAX_HALF_MOVES];
int half_move_count; /* demi-coups du coup, dans un ordre jouable */
int ply; /* niveau de l'évaluation (-1 : non évalué) */
float probabilities[ENGINE_NUM_PROBABILITIES];
float equity; /* équité de classement (avec videau) */
@@ -96,8 +114,50 @@ typedef struct {
* « DOUBLE_TAKE », « DOUBLE_PASS »,
* « TOOGOOD_PASS », « NOT_AVAILABLE »… */
const char *decision_text; /* libellé anglais de gnubg, ex. « Double, take » */
int available; /* le joueur au trait a le droit de doubler */
int should_double; /* gnubg recommande de doubler : décision
* DOUBLE_TAKE, DOUBLE_PASS, DOUBLE_BEAVER,
* REDOUBLE_TAKE ou REDOUBLE_PASS (un double
* « facultatif » n'est pas recommandé) */
int should_take; /* s'il double, l'adversaire doit prendre :
* prendre lui coûte moins que passer
* (double_take <= double_pass) */
} engine_cube;
/* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de
* la sortie). */
typedef struct {
int points[24]; /* pions sur les cases 1 à 24 (indices 0 à 23) */
int bar; /* pions sur la barre */
int off; /* pions sortis */
int pip_count; /* compte de points */
} engine_side;
/* Plateau décodé d'un Position ID. */
typedef struct {
engine_side on_roll; /* le joueur au trait */
engine_side opponent; /* son adversaire */
char swapped_position_id[16]; /* la même position vue de l'adversaire */
} engine_board;
/* Fin de partie. */
typedef struct {
int over; /* la partie est finie */
int winner; /* 0 : le joueur au trait, 1 : son adversaire */
int kind; /* 1 : simple, 2 : gammon, 3 : backgammon */
int points; /* points gagnés : kind fois la valeur du videau,
* mais 1 fois en partie libre avec la règle
* Jacoby quand le videau n'a pas bougé */
} engine_result;
/* Où en est un coup saisi demi-coup par demi-coup. */
typedef struct {
int valid; /* les demi-coups commencent un coup légal */
int complete; /* ils forment à eux seuls un coup légal */
char position_id[16]; /* si valid : position après ces demi-coups, vue
* du joueur qui joue ; sinon chaîne vide */
} engine_half_moves_check;
/* Description lisible d'un Match ID décodé. */
typedef struct {
int match_length; /* 0 : partie libre (money game) */
@@ -125,6 +185,30 @@ const char *engine_status_message(engine_status status);
/* Décode un Match ID. */
engine_status engine_decode_match(const char *match_id, engine_match *match);
/*
* Code un Match ID (12 caractères et le zéro final), sans dés ni double en
* cours. cube_value : puissance de 2 de 1 à 4096 ; cube_owner : -1, 0 ou 1.
*/
engine_status engine_encode_match(const engine_match *match, char match_id[13]);
/* Décode un Position ID en plateau. */
engine_status engine_decode_position(const char *position_id, engine_board *board);
/* La partie est-elle finie dans cette position, et combien rapporte-t-elle ? */
engine_status engine_game_result(const char *position_id, const char *match_id,
engine_result *result);
/*
* Vérifie des demi-coups saisis un par un (count de 0 à
* ENGINE_MAX_HALF_MOVES), avec le jet die1, die2 : chacun doit être permis
* par les règles, et l'ensemble doit pouvoir se compléter en un des coups
* légaux d'engine_legal_moves. Des demi-coups refusés ne sont pas une erreur :
* check->valid vaut alors 0.
*/
engine_status engine_check_half_moves(const char *position_id, int die1, int die2,
const engine_half_move *half_moves, int count,
engine_half_moves_check *check);
/*
* Coups légaux pour une position et un jet (die1, die2 dans 1..6), sans
* évaluation (ply vaut -1). moves doit pouvoir contenir max_moves coups ;
+13
View File
@@ -51,6 +51,19 @@ if [ "${#scripts[@]}" -gt 0 ]; then
fi
fi
# Application web (ADR 0009), dans Docker : Biome (style et erreurs
# courantes) et TypeScript (types). Le domaine est vérifié une seconde fois
# seul, sans les types de Node ni du navigateur : il doit rester pur.
if ! docker image inspect "${WEB_IMAGE:?lancer via make lint}" >/dev/null 2>&1; then
signaler "Outils web absents : lancer d'abord make setup"
else
web=(docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src/app "$WEB_IMAGE")
"${web[@]}" biome check . || signaler "Biome a trouvé des problèmes (voir ci-dessus)"
"${web[@]}" tsc -p tsconfig.json --noEmit || signaler "TypeScript a trouvé des erreurs (voir ci-dessus)"
"${web[@]}" tsc -p src/domain/tsconfig.json \
|| signaler "le domaine utilise Node ou le navigateur (voir ci-dessus)"
fi
if [ "$erreurs" -gt 0 ]; then
echo "Lint : $erreurs problème(s)."
exit 1
+81
View File
@@ -138,6 +138,87 @@ attendre_erreur usage bestmove "$depart" "$libre_j0"
attendre_erreur usage inconnue "$depart" "$libre_j0"
attendre_erreur invalid_data --data /dossier/absent bestmove "$depart" "$libre_j0" 31
# 6. Ce dont a besoin le domaine d'une partie (étape 0d.1).
# Les coups légaux donnent leurs demi-coups, dans un ordre jouable.
grep -qF '"move": "8/5 6/5", "position_id": "4HPwATCwZ/ABMA", "half_moves": [[8, 5], [6, 5]]' \
<<<"$($cli legal "$depart" "$libre_j0" 31)" || echouer "legal : demi-coups de 8/5 6/5 absents"
# Match ID : codage, et décodage identique.
[ "$(valeur match_id "$($cli matchid 1 -1)")" = "$libre_j0" ] || echouer "matchid 1 -1 : $libre_j0 attendu"
videau4="$(valeur match_id "$($cli matchid 4 1)")"
sortie="$($cli eval "$depart" "$videau4")"
[ "$(valeur cube_value "$sortie") $(valeur cube_owner "$sortie")" = "4 1" ] \
|| echouer "matchid 4 1 : videau mal codé ($sortie)"
attendre_erreur invalid_match_id matchid 3 0
attendre_erreur invalid_match_id matchid 2 5
# Plateau : position de départ, symétrique, sans résultat.
sortie="$($cli position "$depart" "$libre_j0")"
grep -qF '"on_roll": {"points": [0, 0, 0, 0, 0, 5, 0, 3, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2], "bar": 0, "off": 0, "pip_count": 167}' <<<"$sortie" \
|| echouer "position de départ mal décodée : $sortie"
[ "$(valeur swapped_position_id "$sortie")" = "$depart" ] || echouer "départ : la position vue de l'adversaire diffère"
grep -qF '"result": null' <<<"$sortie" || echouer "départ : la partie ne devrait pas être finie"
attendre_erreur invalid_position_id position 'ceci-nest-pas-un-id' "$libre_j0"
# Fin de partie : le joueur au trait a tout sorti, l'adversaire aucun pion
# (gammon). Règle Jacoby : 1 point tant que le videau est au centre ; sinon
# 2 fois le videau.
fini=/38AAAAAAAAAAA
grep -qF '"result": {"winner": "on_roll", "kind": "gammon", "points": 1}' \
<<<"$($cli position "$fini" "$libre_j0")" || echouer "gammon avec Jacoby, videau au centre : 1 point attendu"
grep -qF '"result": {"winner": "on_roll", "kind": "gammon", "points": 8}' \
<<<"$($cli position "$fini" "$videau4")" || echouer "gammon, videau à 4 : 8 points attendus"
vu_du_perdant="$(valeur swapped_position_id "$($cli position "$fini" "$libre_j0")")"
grep -qF '"winner": "opponent"' <<<"$($cli position "$vu_du_perdant" "$libre_j0")" \
|| echouer "fin de partie vue du perdant : l'adversaire devrait gagner"
# Demi-coups : « valid complete position_id » pour une saisie.
demi_coups() {
local sortie valid complete
sortie="$($cli halfmoves "$@")"
valid="$(grep -oE '"valid": (true|false)' <<<"$sortie" | cut -d' ' -f2)"
complete="$(grep -oE '"complete": (true|false)' <<<"$sortie" | cut -d' ' -f2)"
echo "$valid $complete $(valeur result_position_id "$sortie")"
}
attendre_demi_coups() {
local attendu="$1"
shift
local obtenu
obtenu="$(demi_coups "$@")"
[ "$obtenu" = "$attendu" ] || echouer "halfmoves $* : « $obtenu » au lieu de « $attendu »"
}
attendre_demi_coups "true false 4HPwATDQZ/ABMA" "$depart" 31 "8/5"
attendre_demi_coups "true true 4HPwATCwZ/ABMA" "$depart" 31 "8/5 6/5"
attendre_demi_coups "true true 4HPwATCwZ/ABMA" "$depart" 31 "6/5 8/5"
attendre_demi_coups "true false $depart" "$depart" 31
# Case 12 tenue par l'adversaire ; 8/4 demande deux dés.
attendre_demi_coups "false false " "$depart" 31 "13/12"
attendre_demi_coups "false false " "$depart" 31 "8/4"
# Le même pion avec les deux dés.
attendre_demi_coups "true true 4HPwATDgc/ABIQ" "$depart" 31 "24/21 21/20"
# Sortie : le pion le plus éloigné d'abord avec un dé trop fort.
attendre_demi_coups "true true /38AAAAAAAAAAA" "$course" 66 "6/off 5/off"
attendre_demi_coups "false false " "$course" 66 "5/off"
attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off"
# Course, jet 21 : il faut jouer les deux dés.
attendre_demi_coups "false false " "$course" 21 "6/3"
attendre_demi_coups "true true /38AAAAkAAAAAA" "$course" 21 "6/5 5/3"
# Aucun coup possible : rien n'est valide.
attendre_demi_coups "false false " 27YBBwAcz8EDQA 66
attendre_erreur usage halfmoves "$depart" 31 "8-5"
attendre_erreur invalid_dice halfmoves "$depart" 71 "8/5"
# Videau : droit de doubler et recommandations.
sortie="$($cli cube "$depart" "$libre_j0")"
grep -qF '"available": true, "double": false, "take": true' <<<"$sortie" \
|| echouer "videau au départ : disponible, pas de double, prendre attendus"
grep -qF '"available": true, "double": true, "take": false' <<<"$($cli cube "$course" "$libre_j0")" \
|| echouer "videau en course gagnée : double et passe attendus"
# Videau chez l'adversaire : le joueur au trait ne peut pas doubler.
grep -qF '"available": false, "double": false' <<<"$($cli cube "$course" "$(valeur match_id "$($cli matchid 2 1)")")" \
|| echouer "videau chez l'adversaire : double indisponible attendu"
if [ "$echecs" -gt 0 ]; then
echo " $echecs vérification(s) du moteur en échec."
exit 1
+40
View File
@@ -33,4 +33,44 @@ done
duplicates="$(find . -name '*.md' ! -name README.md -exec sed -n 's/^english: //p' {} + | sort | uniq -d)"
[ -z "$duplicates" ] || report "traductions anglaises en double : $duplicates"
# Lien glossaire <-> code (ADR 0008), dans les deux sens. Dans le code, un
# commentaire « Glossaire : docs/glossaire/<fiche>.md » désigne le premier
# type exporté qui le suit ; la fiche doit exister et nommer ce type dans son
# champ code. Réciproquement, chaque champ code d'une fiche doit désigner un
# type ainsi relié à elle.
pairs="$(find ../../app/src -name '*.ts' -exec awk '
function flush() { if (ref != "") print ref, "?"; ref = "" }
FNR == 1 { flush() }
match($0, /Glossaire : docs\/glossaire\/[a-z0-9-]+\.md/) {
flush()
ref = substr($0, RSTART + 27, RLENGTH - 27)
next
}
ref != "" && match($0, /^export (interface|type|class|enum) [A-Za-z0-9_]+/) {
n = split(substr($0, RSTART, RLENGTH), words, " ")
print ref, words[n]
ref = ""
}
END { flush() }
' {} +)"
while read -r fiche type; do
[ -n "$fiche" ] || continue
if [ "$type" = "?" ]; then
report "code : référence à $fiche sans type exporté juste après"
elif [ ! -f "$fiche" ]; then
report "code : le type $type renvoie à une fiche inexistante ($fiche)"
elif [ "$(sed -n 's/^code: //p' "$fiche")" != "$type" ]; then
report "$fiche : le type $type y renvoie, mais la fiche n'a pas « code: $type »"
fi
done <<<"$pairs"
for f in *.md; do
[ "$f" = README.md ] && continue
code="$(sed -n 's/^code: //p' "$f")"
[ -n "$code" ] || continue
grep -qx "$f $code" <<<"$pairs" \
|| report "$f : « code: $code » ne désigne aucun type du code qui renvoie à cette fiche"
done
[ "$errors" -eq 0 ]
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Teste l'application web (app/, ADR 0009), dans l'image ustagammon/web :
# - tests unitaires du domaine (faux moteur) et partie complète sur le vrai
# moteur WebAssembly (node:test) ;
# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché
# au vrai moteur (ADR 0008) ;
# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une
# violation volontaire de chaque règle est bien détectée.
#
# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour).
set -euo pipefail
cd "$(dirname "$0")/.."
image="${WEB_IMAGE:?lancer via make test}"
# Lancé depuis `make test` : repartir d'un make indépendant du make parent.
unset MAKEFLAGS MFLAGS MAKELEVEL
make engine-wasm >/dev/null
make web >/dev/null
errors=0
report() {
echo " $1"
errors=$((errors + 1))
}
# Lance une commande des outils web, dépôt monté sous /src ; le premier
# argument est le dossier de travail, relatif au dépôt.
web() {
local dir="$1"
shift
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w "/src/$dir" "$image" "$@"
}
web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \
|| report "tests unitaires ou partie complète en échec (voir ci-dessus)"
web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \
|| report "scénarios en échec (voir ci-dessus)"
web app depcruise --config .dependency-cruiser.cjs src tests \
|| report "règles d'architecture non respectées (voir ci-dessus)"
# Violations volontaires, dans un dossier jetable sous build/ : chaque règle
# 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"
cp app/tsconfig.json "$fixture/"
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"
echo 'import { engine } from "../engine/adapter.js"; export const c = engine;' >"$fixture/src/ui/uses-engine.ts"
echo 'import { e } from "./loop-b.js"; export const d = e;' >"$fixture/src/ui/loop-a.ts"
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
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é"
rm -rf "$fixture"
[ "$errors" -eq 0 ]