Domaine : reprise d'une partie, plateau affiché, demi-coups possibles, droit de doubler

Ce dont l'interface a besoin, sans règle réimplémentée : le plateau vu de
chaque camp, les demi-coups que le moteur accepte à la suite de ceux déjà
saisis, le droit de doubler selon le moteur, et la reprise d'une partie
depuis son journal enregistré. Les types du navigateur sont ajoutés à
l'application ; le domaine reste vérifié sans eux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-02 21:23:06 +01:00
co-authored by Claude Opus 5.5
parent fea94324af
commit 613ff38254
5 changed files with 225 additions and 6 deletions
+104 -5
View File
@@ -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<SideBoards> {
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<HalfMove[]> {
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<string, HalfMove>();
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<boolean> {
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) {
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": [],
"noEmit": true
},
+54
View File
@@ -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);
});
+65
View File
@@ -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);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],