Étape 0d.1 : domaine « partie » en TypeScript, scénario exécuté ; ADR 0009 accepté #11
@@ -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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user