diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index cee9049..45cd7da 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -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; + + /** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu + * de lui, sans dés. */ + encodeMatch(cube: EngineCube): Promise; + + /** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */ + decodeMatch(matchId: MatchId): Promise; +} + +/** + * 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; } /** diff --git a/app/src/domain/game/position-link.ts b/app/src/domain/game/position-link.ts new file mode 100644 index 0000000..dba3a44 --- /dev/null +++ b/app/src/domain/game/position-link.ts @@ -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>; + } | 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 { + 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 { + 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); +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index ef12813..39767f3 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -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 { + // 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, diff --git a/app/src/storage/game-session.ts b/app/src/storage/game-session.ts index 26b6c4f..196ed9c 100644 --- a/app/src/storage/game-session.ts +++ b/app/src/storage/game-session.ts @@ -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; - /** 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) { diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 00c8df4..7981047 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial = {}): 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, }; } diff --git a/app/tests/position-link.test.ts b/app/tests/position-link.test.ts new file mode 100644 index 0000000..632bddb --- /dev/null +++ b/app/tests/position-link.test.ts @@ -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 }); +}); diff --git a/app/tests/steps/partager-une-position.steps.ts b/app/tests/steps/partager-une-position.steps.ts new file mode 100644 index 0000000..267ac5c --- /dev/null +++ b/app/tests/steps/partager-une-position.steps.ts @@ -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 { + 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}`); +}); diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index 2036fc7..886ef6a 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -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 | diff --git a/docs/glossaire/identifiant-de-match.md b/docs/glossaire/identifiant-de-match.md index e97efc9..4960885 100644 --- a/docs/glossaire/identifiant-de-match.md +++ b/docs/glossaire/identifiant-de-match.md @@ -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) diff --git a/docs/glossaire/identifiant-de-position.md b/docs/glossaire/identifiant-de-position.md index e002614..bb1aa17 100644 --- a/docs/glossaire/identifiant-de-position.md +++ b/docs/glossaire/identifiant-de-position.md @@ -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) diff --git a/docs/glossaire/lien-de-position.md b/docs/glossaire/lien-de-position.md new file mode 100644 index 0000000..afb6da8 --- /dev/null +++ b/docs/glossaire/lien-de-position.md @@ -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) diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 56305f2..0c16079 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -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 | diff --git a/docs/scenarios/partager-une-position.feature b/docs/scenarios/partager-une-position.feature new file mode 100644 index 0000000..7155076 --- /dev/null +++ b/docs/scenarios/partager-une-position.feature @@ -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