Domaine : lire et écrire le lien d'une position (ADR 0013)

Le moteur écrit et décode le Match ID ; readPosition valide un lien,
linkOf donne celui d'une partie, setupOf la partie à jouer depuis la
position. Scénario « Partager une position », fiche « Lien de position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-03 21:21:45 +01:00
co-authored by Claude Opus 5.5
parent 3cdd017768
commit 43031d1ec6
13 changed files with 544 additions and 10 deletions
@@ -0,0 +1,171 @@
// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008,
// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite
// puis relue), la position par le domaine et le vrai moteur WebAssembly, et
// les parties par le même enregistrement que l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import {
linkOf,
readPosition,
type SharedPosition,
setupOf,
UnreadableLinkError,
} from "../../src/domain/game/position-link.js";
import { createGameLibrary, type GameLibrary } 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 { parseRoute, routeHash } from "../../src/ui/router.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, seededDice, step } from "../support/play.js";
let engine: Engine;
let library: GameLibrary;
let session: GameSession;
let game: Game;
let gameId: string | null;
let journalBefore: GameJournal;
let address: string;
let shared: SharedPosition | null;
let failure: unknown;
/** Une position de milieu de partie (gnubg), vue du joueur au trait. */
const MIDDLE_GAME = "jGfkASjg8wcBMA";
BeforeAll(async () => {
engine = await loadNodeEngine();
});
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",
);
shared = null;
failure = null;
});
/** Ouvre une adresse comme l'application : le routeur la lit, le moteur
* lit la position. */
async function open(hash: string): Promise<void> {
const route = parseRoute(hash);
assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`);
if (route.screen !== "position") {
return;
}
try {
shared = await readPosition(engine, { position: route.position, matchId: route.match });
} catch (error) {
failure = error;
}
}
Given("votre partie en cours compte quelques coups", async () => {
game = session.newGame();
// Des coups jusqu'à ce que ce soit à vous de lancer les dés.
await playMoves(game, engine, 4);
const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling";
while (!yourRoll() && game.state.phase !== "over") {
await step(game, engine);
}
assert.ok(yourRoll(), "partie finie trop tôt");
session.save(game.journal);
gameId = session.shownId;
journalBefore = game.journal;
});
When("vous copiez le lien de la position", async () => {
const link = await linkOf(engine, game.state);
address = routeHash({ screen: "position", position: link.position, match: link.matchId });
});
Then("le lien contient l'identifiant de la position et celui du match", () => {
const route = parseRoute(address);
assert.ok(route.screen === "position");
assert.equal(route.position, game.state.position);
assert.equal(route.match?.length, 12);
assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/);
});
When("vous ouvrez ce lien", async () => {
await open(address);
});
Then("vous voyez exactement la même position, avec le même videau", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, game.state.cube);
assert.equal(shared.match, null);
});
When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => {
const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" });
address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId });
await open(address);
});
Then("vous voyez cette position, avec la main", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
assert.equal(shared.result, null);
});
Then("votre partie n'a pas changé", () => {
assert.equal(session.shownId, gameId);
assert.deepEqual(game.journal, journalBefore);
});
When("vous choisissez « Jouer à partir d'ici »", () => {
assert.ok(shared !== null);
game = session.newGame(setupOf(shared));
session.save(game.journal);
});
Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => {
assert.notEqual(session.shownId, gameId);
assert.equal(game.state.position, MIDDLE_GAME);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
Then("le videau est à 2, chez l'ordinateur", () => {
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
});
Then("votre partie précédente est toujours enregistrée", async () => {
assert.ok(gameId !== null);
const saved = await library.load(gameId);
assert.deepEqual(saved?.journal, journalBefore);
});
When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => {
// Coupé d'un caractère, puis un caractère hors du base64.
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA");
});
When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => {
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FABMA/cAk*");
});
Then("le lien est déclaré illisible", () => {
assert.equal(shared, null);
assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`);
});