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
+10
View File
@@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
score: [0, 0],
cubeValue: 1,
cubeOwner: -1,
playerOnRoll: 0,
crawford: false,
jacoby: true,
}),
...overrides,
};
}
+99
View File
@@ -0,0 +1,99 @@
// Tests des liens de position (app/src/domain/game/position-link.ts, ADR
// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau
// vu du joueur au trait, identifiants illisibles.
//
// 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 type { EngineCube } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import {
linkOf,
readPosition,
setupOf,
UnreadableLinkError,
} from "../src/domain/game/position-link.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadNodeEngine } from "./support/node-engine.js";
test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => {
const engine = await loadNodeEngine();
const cases: [EngineCube, -1 | 0 | 1][] = [
[{ value: 1, owner: "centered" }, -1],
[{ value: 2, owner: "onRoll" }, 0],
[{ value: 4, owner: "opponent" }, 1],
];
for (const [cube, owner] of cases) {
const id = await engine.encodeMatch(cube);
assert.equal(id.length, 12);
const match = await engine.decodeMatch(id);
assert.equal(match.matchLength, 0);
assert.equal(match.playerOnRoll, 0);
assert.equal(match.cubeValue, cube.value);
assert.equal(match.cubeOwner, owner);
}
});
test("sans Match ID : partie libre, videau au centre", async () => {
const engine = await loadNodeEngine();
const shared = await readPosition(engine, { position: START_POSITION, matchId: null });
assert.deepEqual(shared.cube, { value: 1, owner: null });
assert.equal(shared.match, null);
assert.equal(shared.result, null);
assert.equal(shared.sides.player.pipCount, 167);
assert.equal(shared.sides.computer.pipCount, 167);
assert.deepEqual(setupOf(shared), {
position: START_POSITION,
firstTurn: "player",
cube: { value: 1, owner: null },
});
});
test("des identifiants illisibles donnent une erreur claire", async () => {
const engine = await loadNodeEngine();
for (const link of [
{ position: "abc", matchId: null },
{ position: "4HPwATDgc/ABM*", matchId: null },
{ position: START_POSITION, matchId: "pas un id" },
{ position: START_POSITION, matchId: "" },
]) {
await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link));
}
});
test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => {
const engine = await loadNodeEngine();
// Videau chez l'ordinateur, à 2 : une partie commencée ainsi.
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: {
position: "jGfkASjg8wcBMA",
firstTurn: "player",
cube: { value: 2, owner: "computer" },
},
});
const link = await linkOf(engine, game.state);
const route = parseRoute(
routeHash({ screen: "position", position: link.position, match: link.matchId }),
);
assert.ok(route.screen === "position");
const shared = await readPosition(engine, { position: route.position, matchId: route.match });
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, { value: 2, owner: "computer" });
});
test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => {
const engine = await loadNodeEngine();
// Jet d'ouverture 1-6 : l'ordinateur commence.
const game = Game.start(engine, { dice: fixedDice([1, 6]) });
assert.equal(game.state.turn, "computer");
const link = await linkOf(engine, game.state);
assert.equal(link.position, game.state.position);
const shared = await readPosition(engine, link);
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
});
@@ -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}`);
});