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:
co-authored by
Claude Opus 5.5
parent
3cdd017768
commit
43031d1ec6
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user