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
+30
View File
@@ -74,6 +74,36 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
* de lui, sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
}
/**
* Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur
* du match, videau, joueur au trait. Le domaine ne le décode jamais
* lui-même : il le passe au moteur.
*
* Glossaire : docs/glossaire/identifiant-de-match.md
*/
export type MatchId = string;
/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1,
* comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */
export interface MatchDescription {
/** 0 : partie libre. */
readonly matchLength: number;
readonly score: readonly [number, number];
readonly cubeValue: number;
/** -1 : videau au centre ; sinon le joueur qui le possède. */
readonly cubeOwner: -1 | 0 | 1;
readonly playerOnRoll: 0 | 1;
readonly crawford: boolean;
readonly jacoby: boolean;
}
/**
+126
View File
@@ -0,0 +1,126 @@
// Une position du plateau dans un lien (ADR 0013) : le Position ID et le
// Match ID de GNU Backgammon, lus et écrits par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un lien décrit la position du point de vue du joueur au trait : c'est lui
// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à
// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux
// identifiants (aucune règle de représentation n'est réimplémentée).
import type { Cube } from "./cube.js";
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
import type { GameSetup, SideBoards } from "./game.js";
import type { PositionId } from "./position.js";
import type { Side } from "./side.js";
import type { GameState } from "./state.js";
/**
* Ce que porte l'adresse d'une position : son Position ID, et son Match ID
* s'il est donné (sans lui : partie libre, videau au centre).
*
* Glossaire : docs/glossaire/lien-de-position.md
*/
export interface PositionLink {
readonly position: PositionId;
readonly matchId: MatchId | null;
}
/** Une position partagée, lue par le moteur. Le camp « player » est celui
* au trait. */
export interface SharedPosition {
readonly link: PositionLink;
readonly sides: SideBoards;
readonly cube: Cube;
/** null en partie libre ; sinon la longueur du match et le score. */
readonly match: {
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
} | null;
/** La partie est déjà finie dans cette position (null sinon). */
readonly result: BoardResult | null;
}
/** Un lien que le moteur ne sait pas lire. */
export class UnreadableLinkError extends Error {
constructor(reason: string) {
super(reason);
this.name = "UnreadableLinkError";
}
}
/**
* Lit une position partagée : le moteur décode le Match ID (s'il est
* donné), puis la position avec son videau. Erreur UnreadableLinkError si
* l'un des deux est illisible.
*/
export async function readPosition(engine: Engine, link: PositionLink): Promise<SharedPosition> {
let cube: EngineCube = { value: 1, owner: "centered" };
let match: SharedPosition["match"] = null;
if (link.matchId !== null) {
const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => {
throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`);
});
const onRoll = description.playerOnRoll;
cube = {
value: description.cubeValue,
owner:
description.cubeOwner === -1
? "centered"
: description.cubeOwner === onRoll
? "onRoll"
: "opponent",
};
if (description.matchLength > 0) {
match = {
length: description.matchLength,
score: {
player: description.score[onRoll],
computer: description.score[onRoll === 0 ? 1 : 0],
},
};
}
}
const board = await engine.board(link.position, cube).catch((error: unknown) => {
throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`);
});
return {
link,
sides: { player: board.onRoll, computer: board.opponent },
cube: {
value: cube.value,
owner:
cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer",
},
match,
result: board.result,
};
}
/**
* Le lien de la position d'une partie : la position vue du camp au trait,
* et le Match ID que le moteur écrit pour son videau (partie libre, sans
* dés). Pendant le jet d'ouverture, la position de départ, vue du joueur.
*/
export async function linkOf(engine: Engine, state: GameState): Promise<PositionLink> {
const onRoll: Side = state.turn ?? "player";
const cube: EngineCube =
state.cube.owner === null
? { value: state.cube.value, owner: "centered" }
: {
value: state.cube.value,
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
};
return { position: state.position, matchId: await engine.encodeMatch(cube) };
}
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
* place du camp au trait, avec le même videau ; partie libre. */
export function setupOf(shared: SharedPosition): GameSetup {
return { position: shared.link.position, firstTurn: "player", cube: shared.cube };
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+37 -1
View File
@@ -22,12 +22,19 @@ import type {
Level,
Luck,
LuckRating,
MatchDescription,
MatchId,
Move,
MoveAnalysis,
Ply,
Skill,
} from "../domain/game/engine.js";
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
import {
formatHalfMoves,
type HalfMove,
type PositionId,
START_POSITION,
} from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
@@ -208,6 +215,35 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {
// engine-cli n'a pas de commande qui décode seule un Match ID :
// l'évaluation à 0 pli (quelques millisecondes) le décode, le
// valide (SetCubeInfo de gnubg) et le renvoie lisible.
const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as {
match: {
match_length: number;
score: [number, number];
cube_value: number;
cube_owner: number;
player_on_roll: number;
crawford: boolean;
jacoby: boolean;
};
};
const m = answer.match;
return {
matchLength: m.match_length,
score: m.score,
cubeValue: m.cube_value,
cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1,
playerOnRoll: m.player_on_roll === 1 ? 1 : 0,
crawford: m.crawford,
jacoby: m.jacoby,
};
},
async checkHalfMoves(
position: PositionId,
roll: Roll,
+8 -7
View File
@@ -11,13 +11,13 @@
import { switchToBranch } from "../domain/game/branches.js";
import type { Level } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import type { Game, GameSetup } from "../domain/game/game.js";
import { type GameLibrary, newGameId } from "./game-library.js";
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
* le journal est illisible). */
export interface GameFactory {
readonly start: () => Game;
readonly start: (setup?: GameSetup) => Game;
readonly resume: (journal: GameJournal) => Game;
}
@@ -27,9 +27,10 @@ export interface GameSession {
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
* si elle est illisible. */
restore(): Promise<Game | null>;
/** Une nouvelle partie ; celle qu'elle remplace reste reprenable par
* resumePrevious, jusqu'au prochain changement de partie. */
newGame(): Game;
/** Une nouvelle partie (depuis la position de setup si elle est donnée) ;
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
* prochain changement de partie. */
newGame(setup?: GameSetup): Game;
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
* un échec est ignoré : la partie continue). */
save(journal: GameJournal): void;
@@ -79,10 +80,10 @@ export function createGameSession(
return null; // partie illisible : on en commencera une nouvelle
}
},
newGame() {
newGame(setup) {
previousId = shownId;
shownId = newGameId();
return games.start();
return games.start(setup);
},
save(journal) {
if (shownId !== null) {
+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}`);
});
+1
View File
@@ -52,6 +52,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Identifiant de match](identifiant-de-match.md) | Match ID |
| [Identifiant de position](identifiant-de-position.md) | Position ID |
| [Jet](jet.md) | Roll |
| [Lien de position](lien-de-position.md) | Position link |
| [Match](match.md) | Match |
| [Moteur](moteur.md) | Engine |
| [Niveau de jeu](niveau-de-jeu.md) | Level |
+2 -1
View File
@@ -1,9 +1,10 @@
---
term: Identifiant de match
english: Match ID
code: MatchId
---
# Identifiant de match
**En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de position](identifiant-de-position.md)
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md)
+1 -1
View File
@@ -7,4 +7,4 @@ code: PositionId
**En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de match](identifiant-de-match.md)
Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Lien de position
english: Position link
code: PositionLink
---
# Lien de position
**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match.
Le lien montre la position du point de vue du joueur qui a la main : c'est
vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis
cette position, sans toucher à votre partie en cours. Le lien ne contient que
la position : aucune donnée personnelle.
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md)
+1
View File
@@ -48,4 +48,5 @@ Règles d'écriture :
| --- | --- | --- |
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
@@ -0,0 +1,43 @@
# language: fr
Fonctionnalité: Partager une position
Chaque position du plateau a son lien : une adresse qui la rouvre telle
quelle, à envoyer à un ami, à garder pour plus tard ou à coller dans une
question. Le lien contient les deux identifiants de GNU Backgammon, celui
de la position et celui du match, et rien d'autre.
Scénario: Partager la position
Pendant la partie, « Copier le lien de la position », dans le menu ou à
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
l'écran. Ouvert ailleurs, le lien montre la même position.
Étant donné que votre partie en cours compte quelques coups
Quand vous copiez le lien de la position
Alors le lien contient l'identifiant de la position et celui du match
Quand vous ouvrez ce lien
Alors vous voyez exactement la même position, avec le même videau
Scénario: Ouvrir un lien vers une position
Un lien vers une position montre le plateau, sans le rendre jouable :
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
nouvelle partie contre l'ordinateur depuis cette position, et votre
partie en cours reste dans « Mes parties ». « Retour à ma partie » vous
ramène à votre partie, intacte.
Étant donné que votre partie en cours compte quelques coups
Quand vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur
Alors vous voyez cette position, avec la main
Et votre partie n'a pas changé
Quand vous choisissez « Jouer à partir d'ici »
Alors une nouvelle partie commence à cette position, à vous de lancer les dés
Et le videau est à 2, chez l'ordinateur
Et votre partie précédente est toujours enregistrée
Scénario: Ouvrir un lien abîmé
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
« Retour à ma partie » reste là.
Quand vous ouvrez un lien dont l'identifiant de position est abîmé
Alors le lien est déclaré illisible
Quand vous ouvrez un lien dont l'identifiant de match est abîmé
Alors le lien est déclaré illisible