diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 45cd7da..1a19a7e 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -51,6 +51,24 @@ export interface Engine { /** La décision de videau de l'ordinateur à ce niveau, bruit compris. */ levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise; + /** L'ordinateur au trait, à ce niveau, double-t-il avant de lancer les + * dés ? Comme ComputerTurn de gnubg (play.c) : accès au videau, fenêtre + * de marché, double facultatif seulement à 0 pli. */ + levelDouble(position: PositionId, cube: EngineCube, level: Level): Promise; + + /** Le joueur au trait double : l'ordinateur, son adversaire, prend-il à + * ce niveau ? Comme ComputerTurn de gnubg (play.c). */ + levelTake(position: PositionId, cube: EngineCube, level: Level): Promise; + + /** Le match après une partie gagnée par winner pour points points : + * score, fin du match et partie Crawford suivante, comme gnubg de + * bureau. */ + matchAfterGame( + match: EngineMatch, + winner: "onRoll" | "opponent", + points: number, + ): Promise; + /** L'analyse du coup joué (played : position après le coup, vue du joueur * qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si * aucun coup n'était possible. */ @@ -75,8 +93,8 @@ 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. */ + /** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui + * (partie libre sans match), sans dés. */ encodeMatch(cube: EngineCube): Promise; /** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */ @@ -212,10 +230,29 @@ export interface CubeEquities { */ export type Ply = number; -/** Le videau vu du joueur au trait. */ +/** Le videau vu du joueur au trait, et le match s'il y en a un (sans lui : + * partie libre, avec la règle Jacoby). */ export interface EngineCube { readonly value: number; readonly owner: "centered" | "onRoll" | "opponent"; + readonly match?: EngineMatch; +} + +/** Un match vu du joueur au trait : sa longueur, le score avant la partie, + * et si la partie est la partie Crawford. Le moteur le code dans le Match + * ID. */ +export interface EngineMatch { + readonly length: number; + readonly score: { readonly onRoll: number; readonly opponent: number }; + readonly crawford: boolean; +} + +/** Le match après une partie, vu du même joueur que le match d'avant. */ +export interface EngineMatchAfter { + readonly score: { readonly onRoll: number; readonly opponent: number }; + readonly over: boolean; + /** La partie suivante est la partie Crawford. */ + readonly crawford: boolean; } /** diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts index 8bf3596..f4f44f4 100644 --- a/app/src/domain/game/events.ts +++ b/app/src/domain/game/events.ts @@ -13,13 +13,50 @@ // le moteur : l'état se recalcule par une simple boucle (state.ts). import type { Cube } from "./cube.js"; -import type { ResultKind } from "./engine.js"; +import type { Level, ResultKind } from "./engine.js"; +import type { Match, MatchAfterGame } from "./match.js"; import type { HalfMove, PositionId } from "./position.js"; import type { Roll } from "./roll.js"; import type { Side } from "./side.js"; -/** Version du format des événements. */ -export const EVENT_VERSION = 1; +/** + * Version du format des événements : + * - 1 : jusqu'à l'étape 1b, partie libre au niveau 2 plis ; + * - 2 (étape 1c) : GameStarted porte le niveau de l'ordinateur (level) et, + * pour une partie de match, le match (match) ; GameEnded porte le match + * après la partie. migrateJournal passe un journal de la version 1 à la 2. + */ +export const EVENT_VERSION = 2; + +/** Le niveau des parties de la version 1 : 2 plis (STANDARD_PLY, sans + * bruit), le seul qui existait. */ +const VERSION_1_LEVEL: Level = "2ply"; + +/** + * Un journal enregistré, à la version courante du format. Version 1 : le + * niveau, implicite, devient explicite (2 plis) ; rien d'autre ne change, + * une partie de la version 1 étant une partie libre. Un événement d'une + * version inconnue est laissé tel quel (Game.resume le refuse). + */ +export function migrateJournal(journal: GameJournal): GameJournal { + if (journal.events.every((event) => event.version === EVENT_VERSION)) { + return journal; + } + const events = journal.events.map((event): GameEvent => { + if (event.version !== 1) { + return event; + } + if (event.type === "GameStarted") { + return Object.freeze({ + ...event, + level: event.level ?? VERSION_1_LEVEL, + version: EVENT_VERSION, + }); + } + return Object.freeze({ ...event, version: EVENT_VERSION }); + }); + return { events, head: journal.head }; +} interface EventBase { /** Identifiant, unique dans la partie. */ @@ -31,12 +68,18 @@ interface EventBase { /** Début de partie : la position, le videau, et qui commence (null : le jet * d'ouverture le dira). La position est vue de celui qui commence ; celle de - * départ est la même pour les deux. */ + * départ est la même pour les deux. level et match viennent avec la version + * 2 du format (étape 1c). */ export interface GameStarted extends EventBase { readonly type: "GameStarted"; readonly position: PositionId; readonly cube: Cube; readonly firstTurn: Side | null; + /** Le niveau de l'ordinateur ; absent : DEFAULT_LEVEL (state.ts). Les + * journaux de la version 1 l'ont après migrateJournal. */ + readonly level?: Level; + /** Le match dont c'est une partie ; absent : partie libre. */ + readonly match?: Match; } /** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un @@ -80,6 +123,9 @@ export interface GameEnded extends EventBase { readonly winner: Side; readonly kind: ResultKind; readonly points: number; + /** Dans un match : le match après cette partie, dit par le moteur + * (absent en partie libre). */ + readonly match?: MatchAfterGame; } /** diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 40f5752..165749e 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -1,4 +1,4 @@ -// Une partie libre contre l'ordinateur. +// Une partie contre l'ordinateur : partie libre, ou une partie d'un match. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -6,11 +6,12 @@ // Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute // des événements au journal (events.ts), d'où l'état est recalculé // (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de -// l'ordinateur, videau, fin de partie et points. +// l'ordinateur, videau (Crawford compris), fin de partie, points et score du +// match. import { type Branch, branches, switchToBranch } from "./branches.js"; import { CENTERED_CUBE, type Cube } from "./cube.js"; -import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js"; +import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js"; import { appendEvent, currentLine, @@ -19,16 +20,18 @@ import { type GameEvent, type GameJournal, goBackTo, + migrateJournal, type NewGameEvent, } from "./events.js"; +import { type Match, nextGameOf } from "./match.js"; import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js"; import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js"; import { otherSide, type Side } from "./side.js"; import { type GameState, replay } from "./state.js"; /** - * Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome), - * le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009). + * Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le + * meilleur coup à ce niveau prend au plus 348 ms (ADR 0009). */ export const STANDARD_PLY: Ply = 2; @@ -62,12 +65,19 @@ export interface MoveSteps { readonly hits: readonly boolean[]; } -export interface GameOptions { +/** Ce que l'on choisit pour une nouvelle partie. */ +export interface NewGame { + /** Une position de départ particulière (sans jet d'ouverture). */ + readonly setup?: GameSetup; + /** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */ + readonly level?: Level; + /** Le match dont ce sera une partie ; absent : partie libre. */ + readonly match?: Match; +} + +export interface GameOptions extends NewGame { /** Source des dés ; le hasard ordinaire par défaut. */ readonly dice?: DiceSource; - /** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */ - readonly ply?: Ply; - readonly setup?: GameSetup; } /** @@ -78,7 +88,6 @@ export interface GameOptions { export class Game { readonly #engine: Engine; readonly #dice: DiceSource; - readonly #ply: Ply; #journal: GameJournal = EMPTY_JOURNAL; // Coup du joueur en cours de saisie : pas encore dans le journal (il n'y // entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la @@ -88,11 +97,11 @@ export class Game { private constructor(engine: Engine, options: GameOptions) { this.#engine = engine; this.#dice = options.dice ?? randomDice; - this.#ply = options.ply ?? STANDARD_PLY; } /** Commence une partie : à la position de départ avec le jet d'ouverture, - * ou à la position de options.setup. */ + * ou à la position de options.setup ; au niveau options.level, et dans + * le match options.match s'il est donné. */ static start(engine: Engine, options: GameOptions = {}): Game { const game = new Game(engine, options); const setup = options.setup; @@ -101,6 +110,8 @@ export class Game { position: setup?.position ?? START_POSITION, cube: setup?.cube ?? CENTERED_CUBE, firstTurn: setup?.firstTurn ?? null, + ...(options.level === undefined ? {} : { level: options.level }), + ...(options.match === undefined ? {} : { match: options.match }), }); while (game.state.phase === "opening") { game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) }); @@ -112,11 +123,13 @@ export class Game { * Reprend une partie enregistrée, à partir de son journal. Erreur si le * journal est d'un autre format ou incohérent. */ - static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game { - const first = journal.events[0]; - if (first?.type !== "GameStarted" || journal.head === null) { + static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game { + const first = saved.events[0]; + if (first?.type !== "GameStarted" || saved.head === null) { throw new Error("journal de partie vide ou mal formé"); } + // Un journal d'une version précédente passe à la version courante. + const journal = migrateJournal(saved); if (journal.events.some((event) => event.version !== EVENT_VERSION)) { throw new Error("journal de partie d'une autre version"); } @@ -141,6 +154,18 @@ export class Game { return replay(this.events); } + /** La partie finie appartient à un match qui continue : ce que sera la + * partie suivante (score et Crawford dits par le moteur à la fin de + * celle-ci) ; null sinon. */ + get nextGame(): NewGame | null { + const state = this.state; + if (state.phase !== "over" || state.match === null || state.matchAfter === null) { + return null; + } + const match = nextGameOf(state.match, state.matchAfter); + return match === null ? null : { level: state.level, match }; + } + /** Les demi-coups déjà saisis du coup en cours. */ get pendingHalfMoves(): readonly HalfMove[] { return this.#pending.map((p) => p.halfMove); @@ -182,7 +207,7 @@ export class Game { const state = this.state; // La position est vue du camp qui a la main (le joueur pendant le jet // d'ouverture : la position de départ est la même pour les deux). - return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube); + return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state); } /** @@ -214,7 +239,7 @@ export class Game { positions.push(check.position); } const boards = await Promise.all( - positions.map((p) => this.#sideBoards(p, played.side, before.cube)), + positions.map((p) => this.#sideBoards(p, played.side, before)), ); const opponent = otherSide(played.side); const hits = played.halfMoves.map( @@ -260,10 +285,11 @@ export class Game { if (state.turn !== "player" || state.phase !== "rolling") { return false; } - // Le droit de doubler ne dépend pas du niveau : 0 pli suffit. + // Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le + // moteur le refuse aussi pendant la partie Crawford. const decision = await this.#engine.cubeDecision( state.position, - engineCube(state.cube, "player"), + engineCube(state, "player"), 0, ); return decision.available; @@ -349,62 +375,61 @@ export class Game { } /** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe - * selon le moteur. */ + * à son niveau, selon le moteur. */ async double(): Promise { const state = this.#expect("player", "rolling"); - const decision = await this.#engine.cubeDecision( - state.position, - engineCube(state.cube, "player"), - this.#ply, - ); + const cube = engineCube(state, "player"); + const decision = await this.#engine.cubeDecision(state.position, cube, 0); if (!decision.available) { - throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur"); + throw new Error("vous ne pouvez pas doubler maintenant"); } + const take = await this.#engine.levelTake(state.position, cube, state.level); this.#append({ type: "CubeOffered", side: "player" }); - this.#answerDouble("computer", decision.take); + await this.#answerDouble("computer", take); } /** Le joueur prend le double de l'ordinateur. */ take(): void { this.#expect("computer", "doubled"); - this.#answerDouble("player", true); + this.#append({ type: "CubeTaken", side: "player" }); } /** Le joueur passe le double de l'ordinateur : il perd la partie. */ - drop(): void { + async drop(): Promise { this.#expect("computer", "doubled"); - this.#answerDouble("player", false); + await this.#answerDouble("player", false); } // --------------------------------------------------------- Ordinateur /** - * Le tour de l'ordinateur : il double si le moteur le recommande (le - * tour s'arrête alors, en attendant la réponse du joueur), sinon il lance - * les dés et joue le meilleur coup du moteur. + * Le tour de l'ordinateur, à son niveau : il double si le moteur le dit + * (le tour s'arrête alors, en attendant la réponse du joueur), sinon il + * lance les dés et joue son coup. Le déroulement est celui de + * l'ordinateur de gnubg (ComputerTurn, play.c), repris par le moteur. */ async playComputerTurn(): Promise { let state = this.#expect("computer", "rolling", "moving"); if (state.phase === "rolling") { - const decision = await this.#engine.cubeDecision( + const doubles = await this.#engine.levelDouble( state.position, - engineCube(state.cube, "computer"), - this.#ply, + engineCube(state, "computer"), + state.level, ); - if (decision.double) { + if (doubles) { this.#append({ type: "CubeOffered", side: "computer" }); return; } this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) }); state = this.state; } - const best = await this.#engine.bestMove( + const move = await this.#engine.levelMove( state.position, - engineCube(state.cube, "computer"), + engineCube(state, "computer"), rollToPlay(state), - this.#ply, + state.level, ); - await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null); + await this.#playMove("computer", move?.halfMoves ?? [], move?.position ?? null); } // ------------------------------------------------------------ Retour @@ -445,8 +470,8 @@ export class Game { } /** Le plateau d'une position vue de onRoll, décodé par le moteur. */ - async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise { - const board = await this.#engine.board(position, engineCube(cube, onRoll)); + async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise { + const board = await this.#engine.board(position, engineCube(state, onRoll)); return onRoll === "player" ? { player: board.onRoll, computer: board.opponent } : { player: board.opponent, computer: board.onRoll }; @@ -474,10 +499,7 @@ export class Game { after: PositionId | null, ): Promise { const state = this.state; - const board = await this.#engine.board( - after ?? state.position, - engineCube(state.cube, side), - ); + const board = await this.#engine.board(after ?? state.position, engineCube(state, side)); this.#append({ type: "MovePlayed", side, @@ -485,28 +507,54 @@ export class Game { position: board.swappedPosition, }); if (board.result !== null) { - this.#append({ - type: "GameEnded", - winner: board.result.winner === "onRoll" ? side : otherSide(side), - kind: board.result.kind, - points: board.result.points, - }); + await this.#endGame( + board.result.winner === "onRoll" ? side : otherSide(side), + board.result.kind, + board.result.points, + ); } } /** taker prend ou passe le double proposé par l'autre camp. */ - #answerDouble(taker: Side, take: boolean): void { + async #answerDouble(taker: Side, take: boolean): Promise { if (take) { this.#append({ type: "CubeTaken", side: taker }); return; } // Passer : on perd la partie à la valeur du videau d'avant le double. this.#append({ type: "CubeDropped", side: taker }); + await this.#endGame(otherSide(taker), "single", this.state.cube.value); + } + + /** Fin de partie ; dans un match, le moteur dit ce que devient le match + * (score, fin du match, partie Crawford suivante). */ + async #endGame(winner: Side, kind: ResultKind, points: number): Promise { + const match = this.state.match; + if (match === null) { + this.#append({ type: "GameEnded", winner, kind, points }); + return; + } + // Le joueur est « au trait » pour le moteur : les scores restent à + // leur place. + const after = await this.#engine.matchAfterGame( + { + length: match.length, + score: { onRoll: match.score.player, opponent: match.score.computer }, + crawford: match.crawford, + }, + winner === "player" ? "onRoll" : "opponent", + points, + ); this.#append({ type: "GameEnded", - winner: otherSide(taker), - kind: "single", - points: this.state.cube.value, + winner, + kind, + points, + match: { + score: { player: after.score.onRoll, computer: after.score.opponent }, + over: after.over, + crawford: after.crawford, + }, }); } } @@ -576,10 +624,21 @@ function occupiedPoints(side: BoardSide): number[] { return points; } -/** Le videau vu du camp au trait, pour le moteur. */ -function engineCube(cube: Cube, onRoll: Side): EngineCube { - if (cube.owner === null) { - return { value: cube.value, owner: "centered" }; +/** Le videau et le match vus du camp au trait, pour le moteur. */ +export function engineCube(state: Pick, onRoll: Side): EngineCube { + const { cube, match } = state; + const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent"; + if (match === null) { + return { value: cube.value, owner }; } - return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" }; + const opponent = otherSide(onRoll); + return { + value: cube.value, + owner, + match: { + length: match.length, + score: { onRoll: match.score[onRoll], opponent: match.score[opponent] }, + crawford: match.crawford, + }, + }; } diff --git a/app/src/domain/game/level.ts b/app/src/domain/game/level.ts new file mode 100644 index 0000000..352f51e --- /dev/null +++ b/app/src/domain/game/level.ts @@ -0,0 +1,50 @@ +// Les niveaux de l'ordinateur tels que le joueur les choisit. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Six noms (docs/identite/charte/ecrans-etape-1.md, chapitre 1c), chacun +// relié à un des niveaux prédéfinis de GNU Backgammon (Level, engine.ts). La +// correspondance est documentée dans docs/glossaire/niveau-de-jeu.md : +// - Débutant, Amateur, Intermédiaire et Avancé : les quatre niveaux de gnubg +// à 0 pli avec bruit (beginner, casual, intermediate, advanced) ; +// - Expert : 0 pli sans bruit, que gnubg appelait autrefois « expert » ; +// - Champion : 2 plis, l'ancien « world class » de gnubg, le plus fort qui +// tienne dans le temps du téléphone (au plus 348 ms par coup sur le +// Fairphone 4, ADR 0009). 3 et 4 plis ne sont pas proposés. + +import type { Level } from "./engine.js"; + +/** Un niveau proposé au joueur, du plus faible au plus fort. */ +export type ComputerLevel = + | "beginner" + | "amateur" + | "intermediate" + | "advanced" + | "expert" + | "champion"; + +export const COMPUTER_LEVELS: readonly ComputerLevel[] = [ + "beginner", + "amateur", + "intermediate", + "advanced", + "expert", + "champion", +]; + +/** Le niveau de gnubg de chaque niveau proposé. */ +export const ENGINE_LEVELS: Readonly> = { + beginner: "beginner", + amateur: "casual", + intermediate: "intermediate", + advanced: "advanced", + expert: "0ply", + champion: "2ply", +}; + +/** Le niveau proposé qui correspond à un niveau de gnubg (null pour 1, 3 et + * 4 plis, jamais proposés). */ +export function computerLevelOf(level: Level): ComputerLevel | null { + return COMPUTER_LEVELS.find((name) => ENGINE_LEVELS[name] === level) ?? null; +} diff --git a/app/src/domain/game/match.ts b/app/src/domain/game/match.ts new file mode 100644 index 0000000..e5960b8 --- /dev/null +++ b/app/src/domain/game/match.ts @@ -0,0 +1,55 @@ +// Le match : une suite de parties jusqu'à un nombre de points. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Chaque partie d'un match est une partie à part entière (son journal, son +// enregistrement), qui porte le match tel qu'il est à son début. Les règles +// viennent du moteur : le score après une partie, la fin du match et la +// partie Crawford (Engine.matchAfterGame, qui reprend gnubg de bureau). Ce +// module ne fait que recopier ce que le moteur a dit. + +import type { Side } from "./side.js"; + +/** + * Un match, au début d'une de ses parties : sa longueur, le score avant + * cette partie, et si elle est la partie Crawford (où personne ne peut + * doubler). + * + * Glossaire : docs/glossaire/match.md + */ +export interface Match { + /** Le nombre de points à atteindre pour gagner le match. */ + readonly length: number; + readonly score: Readonly>; + readonly crawford: boolean; +} + +/** Le match après une partie, tel que le moteur le calcule. */ +export interface MatchAfterGame { + readonly score: Readonly>; + /** Le gagnant de la partie a gagné le match. */ + readonly over: boolean; + /** La partie suivante est la partie Crawford. */ + readonly crawford: boolean; +} + +/** Les longueurs de match proposées à l'écran « Nouvelle partie ». */ +export const MATCH_LENGTHS = [3, 5, 7, 11] as const; + +/** La première partie d'un match en length points : 0 à 0. */ +export function firstGameOf(length: number): Match { + if (!Number.isInteger(length) || length < 1) { + throw new Error(`longueur de match invalide : ${length}`); + } + return { length, score: { player: 0, computer: 0 }, crawford: false }; +} + +/** La partie suivante d'un match, d'après ce que le moteur a dit à la fin + * de la précédente ; null si le match est fini. */ +export function nextGameOf(match: Match, after: MatchAfterGame): Match | null { + if (after.over) { + return null; + } + return { length: match.length, score: after.score, crawford: after.crawford }; +} diff --git a/app/src/domain/game/position-link.ts b/app/src/domain/game/position-link.ts index dba3a44..2bb419c 100644 --- a/app/src/domain/game/position-link.ts +++ b/app/src/domain/game/position-link.ts @@ -11,7 +11,7 @@ import type { Cube } from "./cube.js"; import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js"; -import type { GameSetup, SideBoards } from "./game.js"; +import { engineCube, type GameSetup, type SideBoards } from "./game.js"; import type { PositionId } from "./position.js"; import type { Side } from "./side.js"; import type { GameState } from "./state.js"; @@ -100,19 +100,16 @@ export async function readPosition(engine: Engine, link: PositionLink): Promise< /** * 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. + * et le Match ID que le moteur écrit pour son videau et son match (score, + * Crawford), 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) }; + return { + position: state.position, + matchId: await engine.encodeMatch(engineCube(state, onRoll)), + }; } /** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la diff --git a/app/src/domain/game/saved-game.ts b/app/src/domain/game/saved-game.ts index 793060c..ede5121 100644 --- a/app/src/domain/game/saved-game.ts +++ b/app/src/domain/game/saved-game.ts @@ -10,7 +10,8 @@ import { branches } from "./branches.js"; import type { Level } from "./engine.js"; import { currentLine, type GameJournal } from "./events.js"; -import { type GameResult, replay } from "./state.js"; +import type { Side } from "./side.js"; +import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js"; /** * Le résumé d'une partie enregistrée. @@ -33,6 +34,18 @@ export interface SavedGameSummary { readonly moveCount: number; /** Nombre de branches (1 : jamais rejouée). */ readonly branchCount: number; + /** La partie d'un match : sa longueur, le score (après la partie si + * elle est finie, avant sinon) et si le match est fini. Absent en + * partie libre, et dans les résumés enregistrés avant l'étape 1c. */ + readonly match?: SavedMatch; +} + +/** Le match d'une partie enregistrée, pour la liste. */ +export interface SavedMatch { + readonly length: number; + readonly score: Readonly>; + readonly crawford: boolean; + readonly over: boolean; } /** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son @@ -41,6 +54,12 @@ export interface SavedGameDetails { readonly level: Level; } +/** Le niveau de l'ordinateur d'une partie, d'après son journal. */ +export function levelOf(journal: GameJournal): Level { + const start = journal.events[0]; + return start?.type === "GameStarted" ? (start.level ?? DEFAULT_LEVEL) : DEFAULT_LEVEL; +} + /** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant * (le domaine ne lit pas l'horloge). */ export function summarizeGame( @@ -60,5 +79,15 @@ export function summarizeGame( result: state.result, moveCount: line.filter((event) => event.type === "MovePlayed").length, branchCount: branches(journal).length, + ...(state.match === null + ? {} + : { + match: { + length: state.match.length, + score: state.matchAfter?.score ?? state.match.score, + crawford: state.match.crawford, + over: state.matchAfter?.over ?? false, + }, + }), }; } diff --git a/app/src/domain/game/sgf.ts b/app/src/domain/game/sgf.ts index b945ffb..0b2cf71 100644 --- a/app/src/domain/game/sgf.ts +++ b/app/src/domain/game/sgf.ts @@ -78,11 +78,18 @@ export async function exportSgf( return properties === null ? below : [{ properties, children: below }]; }); - // La partie telle que la voit gnubg : la ligne courante. - const result = replay(currentLine(journal)).result; + // La partie telle que la voit gnubg : la ligne courante. Dans un match, + // sa longueur et le score avant la partie (W, l'ordinateur, est le + // joueur 0 de gnubg), et les règles comme gnubg les écrit (sgf.c) : + // Crawford, et CrawfordGame pour la partie Crawford. Le numéro de la + // partie dans le match n'est pas gardé : 0. + const { result, match } = replay(currentLine(journal)); const root = [ "FF[4]GM[6]CA[UTF-8]AP[Ustagammon]", - "MI[length:0][game:0][ws:0][bs:0]", + match === null + ? "MI[length:0][game:0][ws:0][bs:0]" + : `MI[length:${match.length}][game:0][ws:${match.score.computer}][bs:${match.score.player}]` + + `RU[Crawford${match.crawford ? ":CrawfordGame" : ""}]`, `PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`, options.date === undefined ? "" : `DT[${escapeText(options.date)}]`, result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`, diff --git a/app/src/domain/game/state.ts b/app/src/domain/game/state.ts index c9aae94..38ab691 100644 --- a/app/src/domain/game/state.ts +++ b/app/src/domain/game/state.ts @@ -4,8 +4,9 @@ // Licence : GPLv3 ou ultérieure (voir LICENSE). import { CENTERED_CUBE, type Cube } from "./cube.js"; -import type { ResultKind } from "./engine.js"; +import type { Level, ResultKind } from "./engine.js"; import type { GameEvent } from "./events.js"; +import type { Match, MatchAfterGame } from "./match.js"; import { type PositionId, START_POSITION } from "./position.js"; import type { Roll } from "./roll.js"; import { otherSide, type Side } from "./side.js"; @@ -36,8 +37,22 @@ export interface GameState { readonly roll: Roll | null; readonly cube: Cube; readonly result: GameResult | null; + /** Le niveau de l'ordinateur. */ + readonly level: Level; + /** Le match au début de la partie (null : partie libre). */ + readonly match: Match | null; + /** Le match après la partie, une fois finie (null sinon, ou en partie + * libre). */ + readonly matchAfter: MatchAfterGame | null; } +/** + * Le niveau d'une partie qui n'en dit rien : 2 plis, celui de toutes les + * parties d'avant le choix du niveau (étape 1c). Sur le Fairphone 4, le + * meilleur coup à ce niveau prend au plus 348 ms (ADR 0009). + */ +export const DEFAULT_LEVEL: Level = "2ply"; + /** Avant le premier événement. */ const NOT_STARTED: GameState = { phase: "opening", @@ -46,6 +61,9 @@ const NOT_STARTED: GameState = { roll: null, cube: CENTERED_CUBE, result: null, + level: DEFAULT_LEVEL, + match: null, + matchAfter: null, }; /** Rejoue des événements, du premier au dernier. */ @@ -63,6 +81,8 @@ export function applyEvent(state: GameState, event: GameEvent): GameState { cube: event.cube, turn: event.firstTurn, phase: event.firstTurn === null ? "opening" : "rolling", + level: event.level ?? DEFAULT_LEVEL, + match: event.match ?? null, }; case "DiceRolled": if (event.side !== null) { @@ -103,6 +123,7 @@ export function applyEvent(state: GameState, event: GameEvent): GameState { phase: "over", roll: null, result: { winner: event.winner, kind: event.kind, points: event.points }, + matchAfter: event.match ?? null, }; } } diff --git a/app/src/domain/learning/tutorial.ts b/app/src/domain/learning/tutorial.ts index fb477aa..2f54f0e 100644 --- a/app/src/domain/learning/tutorial.ts +++ b/app/src/domain/learning/tutorial.ts @@ -173,7 +173,7 @@ export class MovePractice { const game = Game.start(engine, { setup: { position, firstTurn: "player" }, dice: fixedDice(roll), - ply: 0, + level: "0ply", }); await game.roll(); return new MovePractice(game, expected, roll); diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index 39767f3..c7e26fe 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -17,6 +17,7 @@ import type { CubeDecision, Engine, EngineCube, + EngineMatch, EvaluatedMove, HalfMovesCheck, Level, @@ -75,13 +76,23 @@ export function createWasmEngine(run: RunCommand): Engine { const matchIds = new Map(); async function matchId(cube: EngineCube): Promise { - // Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au - // trait, 1 son adversaire, -1 le centre. + // Joueur 0 au trait : le propriétaire 0 est le joueur au trait, 1 son + // adversaire, -1 le centre ; le score 0 est le sien. const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner]; - const key = `${cube.value} ${owner}`; + const args = ["matchid", String(cube.value), String(owner)]; + const match = cube.match; + if (match !== undefined) { + args.push( + String(match.length), + String(match.score.onRoll), + String(match.score.opponent), + match.crawford ? "1" : "0", + ); + } + const key = args.join(" "); let id = matchIds.get(key); if (id === undefined) { - const answer = (await run(["matchid", String(cube.value), String(owner)])) as { + const answer = (await run(args)) as { match_id: string; }; id = answer.match_id; @@ -129,6 +140,32 @@ export function createWasmEngine(run: RunCommand): Engine { return toCubeDecision((await run(args)) as CliCubeDecision); }, + async levelDouble(position: PositionId, cube: EngineCube, level: Level) { + const args = ["--level", level, "double", position, await matchId(cube)]; + return ((await run(args)) as { double: boolean }).double; + }, + + async levelTake(position: PositionId, cube: EngineCube, level: Level) { + const args = ["--level", level, "take", position, await matchId(cube)]; + return ((await run(args)) as { take: boolean }).take; + }, + + async matchAfterGame(match: EngineMatch, winner: "onRoll" | "opponent", points: number) { + // Le joueur au trait est le joueur 0 du Match ID. + const id = await matchId({ value: 1, owner: "centered", match }); + const answer = (await run([ + "aftergame", + id, + winner === "onRoll" ? "0" : "1", + String(points), + ])) as { score: [number, number]; match_over: boolean; crawford: boolean }; + return { + score: { onRoll: answer.score[0], opponent: answer.score[1] }, + over: answer.match_over, + crawford: answer.crawford, + }; + }, + async analyseMove( position: PositionId, cube: EngineCube, diff --git a/app/src/storage/game-session.ts b/app/src/storage/game-session.ts index 196ed9c..b5f4a7d 100644 --- a/app/src/storage/game-session.ts +++ b/app/src/storage/game-session.ts @@ -9,15 +9,15 @@ // quelle, sur un stockage en mémoire. 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, GameSetup } from "../domain/game/game.js"; +import type { Game, NewGame } from "../domain/game/game.js"; +import { levelOf } from "../domain/game/saved-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: (setup?: GameSetup) => Game; + readonly start: (newGame?: NewGame) => Game; readonly resume: (journal: GameJournal) => Game; } @@ -27,10 +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 (depuis la position de setup si elle est donnée) ; + /** Une nouvelle partie (niveau, match, position de départ éventuels) ; * celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au * prochain changement de partie. */ - newGame(setup?: GameSetup): Game; + newGame(newGame?: NewGame): Game; /** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ; * un échec est ignoré : la partie continue). */ save(journal: GameJournal): void; @@ -42,11 +42,7 @@ export interface GameSession { open(id: string, branchHead?: string): Promise; } -export function createGameSession( - library: GameLibrary, - games: GameFactory, - level: Level, -): GameSession { +export function createGameSession(library: GameLibrary, games: GameFactory): GameSession { let shownId: string | null = null; let previousId: string | null = null; @@ -80,14 +76,14 @@ export function createGameSession( return null; // partie illisible : on en commencera une nouvelle } }, - newGame(setup) { + newGame(newGame) { previousId = shownId; shownId = newGameId(); - return games.start(setup); + return games.start(newGame); }, save(journal) { if (shownId !== null) { - library.save(shownId, journal, { level }).catch(() => {}); + library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {}); } }, async resumePrevious() { @@ -123,7 +119,7 @@ export function createGameSession( if (branchHead === undefined) { await library.setCurrent(id); } else { - await library.save(id, journal, { level: saved.summary.level }); + await library.save(id, journal, { level: levelOf(journal) }); } return game; }, diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 3c6e1ba..5cc9b02 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -29,6 +29,7 @@ import { TEXTS } from "./texts.js"; /** Les scénarios montrés dans l'aide, dans l'ordre. */ export const FEATURES = [ "docs/scenarios/jouer-une-partie.feature", + "docs/scenarios/jouer-un-match.feature", "docs/scenarios/apprendre-les-regles.feature", "docs/scenarios/mes-parties.feature", "docs/scenarios/partager-une-position.feature", diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index c8a1556..2226cfd 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -29,6 +29,8 @@ export type Route = | { readonly screen: "games"; readonly game: string | null } /** #/menu. */ | { readonly screen: "menu" } + /** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */ + | { readonly screen: "newGame" } /** #/position// (Match ID facultatif). */ | { readonly screen: "position"; readonly position: string; readonly match: string | null } /** Une adresse que l'application ne connaît pas. */ @@ -86,6 +88,8 @@ export function parseRoute(hash: string): Route { return name === null ? { screen: "about" } : unknown; case "menu": return name === null ? { screen: "menu" } : unknown; + case "nouvelle-partie": + return name === null ? { screen: "newGame" } : unknown; case "parties": return { screen: "games", game: name }; case "installer": @@ -166,6 +170,8 @@ export function routeHash(route: Route): string { return route.game === null ? "#/parties" : `#/parties/${route.game}`; case "menu": return "#/menu"; + case "newGame": + return "#/nouvelle-partie"; case "position": { const position = `#/position/${encodeURIComponent(route.position)}`; return route.match === null diff --git a/app/src/ui/settings.ts b/app/src/ui/settings.ts index 027bf75..402ebba 100644 --- a/app/src/ui/settings.ts +++ b/app/src/ui/settings.ts @@ -8,6 +8,10 @@ // toujours une valeur par défaut, et un réglage enregistré par une version // précédente se relit sans perte. +import type { NewGame } from "../domain/game/game.js"; +import { COMPUTER_LEVELS, type ComputerLevel, ENGINE_LEVELS } from "../domain/game/level.js"; +import { firstGameOf, MATCH_LENGTHS } from "../domain/game/match.js"; + export type Theme = "auto" | "light" | "dark"; export interface Settings { @@ -34,8 +38,18 @@ export interface Settings { readonly installDismissedAt: number; /** L'application a été installée (événement appinstalled). */ readonly installed: boolean; + /** Les choix de l'écran « Nouvelle partie », gardés pour la suivante : + * partie libre ou match, longueur du match, niveau de l'ordinateur, + * conseil du maître. */ + readonly gameKind: GameKind; + readonly matchLength: number; + readonly level: ComputerLevel; + readonly masterAdvice: boolean; } +/** Partie libre ou match. */ +export type GameKind = "free" | "match"; + export const DEFAULT_SETTINGS: Settings = { vibrations: true, keepScreenOn: true, @@ -47,6 +61,10 @@ export const DEFAULT_SETTINGS: Settings = { installDismissals: 0, installDismissedAt: 0, installed: false, + gameKind: "free", + matchLength: 5, + level: "intermediate", + masterAdvice: true, }; /** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */ @@ -71,8 +89,9 @@ export function parseSettings(text: string | null): Settings { } catch { // Réglages illisibles : valeurs par défaut. } - const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") => - typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]; + const bool = ( + key: "vibrations" | "keepScreenOn" | "welcomed" | "installed" | "masterAdvice", + ) => (typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]); const count = ( key: | "gamesStarted" @@ -98,9 +117,26 @@ export function parseSettings(text: string | null): Settings { installDismissals: count("installDismissals"), installDismissedAt: count("installDismissedAt"), installed: bool("installed"), + gameKind: raw.gameKind === "match" ? "match" : "free", + matchLength: (MATCH_LENGTHS as readonly unknown[]).includes(raw.matchLength) + ? (raw.matchLength as number) + : DEFAULT_SETTINGS.matchLength, + level: (COMPUTER_LEVELS as readonly unknown[]).includes(raw.level) + ? (raw.level as ComputerLevel) + : DEFAULT_SETTINGS.level, + masterAdvice: bool("masterAdvice"), }; } +/** La partie que lancent les choix de l'écran « Nouvelle partie » : le + * niveau de gnubg du niveau choisi, et la première partie d'un match. */ +export function chosenGame(settings: Settings): NewGame { + const level = ENGINE_LEVELS[settings.level]; + return settings.gameKind === "match" + ? { level, match: firstGameOf(settings.matchLength) } + : { level }; +} + /** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */ export function nextTheme(theme: Theme): Theme { return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto"; diff --git a/app/tests/full-game.test.ts b/app/tests/full-game.test.ts index d698b49..15fc647 100644 --- a/app/tests/full-game.test.ts +++ b/app/tests/full-game.test.ts @@ -52,13 +52,13 @@ async function playToTheEnd(game: Game, engine: Awaited { + test(`partie complète, dés ${seed}, ordinateur au niveau ${level}`, async () => { const engine = await loadNodeEngine(); - const game = Game.start(engine, { dice: seededDice(seed), ply }); + const game = Game.start(engine, { dice: seededDice(seed), level }); await playToTheEnd(game, engine); const events = game.events; diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index 7981047..a81f676 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -10,14 +10,24 @@ import type { Board, CubeDecision, Engine, + EngineCube, HalfMovesCheck, + Level, Move, } from "../src/domain/game/engine.js"; -import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js"; +import { + appendEvent, + currentLine, + EMPTY_JOURNAL, + EVENT_VERSION, + type GameJournal, + goBackTo, + migrateJournal, +} from "../src/domain/game/events.js"; import { Game } from "../src/domain/game/game.js"; import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js"; import { fixedDice } from "../src/domain/game/roll.js"; -import { replay } from "../src/domain/game/state.js"; +import { DEFAULT_LEVEL, replay } from "../src/domain/game/state.js"; /** Un faux moteur : chaque réponse est réglable, et les positions sont des * textes sans signification. */ @@ -51,6 +61,17 @@ function fakeEngine(overrides: Partial = {}): Engine { board: async () => notOver, levelMove: async () => move, levelCubeDecision: async () => noDouble, + levelDouble: async () => false, + levelTake: async () => true, + matchAfterGame: async (match, winner, points) => { + const score = { ...match.score }; + score[winner] += points; + return { + score, + over: score[winner] >= match.length, + crawford: score[winner] === match.length - 1, + }; + }, analyseMove: async () => null, analyseCube: async () => ({ analysed: false, @@ -237,10 +258,8 @@ test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => assert.equal(game.state.phase, "moving"); }); -test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => { - const engine = fakeEngine({ - cubeDecision: async () => ({ available: true, double: true, take: true }), - }); +test("l'ordinateur double si le moteur le dit à son niveau ; le joueur prend", async () => { + const engine = fakeEngine({ levelDouble: async () => true }); const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } }); await game.playComputerTurn(); assert.equal(game.state.phase, "doubled"); @@ -251,22 +270,18 @@ test("l'ordinateur double si le moteur le recommande ; le joueur prend", async ( }); test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => { - const engine = fakeEngine({ - cubeDecision: async () => ({ available: true, double: true, take: true }), - }); + const engine = fakeEngine({ levelDouble: async () => true }); const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } }, }); await game.playComputerTurn(); - game.drop(); + await game.drop(); assert.equal(game.state.phase, "over"); assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 }); }); -test("le joueur double : l'ordinateur passe si le moteur le dit", async () => { - const engine = fakeEngine({ - cubeDecision: async () => ({ available: true, double: true, take: false }), - }); +test("le joueur double : l'ordinateur passe si le moteur le dit à son niveau", async () => { + const engine = fakeEngine({ levelTake: async () => false }); const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } }); await game.double(); assert.deepEqual( @@ -283,7 +298,7 @@ test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async ( const game = Game.start(engine, { setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } }, }); - await assert.rejects(game.double(), /videau/); + await assert.rejects(game.double(), /pas doubler/); assert.equal(game.events.length, 1); }); @@ -339,7 +354,7 @@ test("les événements sont immuables et versionnés ; le rejeu est déterminist }); journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] }); const [started, rolled] = currentLine(journal); - assert.equal(started?.version, 1); + assert.equal(started?.version, 2); assert.equal(started?.parent, null); assert.equal(rolled?.parent, started?.id); assert.throws(() => { @@ -413,3 +428,170 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () = assert.equal(swapped.player.pipCount, 2); assert.equal(swapped.computer.pipCount, 1); }); + +// ------------------------------------------------------------- Match (1c) + +test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => { + const cubes: EngineCube[] = []; + const levels: Level[] = []; + const engine = fakeEngine({ + levelDouble: async (_position, cube, level) => { + cubes.push(cube); + levels.push(level); + return false; + }, + }); + const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false }; + const game = Game.start(engine, { + dice: fixedDice([2, 5, 3, 1, 4, 2]), + level: "intermediate", + match, + }); + const start = game.events[0]; + assert.ok(start?.type === "GameStarted"); + assert.equal(start.level, "intermediate"); + assert.deepEqual(start.match, match); + assert.equal(game.state.level, "intermediate"); + // L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite + // l'ordinateur pense à doubler, et le match lui est donné vu de lui. + await game.playComputerTurn(); + await game.roll(); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); + await game.playComputerTurn(); + assert.deepEqual(levels, ["intermediate"]); + assert.deepEqual(cubes[0]?.match, { + length: 5, + score: { onRoll: 3, opponent: 1 }, + crawford: false, + }); +}); + +test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => { + const winning: Board = { + onRoll: { points: [], bar: 0, off: 15, pipCount: 0 }, + opponent: { points: [], bar: 0, off: 0, pipCount: 100 }, + swappedPosition: "SWAPPED", + result: { winner: "onRoll", kind: "gammon", points: 2 }, + }; + const engine = fakeEngine({ board: async () => winning }); + const game = Game.start(engine, { + dice: fixedDice([5, 2]), + level: "advanced", + match: { length: 3, score: { player: 0, computer: 0 }, crawford: false }, + }); + assert.equal(game.nextGame, null); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); + const end = game.events.at(-1); + assert.ok(end?.type === "GameEnded"); + assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true }); + const next = game.nextGame; + assert.deepEqual(next, { + level: "advanced", + match: { length: 3, score: { player: 2, computer: 0 }, crawford: true }, + }); + + // La partie Crawford, gagnée : le match est fini, plus de partie suivante. + const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) }); + await crawford.playHalfMove({ from: 8, to: 5 }); + await crawford.playHalfMove({ from: 6, to: 5 }); + await crawford.confirmMove(); + assert.deepEqual(crawford.state.matchAfter, { + score: { player: 4, computer: 0 }, + over: true, + crawford: false, + }); + assert.equal(crawford.nextGame, null); +}); + +test("passer un double dans un match : le moteur compte le match", async () => { + const engine = fakeEngine({ levelDouble: async () => true }); + const game = Game.start(engine, { + dice: fixedDice([2, 5, 3, 1]), + match: { length: 3, score: { player: 0, computer: 1 }, crawford: false }, + }); + // L'ordinateur joue son jet d'ouverture, le joueur son coup, puis + // l'ordinateur double. + await game.playComputerTurn(); + await game.roll(); + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); + await game.playComputerTurn(); + assert.equal(game.state.phase, "doubled"); + await game.drop(); + assert.deepEqual(game.state.matchAfter, { + score: { player: 0, computer: 2 }, + over: false, + crawford: true, + }); +}); + +test("une partie libre n'a ni match ni partie suivante", () => { + const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) }); + assert.equal(game.state.match, null); + assert.equal(game.state.level, DEFAULT_LEVEL); + assert.equal(game.nextGame, null); +}); + +test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => { + // Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted + // sans level ni match, un coup joué, une branche. + const v1 = { + events: [ + { + id: "1", + parent: null, + version: 1, + type: "GameStarted", + position: START_POSITION, + cube: { value: 1, owner: null }, + firstTurn: null, + }, + { id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] }, + { + id: "3", + parent: "2", + version: 1, + type: "MovePlayed", + side: "player", + halfMoves: [ + { from: 8, to: 5 }, + { from: 6, to: 5 }, + ], + position: "SWAPPED", + }, + { id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] }, + ], + head: "3", + } as unknown as GameJournal; + const before = replay(currentLine(v1)); + const migrated = migrateJournal(v1); + assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION)); + assert.equal(migrated.head, "3"); + const start = migrated.events[0]; + assert.ok(start?.type === "GameStarted"); + assert.equal(start.level, "2ply"); + assert.equal(start.match, undefined); + // Rien d'autre ne change : mêmes événements, même arbre, même état. + const strip = (journal: GameJournal) => + journal.events.map(({ version: _, ...event }) => { + const { level: _level, ...rest } = event as typeof event & { level?: unknown }; + return rest; + }); + assert.deepEqual(strip(migrated), strip(v1)); + assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" }); + // Une version courante passe telle quelle. + assert.equal(migrateJournal(migrated), migrated); + + const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1))); + assert.equal(resumed.state.level, "2ply"); + assert.equal(resumed.state.match, null); + assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION)); + // La partie continue en version 2. + await resumed.playComputerTurn(); + assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION)); +}); diff --git a/app/tests/match.test.ts b/app/tests/match.test.ts new file mode 100644 index 0000000..3d03d96 --- /dev/null +++ b/app/tests/match.test.ts @@ -0,0 +1,128 @@ +// Tests du choix de la partie et du match, sans navigateur : réglages de +// l'écran « Nouvelle partie », correspondance des niveaux avec ceux de +// GNU Backgammon, résumé d'une partie de match enregistrée, adaptateur du +// moteur (Match ID d'un match) sur le vrai moteur. +// +// 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 { Game } from "../src/domain/game/game.js"; +import { COMPUTER_LEVELS, computerLevelOf, ENGINE_LEVELS } from "../src/domain/game/level.js"; +import { firstGameOf, MATCH_LENGTHS, nextGameOf } from "../src/domain/game/match.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { fixedDice } from "../src/domain/game/roll.js"; +import { levelOf, summarizeGame } from "../src/domain/game/saved-game.js"; +import { chosenGame, DEFAULT_SETTINGS, parseSettings } from "../src/ui/settings.js"; +import { loadNodeEngine } from "./support/node-engine.js"; + +test("les six niveaux montrés au joueur, du plus faible au plus fort, et leur niveau de gnubg", () => { + assert.deepEqual(COMPUTER_LEVELS, [ + "beginner", + "amateur", + "intermediate", + "advanced", + "expert", + "champion", + ]); + assert.deepEqual( + COMPUTER_LEVELS.map((level) => ENGINE_LEVELS[level]), + ["beginner", "casual", "intermediate", "advanced", "0ply", "2ply"], + ); + assert.equal(computerLevelOf("casual"), "amateur"); + assert.equal(computerLevelOf("3ply"), null); +}); + +test("réglages de la nouvelle partie : partie libre, Intermédiaire, conseil activé par défaut ; valeurs illisibles ignorées", () => { + assert.equal(DEFAULT_SETTINGS.gameKind, "free"); + assert.equal(DEFAULT_SETTINGS.level, "intermediate"); + assert.equal(DEFAULT_SETTINGS.masterAdvice, true); + // Réglages d'avant l'étape 1c : les valeurs par défaut. + const old = parseSettings('{"vibrations": false, "gamesStarted": 4}'); + assert.equal(old.gameKind, "free"); + assert.equal(old.matchLength, 5); + assert.equal(old.level, "intermediate"); + const saved = parseSettings( + '{"gameKind": "match", "matchLength": 7, "level": "champion", "masterAdvice": false}', + ); + assert.deepEqual( + [saved.gameKind, saved.matchLength, saved.level, saved.masterAdvice], + ["match", 7, "champion", false], + ); + const wrong = parseSettings('{"gameKind": "x", "matchLength": 4, "level": "4ply"}'); + assert.deepEqual([wrong.gameKind, wrong.matchLength, wrong.level], ["free", 5, "intermediate"]); + assert.deepEqual([...MATCH_LENGTHS], [3, 5, 7, 11]); +}); + +test("la partie choisie : le niveau de gnubg, et la première partie d'un match à 0-0", () => { + assert.deepEqual(chosenGame(DEFAULT_SETTINGS), { level: "intermediate" }); + assert.deepEqual(chosenGame({ ...DEFAULT_SETTINGS, gameKind: "match", level: "expert" }), { + level: "0ply", + match: { length: 5, score: { player: 0, computer: 0 }, crawford: false }, + }); + assert.throws(() => firstGameOf(0), /longueur/); + const match = firstGameOf(3); + assert.equal( + nextGameOf(match, { score: { player: 3, computer: 0 }, over: true, crawford: false }), + null, + ); + assert.deepEqual( + nextGameOf(match, { score: { player: 2, computer: 0 }, over: false, crawford: true }), + { length: 3, score: { player: 2, computer: 0 }, crawford: true }, + ); +}); + +test("le résumé d'une partie de match enregistrée porte le match ; celui d'une partie libre n'en a pas", async () => { + const engine = await loadNodeEngine(); + const times = { startedAt: 1, updatedAt: 2 }; + const free = Game.start(engine, { dice: fixedDice([3, 1]), level: "beginner" }); + assert.equal(levelOf(free.journal), "beginner"); + const freeSummary = summarizeGame("a", free.journal, { level: "beginner" }, times); + assert.equal(freeSummary.match, undefined); + assert.equal(freeSummary.level, "beginner"); + + const match = Game.start(engine, { + dice: fixedDice([3, 1]), + level: "2ply", + match: { length: 5, score: { player: 4, computer: 2 }, crawford: true }, + }); + const summary = summarizeGame("b", match.journal, { level: levelOf(match.journal) }, times); + assert.deepEqual(summary.match, { + length: 5, + score: { player: 4, computer: 2 }, + crawford: true, + over: false, + }); +}); + +test("adaptateur : le Match ID d'un match code la longueur, le score vu du joueur au trait et Crawford", async () => { + const engine = await loadNodeEngine(); + const id = await engine.encodeMatch({ + value: 1, + owner: "centered", + match: { length: 5, score: { onRoll: 4, opponent: 2 }, crawford: true }, + }); + const decoded = await engine.decodeMatch(id); + assert.deepEqual( + [decoded.matchLength, decoded.score, decoded.crawford, decoded.jacoby], + [5, [4, 2], true, false], + ); + // En partie Crawford, le moteur refuse le videau au joueur au trait. + const crawford = await engine.cubeDecision( + START_POSITION, + { + value: 1, + owner: "centered", + match: { length: 5, score: { onRoll: 2, opponent: 4 }, crawford: true }, + }, + 0, + ); + assert.equal(crawford.available, false); + const after = await engine.matchAfterGame( + { length: 5, score: { onRoll: 3, opponent: 2 }, crawford: false }, + "onRoll", + 1, + ); + assert.deepEqual(after, { score: { onRoll: 4, opponent: 2 }, over: false, crawford: true }); +}); diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index 2f53724..9b2efb9 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -36,6 +36,7 @@ const TABLE: [string, Route][] = [ ["#/parties", { screen: "games", game: null }], ["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }], ["#/menu", { screen: "menu" }], + ["#/nouvelle-partie", { screen: "newGame" }], [ "#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA", { screen: "position", position: START, match: MATCH }, diff --git a/app/tests/steps/accueil.steps.ts b/app/tests/steps/accueil.steps.ts index c5f2c46..cc6e8c2 100644 --- a/app/tests/steps/accueil.steps.ts +++ b/app/tests/steps/accueil.steps.ts @@ -83,7 +83,7 @@ Given("vous avez choisi de jouer tout de suite", () => { }); Given("une partie est en cours", () => { - game = Game.start(engine, { dice: seededDice(11), ply: 0 }); + game = Game.start(engine, { dice: seededDice(11), level: "0ply" }); settings = { ...settings, gamesStarted: settings.gamesStarted + 1 }; }); diff --git a/app/tests/steps/jouer-un-match.steps.ts b/app/tests/steps/jouer-un-match.steps.ts new file mode 100644 index 0000000..09a4bf0 --- /dev/null +++ b/app/tests/steps/jouer-un-match.steps.ts @@ -0,0 +1,265 @@ +// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le +// choix de la partie passe par les réglages de l'écran « Nouvelle partie » +// (settings.ts) et la session d'enregistrement ; les parties et le match, +// par le domaine branché sur le vrai moteur, avec des dés imposés. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import assert from "node:assert/strict"; +import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber"; +import type { Engine } from "../../src/domain/game/engine.js"; +import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js"; +import { firstGameOf, type Match } from "../../src/domain/game/match.js"; +import { START_POSITION } from "../../src/domain/game/position.js"; +import { fixedDice } from "../../src/domain/game/roll.js"; +import { createGameLibrary } 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 { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js"; +import { loadNodeEngine } from "../support/node-engine.js"; +import { playToTheEnd, seededDice } from "../support/play.js"; + +setDefaultTimeout(120_000); + +// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et +// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec +// 6-5, le joueur au trait gagne un gammon. +const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA"; +// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait +// gagne une partie simple avec 6-5. +const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA"; + +let engine: Engine; +let settings: Settings; +let session: GameSession; +let game: Game; + +BeforeAll(async () => { + engine = await loadNodeEngine(); +}); + +/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */ +async function playFirstMove(g: Game): Promise { + await g.roll(); + const state = g.state; + assert.ok(state.roll !== null); + const [move] = await engine.legalMoves(state.position, state.roll); + assert.ok(move !== undefined); + for (const halfMove of move.halfMoves) { + assert.ok(await g.playHalfMove(halfMove)); + } + await g.confirmMove(); +} + +// ------------------------------------------------------------ Le niveau + +Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => { + settings = parseSettings(null); + session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), { + start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }), + resume: (journal) => Game.resume(engine, journal), + }); +}); + +Then( + "une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée", + () => { + assert.equal(settings.gameKind, "free"); + assert.equal(settings.level, "intermediate"); + assert.equal(settings.masterAdvice, true); + assert.deepEqual(chosenGame(settings), { level: "intermediate" }); + }, +); + +When("vous choisissez le niveau Champion et commencez la partie", () => { + settings = { ...settings, level: "champion" }; + game = session.newGame(chosenGame(settings)); + session.save(game.journal); +}); + +Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => { + assert.equal(game.state.level, `${plies}ply`); + // Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue + // le coup que le moteur donne pour ce niveau. + await game.playHalfMove({ from: 8, to: 5 }); + await game.playHalfMove({ from: 6, to: 5 }); + await game.confirmMove(); + const before = game.state; + await game.playComputerTurn(); + const played = game.events.findLast((e) => e.type === "MovePlayed"); + assert.ok(played?.type === "MovePlayed" && played.side === "computer"); + const roll = game.events.findLast((e) => e.type === "DiceRolled"); + assert.ok(roll?.type === "DiceRolled"); + const expected = await engine.levelMove( + before.position, + engineCube(before, "computer"), + roll.roll, + "2ply", + ); + assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []); + session.save(game.journal); +}); + +Then("la partie reprise garde ce niveau", async () => { + const resumed = await session.restore(); + assert.ok(resumed !== null); + assert.equal(resumed.state.level, "2ply"); +}); + +Then("vos choix sont proposés à la prochaine nouvelle partie", () => { + const again = parseSettings(JSON.stringify(settings)); + assert.equal(again.level, "champion"); + assert.equal(chosenGame(again).level, "2ply"); +}); + +// ------------------------------------------------------- Un match entier + +// Les parties du match, dans l'ordre. +let games: Game[] = []; + +Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => { + settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" }; + games = []; +}); + +When("le match se joue jusqu'au bout, partie après partie", async () => { + // Des dés reproductibles, partagés par toutes les parties du match. + const dice = seededDice(3); + let next: NewGame | null = chosenGame(settings); + while (next !== null) { + assert.ok(games.length < 20, "le match ne finit pas"); + const g = Game.start(engine, { dice, ...next }); + await playToTheEnd(g, engine); + games.push(g); + next = g.nextGame; + } +}); + +Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => { + const last = games.at(-1)?.state; + assert.ok(last?.matchAfter); + assert.equal(last.matchAfter.over, true); + const { player, computer } = last.matchAfter.score; + assert.ok(Math.max(player, computer) >= n); + // Avant la dernière partie, personne n'avait encore les points. + for (const g of games.slice(0, -1)) { + assert.equal(g.state.matchAfter?.over, false); + const score = g.state.matchAfter?.score; + assert.ok(score !== undefined && Math.max(score.player, score.computer) < n); + } +}); + +Then("chaque partie commence avec le score laissé par la précédente", () => { + assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 }); + for (let i = 1; i < games.length; i++) { + assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score); + } +}); + +Then("la partie Crawford s'est jouée sans videau", () => { + const crawford = games.filter((g) => g.state.match?.crawford === true); + assert.equal(crawford.length, 1, "une partie Crawford, et une seule"); + for (const g of crawford) { + assert.ok(!g.events.some((e) => e.type === "CubeOffered")); + assert.equal(g.state.cube.value, 1); + } +}); + +// ------------------------------------------------------- La partie Crawford + +let crawfordGame: Game; + +Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => { + game = Game.start(engine, { + setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" }, + level: "beginner", + match: firstGameOf(3), + dice: fixedDice([6, 5]), + }); +}); + +When("l'ordinateur gagne la partie", async () => { + await game.playComputerTurn(); + assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 }); +}); + +Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => { + assert.deepEqual(game.state.matchAfter?.score, { player, computer }); +}); + +Then("la partie suivante est la partie Crawford", () => { + assert.equal(game.nextGame?.match?.crawford, true); +}); + +When("la partie Crawford commence", () => { + const next = game.nextGame; + assert.ok(next?.match !== undefined); + crawfordGame = Game.start(engine, { + ...next, + setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); +}); + +Then("personne ne peut doubler pendant cette partie", async () => { + assert.equal(await crawfordGame.canDouble(), false); + // L'ordinateur non plus, dans la même position vue de lui. + const state = crawfordGame.state; + const board = await engine.board(state.position, engineCube(state, "player")); + assert.equal( + await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level), + false, + ); +}); + +When("vous gagnez la partie Crawford", async () => { + await playFirstMove(crawfordGame); + assert.deepEqual(crawfordGame.state.matchAfter, { + score: { player: 1, computer: 2 }, + over: false, + crawford: false, + }); +}); + +Then("vous pouvez de nouveau doubler dans la partie suivante", async () => { + const next = crawfordGame.nextGame; + assert.ok(next?.match !== undefined); + const match: Match = next.match; + assert.equal(match.crawford, false); + const after = Game.start(engine, { + ...next, + setup: { position: START_POSITION, firstTurn: "player" }, + }); + assert.equal(await after.canDouble(), true); +}); + +// ------------------------------------------------------- La règle Jacoby + +Given("vous allez gagner un gammon sans que personne ait doublé", () => { + // La position est choisie à chaque partie, ci-dessous. +}); + +When("vous gagnez une partie libre", async () => { + game = Game.start(engine, { + setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" }, + dice: fixedDice([6, 5]), + }); + await playFirstMove(game); +}); + +When("vous gagnez la même partie dans un match", async () => { + game = Game.start(engine, { + setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" }, + match: firstGameOf(5), + dice: fixedDice([6, 5]), + }); + await playFirstMove(game); +}); + +Then("vous marquez {int} point(s)", (points: number) => { + assert.equal(game.state.result?.winner, "player"); + assert.equal(game.state.result?.kind, "gammon"); + assert.equal(game.state.result?.points, points); +}); diff --git a/app/tests/steps/jouer-une-partie.steps.ts b/app/tests/steps/jouer-une-partie.steps.ts index 52c6cb1..cbe6ad5 100644 --- a/app/tests/steps/jouer-une-partie.steps.ts +++ b/app/tests/steps/jouer-une-partie.steps.ts @@ -426,8 +426,8 @@ When("vous prenez", () => { game.take(); }); -When("vous passez", () => { - game.drop(); +When("vous passez", async () => { + await game.drop(); }); Then("la partie continue à valeur doublée", () => { diff --git a/app/tests/steps/mes-parties.steps.ts b/app/tests/steps/mes-parties.steps.ts index 27db67f..d5256f0 100644 --- a/app/tests/steps/mes-parties.steps.ts +++ b/app/tests/steps/mes-parties.steps.ts @@ -42,7 +42,7 @@ Before(async () => { /** Commence une partie, comme l'application : un nouvel identifiant. */ function startGame(): void { - game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 }); + game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" }); shownId = `partie-${nextSeed++}`; } @@ -58,7 +58,7 @@ async function reopen(): Promise { library = (await openGameLibrary({ localStorage: storage })).library; const saved = await library.current(); assert.ok(saved !== null, "aucune partie en cours"); - game = Game.resume(engine, saved.journal, { ply: 0 }); + game = Game.resume(engine, saved.journal, { level: "0ply" }); shownId = saved.summary.id; } diff --git a/app/tests/steps/partager-une-position.steps.ts b/app/tests/steps/partager-une-position.steps.ts index 267ac5c..c0ae721 100644 --- a/app/tests/steps/partager-une-position.steps.ts +++ b/app/tests/steps/partager-une-position.steps.ts @@ -45,15 +45,10 @@ BeforeAll(async () => { 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", - ); + session = createGameSession(library, { + start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }), + resume: (journal) => Game.resume(engine, journal, { level: "0ply" }), + }); shared = null; failure = null; }); @@ -131,7 +126,7 @@ Then("votre partie n'a pas changé", () => { When("vous choisissez « Jouer à partir d'ici »", () => { assert.ok(shared !== null); - game = session.newGame(setupOf(shared)); + game = session.newGame({ setup: setupOf(shared) }); session.save(game.journal); }); diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index 3c24d49..54dafbb 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -48,14 +48,10 @@ let oldId: string | null; Given("une partie est en cours depuis quelques coups", async () => { library = createGameLibrary(createKeyValueBackend(memoryStorage())); let dice = fixedDice([3, 1, 6, 4, 5, 2]); - session = createGameSession( - library, - { - start: () => Game.start(engine, { dice }), - resume: (journal) => Game.resume(engine, journal, { dice }), - }, - "2ply", - ); + session = createGameSession(library, { + start: () => Game.start(engine, { dice }), + resume: (journal) => Game.resume(engine, journal, { dice }), + }); game = session.newGame(); dice = () => 1 + Math.floor(Math.random() * 6); for (const halfMove of parseHalfMoves("8/5 6/5")) { @@ -178,7 +174,7 @@ Given("une nouvelle version d'Ustagammon est disponible", () => { Given("vous avez déjà déplacé un pion de votre coup", async () => { // Jet d'ouverture 3-1 : vous commencez. - game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 }); + game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" }); assert.equal(game.state.turn, "player"); assert.ok(await game.playHalfMove({ from: 8, to: 5 })); }); diff --git a/app/tests/storage.test.ts b/app/tests/storage.test.ts index 35ab8d5..a6bc7ec 100644 --- a/app/tests/storage.test.ts +++ b/app/tests/storage.test.ts @@ -226,14 +226,10 @@ test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une b const games = library(); // Game.start et Game.resume n'interrogent pas le moteur. const engine = {} as Engine; - const session = createGameSession( - games, - { - start: () => Game.start(engine, { dice: fixedDice([5, 2]) }), - resume: (j) => Game.resume(engine, j), - }, - "2ply", - ); + const session = createGameSession(games, { + start: () => Game.start(engine, { dice: fixedDice([5, 2]) }), + resume: (j) => Game.resume(engine, j), + }); assert.equal(await session.restore(), null); assert.equal(await session.resumePrevious(), null); diff --git a/app/tests/support/play.ts b/app/tests/support/play.ts index d938774..40d3418 100644 --- a/app/tests/support/play.ts +++ b/app/tests/support/play.ts @@ -33,7 +33,7 @@ export async function step( if (strategy.take ?? true) { game.take(); } else { - game.drop(); + await game.drop(); } } else if (state.turn === "computer") { await game.playComputerTurn(); diff --git a/app/tests/tools/sgf-samples.ts b/app/tests/tools/sgf-samples.ts index dd8321b..065949e 100644 --- a/app/tests/tools/sgf-samples.ts +++ b/app/tests/tools/sgf-samples.ts @@ -44,7 +44,7 @@ async function main(): Promise { [3, 0], [11, 2], ] as const) { - const game = Game.start(engine, { dice: seededDice(seed), ply: 0 }); + const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" }); await playToTheEnd(game, engine, choice); games.push([`complete-${seed}`, game]); } @@ -52,7 +52,7 @@ async function main(): Promise { // Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un // jet du joueur (même jet, autre coup), et une branche d'une branche. { - const game = Game.start(engine, { dice: seededDice(5), ply: 0 }); + const game = Game.start(engine, { dice: seededDice(5), level: "0ply" }); await playMoves(game, engine, 14); const original = game.events.at(-1)?.id as string; game.goBackTo(lastMoveId(game, 6)); @@ -70,7 +70,7 @@ async function main(): Promise { // Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur. { - const game = Game.start(engine, { dice: seededDice(9), ply: 0 }); + const game = Game.start(engine, { dice: seededDice(9), level: "0ply" }); for (let i = 0; i < 400 && game.state.phase !== "over"; i++) { await step(game, engine, { double: true }); } @@ -83,7 +83,7 @@ async function main(): Promise { { const game = Game.start(engine, { dice: seededDice(13), - ply: 0, + level: "0ply", setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" }, }); await step(game, engine); @@ -99,7 +99,7 @@ async function main(): Promise { { const game = Game.start(engine, { dice: seededDice(17), - ply: 0, + level: "0ply", setup: { position: "t+0OAADg/wcAQA", firstTurn: "player", diff --git a/docs/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index dba67cc..337266c 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -106,3 +106,25 @@ clés `localStorage` de l'application (`ustagammon.settings`, d'événements parmi les formats durables : toute modification passe par une montée de version avec migration testée, jamais par une réécriture qui perdrait les parties des joueurs. + +### 2026-11-09 — Version 2 des événements : niveau et match + +Avec le choix du niveau et les matchs (étape 1c), le format des événements +passe à la version 2 (`EVENT_VERSION`, `app/src/domain/game/events.ts`) : + +- `GameStarted` porte le niveau de l'ordinateur (`level`, un niveau de gnubg) + et, pour une partie de match, le match à son début (`match` : longueur, + score, partie Crawford) ; +- `GameEnded` porte, dans un match, le match après la partie (score, fin du + match, partie Crawford suivante), tel que le moteur l'a calculé. + +Chaque partie d'un match est une partie à part entière, avec son journal et +son enregistrement ; la suivante reprend le match que la précédente a laissé. +La migration (`migrateJournal`, testée dans `app/tests/game.test.ts`) passe un +journal de la version 1 à la version 2 au moment de le reprendre : le niveau, +implicite, devient explicite (2 plis, le seul qui existait) ; rien d'autre ne +change, une partie de la version 1 étant une partie libre. Le schéma IndexedDB +ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif +`match`, recalculé à chaque enregistrement, et les réglages +(`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`, +`masterAdvice`) qui ont une valeur par défaut quand ils manquent. diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 1fb8151..6f80722 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -98,3 +98,17 @@ Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma : Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste la partie en cours, pour que les liens existants ne changent pas. + +### 2026-11-09 — Adresse de l'écran « Nouvelle partie » + +Ajoutée avec le choix de la partie et le match (étape 1c), dans le même +schéma : + +| Écran | Adresse | +| --- | --- | +| Nouvelle partie : type de partie, niveau, conseil du maître | `#/nouvelle-partie` | + +Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Commencer » +remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone +ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le +Match ID du match (longueur, score, partie Crawford), écrit par le moteur. diff --git a/docs/glossaire/match.md b/docs/glossaire/match.md index bcd35da..fbcbb93 100644 --- a/docs/glossaire/match.md +++ b/docs/glossaire/match.md @@ -1,9 +1,16 @@ --- term: Match english: Match +code: Match --- # Match **En bref** : suite de parties jouée jusqu'à ce qu'un joueur atteigne un nombre de points fixé à l'avance, par exemple 5 points. -Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md) +Chaque partie rapporte ses points à son gagnant : 1 pour une partie simple, +2 pour un gammon, 3 pour un backgammon, multipliés par la valeur du videau. +Le premier qui atteint la longueur du match le gagne. Dans Ustagammon, un +match se joue en 3, 5, 7 ou 11 points ; le score, la fin du match et la +partie Crawford sont calculés par GNU Backgammon. + +Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md), [Identifiant de match](identifiant-de-match.md) diff --git a/docs/glossaire/niveau-de-jeu.md b/docs/glossaire/niveau-de-jeu.md index d5d533c..c213bc5 100644 --- a/docs/glossaire/niveau-de-jeu.md +++ b/docs/glossaire/niveau-de-jeu.md @@ -15,4 +15,19 @@ la position seule : dans la même position, avec le même jet, l'ordinateur joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent de 0 à 4 plis. +L'écran « Nouvelle partie » en propose six : + +| Nom montré | Niveau de GNU Backgammon | +| --- | --- | +| Débutant | beginner (0 pli, bruit 0,060) | +| Amateur | casual play (0 pli, bruit 0,050) | +| Intermédiaire | intermediate (0 pli, bruit 0,040) | +| Avancé | advanced (0 pli, bruit 0,015) | +| Expert | 0 pli sans bruit, l'ancien « expert » de gnubg | +| Champion | 2 plis, l'ancien « world class » de gnubg | + +Champion est le plus fort qui joue sans attente sur le téléphone ; 3 et +4 plis ne sont pas proposés. L'ordinateur double, prend et passe comme celui +de GNU Backgammon, au même niveau. + Voir aussi : [Pli](pli.md), [Moteur](moteur.md) diff --git a/docs/glossaire/regle-de-crawford.md b/docs/glossaire/regle-de-crawford.md index e551882..36e84eb 100644 --- a/docs/glossaire/regle-de-crawford.md +++ b/docs/glossaire/regle-de-crawford.md @@ -6,4 +6,12 @@ english: Crawford rule **En bref** : en match, dans la partie qui suit le moment où un joueur arrive à un point de la victoire, personne ne peut doubler. +Cette partie s'appelle la partie Crawford. Elle protège celui qui arrive +au bord de la victoire : sans elle, son adversaire doublerait aussitôt, +puisqu'il n'a plus rien à perdre. Après la partie Crawford, le videau +revient, et celui qui est mené peut de nouveau doubler. La règle ne joue +qu'une fois par match ; si les deux joueurs arrivent à un point de la +victoire, il n'y a pas de partie Crawford. Dans Ustagammon, elle s'applique +toujours aux matchs, comme dans GNU Backgammon. + Voir aussi : [Match](match.md), [Doubler](doubler.md) diff --git a/docs/glossaire/regle-de-jacoby.md b/docs/glossaire/regle-de-jacoby.md index 8d081c6..7fb2268 100644 --- a/docs/glossaire/regle-de-jacoby.md +++ b/docs/glossaire/regle-de-jacoby.md @@ -6,4 +6,9 @@ english: Jacoby rule **En bref** : en partie libre, hors match, un gammon ou un backgammon ne compte que si le videau a été utilisé. -Voir aussi : [Gammon](gammon.md), [Videau](videau.md) +Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un +point. La règle encourage à doubler une position forte plutôt qu'à jouer le +gammon sans risque. Elle s'applique toujours aux parties libres d'Ustagammon, +jamais aux matchs. + +Voir aussi : [Gammon](gammon.md), [Videau](videau.md), [Match](match.md) diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index ec4349b..6038d7b 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -56,6 +56,7 @@ Règles d'écriture : | Fichier | Tâche | Depuis | | --- | --- | --- | | [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d | +| [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c | | [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), la mettre à jour | Étape 0e | diff --git a/docs/scenarios/jouer-un-match.feature b/docs/scenarios/jouer-un-match.feature new file mode 100644 index 0000000..002c237 --- /dev/null +++ b/docs/scenarios/jouer-un-match.feature @@ -0,0 +1,61 @@ +# language: fr +Fonctionnalité: Choisir sa partie et jouer un match + + L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin + d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7 + ou 11 points, le niveau de l'ordinateur, et si le maître commente vos + coups. Vos choix sont gardés pour la prochaine fois. + + Scénario: Comment choisir le niveau de l'ordinateur ? + Six niveaux, du Débutant, qui laisse passer des occasions, au Champion, + GNU Backgammon à son meilleur niveau sur ce téléphone. La première fois, + c'est une partie libre contre un ordinateur Intermédiaire, avec le + conseil du maître. L'ordinateur garde le niveau choisi toute la partie, + même si vous fermez l'application. + + Étant donné que vous ouvrez l'écran « Nouvelle partie » pour la première fois + Alors une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée + Quand vous choisissez le niveau Champion et commencez la partie + Alors l'ordinateur joue au niveau 2 plis de GNU Backgammon + Et la partie reprise garde ce niveau + Et vos choix sont proposés à la prochaine nouvelle partie + + Scénario: Comment jouer un match ? + Un match se joue en plusieurs parties : chacune rapporte ses points au + gagnant (le double pour un gammon, le triple pour un backgammon, fois la + valeur du videau), et le premier qui atteint le nombre de points du + match le gagne. À la fin de chaque partie, « Partie suivante » lance la + suivante, avec le score du match. + + Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant + Quand le match se joue jusqu'au bout, partie après partie + Alors le match se termine dès qu'un joueur atteint 3 points + Et chaque partie commence avec le score laissé par la précédente + Et la partie Crawford s'est jouée sans videau + + Scénario: Qu'est-ce que la partie Crawford ? + Dans un match, quand un joueur arrive à un point de la victoire, la + partie suivante se joue sans videau : personne ne peut doubler. C'est la + règle de Crawford ; l'écran l'annonce par « partie Crawford ». Dans les + parties d'après, celui qui est mené peut de nouveau doubler. + + Étant donné qu'un match en 3 points commence et que l'ordinateur va gagner un gammon + Quand l'ordinateur gagne la partie + Alors l'ordinateur mène le match 2 à 0 + Et la partie suivante est la partie Crawford + Quand la partie Crawford commence + Alors personne ne peut doubler pendant cette partie + Quand vous gagnez la partie Crawford + Alors vous pouvez de nouveau doubler dans la partie suivante + + Scénario: Pourquoi mon gammon ne m'a-t-il rapporté qu'un point ? + En partie libre, la règle de Jacoby s'applique : tant que personne n'a + doublé, un gammon ou un backgammon ne rapporte qu'un point. Elle + encourage à doubler plutôt qu'à jouer le gammon sans risque. En match, + elle ne s'applique pas : le gammon compte toujours double. + + Étant donné que vous allez gagner un gammon sans que personne ait doublé + Quand vous gagnez une partie libre + Alors vous marquez 1 point + Quand vous gagnez la même partie dans un match + Alors vous marquez 2 points