Domaine : match, niveau de l'ordinateur, événements en version 2

Partie de match (score et Crawford dits par le moteur), niveau choisi
porté par GameStarted, ordinateur joué comme ComputerTurn de gnubg ;
migration des journaux de la version 1 ; scénarios, glossaire, ADR 0007
et 0013 amendés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-09 19:07:21 +01:00
co-authored by Claude Opus 5.5
parent c0f827c5ba
commit 339d699488
36 changed files with 1240 additions and 167 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Given("vous avez choisi de jouer tout de suite", () => {
});
Given("une partie est en cours", () => {
game = Game.start(engine, { dice: seededDice(11), ply: 0 });
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
});
+265
View File
@@ -0,0 +1,265 @@
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
// (settings.ts) et la session d'enregistrement ; les parties et le match,
// par le domaine branché sur le vrai moteur, avec des dés imposés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
import { START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createGameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
setDefaultTimeout(120_000);
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
// 6-5, le joueur au trait gagne un gammon.
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
// gagne une partie simple avec 6-5.
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
let engine: Engine;
let settings: Settings;
let session: GameSession;
let game: Game;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
async function playFirstMove(g: Game): Promise<void> {
await g.roll();
const state = g.state;
assert.ok(state.roll !== null);
const [move] = await engine.legalMoves(state.position, state.roll);
assert.ok(move !== undefined);
for (const halfMove of move.halfMoves) {
assert.ok(await g.playHalfMove(halfMove));
}
await g.confirmMove();
}
// ------------------------------------------------------------ Le niveau
Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => {
settings = parseSettings(null);
session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), {
start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }),
resume: (journal) => Game.resume(engine, journal),
});
});
Then(
"une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée",
() => {
assert.equal(settings.gameKind, "free");
assert.equal(settings.level, "intermediate");
assert.equal(settings.masterAdvice, true);
assert.deepEqual(chosenGame(settings), { level: "intermediate" });
},
);
When("vous choisissez le niveau Champion et commencez la partie", () => {
settings = { ...settings, level: "champion" };
game = session.newGame(chosenGame(settings));
session.save(game.journal);
});
Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => {
assert.equal(game.state.level, `${plies}ply`);
// Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue
// le coup que le moteur donne pour ce niveau.
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const before = game.state;
await game.playComputerTurn();
const played = game.events.findLast((e) => e.type === "MovePlayed");
assert.ok(played?.type === "MovePlayed" && played.side === "computer");
const roll = game.events.findLast((e) => e.type === "DiceRolled");
assert.ok(roll?.type === "DiceRolled");
const expected = await engine.levelMove(
before.position,
engineCube(before, "computer"),
roll.roll,
"2ply",
);
assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []);
session.save(game.journal);
});
Then("la partie reprise garde ce niveau", async () => {
const resumed = await session.restore();
assert.ok(resumed !== null);
assert.equal(resumed.state.level, "2ply");
});
Then("vos choix sont proposés à la prochaine nouvelle partie", () => {
const again = parseSettings(JSON.stringify(settings));
assert.equal(again.level, "champion");
assert.equal(chosenGame(again).level, "2ply");
});
// ------------------------------------------------------- Un match entier
// Les parties du match, dans l'ordre.
let games: Game[] = [];
Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => {
settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" };
games = [];
});
When("le match se joue jusqu'au bout, partie après partie", async () => {
// Des dés reproductibles, partagés par toutes les parties du match.
const dice = seededDice(3);
let next: NewGame | null = chosenGame(settings);
while (next !== null) {
assert.ok(games.length < 20, "le match ne finit pas");
const g = Game.start(engine, { dice, ...next });
await playToTheEnd(g, engine);
games.push(g);
next = g.nextGame;
}
});
Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => {
const last = games.at(-1)?.state;
assert.ok(last?.matchAfter);
assert.equal(last.matchAfter.over, true);
const { player, computer } = last.matchAfter.score;
assert.ok(Math.max(player, computer) >= n);
// Avant la dernière partie, personne n'avait encore les points.
for (const g of games.slice(0, -1)) {
assert.equal(g.state.matchAfter?.over, false);
const score = g.state.matchAfter?.score;
assert.ok(score !== undefined && Math.max(score.player, score.computer) < n);
}
});
Then("chaque partie commence avec le score laissé par la précédente", () => {
assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 });
for (let i = 1; i < games.length; i++) {
assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score);
}
});
Then("la partie Crawford s'est jouée sans videau", () => {
const crawford = games.filter((g) => g.state.match?.crawford === true);
assert.equal(crawford.length, 1, "une partie Crawford, et une seule");
for (const g of crawford) {
assert.ok(!g.events.some((e) => e.type === "CubeOffered"));
assert.equal(g.state.cube.value, 1);
}
});
// ------------------------------------------------------- La partie Crawford
let crawfordGame: Game;
Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" },
level: "beginner",
match: firstGameOf(3),
dice: fixedDice([6, 5]),
});
});
When("l'ordinateur gagne la partie", async () => {
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
});
Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => {
assert.deepEqual(game.state.matchAfter?.score, { player, computer });
});
Then("la partie suivante est la partie Crawford", () => {
assert.equal(game.nextGame?.match?.crawford, true);
});
When("la partie Crawford commence", () => {
const next = game.nextGame;
assert.ok(next?.match !== undefined);
crawfordGame = Game.start(engine, {
...next,
setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
});
Then("personne ne peut doubler pendant cette partie", async () => {
assert.equal(await crawfordGame.canDouble(), false);
// L'ordinateur non plus, dans la même position vue de lui.
const state = crawfordGame.state;
const board = await engine.board(state.position, engineCube(state, "player"));
assert.equal(
await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level),
false,
);
});
When("vous gagnez la partie Crawford", async () => {
await playFirstMove(crawfordGame);
assert.deepEqual(crawfordGame.state.matchAfter, {
score: { player: 1, computer: 2 },
over: false,
crawford: false,
});
});
Then("vous pouvez de nouveau doubler dans la partie suivante", async () => {
const next = crawfordGame.nextGame;
assert.ok(next?.match !== undefined);
const match: Match = next.match;
assert.equal(match.crawford, false);
const after = Game.start(engine, {
...next,
setup: { position: START_POSITION, firstTurn: "player" },
});
assert.equal(await after.canDouble(), true);
});
// ------------------------------------------------------- La règle Jacoby
Given("vous allez gagner un gammon sans que personne ait doublé", () => {
// La position est choisie à chaque partie, ci-dessous.
});
When("vous gagnez une partie libre", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
When("vous gagnez la même partie dans un match", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
match: firstGameOf(5),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
Then("vous marquez {int} point(s)", (points: number) => {
assert.equal(game.state.result?.winner, "player");
assert.equal(game.state.result?.kind, "gammon");
assert.equal(game.state.result?.points, points);
});
+2 -2
View File
@@ -426,8 +426,8 @@ When("vous prenez", () => {
game.take();
});
When("vous passez", () => {
game.drop();
When("vous passez", async () => {
await game.drop();
});
Then("la partie continue à valeur doublée", () => {
+2 -2
View File
@@ -42,7 +42,7 @@ Before(async () => {
/** Commence une partie, comme l'application : un nouvel identifiant. */
function startGame(): void {
game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 });
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
shownId = `partie-${nextSeed++}`;
}
@@ -58,7 +58,7 @@ async function reopen(): Promise<void> {
library = (await openGameLibrary({ localStorage: storage })).library;
const saved = await library.current();
assert.ok(saved !== null, "aucune partie en cours");
game = Game.resume(engine, saved.journal, { ply: 0 });
game = Game.resume(engine, saved.journal, { level: "0ply" });
shownId = saved.summary.id;
}
+5 -10
View File
@@ -45,15 +45,10 @@ BeforeAll(async () => {
Before(() => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
session = createGameSession(
library,
{
start: (setup) =>
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
},
"0ply",
);
session = createGameSession(library, {
start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }),
resume: (journal) => Game.resume(engine, journal, { level: "0ply" }),
});
shared = null;
failure = null;
});
@@ -131,7 +126,7 @@ Then("votre partie n'a pas changé", () => {
When("vous choisissez « Jouer à partir d'ici »", () => {
assert.ok(shared !== null);
game = session.newGame(setupOf(shared));
game = session.newGame({ setup: setupOf(shared) });
session.save(game.journal);
});
@@ -48,14 +48,10 @@ let oldId: string | null;
Given("une partie est en cours depuis quelques coups", async () => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
session = createGameSession(
library,
{
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
},
"2ply",
);
session = createGameSession(library, {
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
});
game = session.newGame();
dice = () => 1 + Math.floor(Math.random() * 6);
for (const halfMove of parseHalfMoves("8/5 6/5")) {
@@ -178,7 +174,7 @@ Given("une nouvelle version d'Ustagammon est disponible", () => {
Given("vous avez déjà déplacé un pion de votre coup", async () => {
// Jet d'ouverture 3-1 : vous commencez.
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 });
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
assert.equal(game.state.turn, "player");
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
});