Mes parties : scénarios, glossaire, test navigateur sur IndexedDB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
274fbf6252
commit
31815134e5
@@ -0,0 +1,203 @@
|
||||
// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles
|
||||
// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le
|
||||
// stockage des parties sur un localStorage simulé en mémoire. Fermer puis
|
||||
// rouvrir l'application revient à rouvrir ce stockage et à reprendre la
|
||||
// partie en cours, comme main.ts au démarrage.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import type { GameLibrary } from "../../src/storage/game-library.js";
|
||||
import type { KeyValueStorage } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, seededDice } from "../support/play.js";
|
||||
|
||||
let engine: Engine;
|
||||
let storage: KeyValueStorage;
|
||||
let library: GameLibrary;
|
||||
let game: Game;
|
||||
let shownId: string;
|
||||
/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */
|
||||
let played: { id: string; journal: GameJournal }[];
|
||||
/** La partie d'origine, avant « Rejouer d'ici ». */
|
||||
let original: { head: string; ids: string[] };
|
||||
/** Le coup du joueur d'où l'on rejoue. */
|
||||
let replayed: MovePlayed;
|
||||
let sgf: string;
|
||||
let nextSeed = 1;
|
||||
|
||||
Before(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
storage = memoryStorage();
|
||||
library = (await openGameLibrary({ localStorage: storage })).library;
|
||||
played = [];
|
||||
});
|
||||
|
||||
/** Commence une partie, comme l'application : un nouvel identifiant. */
|
||||
function startGame(): void {
|
||||
game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 });
|
||||
shownId = `partie-${nextSeed++}`;
|
||||
}
|
||||
|
||||
/** Joue des coups, en enregistrant après chacun comme l'application. */
|
||||
async function play(moves: number): Promise<void> {
|
||||
for (let i = 0; i < moves; i++) {
|
||||
await playMoves(game, engine, 1);
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
shownId = saved.summary.id;
|
||||
}
|
||||
|
||||
/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses
|
||||
* dés déjà lancés). Renvoie ce coup. */
|
||||
function goBackToSecondMove(): MovePlayed {
|
||||
original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) };
|
||||
const second = game.events.filter(
|
||||
(e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player",
|
||||
)[1];
|
||||
assert.ok(second?.parent != null, "moins de deux coups du joueur");
|
||||
game.goBackTo(second.parent);
|
||||
return second;
|
||||
}
|
||||
|
||||
/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */
|
||||
async function playAnotherMove(first: MovePlayed): Promise<void> {
|
||||
const state = game.state;
|
||||
assert.ok(state.roll !== null);
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
// Move.position est vue de celui qui joue, MovePlayed.position de son
|
||||
// adversaire : on compare les demi-coups.
|
||||
const same = (m: (typeof moves)[number]) =>
|
||||
m.halfMoves.length === first.halfMoves.length &&
|
||||
m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from);
|
||||
const move = moves.find((m) => !same(m));
|
||||
assert.ok(move !== undefined, "un seul coup possible");
|
||||
for (const halfMove of move.halfMoves) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed" && last.position !== first.position);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Reprendre une partie parmi plusieurs
|
||||
|
||||
Given("vous avez joué deux parties, l'une après l'autre", async () => {
|
||||
for (const moves of [5, 3]) {
|
||||
startGame();
|
||||
await play(moves);
|
||||
played.push({ id: shownId, journal: game.journal });
|
||||
}
|
||||
});
|
||||
|
||||
When("vous fermez l'application puis la rouvrez", reopen);
|
||||
|
||||
Then("la dernière partie jouée est à l'écran", () => {
|
||||
const last = played.at(-1);
|
||||
assert.equal(shownId, last?.id);
|
||||
assert.deepEqual(game.journal, last?.journal);
|
||||
});
|
||||
|
||||
Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => {
|
||||
const ids = (await library.list()).map((s) => s.id);
|
||||
assert.deepEqual(ids, played.map((p) => p.id).reverse());
|
||||
});
|
||||
|
||||
When("vous reprenez la première partie", async () => {
|
||||
await library.setCurrent(played[0]?.id as string);
|
||||
await reopen();
|
||||
});
|
||||
|
||||
Then("elle est à l'écran, telle que vous l'aviez laissée", () => {
|
||||
assert.equal(shownId, played[0]?.id);
|
||||
assert.deepEqual(game.journal, played[0]?.journal);
|
||||
});
|
||||
|
||||
// ------------------------------------------- Rejouer d'ici sans perdre l'original
|
||||
|
||||
Given("vous avez joué plusieurs coups", async () => {
|
||||
startGame();
|
||||
await play(8);
|
||||
});
|
||||
|
||||
When("vous rejouez d'ici à partir de votre deuxième coup", () => {
|
||||
// Le retour seul : le coup suivant ouvrira la branche.
|
||||
replayed = goBackToSecondMove();
|
||||
});
|
||||
|
||||
When("vous choisissez un autre coup", async () => {
|
||||
await playAnotherMove(replayed);
|
||||
});
|
||||
|
||||
Then("la partie a deux branches", () => {
|
||||
assert.equal(game.branches.length, 2);
|
||||
});
|
||||
|
||||
Then("la partie d'origine est intacte", () => {
|
||||
const origin = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.equal(origin?.head, original.head);
|
||||
const copy = Game.resume(engine, game.journal);
|
||||
copy.switchBranch(original.head);
|
||||
assert.deepEqual(
|
||||
copy.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
Then("vous êtes sur la nouvelle branche", () => {
|
||||
const current = game.branches.find((b) => b.containsHead);
|
||||
assert.ok(current !== undefined && current.forkedFrom !== null);
|
||||
assert.equal(current.head, game.journal.head);
|
||||
});
|
||||
|
||||
Then("vous pouvez revenir à la partie d'origine, au coup près", () => {
|
||||
const branch = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.ok(branch !== undefined);
|
||||
game.switchBranch(branch.head);
|
||||
assert.deepEqual(
|
||||
game.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Exporter une partie
|
||||
|
||||
Given("vous avez rejoué d'ici une autre suite", async () => {
|
||||
await playAnotherMove(goBackToSecondMove());
|
||||
await play(2);
|
||||
});
|
||||
|
||||
When("vous exportez la partie", async () => {
|
||||
sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
});
|
||||
});
|
||||
|
||||
Then("vous obtenez un fichier SGF de GNU Backgammon", () => {
|
||||
assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40));
|
||||
assert.ok(sgf.trimEnd().endsWith(")"));
|
||||
});
|
||||
|
||||
Then("il contient vos coups, et l'autre branche en variante", () => {
|
||||
// Un nœud par coup joué, toutes branches comprises (B ou W, puis les
|
||||
// dés), et deux variantes à l'embranchement.
|
||||
const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length;
|
||||
assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves);
|
||||
assert.equal(sgf.match(/\(\n;/g)?.length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user