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

Merged
pierre merged 11 commits from plateau into main 2026-11-02 20:14:20 +00:00
14 changed files with 1314 additions and 0 deletions
Showing only changes of commit 602d0eafed - Show all commits
+19
View File
@@ -0,0 +1,19 @@
// Le videau.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Side } from "./side.js";
/**
* Le videau : sa valeur (1, 2, 4…) et le camp qui le possède, seul à pouvoir
* redoubler (null : au centre, les deux peuvent doubler).
*
* Glossaire : docs/glossaire/videau.md
*/
export interface Cube {
readonly value: number;
readonly owner: Side | null;
}
export const CENTERED_CUBE: Cube = { value: 1, owner: null };
+119
View File
@@ -0,0 +1,119 @@
// Ce que le domaine demande au moteur (ADR 0007, point 2).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le domaine définit cette interface ; un adaptateur par plateforme
// l'implémente (src/engine/ pour le moteur WebAssembly). Toutes les règles du
// jeu viennent d'ici : le domaine n'en réimplémente aucune.
//
// Les positions sont toujours vues du joueur au trait, celui qui joue ou
// double. Les méthodes sont asynchrones : dans le navigateur, le moteur
// calcule dans un Web Worker.
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/**
* Le moteur de jeu : GNU Backgammon.
*
* Glossaire : docs/glossaire/moteur.md
*/
export interface Engine {
/** Les coups légaux d'un jet (liste vide : le joueur ne peut pas jouer). */
legalMoves(position: PositionId, roll: Roll): Promise<Move[]>;
/** Le meilleur coup au niveau ply, ou null si aucun coup n'est possible. */
bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise<Move | null>;
/** La décision de videau du joueur au trait, avant de lancer les dés. */
cubeDecision(position: PositionId, cube: EngineCube, ply: Ply): Promise<CubeDecision>;
/** Où en est un coup saisi demi-coup par demi-coup. */
checkHalfMoves(
position: PositionId,
roll: Roll,
halfMoves: readonly HalfMove[],
): Promise<HalfMovesCheck>;
/** Le plateau d'une position, et le résultat si la partie y est finie. */
board(position: PositionId, cube: EngineCube): Promise<Board>;
}
/**
* Niveau de réflexion du moteur, en plis : 0 (évaluation directe) à 4.
*
* Glossaire : docs/glossaire/pli.md
*/
export type Ply = number;
/** Le videau vu du joueur au trait. */
export interface EngineCube {
readonly value: number;
readonly owner: "centered" | "onRoll" | "opponent";
}
/**
* Un coup légal complet.
*
* Glossaire : docs/glossaire/coup.md
*/
export interface Move {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
/** Ses demi-coups, dans un ordre jouable. */
readonly halfMoves: readonly HalfMove[];
/** La position après le coup, vue du joueur qui vient de jouer. */
readonly position: PositionId;
}
export interface CubeDecision {
/** Le joueur au trait a le droit de doubler. */
readonly available: boolean;
/** Le moteur lui recommande de doubler. */
readonly double: boolean;
/** S'il double, le moteur recommande à l'adversaire de prendre. */
readonly take: boolean;
}
export interface HalfMovesCheck {
/** Les demi-coups commencent un coup légal. */
readonly valid: boolean;
/** Ils forment un coup légal complet. */
readonly complete: boolean;
/** La position après eux, vue du joueur qui joue (null si refusés). */
readonly position: PositionId | null;
}
/**
* Un plateau décodé, pour l'affichage et pour savoir si la partie est finie.
*
* Glossaire : docs/glossaire/plateau.md
*/
export interface Board {
readonly onRoll: BoardSide;
readonly opponent: BoardSide;
/** La même position vue de l'adversaire. */
readonly swappedPosition: PositionId;
/** null tant que la partie n'est pas finie. */
readonly result: BoardResult | null;
}
/** Un camp, vu de lui-même : sa case 1 est la plus proche de sa sortie. */
export interface BoardSide {
/** Nombre de pions sur les cases 1 à 24 (indices 0 à 23). */
readonly points: readonly number[];
readonly bar: number;
readonly off: number;
readonly pipCount: number;
}
/** Simple, gammon ou backgammon. */
export type ResultKind = "single" | "gammon" | "backgammon";
export interface BoardResult {
readonly winner: "onRoll" | "opponent";
readonly kind: ResultKind;
/** Points gagnés, videau et règle Jacoby compris. */
readonly points: number;
}
+148
View File
@@ -0,0 +1,148 @@
// Les événements d'une partie et leur journal (ADR 0007, point 3).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une partie est la suite de ce qui s'est passé. Un événement n'est jamais
// modifié ; chacun pointe vers le précédent (parent). Les événements forment
// donc un arbre : une branche est un nouvel événement accroché à un ancien,
// et annuler revient à repartir du parent. Le format des événements est un
// contrat durable : le changer demande une migration et un ADR.
//
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
// le moteur : l'état se recalcule par une simple boucle (state.ts).
import type { Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
/** Version du format des événements. */
export const EVENT_VERSION = 1;
interface EventBase {
/** Identifiant, unique dans la partie. */
readonly id: string;
/** L'événement précédent ; null pour le premier. */
readonly parent: string | null;
readonly version: number;
}
/** Début de partie : la position, le videau, et qui commence (null : le jet
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
* départ est la même pour les deux. */
export interface GameStarted extends EventBase {
readonly type: "GameStarted";
readonly position: PositionId;
readonly cube: Cube;
readonly firstTurn: Side | null;
}
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
* dé : roll[0] est celui du joueur, roll[1] celui de l'ordinateur. */
export interface DiceRolled extends EventBase {
readonly type: "DiceRolled";
readonly side: Side | null;
readonly roll: Roll;
}
/** Un coup joué (aucun demi-coup si le jet ne permettait pas de jouer). La
* position est celle d'après le coup, vue de l'adversaire, qui a la main. */
export interface MovePlayed extends EventBase {
readonly type: "MovePlayed";
readonly side: Side;
readonly halfMoves: readonly HalfMove[];
readonly position: PositionId;
}
/** side propose de doubler. */
export interface CubeOffered extends EventBase {
readonly type: "CubeOffered";
readonly side: Side;
}
/** side prend le double : le videau double de valeur et passe chez lui. */
export interface CubeTaken extends EventBase {
readonly type: "CubeTaken";
readonly side: Side;
}
/** side passe le double : il perd la partie (GameEnded suit). */
export interface CubeDropped extends EventBase {
readonly type: "CubeDropped";
readonly side: Side;
}
/** Fin de partie. */
export interface GameEnded extends EventBase {
readonly type: "GameEnded";
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
}
/**
* Ce qui s'est passé à un moment de la partie.
*
* Glossaire : docs/glossaire/evenement-de-partie.md
*/
export type GameEvent =
| GameStarted
| DiceRolled
| MovePlayed
| CubeOffered
| CubeTaken
| CubeDropped
| GameEnded;
/** Un événement avant son ajout au journal (sans id, parent ni version). */
export type NewGameEvent = WithoutBase<GameEvent>;
// Omit appliqué à chaque sorte d'événement séparément (et non à leur union,
// qui ne garderait que les champs communs).
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
/** Tous les événements d'une partie, branches comprises, et l'événement
* courant (head) : la partie en est là. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
/** Ajoute un événement après l'événement courant, qui devient le nouveau. */
export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJournal {
const added = Object.freeze({
...event,
id: String(journal.events.length + 1),
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
return { events: [...journal.events, added], head: added.id };
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
export function currentLine(journal: GameJournal): GameEvent[] {
const byId = new Map(journal.events.map((event) => [event.id, event]));
const line: GameEvent[] = [];
let id = journal.head;
while (id !== null) {
const event = byId.get(id);
if (event === undefined) {
throw new Error(`événement introuvable : ${id}`);
}
line.push(event);
id = event.parent;
}
return line.reverse();
}
/** Revient à un événement passé ; le prochain ajout ouvrira une branche. */
export function goBackTo(journal: GameJournal, id: string): GameJournal {
if (!journal.events.some((event) => event.id === id)) {
throw new Error(`événement introuvable : ${id}`);
}
return { events: journal.events, head: id };
}
+313
View File
@@ -0,0 +1,313 @@
// Une partie libre contre l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
// des événements au journal (events.ts), d'où l'état est recalculé
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { Engine, EngineCube, Ply } from "./engine.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
type GameEvent,
type GameJournal,
goBackTo,
type NewGameEvent,
} from "./events.js";
import { type HalfMove, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
/**
* Niveau « standard » de l'ordinateur : 2 plis. C'est le niveau mesuré à
* l'étape 0b (au plus 0,3 s par coup sous Chromium et 2,3 s sous Firefox sur
* le PC de 2011) ; il reste à confirmer sur le téléphone.
*/
export const STANDARD_PLY: Ply = 2;
/** Une partie qui ne commence pas à la position de départ. */
export interface GameSetup {
/** La position, vue de firstTurn. */
readonly position: PositionId;
/** Le camp qui a la main ; il n'y a pas de jet d'ouverture. */
readonly firstTurn: Side;
readonly cube?: Cube;
}
export interface GameOptions {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
readonly ply?: Ply;
readonly setup?: GameSetup;
}
/**
* Une partie contre l'ordinateur.
*
* Glossaire : docs/glossaire/partie.md
*/
export class Game {
readonly #engine: Engine;
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal.
#pending: HalfMove[] = [];
#pendingPosition: PositionId | null = null;
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
this.#dice = options.dice ?? randomDice;
this.#ply = options.ply ?? STANDARD_PLY;
}
/** Commence une partie : à la position de départ avec le jet d'ouverture,
* ou à la position de options.setup. */
static start(engine: Engine, options: GameOptions = {}): Game {
const game = new Game(engine, options);
const setup = options.setup;
game.#append({
type: "GameStarted",
position: setup?.position ?? START_POSITION,
cube: setup?.cube ?? CENTERED_CUBE,
firstTurn: setup?.firstTurn ?? null,
});
while (game.state.phase === "opening") {
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
}
return game;
}
/** Tous les événements, branches comprises, et l'événement courant. */
get journal(): GameJournal {
return this.#journal;
}
/** Les événements du début jusqu'à maintenant. */
get events(): GameEvent[] {
return currentLine(this.#journal);
}
get state(): GameState {
return replay(this.events);
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending;
}
/** La position à afficher : après les demi-coups déjà saisis. */
get displayedPosition(): PositionId {
return this.#pendingPosition ?? this.state.position;
}
// ------------------------------------------------------------- Joueur
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
async roll(): Promise<void> {
const state = this.#expect("player", "rolling");
const roll = rollDice(this.#dice);
this.#append({ type: "DiceRolled", side: "player", roll });
const moves = await this.#engine.legalMoves(state.position, roll);
if (moves.length === 0) {
await this.#playMove("player", [], null);
}
}
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* mène à aucun coup légal : il est refusé et rien ne change. Quand les
* demi-coups forment un coup complet, le coup est joué.
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
const halfMoves = [...this.#pending, halfMove];
const check = await this.#engine.checkHalfMoves(
state.position,
rollToPlay(state),
halfMoves,
);
if (!check.valid || check.position === null) {
return false;
}
if (check.complete) {
this.cancelHalfMoves();
await this.#playMove("player", halfMoves, check.position);
} else {
this.#pending = halfMoves;
this.#pendingPosition = check.position;
}
return true;
}
/** Oublie les demi-coups saisis du coup en cours. */
cancelHalfMoves(): void {
this.#pending = [];
this.#pendingPosition = null;
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
* selon le moteur. */
async double(): Promise<void> {
const state = this.#expect("player", "rolling");
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
this.#ply,
);
if (!decision.available) {
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
}
this.#append({ type: "CubeOffered", side: "player" });
this.#answerDouble("computer", decision.take);
}
/** Le joueur prend le double de l'ordinateur. */
take(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", true);
}
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
drop(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", false);
}
// --------------------------------------------------------- Ordinateur
/**
* Le tour de l'ordinateur : il double si le moteur le recommande (le
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
* les dés et joue le meilleur coup du moteur.
*/
async playComputerTurn(): Promise<void> {
let state = this.#expect("computer", "rolling", "moving");
if (state.phase === "rolling") {
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "computer"),
this.#ply,
);
if (decision.double) {
this.#append({ type: "CubeOffered", side: "computer" });
return;
}
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
state = this.state;
}
const best = await this.#engine.bestMove(
state.position,
engineCube(state.cube, "computer"),
rollToPlay(state),
this.#ply,
);
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
}
// ------------------------------------------------------------ Retour
/** Revient à un événement passé (« Rejouer d'ici ») : la suite reste dans
* le journal, et la prochaine action ouvre une nouvelle branche. */
goBackTo(eventId: string): void {
this.cancelHalfMoves();
this.#journal = goBackTo(this.#journal, eventId);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
if (last?.parent == null) {
throw new Error("rien à annuler");
}
this.goBackTo(last.parent);
}
// ------------------------------------------------------------ Interne
#append(event: NewGameEvent): void {
this.#journal = appendEvent(this.#journal, event);
}
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
const state = this.state;
if (state.turn !== side || !phases.includes(state.phase)) {
throw new Error(
`action impossible : la main est à ${state.turn ?? "personne"}, phase ${state.phase}`,
);
}
return state;
}
/**
* Enregistre un coup. after est la position après le coup, vue de celui
* qui joue (null s'il ne pouvait pas jouer). Le moteur dit si la partie
* est finie et donne la position vue de l'adversaire.
*/
async #playMove(
side: Side,
halfMoves: readonly HalfMove[],
after: PositionId | null,
): Promise<void> {
const state = this.state;
const board = await this.#engine.board(
after ?? state.position,
engineCube(state.cube, side),
);
this.#append({
type: "MovePlayed",
side,
halfMoves: halfMoves.map((h) => ({ from: h.from, to: h.to })),
position: board.swappedPosition,
});
if (board.result !== null) {
this.#append({
type: "GameEnded",
winner: board.result.winner === "onRoll" ? side : otherSide(side),
kind: board.result.kind,
points: board.result.points,
});
}
}
/** taker prend ou passe le double proposé par l'autre camp. */
#answerDouble(taker: Side, take: boolean): void {
if (take) {
this.#append({ type: "CubeTaken", side: taker });
return;
}
// Passer : on perd la partie à la valeur du videau d'avant le double.
this.#append({ type: "CubeDropped", side: taker });
this.#append({
type: "GameEnded",
winner: otherSide(taker),
kind: "single",
points: this.state.cube.value,
});
}
}
/** Le jet à jouer, en phase moving. */
function rollToPlay(state: GameState): Roll {
if (state.roll === null) {
throw new Error("aucun jet à jouer");
}
return state.roll;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
return { value: cube.value, owner: "centered" };
}
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
}
+73
View File
@@ -0,0 +1,73 @@
// Positions et demi-coups.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Identifiant d'une position (Position ID de gnubg, 14 caractères). Il code le
* plateau vu du joueur au trait. Le domaine ne le décode jamais lui-même : il
* le passe au moteur.
*
* Glossaire : docs/glossaire/identifiant-de-position.md
*/
export type PositionId = string;
/** La position de départ, identique pour les deux joueurs. */
export const START_POSITION: PositionId = "4HPwATDgc/ABMA";
/** Case de la barre et de la sortie dans un demi-coup. */
export const BAR = 25;
export const OFF = 0;
/**
* Un pion déplacé avec un seul dé, d'une case à une autre. Les cases sont
* vues du joueur qui joue : 1 à 24, BAR (25) pour la barre, OFF (0) pour la
* sortie.
*
* Glossaire : docs/glossaire/demi-coup.md
*/
export interface HalfMove {
readonly from: number;
readonly to: number;
}
/** Lit un coup noté « 8/5 6/5 », « bar/22 » ou « 6/off » (un dé par demi-coup). */
export function parseHalfMoves(text: string): HalfMove[] {
return text
.trim()
.split(/\s+/)
.filter((part) => part !== "")
.map((part) => {
const [from, to, ...rest] = part.split("/");
if (from === undefined || to === undefined || rest.length > 0) {
throw new Error(`demi-coup mal formé : ${part}`);
}
return { from: parsePoint(from), to: parsePoint(to) };
});
}
/** Écrit des demi-coups sous la forme « 8/5 6/5 ». */
export function formatHalfMoves(halfMoves: readonly HalfMove[]): string {
return halfMoves.map((h) => `${formatPoint(h.from)}/${formatPoint(h.to)}`).join(" ");
}
function parsePoint(text: string): number {
if (text === "bar") {
return BAR;
}
if (text === "off") {
return OFF;
}
const point = Number(text);
if (!Number.isInteger(point) || point < 1 || point > 24) {
throw new Error(`case invalide : ${text}`);
}
return point;
}
function formatPoint(point: number): string {
if (point === BAR) {
return "bar";
}
return point === OFF ? "off" : String(point);
}
+46
View File
@@ -0,0 +1,46 @@
// Jets de dés et source du hasard.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Un jet : les deux dés, chacun de 1 à 6.
*
* Glossaire : docs/glossaire/jet.md
*/
export type Roll = readonly [number, number];
/**
* Source de dés : chaque appel renvoie un dé, de 1 à 6. Elle est passée au
* domaine (ADR 0007, point 4) : hasard véritable dans l'application, dés
* imposés dans les tests.
*/
export type DiceSource = () => number;
/** Le hasard ordinaire. */
export const randomDice: DiceSource = () => 1 + Math.floor(Math.random() * 6);
/** Des dés imposés, dans l'ordre ; erreur s'il n'en reste plus. */
export function fixedDice(values: readonly number[]): DiceSource {
let next = 0;
return () => {
const value = values[next];
if (value === undefined) {
throw new Error("plus de dés imposés");
}
next++;
return value;
};
}
/** Tire un jet de deux dés. */
export function rollDice(dice: DiceSource): Roll {
return [checkDie(dice()), checkDie(dice())];
}
function checkDie(value: number): number {
if (!Number.isInteger(value) || value < 1 || value > 6) {
throw new Error(`dé invalide : ${value}`);
}
return value;
}
+11
View File
@@ -0,0 +1,11 @@
// Les deux camps d'une partie contre l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Le joueur humain (« vous ») ou l'ordinateur. */
export type Side = "player" | "computer";
export function otherSide(side: Side): Side {
return side === "player" ? "computer" : "player";
}
+108
View File
@@ -0,0 +1,108 @@
// L'état d'une partie, recalculé en rejouant ses événements.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { GameEvent } from "./events.js";
import { type PositionId, START_POSITION } from "./position.js";
import type { Roll } from "./roll.js";
import { otherSide, type Side } from "./side.js";
/**
* Où en est la partie :
* - opening : jet d'ouverture, chacun lance un dé ;
* - rolling : turn peut doubler, puis lance les dés ;
* - moving : turn joue le jet roll ;
* - doubled : turn a proposé de doubler, l'adversaire doit prendre ou passer ;
* - over : partie finie, voir result.
*/
export type Phase = "opening" | "rolling" | "moving" | "doubled" | "over";
export interface GameResult {
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
}
export interface GameState {
readonly phase: Phase;
/** Le camp qui a la main (null pendant le jet d'ouverture). */
readonly turn: Side | null;
/** La position, vue du camp qui a la main. */
readonly position: PositionId;
/** Le jet à jouer (phase moving). */
readonly roll: Roll | null;
readonly cube: Cube;
readonly result: GameResult | null;
}
/** Avant le premier événement. */
const NOT_STARTED: GameState = {
phase: "opening",
turn: null,
position: START_POSITION,
roll: null,
cube: CENTERED_CUBE,
result: null,
};
/** Rejoue des événements, du premier au dernier. */
export function replay(events: readonly GameEvent[]): GameState {
return events.reduce(applyEvent, NOT_STARTED);
}
/** L'état après un événement. */
export function applyEvent(state: GameState, event: GameEvent): GameState {
switch (event.type) {
case "GameStarted":
return {
...NOT_STARTED,
position: event.position,
cube: event.cube,
turn: event.firstTurn,
phase: event.firstTurn === null ? "opening" : "rolling",
};
case "DiceRolled":
if (event.side !== null) {
return { ...state, phase: "moving", roll: event.roll };
}
// Jet d'ouverture : le plus fort dé commence et joue les deux
// dés ; en cas d'égalité, on relance.
if (event.roll[0] === event.roll[1]) {
return state;
}
return {
...state,
phase: "moving",
turn: event.roll[0] > event.roll[1] ? "player" : "computer",
roll: event.roll,
};
case "MovePlayed":
return {
...state,
phase: "rolling",
turn: otherSide(event.side),
position: event.position,
roll: null,
};
case "CubeOffered":
return { ...state, phase: "doubled" };
case "CubeTaken":
return {
...state,
phase: "rolling",
cube: { value: state.cube.value * 2, owner: event.side },
};
case "CubeDropped":
return state;
case "GameEnded":
return {
...state,
phase: "over",
roll: null,
result: { winner: event.winner, kind: event.kind, points: event.points },
};
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": [],
"noEmit": true
},
"include": ["."]
}
+144
View File
@@ -0,0 +1,144 @@
// L'interface Engine du domaine, implémentée avec le moteur WebAssembly.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le moteur WebAssembly répond aux mêmes commandes qu'engine-cli, en JSON
// (web/engine-wasm.mjs). Cet adaptateur traduit chaque méthode en commande et
// chaque réponse en types du domaine. Il ne sait pas comment la commande est
// exécutée : directement sous Node, dans un Web Worker dans le navigateur.
import type {
Board,
BoardResult,
BoardSide,
CubeDecision,
Engine,
EngineCube,
HalfMovesCheck,
Move,
Ply,
} from "../domain/game/engine.js";
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
* promesse est rejetée si le moteur signale une erreur. */
export type RunCommand = (args: string[]) => Promise<unknown>;
// Les réponses d'engine-cli utilisées ici (voir engine-cli/main.c).
interface CliMove {
move: string;
position_id: string;
half_moves: [number, number][];
}
interface CliSide {
points: number[];
bar: number;
off: number;
pip_count: number;
}
interface CliBoard {
on_roll: CliSide;
opponent: CliSide;
swapped_position_id: string;
result: { winner: "on_roll" | "opponent"; kind: BoardResult["kind"]; points: number } | null;
}
export function createWasmEngine(run: RunCommand): Engine {
// Un Match ID par état du videau : le moteur les code une fois.
const matchIds = new Map<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
// trait, 1 son adversaire, -1 le centre.
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
const key = `${cube.value} ${owner}`;
let id = matchIds.get(key);
if (id === undefined) {
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
match_id: string;
};
id = answer.match_id;
matchIds.set(key, id);
}
return id;
}
return {
async legalMoves(position: PositionId, roll: Roll): Promise<Move[]> {
// Le Match ID n'a pas d'effet sur les coups légaux.
const answer = (await run(["legal", position, "MAEAAAAAAAAA", diceText(roll)])) as {
moves: CliMove[];
};
return answer.moves.map(toMove);
},
async bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "bestmove", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null };
return answer.best_move === null ? null : toMove(answer.best_move);
},
async cubeDecision(
position: PositionId,
cube: EngineCube,
ply: Ply,
): Promise<CubeDecision> {
const args = ["--ply", String(ply), "cube", position, await matchId(cube)];
const answer = (await run(args)) as CubeDecision;
return { available: answer.available, double: answer.double, take: answer.take };
},
async checkHalfMoves(
position: PositionId,
roll: Roll,
halfMoves: readonly HalfMove[],
): Promise<HalfMovesCheck> {
const args = ["halfmoves", position, diceText(roll), formatHalfMoves(halfMoves)];
const answer = (await run(args)) as {
valid: boolean;
complete: boolean;
result_position_id: string | null;
};
return {
valid: answer.valid,
complete: answer.complete,
position: answer.result_position_id,
};
},
async board(position: PositionId, cube: EngineCube): Promise<Board> {
const answer = (await run(["position", position, await matchId(cube)])) as CliBoard;
return {
onRoll: toSide(answer.on_roll),
opponent: toSide(answer.opponent),
swappedPosition: answer.swapped_position_id,
result:
answer.result === null
? null
: {
winner: answer.result.winner === "on_roll" ? "onRoll" : "opponent",
kind: answer.result.kind,
points: answer.result.points,
},
};
},
};
}
function diceText(roll: Roll): string {
return `${roll[0]}${roll[1]}`;
}
function toMove(move: CliMove): Move {
return {
notation: move.move,
halfMoves: move.half_moves.map(([from, to]) => ({ from, to })),
position: move.position_id,
};
}
function toSide(side: CliSide): BoardSide {
return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count };
}
View File
+68
View File
@@ -0,0 +1,68 @@
// Une partie complète sur le vrai moteur WebAssembly, avec des dés imposés :
// l'ordinateur contre une stratégie simple du joueur (toujours le premier
// coup légal, saisi demi-coup par demi-coup, et toujours prendre).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { Game } from "../src/domain/game/game.js";
import type { DiceSource } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
import { loadNodeEngine } from "./support/node-engine.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/** Joue jusqu'à la fin ; renvoie le nombre d'actions. */
async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNodeEngine>>) {
let actions = 0;
while (game.state.phase !== "over") {
assert.ok(++actions < 2000, "la partie ne finit pas");
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
game.take();
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
await game.roll();
} else {
assert.ok(state.roll !== null);
const [first] = await engine.legalMoves(state.position, state.roll);
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
for (const halfMove of first.halfMoves) {
assert.equal(await game.playHalfMove(halfMove), true);
}
}
}
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: seededDice(seed), ply });
await playToTheEnd(game, engine);
const events = game.events;
const last = events.at(-1);
assert.equal(last?.type, "GameEnded");
const result = game.state.result;
assert.ok(result !== null);
assert.ok(result.points >= 1);
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "player"));
assert.ok(events.some((e) => e.type === "MovePlayed" && e.side === "computer"));
// Rejouer le journal redonne le même état.
assert.deepEqual(replay(events), game.state);
});
}
+220
View File
@@ -0,0 +1,220 @@
// Tests unitaires du domaine « partie », avec un faux moteur (ADR 0007,
// point 7) : journal, rejeu, branches, déroulement des tours et du videau.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
Board,
CubeDecision,
Engine,
HalfMovesCheck,
Move,
} from "../src/domain/game/engine.js";
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
* textes sans signification. */
function fakeEngine(overrides: Partial<Engine> = {}): Engine {
const move: Move = {
notation: "8/5 6/5",
halfMoves: parseHalfMoves("8/5 6/5"),
position: "AFTER",
};
const notOver: Board = {
onRoll: { points: [], bar: 0, off: 0, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 0 },
swappedPosition: "SWAPPED",
result: null,
};
const noDouble: CubeDecision = { available: true, double: false, take: true };
return {
legalMoves: async () => [move],
bestMove: async () => move,
cubeDecision: async () => noDouble,
checkHalfMoves: async (_position, _roll, halfMoves): Promise<HalfMovesCheck> => {
const text = formatHalfMoves(halfMoves);
if (text === "8/5") {
return { valid: true, complete: false, position: "HALF" };
}
if (text === "8/5 6/5") {
return { valid: true, complete: true, position: "AFTER" };
}
return { valid: false, complete: false, position: null };
},
board: async () => notOver,
...overrides,
};
}
test("le jet d'ouverture se relance en cas d'égalité, puis le plus fort dé commence", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([4, 4, 2, 5]) });
const rolls = game.events.filter((e) => e.type === "DiceRolled");
assert.deepEqual(
rolls.map((e) => e.roll),
[
[4, 4],
[2, 5],
],
);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "moving");
assert.deepEqual(game.state.roll, [2, 5]);
assert.equal(game.state.position, START_POSITION);
});
test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change rien", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.equal(await game.playHalfMove({ from: 13, to: 12 }), false);
assert.equal(game.events.length, 2);
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
assert.equal(game.displayedPosition, "HALF");
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
const last = game.events.at(-1);
assert.equal(last?.type, "MovePlayed");
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.state.turn, "computer");
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.position, "SWAPPED");
});
test("sans coup possible, le tour passe", async () => {
const engine = fakeEngine({ legalMoves: async () => [] });
const game = Game.start(engine, {
dice: fixedDice([6, 6]),
setup: { position: "BLOCKED", firstTurn: "player" },
});
await game.roll();
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed");
assert.deepEqual(last.halfMoves, []);
assert.equal(game.state.turn, "computer");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
game.take();
assert.deepEqual(game.state.cube, { value: 2, owner: "player" });
assert.equal(game.state.phase, "rolling");
assert.equal(game.state.turn, "computer");
});
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
});
await game.playComputerTurn();
game.drop();
assert.equal(game.state.phase, "over");
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
});
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: false }),
});
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
await game.double();
assert.deepEqual(
game.events.slice(-3).map((e) => e.type),
["CubeOffered", "CubeDropped", "GameEnded"],
);
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
});
test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: false, double: false, take: true }),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
});
await assert.rejects(game.double(), /videau/);
assert.equal(game.events.length, 1);
});
test("la fin de partie vient du moteur : gagnant, sorte et points", async () => {
const engine = fakeEngine({
board: async () => ({
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
swappedPosition: "SWAPPED",
result: { winner: "onRoll", kind: "gammon", points: 2 },
}),
});
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer" },
dice: fixedDice([2, 1]),
});
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
assert.throws(() => game.take(), /action impossible/);
});
test("annuler repart du parent ; l'action suivante ouvre une branche", async () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
const played = game.events.at(-1);
assert.equal(played?.type, "MovePlayed");
game.undo();
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
assert.equal(moves.length, 2);
assert.equal(moves[0]?.parent, moves[1]?.parent);
assert.notEqual(game.events.at(-1)?.id, played?.id);
game.goBackTo(played?.id ?? "");
assert.equal(game.events.at(-1)?.id, played?.id);
});
test("les événements sont immuables et versionnés ; le rejeu est déterministe", () => {
let journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
const [started, rolled] = currentLine(journal);
assert.equal(started?.version, 1);
assert.equal(started?.parent, null);
assert.equal(rolled?.parent, started?.id);
assert.throws(() => {
(rolled as { parent: string | null }).parent = null;
}, TypeError);
assert.deepEqual(replay(currentLine(journal)), replay(currentLine(journal)));
assert.equal(replay(currentLine(goBackTo(journal, "1"))).phase, "opening");
});
test("lecture et écriture des demi-coups", () => {
assert.deepEqual(parseHalfMoves("bar/22 6/off"), [
{ from: 25, to: 22 },
{ from: 6, to: 0 },
]);
assert.equal(formatHalfMoves(parseHalfMoves(" 8/5 6/5 ")), "8/5 6/5");
assert.throws(() => parseHalfMoves("8-5"), /mal formé/);
assert.throws(() => parseHalfMoves("30/5"), /case invalide/);
});
+37
View File
@@ -0,0 +1,37 @@
// Le vrai moteur WebAssembly sous Node, pour les tests et les scénarios.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Charge build/wasm/ (make engine-wasm) avec web/engine-wasm.mjs, comme la
// page de test du moteur, et le branche sur l'adaptateur du domaine. Le moteur
// n'est chargé qu'une fois par processus.
import { fileURLToPath } from "node:url";
import type { Engine } from "../../src/domain/game/engine.js";
import { createWasmEngine } from "../../src/engine/wasm-engine.js";
// Ce fichier est compilé dans build/web/tests/support/ : la racine du dépôt
// est quatre dossiers plus haut.
const root = new URL("../../../../", import.meta.url);
interface WasmEngine {
run(args: string[]): { result: unknown; ms: number };
}
let loading: Promise<Engine> | undefined;
export function loadNodeEngine(): Promise<Engine> {
loading ??= load();
return loading;
}
async function load(): Promise<Engine> {
const module = await import(new URL("build/wasm/engine.mjs", root).href);
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
const wasm: WasmEngine = await wrapper.loadEngine(
module.default,
fileURLToPath(new URL("build/wasm/", root)),
);
return createWasmEngine(async (args) => wasm.run(args).result);
}