Domaine « partie » en journal d'événements, branché au moteur WebAssembly
app/src/domain/game/ : une partie libre contre l'ordinateur, enregistrée comme un arbre d'événements immuables et versionnés (GameStarted, DiceRolled, MovePlayed, CubeOffered, CubeTaken, CubeDropped, GameEnded) ; l'état se recalcule en les rejouant, annuler repart du parent, une action après un retour ouvre une branche. Dés injectés. Le joueur saisit son coup demi-coup par demi-coup ; l'ordinateur joue et décide du videau à 2 plis. Toutes les règles viennent du moteur, derrière l'interface Engine que le domaine définit. app/src/engine/wasm-engine.ts : l'adaptateur Engine sur les commandes d'engine-cli du moteur WebAssembly. Tests : unitaires avec un faux moteur, et deux parties complètes jusqu'à GameEnded sur le vrai moteur, avec des dés imposés. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54883f4ca3
commit
602d0eafed
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user