Étape 0d.1 : domaine « partie » en TypeScript, scénario exécuté ; ADR 0009 accepté #11
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"files": {
|
||||
"includes": ["**", "!**/node_modules"]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ustagammon-app",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -14,11 +14,12 @@ Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
|
||||
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
|
||||
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
|
||||
page de test, page de mesure et guide de test (0b), mesures sur le Fairphone 4 et
|
||||
socle PWA en TypeScript (ADR 0009 ; 0c Android natif abandonnée).
|
||||
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 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0d.1 | Domaine « partie » en TypeScript (journal d'événements), scénario exécuté sur le vrai moteur, garde-fous d'architecture | 🔄 | `plans/2026-11-02-domaine-partie.md` |
|
||||
| 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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
---
|
||||
term: Coup
|
||||
english: Move
|
||||
code: Move
|
||||
---
|
||||
# Coup
|
||||
|
||||
|
||||
@@ -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,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,6 +1,7 @@
|
||||
---
|
||||
term: Jet
|
||||
english: Roll
|
||||
code: Roll
|
||||
---
|
||||
# Jet
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
term: Moteur
|
||||
english: Engine
|
||||
code: Engine
|
||||
---
|
||||
# Moteur
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
term: Partie
|
||||
english: Game
|
||||
code: Game
|
||||
---
|
||||
# Partie
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
---
|
||||
term: Pli
|
||||
english: Ply
|
||||
code: Ply
|
||||
---
|
||||
# Pli
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
term: Videau
|
||||
english: Cube
|
||||
code: Cube
|
||||
---
|
||||
# Videau
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Domaine « partie » en TypeScript, scénarios exécutés, garde-fous
|
||||
|
||||
- **Date** : 2026-11-02
|
||||
- **Étape** : 0d.1
|
||||
- **Statut** : en cours
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree isolé
|
||||
|
||||
## Objectif
|
||||
|
||||
Le domaine d'une partie contre l'ordinateur existe en TypeScript, sous forme de
|
||||
journal d'événements (ADR 0007), branché au moteur WebAssembly par une
|
||||
interface `Engine`. Le scénario `docs/scenarios/jouer-une-partie.feature`
|
||||
s'exécute sous Node, sur le vrai moteur, dans `make test`. Les règles
|
||||
d'architecture et le lien glossaire ↔ code sont vérifiés par les tests.
|
||||
L'interface graphique et la PWA sont l'étape suivante (0d.2), pas celle-ci.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Outillage** (ADR 0009, ADR 0004) : une image `docker/web/` (Node
|
||||
`node:22.11.0-alpine3.20` déjà présent, versions npm figées et vérifiées) avec
|
||||
TypeScript, Biome, `@cucumber/cucumber` et `dependency-cruiser`. Cibles
|
||||
`make web` (compile `app/` vers `build/web/` avec `tsc`), et intégration à
|
||||
`make lint` (Biome et `tsc --noEmit`) et `make test`. Étiquette d'image dans
|
||||
le `Makefile`, `make setup` la construit. `CREDITS.md` à jour.
|
||||
2. **Arborescence** (à ajuster si plus simple) :
|
||||
- `app/src/domain/` : le domaine pur, sous-dossier `game/` ;
|
||||
- `app/src/engine/` : l'adaptateur qui implémente `Engine` avec le moteur
|
||||
WebAssembly (réutiliser `web/engine-wasm.mjs` et `build/wasm/`) ;
|
||||
- `app/src/ui/` : vide pour l'instant (étape 0d.2) ;
|
||||
- `app/tests/` : tests unitaires (runner intégré de Node, `node:test`) et
|
||||
définitions des étapes Cucumber.
|
||||
Les pages de prototype existantes (`web/index.html`, `web/bench.html`)
|
||||
doivent continuer de fonctionner.
|
||||
3. **Interface `Engine`** définie par le domaine (coups légaux avec position
|
||||
résultante, meilleur coup pour un niveau, décision de videau, décodage d'une
|
||||
position en plateau pour l'affichage). Compléter `engine.h` et `engine-cli`
|
||||
si une information manque (par exemple décoder un Position ID en nombre de
|
||||
pions par case, savoir si la partie est finie et combien elle rapporte), en
|
||||
restant simple, avec tests natifs.
|
||||
4. **Journal d'événements** (ADR 0007, point 3) : événements immuables avec
|
||||
identifiant, parent et version de format (`GameStarted`, `DiceRolled`,
|
||||
`MovePlayed`, `CubeOffered`, `CubeTaken`, `CubeDropped`, `GameEnded`). L'état
|
||||
se calcule en rejouant le journal. Une branche est un nouvel événement
|
||||
accroché à un ancien ; annuler revient à repartir du parent. Le hasard des
|
||||
dés est injecté (source de dés passée au domaine).
|
||||
5. **Règles de la partie de 0d** : partie libre contre l'ordinateur (Jacoby
|
||||
selon les réglages par défaut de gnubg) ; l'ordinateur joue le meilleur coup
|
||||
au niveau « standard » (choisir un niveau qui tienne les 2 s sur téléphone :
|
||||
2 plis d'après les mesures 0b, à confirmer) ; il décide du videau avec le
|
||||
moteur ; le joueur peut prendre ou passer, et peut lui-même doubler (le
|
||||
moteur décide alors si l'ordinateur prend ou passe). Le joueur saisit son
|
||||
coup demi-coup par demi-coup (case de départ, case d'arrivée) ; le domaine
|
||||
n'accepte que ce qui mène à un coup légal complet. Aucune règle du jeu n'est
|
||||
réimplémentée : tout vient du moteur.
|
||||
6. **Glossaire ↔ code** (ADR 0008) : chaque type du domaine qui correspond à
|
||||
un terme porte `Glossaire : docs/glossaire/<fiche>.md` en commentaire, et la
|
||||
fiche reçoit `code: <Type>`. Ajouter les fiches manquantes. Étendre
|
||||
`tests/test_glossary.sh` pour vérifier le lien dans les deux sens.
|
||||
7. **Scénarios exécutés** : définitions des étapes en français pour
|
||||
`docs/scenarios/jouer-une-partie.feature`, branchées sur le domaine et le
|
||||
vrai moteur WebAssembly (sous Node), avec des dés imposés pour être
|
||||
reproductibles. Si une étape du scénario est impossible à exécuter telle
|
||||
quelle, ajuster le scénario en gardant son sens pour le joueur, et le dire.
|
||||
8. **Garde-fous d'architecture** (dependency-cruiser) : le domaine n'importe
|
||||
ni `engine/`, ni `ui/`, ni rien de spécifique au navigateur ; `ui/`
|
||||
n'importe pas `engine/` ; pas de dépendance circulaire. Vérifier qu'une
|
||||
violation volontaire fait échouer `make test`.
|
||||
|
||||
Contraintes :
|
||||
- `CLAUDE.md`, ADR 0004, 0007, 0008, 0009 : simplicité avant tout, pas de
|
||||
patron de conception sans besoin, identifiants en anglais, commentaires et
|
||||
docs en français, tout outil dans Docker, `-j2` ;
|
||||
- ne pas modifier `engine/gnubg/` ni les références ;
|
||||
- `make lint` et `make test` doivent passer ; commits petits, en français, avec
|
||||
la ligne `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` ;
|
||||
- ne pas pousser ni créer de merge request ; ne rien simuler.
|
||||
|
||||
Rapport attendu : ce qui a été fait, l'API du domaine en quelques lignes, les
|
||||
fiches de glossaire ajoutées, le résultat des scénarios, les vérifications des
|
||||
garde-fous, les difficultés, les écarts au plan.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- `make test` exécute le scénario « Jouer une partie contre l'ordinateur » sur
|
||||
le vrai moteur et passe.
|
||||
- Une partie complète (jusqu'à `GameEnded`) se joue dans un test, l'ordinateur
|
||||
contre une stratégie simple du joueur, avec des dés imposés.
|
||||
- Les garde-fous d'architecture et le lien glossaire ↔ code échouent sur une
|
||||
violation volontaire.
|
||||
- `make lint` et `make test` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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 ]
|
||||
Reference in New Issue
Block a user