From 602d0eafedaf198e48335b165cc696ce1d30963b Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 2 Nov 2026 20:42:08 +0100 Subject: [PATCH] =?UTF-8?q?Domaine=20=C2=AB=20partie=20=C2=BB=20en=20journ?= =?UTF-8?q?al=20d'=C3=A9v=C3=A9nements,=20branch=C3=A9=20au=20moteur=20Web?= =?UTF-8?q?Assembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/src/domain/game/cube.ts | 19 ++ app/src/domain/game/engine.ts | 119 ++++++++++++ app/src/domain/game/events.ts | 148 +++++++++++++++ app/src/domain/game/game.ts | 313 +++++++++++++++++++++++++++++++ app/src/domain/game/position.ts | 73 +++++++ app/src/domain/game/roll.ts | 46 +++++ app/src/domain/game/side.ts | 11 ++ app/src/domain/game/state.ts | 108 +++++++++++ app/src/domain/tsconfig.json | 8 + app/src/engine/wasm-engine.ts | 144 ++++++++++++++ app/src/ui/.gitkeep | 0 app/tests/full-game.test.ts | 68 +++++++ app/tests/game.test.ts | 220 ++++++++++++++++++++++ app/tests/support/node-engine.ts | 37 ++++ 14 files changed, 1314 insertions(+) create mode 100644 app/src/domain/game/cube.ts create mode 100644 app/src/domain/game/engine.ts create mode 100644 app/src/domain/game/events.ts create mode 100644 app/src/domain/game/game.ts create mode 100644 app/src/domain/game/position.ts create mode 100644 app/src/domain/game/roll.ts create mode 100644 app/src/domain/game/side.ts create mode 100644 app/src/domain/game/state.ts create mode 100644 app/src/domain/tsconfig.json create mode 100644 app/src/engine/wasm-engine.ts create mode 100644 app/src/ui/.gitkeep create mode 100644 app/tests/full-game.test.ts create mode 100644 app/tests/game.test.ts create mode 100644 app/tests/support/node-engine.ts diff --git a/app/src/domain/game/cube.ts b/app/src/domain/game/cube.ts new file mode 100644 index 0000000..0534115 --- /dev/null +++ b/app/src/domain/game/cube.ts @@ -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 }; diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts new file mode 100644 index 0000000..12d4663 --- /dev/null +++ b/app/src/domain/game/engine.ts @@ -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; + + /** Le meilleur coup au niveau ply, ou null si aucun coup n'est possible. */ + bestMove(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply): Promise; + + /** La décision de videau du joueur au trait, avant de lancer les dés. */ + cubeDecision(position: PositionId, cube: EngineCube, ply: Ply): Promise; + + /** Où en est un coup saisi demi-coup par demi-coup. */ + checkHalfMoves( + position: PositionId, + roll: Roll, + halfMoves: readonly HalfMove[], + ): Promise; + + /** Le plateau d'une position, et le résultat si la partie y est finie. */ + board(position: PositionId, cube: EngineCube): Promise; +} + +/** + * 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; +} diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts new file mode 100644 index 0000000..8bf3596 --- /dev/null +++ b/app/src/domain/game/events.ts @@ -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; + +// Omit appliqué à chaque sorte d'événement séparément (et non à leur union, +// qui ne garderait que les champs communs). +type WithoutBase = E extends unknown ? Omit : 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 }; +} diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts new file mode 100644 index 0000000..6b2e211 --- /dev/null +++ b/app/src/domain/game/game.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/app/src/domain/game/position.ts b/app/src/domain/game/position.ts new file mode 100644 index 0000000..66ad199 --- /dev/null +++ b/app/src/domain/game/position.ts @@ -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); +} diff --git a/app/src/domain/game/roll.ts b/app/src/domain/game/roll.ts new file mode 100644 index 0000000..0dcced3 --- /dev/null +++ b/app/src/domain/game/roll.ts @@ -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; +} diff --git a/app/src/domain/game/side.ts b/app/src/domain/game/side.ts new file mode 100644 index 0000000..3416438 --- /dev/null +++ b/app/src/domain/game/side.ts @@ -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"; +} diff --git a/app/src/domain/game/state.ts b/app/src/domain/game/state.ts new file mode 100644 index 0000000..c9aae94 --- /dev/null +++ b/app/src/domain/game/state.ts @@ -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 }, + }; + } +} diff --git a/app/src/domain/tsconfig.json b/app/src/domain/tsconfig.json new file mode 100644 index 0000000..51a14fc --- /dev/null +++ b/app/src/domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": [], + "noEmit": true + }, + "include": ["."] +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts new file mode 100644 index 0000000..019ccca --- /dev/null +++ b/app/src/engine/wasm-engine.ts @@ -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; + +// 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(); + + async function matchId(cube: EngineCube): Promise { + // 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 { + // 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 { + 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 { + 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 { + 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 }; +} diff --git a/app/src/ui/.gitkeep b/app/src/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts new file mode 100644 index 0000000..23a4c69 --- /dev/null +++ b/app/tests/full-game.test.ts @@ -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>) { + 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); + }); +} diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts new file mode 100644 index 0000000..00660db --- /dev/null +++ b/app/tests/game.test.ts @@ -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 { + 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 => { + 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/); +}); diff --git a/app/tests/support/node-engine.ts b/app/tests/support/node-engine.ts new file mode 100644 index 0000000..a84bc45 --- /dev/null +++ b/app/tests/support/node-engine.ts @@ -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 | undefined; + +export function loadNodeEngine(): Promise { + loading ??= load(); + return loading; +} + +async function load(): Promise { + 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); +}