diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 6b2e211..d9f85b2 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -9,25 +9,25 @@ // 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 type { BoardSide, Engine, EngineCube, Ply } from "./engine.js"; import { appendEvent, currentLine, EMPTY_JOURNAL, + EVENT_VERSION, type GameEvent, type GameJournal, goBackTo, type NewGameEvent, } from "./events.js"; -import { type HalfMove, type PositionId, START_POSITION } from "./position.js"; +import { BAR, type HalfMove, OFF, 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. + * Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome), + * le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009). */ export const STANDARD_PLY: Ply = 2; @@ -40,6 +40,13 @@ export interface GameSetup { readonly cube?: Cube; } +/** Le plateau vu de chaque camp, chacun avec sa propre numérotation des + * cases (sa case 1 est la plus proche de sa sortie). */ +export interface SideBoards { + readonly player: BoardSide; + readonly computer: BoardSide; +} + export interface GameOptions { /** Source des dés ; le hasard ordinaire par défaut. */ readonly dice?: DiceSource; @@ -85,6 +92,25 @@ export class Game { return game; } + /** + * Reprend une partie enregistrée, à partir de son journal. Erreur si le + * journal est d'un autre format ou incohérent. + */ + static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game { + const first = journal.events[0]; + if (first?.type !== "GameStarted" || journal.head === null) { + throw new Error("journal de partie vide ou mal formé"); + } + if (journal.events.some((event) => event.version !== EVENT_VERSION)) { + throw new Error("journal de partie d'une autre version"); + } + const game = new Game(engine, options); + game.#journal = journal; + // Vérifie que chaque événement de la ligne courante existe. + currentLine(journal); + return game; + } + /** Tous les événements, branches comprises, et l'événement courant. */ get journal(): GameJournal { return this.#journal; @@ -109,6 +135,68 @@ export class Game { return this.#pendingPosition ?? this.state.position; } + /** Le plateau affiché (après les demi-coups déjà saisis), décodé par le + * moteur. */ + async displayedBoard(): Promise { + const state = this.state; + // La position est vue du camp qui a la main (le joueur pendant le jet + // d'ouverture : la position de départ est la même pour les deux). + const onRoll = state.turn ?? "player"; + const board = await this.#engine.board( + this.displayedPosition, + engineCube(state.cube, onRoll), + ); + return onRoll === "player" + ? { player: board.onRoll, computer: board.opponent } + : { player: board.opponent, computer: board.onRoll }; + } + + /** + * Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux + * déjà saisis (liste vide hors de son tour de jeu). Chaque candidat + * déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé + * dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à + * un coup légal. Aucune règle n'est jugée ici. + */ + async nextHalfMoves(): Promise { + const state = this.state; + if (state.turn !== "player" || state.phase !== "moving") { + return []; + } + const roll = rollToPlay(state); + const { player } = await this.displayedBoard(); + const candidates = new Map(); + for (const from of occupiedPoints(player)) { + for (const die of roll) { + const to = Math.max(from - die, OFF); + candidates.set(`${from}/${to}`, { from, to }); + } + } + const moves = [...candidates.values()]; + const checks = await Promise.all( + moves.map((h) => + this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]), + ), + ); + return moves.filter((_, i) => checks[i]?.valid === true); + } + + /** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le + * permet.) */ + async canDouble(): Promise { + const state = this.state; + if (state.turn !== "player" || state.phase !== "rolling") { + return false; + } + // Le droit de doubler ne dépend pas du niveau : 0 pli suffit. + const decision = await this.#engine.cubeDecision( + state.position, + engineCube(state.cube, "player"), + 0, + ); + return decision.available; + } + // ------------------------------------------------------------- Joueur /** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */ @@ -304,6 +392,17 @@ function rollToPlay(state: GameState): Roll { return state.roll; } +/** Les cases d'où un camp a des pions à jouer : la barre, puis 24 à 1. */ +function occupiedPoints(side: BoardSide): number[] { + const points = side.bar > 0 ? [BAR] : []; + for (let point = 24; point >= 1; point--) { + if ((side.points[point - 1] ?? 0) > 0) { + points.push(point); + } + } + return points; +} + /** Le videau vu du camp au trait, pour le moteur. */ function engineCube(cube: Cube, onRoll: Side): EngineCube { if (cube.owner === null) { diff --git a/app/src/domain/tsconfig.json b/app/src/domain/tsconfig.json index 51a14fc..739394d 100644 --- a/app/src/domain/tsconfig.json +++ b/app/src/domain/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2023"], "types": [], "noEmit": true }, diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 00660db..d7ec808 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -218,3 +218,57 @@ test("lecture et écriture des demi-coups", () => { assert.throws(() => parseHalfMoves("8-5"), /mal formé/); assert.throws(() => parseHalfMoves("30/5"), /case invalide/); }); + +test("une partie se reprend depuis son journal ; un journal d'une autre version est refusé", async () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + const saved = JSON.parse(JSON.stringify(game.journal)); + + const resumed = Game.resume(fakeEngine(), saved); + assert.deepEqual(resumed.state, game.state); + assert.deepEqual(resumed.events, game.events); + + const other = { ...saved, events: saved.events.map((e: object) => ({ ...e, version: 99 })) }; + assert.throws(() => Game.resume(fakeEngine(), other), /version/); + assert.throws(() => Game.resume(fakeEngine(), EMPTY_JOURNAL), /mal formé/); + assert.throws(() => Game.resume(fakeEngine(), { ...saved, head: "inconnu" }), /introuvable/); +}); + +test("le droit de doubler vient du moteur, seulement avant de lancer les dés", async () => { + let available = true; + const engine = fakeEngine({ + cubeDecision: async () => ({ available, double: false, take: true }), + }); + const game = Game.start(engine, { + dice: fixedDice([3, 1]), + setup: { position: "X", firstTurn: "player" }, + }); + assert.equal(await game.canDouble(), true); + available = false; + assert.equal(await game.canDouble(), false); + available = true; + await game.roll(); + assert.equal(game.state.phase, "moving"); + assert.equal(await game.canDouble(), false); +}); + +test("le plateau affiché est donné du point de vue de chaque camp", async () => { + const side = (pipCount: number) => ({ points: [], bar: 0, off: 0, pipCount }); + const engine = fakeEngine({ + board: async () => ({ + onRoll: side(1), + opponent: side(2), + swappedPosition: "SWAPPED", + result: null, + }), + }); + const playerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "player" } }); + const sides = await playerTurn.displayedBoard(); + assert.equal(sides.player.pipCount, 1); + assert.equal(sides.computer.pipCount, 2); + const computerTurn = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } }); + const swapped = await computerTurn.displayedBoard(); + assert.equal(swapped.player.pipCount, 2); + assert.equal(swapped.computer.pipCount, 1); +}); diff --git a/app/tests/next-half-moves.test.ts b/app/tests/next-half-moves.test.ts new file mode 100644 index 0000000..1e8d904 --- /dev/null +++ b/app/tests/next-half-moves.test.ts @@ -0,0 +1,65 @@ +// Ce que l'interface propose au joueur, sur le vrai moteur WebAssembly : les +// demi-coups possibles (mis en évidence quand il touche un pion) et le +// plateau affiché pendant la saisie d'un coup. +// +// 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 { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5 +// (même position que dans les étapes du scénario). +const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA"; + +/** Les demi-coups en notation, triés : « 13/10 13/12… ». */ +function text(halfMoves: readonly HalfMove[]): string { + return halfMoves + .map((h) => formatHalfMoves([h])) + .sort() + .join(" "); +} + +test("au départ avec 3-1 : chaque pion jouable, avec chaque dé", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([3, 1]) }); + assert.equal(text(await game.nextHalfMoves()), "13/10 24/21 24/23 6/3 6/5 8/5 8/7"); + // 13/12 manque : votre case 12 est tenue par l'ordinateur. Après 8/5, + // il ne reste que le 1. + assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true); + assert.equal(text(await game.nextHalfMoves()), "24/23 5/4 6/5 8/7"); +}); + +test("en fin de partie, les demi-coups vers la sortie viennent du moteur", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { + dice: fixedDice([6, 5]), + setup: { position: PLAYER_ALMOST_DONE, firstTurn: "player" }, + }); + await game.roll(); + assert.equal(text(await game.nextHalfMoves()), "5/off 6/1 6/off"); +}); + +test("hors du tour de jeu du joueur, rien n'est proposé", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([1, 3]) }); + assert.equal(game.state.turn, "computer"); + assert.deepEqual(await game.nextHalfMoves(), []); +}); + +test("le plateau affiché suit les demi-coups saisis", async () => { + const engine = await loadNodeEngine(); + const game = Game.start(engine, { dice: fixedDice([3, 1]) }); + const before = await game.displayedBoard(); + assert.equal(before.player.points[7], 3); + assert.equal(before.computer.points[23], 2); + await game.playHalfMove({ from: 8, to: 5 }); + const after = await game.displayedBoard(); + assert.equal(after.player.points[7], 2); + assert.equal(after.player.points[4], 1); + assert.equal(after.player.pipCount, before.player.pipCount - 3); +}); diff --git a/app/tsconfig.json b/app/tsconfig.json index 59a052b..bff4bff 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"],