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/main.ts b/app/src/main.ts index 321ad31..e34bcba 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -40,15 +40,12 @@ const opening = (async () => { indexedDB: attempt(() => window.indexedDB), localStorage, }); - // Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY). - const session = createGameSession( - library, - { - start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }), - resume: (journal) => Game.resume(engine, journal), - }, - "2ply", - ); + // Le niveau et le match de chaque partie viennent de l'écran « Nouvelle + // partie » (ou de la partie précédente du match). + const session = createGameSession(library, { + start: (newGame) => Game.start(engine, newGame ?? {}), + resume: (journal) => Game.resume(engine, journal), + }); const saved = await session.restore(); return { library, session, saved }; })(); @@ -68,7 +65,7 @@ startApp({ stored = opened; }), ]), - newGame: (setup) => storage().session.newGame(setup), + newGame: (newGame) => storage().session.newGame(newGame), positions: { read: (link) => readPosition(engine, link), linkOf: (game) => linkOf(engine, game.state), 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/app.ts b/app/src/ui/app.ts index 0733953..a2e7029 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -23,11 +23,12 @@ import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; -import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js"; +import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; +import type { GameState } from "../domain/game/state.js"; import { boardModel, moveTargets, @@ -42,6 +43,7 @@ import { type BoardDecor, checkerSpot, type DiceAction, + type MatchScore, NO_MARKS, renderBoard, type ShownDie, @@ -58,9 +60,18 @@ import { HOME_TEXTS } from "./home-texts.js"; import { createHomeView } from "./home-view.js"; import { icon } from "./icons.js"; import { installPlatform, startInstaller } from "./install.js"; +import { MATCH_TEXTS } from "./match-texts.js"; import { replacingLink, startNavigation } from "./navigation.js"; +import { createNewGameView } from "./new-game-view.js"; import { isHelpScreen, type Route, routeHash } from "./router.js"; -import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js"; +import { + chosenGame, + isFirstLaunch, + longPrompts, + nextTheme, + parseSettings, + type Settings, +} from "./settings.js"; import { type PromptText, TEXTS } from "./texts.js"; import { seenAfter } from "./tutorial-nav.js"; import { createTutorialView, type TutorialAccess } from "./tutorial-view.js"; @@ -84,9 +95,9 @@ export interface CachedBoard { export interface AppOptions { /** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */ readonly engineReady: Promise; - /** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis - * une position donnée (setup). */ - readonly newGame: (setup?: GameSetup) => Game; + /** Une nouvelle partie (le jet d'ouverture est déjà tiré) : niveau, + * match, ou position de départ (setup). */ + readonly newGame: (newGame?: NewGame) => Game; /** Les liens vers une position (ADR 0013). */ readonly positions: PositionsAccess; /** La partie enregistrée, ou null s'il n'y en a pas. */ @@ -173,6 +184,7 @@ export function startApp(options: AppOptions): void { strip, who: child(strip, ".who"), lead: child(strip, ".lead"), + score: child(strip, ".match-score"), count: child(strip, ".count"), }; }; @@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void { menu: byId("menu"), menuTitle: byId("menu-title"), menuClose: byId("menu-close"), - menuNewGame: byId("menu-new-game"), + menuNewGame: byId("menu-new-game"), + newGameSection: byId("new-game"), + matchLine: byId("match-line"), menuGames: byId("menu-games"), menuCopyLink: byId("menu-copy-link"), menuFullscreen: byId("menu-fullscreen"), @@ -510,7 +524,8 @@ export function startApp(options: AppOptions): void { if (!animating) { renderBoard(ui.board, g, model, { dice: shownDice(game, stuck), - cube: state.cube, + // Partie Crawford : pas de videau. + cube: state.match?.crawford === true ? null : state.cube, movable: new Set(targets.keys()), selected, targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])), @@ -526,6 +541,7 @@ export function startApp(options: AppOptions): void { }); } renderStrips(model.pipCount, state.phase === "over" ? null : state.turn); + renderMatch(state); // Ligne d'information : un jet sans coup possible reste annoncé // jusqu'à l'action suivante, même après un rechargement de la page. @@ -561,6 +577,7 @@ export function startApp(options: AppOptions): void { show(ui.take, doubled); show(ui.drop, doubled); show(ui.again, over && panelDismissed); + ui.again.textContent = afterGameLabel(game); show(ui.replay, shownMove !== null); // Le coup de l'ordinateur montré : sa position se copie en lien. show(ui.copyLink, shownMove !== null && currentLink !== null); @@ -573,7 +590,6 @@ export function startApp(options: AppOptions): void { ui.skip, ui.replay, ui.again, - ui.menuNewGame, ui.positionPlay, ]) { button.disabled = busy; @@ -624,7 +640,6 @@ export function startApp(options: AppOptions): void { button.hidden = true; } ui.moveHint.hidden = true; - ui.menuNewGame.disabled = true; ui.menuCopyLink.disabled = true; ui.positionPlay.disabled = true; if (notice !== null) { @@ -646,9 +661,42 @@ export function startApp(options: AppOptions): void { back: replay ? "replay" : "undo", backEnabled: !busy && (replay || undoable), menuEnabled: true, + match: game === null ? null : matchScore(game.state), }; } + /** Le match : la ligne « Match en N points » sous la barre haute, et le + * score de chaque camp dans son bandeau (portrait). */ + function renderMatch(state: GameState): void { + const score = matchScore(state); + ui.matchLine.hidden = score === null; + for (const side of ["computer", "player"] as const) { + const badge = ui.strips[side].score; + badge.hidden = score === null; + badge.textContent = score === null ? "" : String(score[side]); + badge.setAttribute( + "aria-label", + score === null + ? "" + : side === "player" + ? MATCH_TEXTS.playerScore(score.player) + : MATCH_TEXTS.computerScore(score.computer), + ); + } + if (score === null || state.match === null) { + ui.matchLine.textContent = ""; + ui.matchLine.removeAttribute("aria-label"); + return; + } + ui.matchLine.textContent = MATCH_TEXTS.matchLine(score.length, state.match.crawford); + ui.matchLine.setAttribute( + "aria-label", + `${MATCH_TEXTS.scoreSpoken(score.length, score.computer, score.player)}${ + state.match.crawford ? ", partie Crawford" : "" + }`, + ); + } + /** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */ function renderStrips(pips: Readonly>, turn: Side | null): void { const lead = pipLead(pips); @@ -681,7 +729,7 @@ export function startApp(options: AppOptions): void { ui.panelText.textContent = end.title; detail.textContent = end.detail; ui.panelSecondary.textContent = TEXTS.reviewGame; - ui.panelPrimary.textContent = TEXTS.newGame; + ui.panelPrimary.textContent = afterGameLabel(g); ui.panelVeil.dataset.kind = "over"; } else if (ask) { overline.textContent = ""; @@ -736,6 +784,9 @@ export function startApp(options: AppOptions): void { if (navigation.route.screen === "home") { renderHome(); } + if (navigation.route.screen === "newGame") { + newGameView.render(busy || game === null); + } } /** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */ @@ -850,7 +901,7 @@ export function startApp(options: AppOptions): void { } const decor = { dice: shownDice(game, false), - cube: game.state.cube, + cube: game.state.match?.crawford === true ? null : game.state.cube, ...NO_MARKS, band: null, controls: null, @@ -935,24 +986,27 @@ export function startApp(options: AppOptions): void { } } - function startNewGame(setup?: GameSetup): void { - game = options.newGame(setup); + /** Commence une partie : celle des choix de l'écran « Nouvelle partie » + * si rien d'autre n'est demandé. */ + function startNewGame(chosen: NewGame = chosenGame(settings)): void { + game = options.newGame(chosen); panelDismissed = false; announcedEnd = null; saveSettings({ gamesStarted: settings.gamesStarted + 1 }); const opening = game.events.findLast((e) => e.type === "DiceRolled"); info = - setup !== undefined + chosen.setup !== undefined ? TEXTS.startedFromPosition : opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : ""; } - /** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une - * position) : tout de suite, sans confirmation ; une partie en cours - * reste reprenable pendant 10 s (interaction.md). */ - function newGame(setup?: GameSetup): void { + /** Une nouvelle partie (« Commencer » de l'écran « Nouvelle partie », + * « Jouer à partir d'ici » depuis une position, « Partie suivante » d'un + * match) : tout de suite, sans confirmation ; une partie en cours reste + * reprenable pendant 10 s (interaction.md). */ + function newGame(chosen?: NewGame): void { closeOverlays(); if (busy || game === null) { return; @@ -961,7 +1015,19 @@ export function startApp(options: AppOptions): void { if (old !== null) { showBanner(old); } - void act(() => startNewGame(setup)); + void act(() => startNewGame(chosen)); + } + + /** Après une partie finie : la partie suivante du match s'il continue, + * sinon l'écran « Nouvelle partie ». */ + function afterGame(): void { + const next = game?.nextGame ?? null; + if (next !== null) { + newGame(next); + } else { + closeOverlays(); + navigation.go(routeHash({ screen: "newGame" })); + } } function showBanner(journal: GameJournal): void { @@ -1101,8 +1167,8 @@ export function startApp(options: AppOptions): void { drop: () => { const g = canAct(); if (g?.state.phase === "doubled" && g.state.turn === "computer") { - void act((g) => { - g.drop(); + void act(async (g) => { + await g.drop(); info = ""; }); } @@ -1129,7 +1195,7 @@ export function startApp(options: AppOptions): void { selected = null; void settle(true); }, - again: () => newGame(), + again: () => afterGame(), // Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son // lien) ; le refermer, revenir à l'écran d'avant. menu: () => { @@ -1161,6 +1227,8 @@ export function startApp(options: AppOptions): void { navigation.leave(isHelpScreen); } else if (route.screen === "position") { navigation.leave((r) => r.screen === "position"); + } else if (route.screen === "newGame") { + navigation.leave((r) => r.screen === "newGame"); } else if (route.screen === "tutorial" || route.screen === "tutorialEnd") { navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd"); } @@ -1233,7 +1301,7 @@ export function startApp(options: AppOptions): void { if (ui.panelVeil.dataset.kind === "doubled") { actions.take(); } else { - newGame(); + afterGame(); } }; ui.panelSecondary.onclick = () => { @@ -1250,12 +1318,12 @@ export function startApp(options: AppOptions): void { // Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à // brancher. Les liens du menu remplacent son adresse : le retour du // téléphone ne rouvre pas le menu. + replacingLink(ui.menuNewGame, navigation); replacingLink(ui.menuGames, navigation); replacingLink(ui.menuHelp, navigation); replacingLink(ui.menuHome, navigation); replacingLink(ui.menuTutorial, navigation); ui.menuClose.onclick = () => closeOverlays(); - ui.menuNewGame.onclick = () => newGame(); ui.menuCopyLink.onclick = () => { const link = currentLink; closeOverlays(); @@ -1303,7 +1371,8 @@ export function startApp(options: AppOptions): void { const position = below.screen === "position"; const home = below.screen === "home"; const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd"; - const page = help || position || home || tutorial; + const choosing = below.screen === "newGame"; + const page = help || position || home || tutorial || choosing; const wasPage = ui.game.hidden; const wasScreen = ui.root.dataset.screen; ui.game.hidden = page; @@ -1311,9 +1380,16 @@ export function startApp(options: AppOptions): void { ui.position.hidden = !position; ui.home.hidden = !home; ui.tutorial.hidden = !tutorial; + const opening = choosing && ui.newGameSection.hidden; + ui.newGameSection.hidden = !choosing; ui.root.classList.toggle("page-open", page); - // Le tutoriel et le premier lancement ont leur propre en-tête. - ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings))); + // Le tutoriel, le premier lancement et l'écran « Nouvelle partie » + // ont leur propre en-tête. + ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings))); + if (opening) { + newGameView.render(busy || game === null); + newGameView.focus(); + } ui.root.dataset.screen = below.screen; if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) { window.scrollTo(0, 0); @@ -1397,18 +1473,29 @@ export function startApp(options: AppOptions): void { const homeView = createHomeView({ section: ui.home, - newGame: () => { - if (busy || game === null) { - return; - } - navigation.go("#/"); - newGame(); - }, + // « Nouvelle partie » : l'écran des choix, qui la commence. + newGame: () => navigation.go(routeHash({ screen: "newGame" })), install: () => void install(false), dismissInstall: () => saveSettings(dismissInstall(settings)), welcomed: () => saveSettings(welcomed()), }); + // L'écran « Nouvelle partie » : « Commencer » remplace son adresse par + // celle de la partie (le retour du téléphone ne le rouvre pas). + const newGameView = createNewGameView({ + section: ui.newGameSection, + settings: () => settings, + change: (changes) => saveSettings(changes), + start: () => { + if (busy || game === null) { + return; + } + navigation.replace("#/"); + newGame(chosenGame(settings)); + }, + }); + leavingLink(newGameView.closeLink, (route) => route.screen === "newGame"); + let progressLoading = false; function renderHome(): void { const playing = game === null ? null : game.state.phase !== "over"; @@ -1616,7 +1703,7 @@ export function startApp(options: AppOptions): void { // La nouvelle partie est l'écran de jeu, à la place de la position // (le retour du téléphone ne rouvre pas la position). navigation.replace("#/"); - newGame(setupOf(shared)); + newGame({ level: chosenGame(settings).level, setup: setupOf(shared) }); }; ui.positionCopy.onclick = () => { if (shownPosition !== null) { @@ -2032,12 +2119,46 @@ function endParts(game: Game): { title: string; detail: string } { } const { winner, kind, points } = result; const dropped = game.events.some((e) => e.type === "CubeDropped"); + const { match, matchAfter } = game.state; + if (match !== null && matchAfter !== null) { + const { player, computer } = matchAfter.score; + const detail = MATCH_TEXTS.matchDetail( + winner, + points, + match.length, + player, + computer, + matchAfter.over, + ); + return { + title: matchAfter.over + ? MATCH_TEXTS.matchWonTitle(winner) + : TEXTS.endTitle(winner, kind, dropped), + detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail, + }; + } return { title: TEXTS.endTitle(winner, kind, dropped), detail: TEXTS.endDetail(winner, points, game.state.cube.value), }; } +/** Le score du match à montrer : avant la partie, puis après une fois + * qu'elle est finie (null en partie libre). */ +function matchScore(state: GameState): MatchScore | null { + if (state.match === null) { + return null; + } + const score = state.matchAfter?.score ?? state.match.score; + return { length: state.match.length, computer: score.computer, player: score.player }; +} + +/** Le bouton qui suit une partie finie : « Partie suivante » dans un match + * qui continue, sinon « Nouvelle partie » (l'écran des choix). */ +function afterGameLabel(game: Game): string { + return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame; +} + /** Le résultat d'une partie finie, sur une ligne (consigne, une fois le * panneau fermé). */ function resultText(game: Game): string { diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index e2efcc2..13c4ca2 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -27,6 +27,7 @@ import { } from "./board-model.js"; import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js"; import { ICON_PATHS, type IconName } from "./icons.js"; +import { MATCH_TEXTS } from "./match-texts.js"; import { TEXTS } from "./texts.js"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ @@ -49,12 +50,22 @@ export interface BoardControls { readonly back: "undo" | "replay"; readonly backEnabled: boolean; readonly menuEnabled: boolean; + /** Le score du match, sous le menu (null : partie libre). */ + readonly match: MatchScore | null; +} + +/** Le score d'un match, à afficher. */ +export interface MatchScore { + readonly length: number; + readonly computer: number; + readonly player: number; } /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ export interface BoardDecor { readonly dice: readonly ShownDie[]; - readonly cube: Cube; + /** null : pas de videau (partie Crawford). */ + readonly cube: Cube | null; /** Les pions que le joueur peut déplacer. */ readonly movable: ReadonlySet; /** Le pion touché, dont on montre les cases d'arrivée. */ @@ -181,7 +192,7 @@ export function renderBoard( drawBand(svg, g, decor.band); } drawDice(svg, g, decor.dice); - const cube = drawCube(svg, g, decor.cube); + const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube); if (decor.trace !== null) { drawTraceRings(svg, g, board, decor.trace); } @@ -197,7 +208,7 @@ export function renderBoard( addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width); } } - if (decor.canDouble) { + if (decor.canDouble && cube !== null && decor.cube !== null) { cube.classList.add("can-double"); addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target); } @@ -513,6 +524,49 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void { ); } +/** + * Paysage : le score du match, dans une case sous le menu, dans l'ordre des + * camps à l'écran : l'ordinateur en haut, « sur N », vous en bas + * (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran. + */ +function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void { + const line = g.chipFont * 1.2; + const width = g.left - 12; + const group = add(svg, "g", { + class: "match-box", + role: "img", + "aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player), + }); + add(group, "rect", { + class: "match-box-frame", + x: x - width / 2, + y: top, + width, + height: 3 * line + 8, + rx: 6, + }); + const texts: [string, string][] = [ + [String(match.computer), "match-box-score"], + [MATCH_TEXTS.outOf(match.length), "match-box-of"], + [String(match.player), "match-box-score"], + ]; + for (const [i, [text, cssClass]] of texts.entries()) { + add( + group, + "text", + { + class: cssClass, + x, + y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35, + "font-size": g.chipFont, + "text-anchor": "middle", + "aria-hidden": "true", + }, + text, + ); + } +} + /** Paysage : la colonne de commandes à gauche, dans le cadre. */ function drawControls( svg: SVGSVGElement, @@ -557,6 +611,9 @@ function drawControls( } }; control("menu", "menu", TEXTS.menu, top, controls.menuEnabled); + if (controls.match !== null) { + drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match); + } if (controls.back === "replay") { control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled); } else { diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 2918c80..5dc24c6 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/match-texts.ts b/app/src/ui/match-texts.ts new file mode 100644 index 0000000..0266498 --- /dev/null +++ b/app/src/ui/match-texts.ts @@ -0,0 +1,93 @@ +// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ; +// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et +// descriptions des niveaux sont ceux de la charte ; le reste est une +// première version, à valider par Pierre. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { ComputerLevel } from "../domain/game/level.js"; + +function points(n: number): string { + return n > 1 ? `${n} points` : `${n} point`; +} + +export const MATCH_TEXTS = { + // Écran « Nouvelle partie » + title: "Nouvelle partie", + close: "Fermer sans rien changer", + kindLabel: "Type de partie", + free: "Partie libre", + match: "Match", + /** « Le premier à [3] [5] [7] [11] points ». */ + lengthBefore: "Le premier à", + lengthAfter: "points", + lengthLabel: (n: number) => `Match en ${n} points`, + crawfordRule: + "Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la", + crawfordLink: "règle de Crawford", + jacobyRule: + "Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la", + jacobyLink: "règle de Jacoby", + levelTitle: "Niveau de l'ordinateur", + levels: { + beginner: { + name: "Débutant", + text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.", + }, + amateur: { + name: "Amateur", + text: "Un joueur qui connaît les règles et commet encore des erreurs.", + }, + intermediate: { + name: "Intermédiaire", + text: "Un habitué de la table : solide, sans être implacable.", + }, + advanced: { + name: "Avancé", + text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.", + }, + expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." }, + champion: { + name: "Champion", + text: "GNU Backgammon à son meilleur niveau sur ce téléphone.", + }, + } satisfies Record, + duringGame: "Pendant la partie", + advice: "Conseil du maître", + adviceDetail: "Une ligne après chacun de vos coups", + startGame: "Commencer la partie", + startMatch: (n: number) => `Commencer le match en ${n} points`, + kept: "Vos choix sont gardés pour la prochaine fois.", + + // Score du match sur l'écran de jeu + /** « Match en 5 points », et « · partie Crawford ». */ + matchLine: (length: number, crawford: boolean) => + `Match en ${length} points${crawford ? " · partie Crawford" : ""}`, + /** Ce que lit le lecteur d'écran. */ + scoreSpoken: (length: number, computer: number, player: number) => + `Match en ${length} points : l'ordinateur ${computer}, vous ${player}`, + /** Paysage : « sur 5 ». */ + outOf: (length: number) => `sur ${length}`, + playerScore: (n: number) => `Votre score : ${n}`, + computerScore: (n: number) => `Score de l'ordinateur : ${n}`, + + // Fin d'une partie de match + nextGame: "Partie suivante", + nextGameStarted: "Partie suivante du match.", + matchWonTitle: (winner: "player" | "computer") => + winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match", + /** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */ + matchDetail: ( + winner: "player" | "computer", + n: number, + length: number, + player: number, + computer: number, + over: boolean, + ) => + `${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${ + over ? "Score final" : `Match en ${length} points` + } : vous ${player}, l'ordinateur ${computer}.`, + crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.", +}; diff --git a/app/src/ui/new-game-view.ts b/app/src/ui/new-game-view.ts new file mode 100644 index 0000000..b002839 --- /dev/null +++ b/app/src/ui/new-game-view.ts @@ -0,0 +1,199 @@ +// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie +// libre ou match en N points), niveau de l'ordinateur, conseil du maître +// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont +// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie +// en cours ; seul « Commencer » en lance une. + +import { COMPUTER_LEVELS } from "../domain/game/level.js"; +import { MATCH_LENGTHS } from "../domain/game/match.js"; +import { icon } from "./icons.js"; +import { MATCH_TEXTS } from "./match-texts.js"; +import { routeHash } from "./router.js"; +import type { Settings } from "./settings.js"; + +export interface NewGameViewOptions { + readonly section: HTMLElement; + readonly settings: () => Settings; + /** Un choix a changé : le garder. */ + readonly change: (changes: Partial) => void; + /** « Commencer la partie » ou « Commencer le match ». */ + readonly start: () => void; +} + +export interface NewGameView { + /** La croix : un lien « Retour » (l'écran de jeu le branche). */ + readonly closeLink: HTMLAnchorElement; + /** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */ + render(busy: boolean): void; + /** Le premier élément à recevoir le focus à l'ouverture. */ + focus(): void; +} + +function element( + parent: HTMLElement, + tag: K, + options: { id?: string; className?: string; text?: string } = {}, +): HTMLElementTagNameMap[K] { + const child = document.createElement(tag); + if (options.id !== undefined) { + child.id = options.id; + } + if (options.className !== undefined) { + child.className = options.className; + } + if (options.text !== undefined) { + child.textContent = options.text; + } + parent.append(child); + return child; +} + +/** Un bouton qui fait partie d'un groupe de choix exclusifs. */ +function radio(parent: HTMLElement, id: string, className: string, text?: string) { + const button = element(parent, "button", { + id, + className, + ...(text === undefined ? {} : { text }), + }); + button.type = "button"; + button.setAttribute("role", "radio"); + return button; +} + +/** Une phrase sur une règle, terminée par le lien vers sa fiche. */ +function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) { + const p = element(parent, "p", { id, className: "new-game-rule" }); + p.append(`${text} `); + const a = element(p, "a", { text: link }); + a.href = routeHash({ screen: "glossary", term }); + p.append("."); + return p; +} + +export function createNewGameView(options: NewGameViewOptions): NewGameView { + const { section } = options; + section.replaceChildren(); + section.setAttribute("aria-labelledby", "new-game-title"); + + const head = element(section, "div", { className: "new-game-head" }); + const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" }); + closeLink.href = "#/"; + closeLink.setAttribute("aria-label", MATCH_TEXTS.close); + closeLink.append(icon("close")); + element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title }); + + const body = element(section, "div", { className: "new-game-body" }); + + // 1. Type de partie. + const kinds = element(body, "div", { className: "segmented" }); + kinds.setAttribute("role", "radiogroup"); + kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel); + const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free); + const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match); + free.addEventListener("click", () => options.change({ gameKind: "free" })); + match.addEventListener("click", () => options.change({ gameKind: "match" })); + + const matchOptions = element(body, "div", { + id: "new-game-match-options", + className: "new-game-options", + }); + const lengths = element(matchOptions, "div", { className: "lengths" }); + lengths.setAttribute("role", "radiogroup"); + element(lengths, "span", { text: MATCH_TEXTS.lengthBefore }); + const lengthButtons = MATCH_LENGTHS.map((n) => { + const button = radio(lengths, `new-game-length-${n}`, "length", String(n)); + button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n)); + button.addEventListener("click", () => options.change({ matchLength: n })); + return button; + }); + element(lengths, "span", { text: MATCH_TEXTS.lengthAfter }); + rule( + matchOptions, + "new-game-crawford", + MATCH_TEXTS.crawfordRule, + MATCH_TEXTS.crawfordLink, + "regle-de-crawford", + ); + const freeOptions = element(body, "div", { + id: "new-game-free-options", + className: "new-game-options", + }); + rule( + freeOptions, + "new-game-jacoby", + MATCH_TEXTS.jacobyRule, + MATCH_TEXTS.jacobyLink, + "regle-de-jacoby", + ); + + // 2. Niveau de l'ordinateur : six barres de hauteur croissante. + element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle }); + const bars = element(body, "div", { className: "level-bars" }); + bars.setAttribute("role", "radiogroup"); + bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle); + const levelButtons = COMPUTER_LEVELS.map((level, i) => { + const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`); + button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name); + button.dataset.level = level; + element(button, "span", { className: "level-bar" }); + button.addEventListener("click", () => options.change({ level })); + return button; + }); + const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" }); + const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" }); + + // 3. Pendant la partie. + element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame }); + const advice = element(body, "button", { id: "new-game-advice", className: "item" }); + advice.type = "button"; + advice.setAttribute("role", "switch"); + const adviceLabel = element(advice, "span", { className: "item-label" }); + element(adviceLabel, "span", { text: MATCH_TEXTS.advice }); + element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail }); + element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true"); + advice.addEventListener("click", () => + options.change({ masterAdvice: !options.settings().masterAdvice }), + ); + + // 4. Barre d'action collée en bas. + const actions = element(section, "div", { className: "new-game-actions" }); + const start = element(actions, "button", { id: "new-game-start" }); + start.type = "button"; + start.addEventListener("click", () => options.start()); + element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept }); + + return { + closeLink, + render(busy) { + const s = options.settings(); + const isMatch = s.gameKind === "match"; + free.setAttribute("aria-checked", String(!isMatch)); + match.setAttribute("aria-checked", String(isMatch)); + matchOptions.hidden = !isMatch; + freeOptions.hidden = isMatch; + for (const [i, button] of lengthButtons.entries()) { + button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength)); + } + const chosen = COMPUTER_LEVELS.indexOf(s.level); + for (const [i, button] of levelButtons.entries()) { + button.setAttribute("aria-checked", String(i === chosen)); + button.classList.toggle("reached", i <= chosen); + } + levelName.textContent = MATCH_TEXTS.levels[s.level].name; + levelText.textContent = MATCH_TEXTS.levels[s.level].text; + advice.setAttribute("aria-checked", String(s.masterAdvice)); + start.textContent = isMatch + ? MATCH_TEXTS.startMatch(s.matchLength) + : MATCH_TEXTS.startGame; + start.disabled = busy; + }, + focus() { + (section.querySelector("[aria-checked='true']") ?? start).focus(); + }, + }; +} 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 e1097ef..dbfb7f7 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")) { @@ -185,7 +181,7 @@ const shownNow = (otherBanner = false) => Given("vous avez des dés à jouer", () => { // 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.equal(game.state.phase, "moving"); }); 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/etape-1.md b/docs/etape-1.md index 715c358..7dd86f3 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -19,7 +19,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001 | --- | --- | --- | | 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — | | 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — | -| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b | +| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b | | 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a | | 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d | | 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e | 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/plans/2026-11-03-match-et-reglages.md b/docs/plans/2026-11-03-match-et-reglages.md deleted file mode 100644 index b99fd68..0000000 --- a/docs/plans/2026-11-03-match-et-reglages.md +++ /dev/null @@ -1,47 +0,0 @@ -# Match, niveaux et réglages (1c) - -- **Date** : 2026-11-03 -- **Étape** : 1c (`docs/etape-1.md`) -- **Statut** : prévu (après la révision UX et 1b) -- **Exécutant** : sous-agent general-purpose, dans un worktree - -## Objectif - -Choisir sa partie : partie libre ou match en N points, avec les règles de -Crawford et de Jacoby, contre l'ordinateur au niveau voulu. - -## Consignes - -ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg -(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C. - -1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford - et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en - partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le - journal d'événements reste rétrocompatible (les parties existantes se - rechargent). -2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford) - ou remplacé par le Match ID, sans casser les appels existants. -3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau - choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de - déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double - facultatif à 0 pli, avec citation des sources. -4. **Écran de nouvelle partie** (maquettes de la session « identité » si - disponibles, sinon sobre et conforme à la charte) : type de partie, niveau - (noms montrés au joueur à proposer simplement : Débutant, Amateur, - Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux - de gnubg documentée), conseil du maître activé ou non. Par défaut : partie - libre, Intermédiaire, conseil activé. Le réglage est mémorisé. -5. Affichage du score de match et de la partie Crawford sur le plateau. -6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés, - Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si - absentes). `make lint`, `make test`, `make test-browser` (un match court). - -## Critères de réussite - -Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford -appliqué ; les trois commandes passent. - -## Résultat - -À remplir à la clôture. 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 diff --git a/engine-cli/main.c b/engine-cli/main.c index 38ac0c2..bd05cda 100644 --- a/engine-cli/main.c +++ b/engine-cli/main.c @@ -19,9 +19,19 @@ * vérifie des demi-coups saisis un par * un (ex. « 8/5 6/5 », « bar/22 », * « 6/off ») - * matchid VIDEAU PROPRIÉTAIRE Match ID d'une partie libre avec la - * règle Jacoby, joueur 0 au trait ; + * matchid VIDEAU PROPRIÉTAIRE [LONGUEUR SCORE0 SCORE1 CRAWFORD] + * Match ID joueur 0 au trait : partie + * libre avec la règle Jacoby, ou match + * en LONGUEUR points (CRAWFORD : 1 + * pour la partie Crawford, sinon 0) ; * PROPRIÉTAIRE : -1 (centre), 0 ou 1 + * double POSITION_ID MATCH_ID (avec --level) l'ordinateur au trait + * double-t-il ? (ComputerTurn de gnubg) + * take POSITION_ID MATCH_ID (avec --level) l'ordinateur prend-il + * le double du joueur au trait ? + * aftergame MATCH_ID GAGNANT POINTS le match après une partie : score, + * fin du match, partie Crawford + * suivante * reference POSITION_ID MATCH_ID [DÉS] * un niveau au format des positions de * référence (tests/reference/README.md) : @@ -42,8 +52,9 @@ * * --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint », * analyses comme l'analyse de partie de gnubg). - * --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves - * et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris. + * --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves, + * cube, double et take : coups classés et videau comme l'ordinateur de gnubg + * les joue, bruit compris. * --data : dossier des données ; par défaut « data » à côté du programme. * * En WebAssembly (engine-wasm/), le même programme sert d'interface : le @@ -71,13 +82,13 @@ /* Liste des commandes, pour les messages d'erreur. */ #define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \ - "levels, analysemove, analysecube ou luck" + "levels, analysemove, analysecube, luck, double, take ou aftergame" /* Coups gardés dans le classement de la commande reference (comme hint). */ #define REFERENCE_MAX_MOVES 10 /* Arguments de la ligne de commande, une fois lus. */ -#define MAX_ARGS 4 +#define MAX_ARGS 6 typedef struct { int ply; @@ -125,6 +136,8 @@ status_code(engine_status status) return "invalid_move"; case ENGINE_ERROR_CUBE_ACTION: return "invalid_cube_action"; + case ENGINE_ERROR_RESULT: + return "invalid_result"; } return "unknown_error"; } @@ -475,24 +488,45 @@ run_half_moves_command(const options *opt) return EXIT_SUCCESS; } -/* matchid : Match ID d'une partie libre avec la règle Jacoby, joueur 0 au - * trait. */ +/* Lit un entier ; renvoie 0 si le texte n'en est pas un. */ +static int +parse_int(const char *text, int *value) +{ + char *end; + + *value = (int) strtol(text, &end, 10); + return !*end && end != text; +} + +/* matchid : Match ID joueur 0 au trait, sans dés. Partie libre avec la + * règle Jacoby, ou match (LONGUEUR SCORE0 SCORE1 CRAWFORD, ce dernier à 0 + * ou 1), sans Jacoby. */ static int run_match_id_command(const options *opt) { engine_match match = { .cube_value = 1, .cube_owner = -1, .jacoby = 1 }; char match_id[13]; - char *end; engine_status status; - if (opt->arg_count != 2) - return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE"); - match.cube_value = (int) strtol(opt->args[0], &end, 10); - if (*end || end == opt->args[0]) + if (opt->arg_count != 2 && opt->arg_count != 6) + return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE " + "[LONGUEUR SCORE0 SCORE1 CRAWFORD]"); + if (!parse_int(opt->args[0], &match.cube_value)) return usage_error("VIDEAU attend un nombre"); - match.cube_owner = (int) strtol(opt->args[1], &end, 10); - if (*end || end == opt->args[1]) + if (!parse_int(opt->args[1], &match.cube_owner)) return usage_error("PROPRIÉTAIRE attend -1, 0 ou 1"); + if (opt->arg_count == 6) { + if (!parse_int(opt->args[2], &match.match_length) || match.match_length < 1 + || !parse_int(opt->args[3], &match.score[0]) || match.score[0] < 0 + || !parse_int(opt->args[4], &match.score[1]) || match.score[1] < 0 + || match.score[0] >= match.match_length || match.score[1] >= match.match_length) + return usage_error("LONGUEUR attend un nombre positif, SCORE0 et SCORE1 des " + "scores inférieurs à la longueur"); + if (!parse_int(opt->args[5], &match.crawford) || match.crawford < 0 + || match.crawford > 1) + return usage_error("CRAWFORD attend 0 ou 1"); + match.jacoby = 0; + } status = engine_encode_match(&match, match_id); if (status != ENGINE_OK) @@ -503,8 +537,67 @@ run_match_id_command(const options *opt) return EXIT_SUCCESS; } +/* double et take (avec --level) : le videau de l'ordinateur, comme + * ComputerTurn de gnubg (voir engine.h). */ +static int +run_level_cube_command(const options *opt) +{ + const char *position_id = opt->args[0], *match_id = opt->args[1]; + engine_status status; + + if (opt->arg_count != 2) + return usage_error("arguments attendus : POSITION_ID MATCH_ID"); + if (opt->level < 0) + return usage_error("double et take demandent --level"); + if (strcmp(opt->command, "double") == 0) { + engine_level_double d; + status = engine_level_double_decision(position_id, match_id, opt->level, &d); + if (status != ENGINE_OK) + return print_engine_error(status); + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id); + print_level_or_ply(opt); + printf(", \"available\": %s, \"market_window\": %s, \"double\": %s, \"decision\": ", + d.available ? "true" : "false", d.market_window ? "true" : "false", + d.doubles ? "true" : "false"); + if (d.decision) + printf("\"%s\"}\n", d.decision); + else + printf("null}\n"); + } else { + int takes; + const char *decision; + status = engine_level_take_decision(position_id, match_id, opt->level, &takes, &decision); + if (status != ENGINE_OK) + return print_engine_error(status); + printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id); + print_level_or_ply(opt); + printf(", \"take\": %s, \"decision\": \"%s\"}\n", takes ? "true" : "false", decision); + } + return EXIT_SUCCESS; +} + +/* aftergame : le match après une partie (voir engine_match_after_game). */ +static int +run_after_game_command(const options *opt) +{ + engine_match_after after; + engine_status status; + int winner, points; + + if (opt->arg_count != 3) + return usage_error("arguments attendus : MATCH_ID GAGNANT POINTS"); + if (!parse_int(opt->args[1], &winner) || !parse_int(opt->args[2], &points)) + return usage_error("GAGNANT et POINTS attendent des nombres"); + status = engine_match_after_game(opt->args[0], winner, points, &after); + if (status != ENGINE_OK) + return print_engine_error(status); + printf("{\"score\": [%d, %d], \"match_over\": %s, \"crawford\": %s}\n", after.score[0], + after.score[1], after.match_over ? "true" : "false", after.crawford ? "true" : "false"); + return EXIT_SUCCESS; +} + /* - * reference : un niveau d'un fichier de résultat de tests/reference/, de + * reference :un niveau d'un fichier de résultat de tests/reference/, de * même structure que plies[""] (voir tests/reference/README.md). */ static int @@ -731,8 +824,9 @@ parse_options(int argc, char **argv, options *opt) if (opt->level >= 0 && has_ply) return "--ply et --level ne vont pas ensemble"; if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0 - && strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0) - return "--level ne s'applique qu'à bestmove, moves et cube"; + && strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0 + && strcmp(opt->command, "double") != 0 && strcmp(opt->command, "take") != 0) + return "--level ne s'applique qu'à bestmove, moves, cube, double et take"; return NULL; } @@ -772,6 +866,10 @@ main(int argc, char **argv) result = run_half_moves_command(&opt); else if (strcmp(opt.command, "matchid") == 0) result = run_match_id_command(&opt); + else if (strcmp(opt.command, "double") == 0 || strcmp(opt.command, "take") == 0) + result = run_level_cube_command(&opt); + else if (strcmp(opt.command, "aftergame") == 0) + result = run_after_game_command(&opt); else if (strcmp(opt.command, "levels") == 0) result = run_levels_command(&opt); else if (strcmp(opt.command, "analysemove") == 0) diff --git a/engine/engine.c b/engine/engine.c index 01ed5f5..d038d74 100644 --- a/engine/engine.c +++ b/engine/engine.c @@ -156,6 +156,8 @@ engine_status_message(engine_status status) return "coup joué absent des coups légaux"; case ENGINE_ERROR_CUBE_ACTION: return "décision de videau inconnue"; + case ENGINE_ERROR_RESULT: + return "résultat de partie invalide"; } return "erreur inconnue"; } @@ -667,6 +669,158 @@ engine_level_cube_decision(const char *position_id, const char *match_id, int le return cube_decision(&pos, &context, cube); } +/* -------------------------------------------- Videau de l'ordinateur */ + +/* Valeur maximale du videau (MAX_CUBE dans backgammon.h de gnubg, non + * repris ici). */ +#define MAX_CUBE_VALUE (1 << 12) + +/* La décision de videau de gnubg pour une position, avec ces réglages. */ +static engine_status +find_cube_decision(position *pos, const evalcontext *context, cubedecision *decision) +{ + float output[2][NUM_ROLLOUT_OUTPUTS]; + float equities[NUM_CUBEFUL_OUTPUTS]; + + if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0) + return ENGINE_ERROR_INTERNAL; + *decision = FindCubeDecision(equities, output, &pos->cube); + if ((size_t) *decision >= sizeof cube_decision_names / sizeof cube_decision_names[0]) + return ENGINE_ERROR_INTERNAL; + return ENGINE_OK; +} + +/* Voir engine.h : ComputerTurn, play.c l. 1312-1398. */ +engine_status +engine_level_double_decision(const char *position_id, const char *match_id, int level, + engine_level_double *result) +{ + position pos; + evalcontext context, market_context; + float output[NUM_ROLLOUT_OUTPUTS]; + cubedecision decision; + engine_status status; + int available = 0; + + memset(result, 0, sizeof *result); + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = eval_context_for_level(level, &context); + if (status != ENGINE_OK) + return status; + + /* « if (ms.fCubeUse && !ms.anDice[0] && ms.nCube < MAX_CUBE && GetDPEq(...)) » */ + GetDPEq(&available, NULL, &pos.cube); + if (!available || pos.cube.nCube >= MAX_CUBE_VALUE) + return ENGINE_OK; + result->available = 1; + + /* Fenêtre de marché : évaluation sans videau, un pli de moins. */ + market_context = context; + market_context.fCubeful = FALSE; + if (market_context.nPlies) + market_context.nPlies--; + if (EvaluatePosition(NULL, (ConstTanBoard) pos.board, output, &pos.cube, &market_context)) + return ENGINE_ERROR_INTERNAL; + if (output[0] < GetDoublePointDeadCube(output, &pos.cube)) + return ENGINE_OK; + result->market_window = 1; + + status = find_cube_decision(&pos, &context, &decision); + if (status != ENGINE_OK) + return status; + result->decision = cube_decision_names[decision]; + switch (decision) { + case DOUBLE_TAKE: + case REDOUBLE_TAKE: + case DOUBLE_PASS: + case REDOUBLE_PASS: + case DOUBLE_BEAVER: + result->doubles = 1; + break; + case OPTIONAL_DOUBLE_BEAVER: + case OPTIONAL_DOUBLE_TAKE: + case OPTIONAL_REDOUBLE_TAKE: + case OPTIONAL_DOUBLE_PASS: + case OPTIONAL_REDOUBLE_PASS: + /* « double if 0-ply except when about to lose game » */ + result->doubles = context.nPlies == 0 && output[0] > 0.001f; + break; + default: + /* « better leave cube where it is » */ + break; + } + return ENGINE_OK; +} + +/* Voir engine.h : ComputerTurn, play.c l. 1139-1235. */ +engine_status +engine_level_take_decision(const char *position_id, const char *match_id, int level, + int *takes, const char **decision_name) +{ + position pos; + evalcontext context; + cubedecision decision; + engine_status status; + + status = decode_position(position_id, match_id, &pos); + if (status == ENGINE_OK) + status = eval_context_for_level(level, &context); + if (status == ENGINE_OK) + status = find_cube_decision(&pos, &context, &decision); + if (status != ENGINE_OK) + return status; + switch (decision) { + case DOUBLE_PASS: + case TOOGOOD_PASS: + case REDOUBLE_PASS: + case TOOGOODRE_PASS: + case OPTIONAL_DOUBLE_PASS: + case OPTIONAL_REDOUBLE_PASS: + *takes = 0; + break; + default: + *takes = 1; + break; + } + *decision_name = cube_decision_names[decision]; + return ENGINE_OK; +} + +/* ------------------------------------------------------------------ Match */ + +/* Voir engine.h : play.c l. 303, 1817-1820 et 4277. */ +engine_status +engine_match_after_game(const char *match_id, int winner, int points, + engine_match_after *after) +{ + engine_match m; + engine_status status = engine_decode_match(match_id, &m); + int length, post_crawford; + + if (status != ENGINE_OK) + return status; + if (winner < 0 || winner > 1 || points < 1) + return ENGINE_ERROR_RESULT; + memset(after, 0, sizeof *after); + length = m.match_length; + after->score[0] = m.score[0]; + after->score[1] = m.score[1]; + after->score[winner] += points; + if (length == 0) + return ENGINE_OK; + after->match_over = after->score[winner] >= length; + if (after->match_over) + return ENGINE_OK; + /* Partie Crawford déjà jouée, lue comme gnubg (play.c l. 303). */ + post_crawford = !m.crawford && (m.score[0] == length - 1 || m.score[1] == length - 1); + /* play.c l. 1817-1820. */ + post_crawford |= m.crawford && after->score[winner] < length; + after->crawford = !post_crawford && !m.crawford && after->score[winner] == length - 1 + && after->score[!winner] != length - 1; + return ENGINE_OK; +} + /* -------------------------------------------- Analyse d'une décision de videau */ /* Le plus petit de deux nombres. */ diff --git a/engine/include/engine.h b/engine/include/engine.h index eac37f3..4e99ed4 100644 --- a/engine/include/engine.h +++ b/engine/include/engine.h @@ -51,7 +51,8 @@ typedef enum { ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */ ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */ ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */ - ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */ + ENGINE_ERROR_CUBE_ACTION, /* décision de videau inconnue */ + ENGINE_ERROR_RESULT /* résultat de partie invalide */ } engine_status; #define ENGINE_MAX_PLY 4 @@ -374,6 +375,71 @@ engine_status engine_level_best_moves(const char *position_id, const char *match engine_status engine_level_cube_decision(const char *position_id, const char *match_id, int level, engine_cube *cube); +/* + * Le videau de l'ordinateur à ce niveau, comme le joue gnubg de bureau + * (ComputerTurn dans play.c, l. 1312-1398, « Consider doubling ») : + * - il ne pense à doubler que s'il a accès au videau (GetDPEq : pas en + * partie Crawford, pas avec un videau mort…) et que le videau vaut moins + * de 4096 (MAX_CUBE) ; + * - pré-filtre « fenêtre de marché » : ses chances de gain, évaluées sans + * videau à un pli de moins que le niveau (bruit compris), doivent atteindre + * le point de double à videau mort (GetDoublePointDeadCube) ; sinon il ne + * double pas, sans évaluer la décision complète ; + * - dans la fenêtre, il double sur DOUBLE_TAKE, REDOUBLE_TAKE, DOUBLE_PASS, + * REDOUBLE_PASS et DOUBLE_BEAVER ; sur un double « facultatif » + * (OPTIONAL_*), seulement s'il évalue à 0 pli et que ses chances de gain + * dépassent 0,001 (« double if 0-ply except when about to lose game ») ; + * sinon il garde le videau. + */ +typedef struct { + int available; /* le joueur au trait a accès au videau */ + int market_window; /* le pré-filtre est passé : décision évaluée */ + int doubles; /* l'ordinateur double */ + const char *decision; /* décision complète (noms d'engine_cube.decision) + * si elle a été évaluée, sinon NULL */ +} engine_level_double; + +engine_status engine_level_double_decision(const char *position_id, const char *match_id, + int level, engine_level_double *result); + +/* + * L'ordinateur, adversaire du joueur au trait qui vient de doubler, prend-il + * ? Comme ComputerTurn (play.c, l. 1139-1235, « normal double by + * opponent ») : la décision de videau est évaluée du point de vue de celui + * qui double, avec les réglages du niveau de l'ordinateur ; il passe sur + * DOUBLE_PASS, TOOGOOD_PASS, REDOUBLE_PASS, TOOGOODRE_PASS et les + * OPTIONAL_*_PASS, et prend sinon (Ustagammon ne joue pas les castors : + * gnubg prend aussi quand il ne peut pas en faire). position_id et match_id + * sont ceux du joueur qui double ; *decision reçoit le nom de la décision. + */ +engine_status engine_level_take_decision(const char *position_id, const char *match_id, + int level, int *takes, const char **decision); + +/* ------------------------------------------------------------------ Match */ + +/* Le match après une partie. */ +typedef struct { + int score[2]; /* score après la partie */ + int match_over; /* le gagnant a atteint la longueur du match */ + int crawford; /* la partie suivante est la partie Crawford */ +} engine_match_after; + +/* + * Le match après une partie gagnée par winner (0 ou 1, numérotation du + * Match ID) pour points points. match_id est celui de la partie jouée : sa + * longueur, son score avant la partie et son drapeau Crawford comptent, le + * reste est ignoré. Comme gnubg de bureau à la fin d'une partie (play.c, + * l. 1817-1820, règle de Crawford automatique) : la partie suivante est la + * partie Crawford si le gagnant arrive à un point du but, que son adversaire + * n'y est pas et qu'il n'y a pas encore eu de partie Crawford. Qu'une partie + * Crawford ait déjà eu lieu se lit dans le Match ID comme gnubg le lit + * (play.c, l. 303 et l. 4277) : pas de drapeau Crawford alors qu'un joueur + * est à un point du but. En partie libre (longueur 0), le score s'ajoute + * seulement. ENGINE_ERROR_RESULT si winner ou points sont invalides. + */ +engine_status engine_match_after_game(const char *match_id, int winner, int points, + engine_match_after *after); + /* ------------------------------------------------------------- Analyse */ /* Appréciation de gnubg pour une perte d'équité (0 ou négative). */ diff --git a/tests/browser.sh b/tests/browser.sh index a91a8fa..9d61203 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -4,9 +4,12 @@ # construite en local, comme tests/test_site.sh. Les captures d'écran vont # dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide, # l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs -# captures dans build/browser-shots/accueil-tutoriel/. +# captures dans build/browser-shots/accueil-tutoriel/. Enfin l'écran « Nouvelle +# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs), +# captures dans build/browser-shots/match/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test. +# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) restreignent le +# test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -19,7 +22,7 @@ cd "$(dirname "$0")/.." image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}" browsers="${BROWSERS:-chromium firefox}" # Les scripts à lancer (tests/browser/.mjs), tous par défaut. -scripts="${SCRIPTS:-play accueil}" +scripts="${SCRIPTS:-play accueil match}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -56,7 +59,7 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots -cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -66,13 +69,21 @@ for browser in $browsers; do -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi - [[ " $scripts " == *" accueil "* ]] || continue - # Accueil, tutoriel, aide, installation et mise à jour ; captures de la - # recette dans build/browser-shots/accueil-tutoriel/. - docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ - -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \ - -w /tools "$image" \ - node accueil.mjs "http://localhost:$port/" "$browser" /shots \ - "http://localhost:$update_port/" /site || status=1 + if [[ " $scripts " == *" accueil "* ]]; then + # Accueil, tutoriel, aide, installation et mise à jour ; captures de + # la recette dans build/browser-shots/accueil-tutoriel/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \ + -w /tools "$image" \ + node accueil.mjs "http://localhost:$port/" "$browser" /shots \ + "http://localhost:$update_port/" /site || status=1 + fi + if [[ " $scripts " == *" match "* ]]; then + # Écran « Nouvelle partie » et match en 3 points ; captures de la + # recette dans build/browser-shots/match/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node match.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs new file mode 100644 index 0000000..188b3e9 --- /dev/null +++ b/tests/browser/match.mjs @@ -0,0 +1,366 @@ +// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué +// jusqu'au bout par l'interface, dans un vrai navigateur sans écran +// (Playwright), sur le site servi par l'image de production +// (docs/plans/2026-11-03-match-et-reglages.md, point 6). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node match.mjs +// Lancé par tests/browser.sh. Les dés de l'application viennent de +// Math.random : il est remplacé ici par un générateur à graine, pour que le +// match soit le même à chaque fois (il passe par une partie Crawford). Le +// joueur joue toujours le premier pion jouable vers sa première case +// d'arrivée, prend les doubles et ne double jamais. Captures de la recette +// design (Chromium) dans /match/ : écran « Nouvelle +// partie », score du match et partie Crawford, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node match.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/match`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +// La graine des dés : un match qui passe par une partie Crawford. +const SEED = 7; + +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + // Sans animation : le match va plus vite, rien d'autre ne change. + reducedMotion: "reduce", +}); +await context.addInitScript((seed) => { + let state = seed; + Math.random = () => { + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}, SEED); +const page = await context.newPage(); +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 60_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what}`); +} + +/** Une capture de la recette, dans les deux tailles (Chromium seulement). */ +async function capture(name) { + if (!full) { + return; + } + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(200); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(200); +} + +/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */ +async function todo() { + for (let i = 0; i < 600; i++) { + const what = await page.evaluate(() => { + if (document.body.classList.contains("busy")) { + return null; + } + const panel = document.getElementById("panel-veil"); + if (!panel.hidden) { + return panel.dataset.kind; + } + const usable = (id) => { + const b = document.getElementById(id); + return !b.hidden && !b.disabled; + }; + for (const id of ["take", "skip", "confirm", "roll"]) { + if (usable(id)) { + return id; + } + } + return document.querySelector(".hit.can-move") ? "move" : null; + }); + if (what !== null) { + return what; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`); +} + +/** Le match tel que l'écran le montre (portrait). */ +async function shownMatch() { + return page.evaluate(() => ({ + line: document.getElementById("match-line").textContent, + lineShown: !document.getElementById("match-line").hidden, + spoken: document.getElementById("match-line").getAttribute("aria-label"), + computer: document.querySelector("#strip-computer .match-score").textContent, + player: document.querySelector("#strip-player .match-score").textContent, + cube: document.querySelectorAll("#board .cube").length, + double: !document.getElementById("double").hidden, + })); +} + +// ---------------------------------------------------- Écran « Nouvelle partie » + +await page.goto(`${base}#/nouvelle-partie`); +await until("moteur chargé", () => !document.getElementById("new-game-start").disabled); +const screen = () => + page.evaluate(() => ({ + free: document.getElementById("new-game-free").getAttribute("aria-checked"), + match: document.getElementById("new-game-match").getAttribute("aria-checked"), + lengthsShown: !document.getElementById("new-game-match-options").hidden, + jacobyShown: !document.getElementById("new-game-free-options").hidden, + level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level, + reached: document.querySelectorAll(".level-bars .reached").length, + name: document.getElementById("new-game-level-name").textContent, + advice: document.getElementById("new-game-advice").getAttribute("aria-checked"), + start: document.getElementById("new-game-start").textContent, + topbar: document.querySelector(".topbar").getClientRects().length > 0, + game: !document.getElementById("game").hidden, + })); +let s = await screen(); +check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown, + `par défaut, partie libre : ${JSON.stringify(s)}`); +check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire", + `par défaut, Intermédiaire : ${JSON.stringify(s)}`); +check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`); +check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie"); +check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby", + "lien vers la règle de Jacoby"); +// Les cibles tactiles : 44 px au moins. +const small = await page.evaluate(() => + [...document.querySelectorAll("#new-game button, #new-game a")] + .filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null) + .map((el) => el.getBoundingClientRect()) + .filter((r) => r.width < 44 || r.height < 44).length, +); +check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`); + +await page.click("#new-game-match"); +await page.click("#new-game-length-3"); +await page.click("#new-game-level-beginner"); +s = await screen(); +check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points", + `match en 3 points : ${JSON.stringify(s)}`); +check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`); +check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford", + "lien vers la règle de Crawford"); +check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"), + "description du niveau Débutant"); +await capture("1-nouvelle-partie"); +// Fermer ne change rien : on revient à la partie, et les choix sont gardés. +const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings")); +await page.click("#new-game-close"); +await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden); +const settings = JSON.parse(before); +check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner", + `choix gardés : ${before}`); +// Le menu y ramène, avec les choix de la dernière fois. +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +await page.click("#menu-new-game"); +await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" && + !document.getElementById("new-game").hidden); +s = await screen(); +check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points", + `choix retrouvés : ${JSON.stringify(s)}`); +await page.click("#new-game-start"); +await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy")); +log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés"); + +// ------------------------------------------------------------- Le match + +let m = await shownMatch(); +check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0", + `début du match : ${JSON.stringify(m)}`); +check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`); + +// Un match ne passe pas toujours par une partie Crawford (un gammon peut +// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les +// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus). +let games = 1; +let crawfordGames = 0; +let scoreShot = false; +let crawfordShot = false; +let reloaded = false; +let actions = 0; +let matches = 1; +for (;;) { + check(actions++ < 20000, "le match ne finit pas"); + const what = await todo(); + const now = await shownMatch(); + const crawford = now.line.endsWith("· partie Crawford"); + if (crawford && what !== "over") { + check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`); + } + if (what === "over") { + const title = await page.textContent("#panel-text"); + const detail = await page.textContent("#panel-detail"); + const primary = await page.textContent("#panel-primary"); + log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`); + if (primary === "Nouvelle partie") { + check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"), + `fin du match : ${title} ; ${detail}`); + const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? []; + check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`); + // Paysage : le score dans la colonne de commandes. + await page.setViewportSize(LANDSCAPE); + await sleep(200); + const box = await page.evaluate(() => + document.querySelector("#board .match-box")?.getAttribute("aria-label")); + check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""), + `score en paysage : « ${box} »`); + await page.setViewportSize(PORTRAIT); + await sleep(200); + // Après le match, « Nouvelle partie » mène à l'écran des choix. + await page.click("#panel-primary"); + await until("écran « Nouvelle partie » après le match", () => + location.hash === "#/nouvelle-partie"); + log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`); + if (crawfordGames > 0 && scoreShot) { + break; + } + check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`); + matches++; + games = 1; + await page.click("#new-game-start"); + await until("match suivant", () => location.hash === "#/" && + !document.body.classList.contains("busy")); + continue; + } + check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail), + `fin de partie dans un match : ${primary} ; ${detail}`); + await page.click("#panel-primary"); + games++; + await until("partie suivante", () => !document.body.classList.contains("busy")); + const next = await shownMatch(); + if (next.line.endsWith("· partie Crawford")) { + crawfordGames++; + check(next.cube === 0, "partie Crawford : le videau est dessiné"); + if (!crawfordShot) { + crawfordShot = true; + await capture("3-partie-crawford"); + } + } else if (!scoreShot) { + scoreShot = true; + await capture("2-score-du-match"); + } + if (!reloaded) { + // Le match continue après un rechargement de la page. + reloaded = true; + // L'application enregistre sans attendre : on laisse IndexedDB + // finir d'écrire la nouvelle partie (Firefox peut être lent). + await until("partie suivante enregistrée", (score) => new Promise((resolve) => { + const open = indexedDB.open("ustagammon"); + open.onerror = () => resolve(false); + open.onsuccess = () => { + const tx = open.result.transaction(["settings", "games"]); + const current = tx.objectStore("settings").get("current"); + current.onsuccess = () => { + const id = current.result?.value; + if (typeof id !== "string") { + resolve(false); + return; + } + const summary = tx.objectStore("games").get(id); + summary.onsuccess = () => { + const s = summary.result; + resolve(s?.status === "playing" && + `${s.match?.score.computer}-${s.match?.score.player}` === score); + open.result.close(); + }; + }; + }; + }), `${next.computer}-${next.player}`).catch(async (error) => { + const stored = await page.evaluate(() => new Promise((resolve) => { + const open = indexedDB.open("ustagammon"); + open.onerror = () => resolve(`IndexedDB : ${open.error}`); + open.onsuccess = () => { + const all = open.result.transaction(["settings", "games"]); + const current = all.objectStore("settings").get("current"); + const games = all.objectStore("games").getAll(); + games.onsuccess = () => resolve(JSON.stringify({ + current: current.result, + games: games.result.map((g) => [g.id, g.status, g.match, g.updatedAt]), + local: Object.keys(localStorage), + })); + }; + })); + throw new Error(`${error.message} ; enregistré : ${stored}`); + }); + await page.reload(); + await until("partie reprise", () => !document.body.classList.contains("busy")); + const again = await shownMatch(); + check(again.line === next.line && again.player === next.player && again.computer === next.computer, + `après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`); + } + continue; + } + // Les doubles de l'ordinateur sont passés : le match avance d'un point + // à la fois, ce qui rend la partie Crawford probable. + if (what === "doubled") { + await page.click("#panel-secondary"); + } else if (what === "take") { + await page.click("#drop"); + } else if (what === "skip" || what === "confirm" || what === "roll") { + await page.click(`#${what}`); + } else if (what === "move") { + await page.click(".hit.can-move"); + if ((await page.locator(".hit.can-land").count()) > 0) { + await page.click(".hit.can-land"); + } + } +} +log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` + + `${actions} actions`); + +const csp = await page.evaluate(() => window.__csp); +check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`); +await browser.close(); +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} +log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 7a45b2e..e54373d 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -923,8 +923,7 @@ async function checkAddresses() { for (const selector of ["#menu-games", "#menu-help"]) { check(await isLink(tab, selector), `${selector} n'est pas un lien`); } - check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1, - "« Nouvelle partie » n'est plus un bouton"); + check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien"); await tab.goBack(); await expectScreen(tab, "retour : menu fermé", playing); await tab.click("#menu-button"); @@ -985,8 +984,20 @@ async function openMenu() { check(await page.isVisible("#menu"), "le menu ne s'ouvre pas"); } -/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie - * le journal de la partie quittée. */ +/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie + * choisie, et l'adresse redevient celle de la partie. */ +async function startChosenGame() { + await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {}); + check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas"); + const hash = await page.evaluate(() => location.hash); + check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`); + await page.click("#new-game-start"); + check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")), + "« Commencer » ne ramène pas à la partie"); +} + +/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet + * immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */ async function newGameFromMenu() { const before = await savedJournal(); const started = () => @@ -995,6 +1006,7 @@ async function newGameFromMenu() { await openMenu(); const start = Date.now(); await page.click("#menu-new-game"); + await startChosenGame(); check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »"); check((await page.textContent("#banner")).includes("Reprendre l'ancienne"), `bandeau : « ${await page.textContent("#banner")} »`); @@ -1558,6 +1570,7 @@ step("mes parties"); { const finished = await savedJournal(); await page.click("#again"); + await startChosenGame(); await waitForPlayer(); const second = await savedJournal(); check(second.head !== finished.head || second.events.length !== finished.events.length, @@ -1639,6 +1652,7 @@ log("compte de points : bulle avec la fiche du glossaire"); step("thème sombre"); await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); +await startChosenGame(); await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); diff --git a/tests/test_engine.sh b/tests/test_engine.sh index d13b4d2..22af1ab 100644 --- a/tests/test_engine.sh +++ b/tests/test_engine.sh @@ -259,6 +259,62 @@ grep -qF '"analysed": false, "cube": null, "equity_loss": 0, "skill": "none"' \ <<<"$($cli analysecube "$course" "$(valeur match_id "$($cli matchid 2 1)")" no_double)" \ || echouer "analysecube, videau chez l'adversaire : pas d'analyse attendue" +# 7. Match et videau de l'ordinateur (étape 1c). + +# Match ID d'un match : longueur, score et Crawford relus à l'identique. +sortie="$($cli matchid 1 -1 5 4 2 1)" +grep -qF '"match_length": 5, "score": [4, 2], "cube_value": 1, "cube_owner": -1, "player_on_roll": 0, "crawford": true, "jacoby": false' \ + <<<"$sortie" || echouer "matchid d'un match mal codé : $sortie" +[ "$(valeur match_id "$sortie")" = "$(valeur match_id "$($cli matchid 1 -1 5 4 2 1)")" ] \ + || echouer "matchid d'un match : pas reproductible" +attendre_erreur usage matchid 1 -1 3 3 0 0 +attendre_erreur usage matchid 1 -1 3 0 0 2 + +# Le match après une partie (règle de Crawford de gnubg, play.c). +apres() { + local id + id="$(valeur match_id "$($cli matchid 1 -1 "$1" "$2" "$3" "$4")")" + $cli aftergame "$id" "$5" "$6" +} +while read -r longueur s0 s1 drapeau gagnant points attendu; do + sortie="$(apres "$longueur" "$s0" "$s1" "$drapeau" "$gagnant" "$points")" + grep -qF "$attendu" <<<"$sortie" \ + || echouer "aftergame $longueur $s0-$s1 crawford $drapeau, $gagnant gagne $points : $sortie" +done <<-'EOF' + 3 0 0 0 0 2 {"score": [2, 0], "match_over": false, "crawford": true} + 3 0 0 0 1 1 {"score": [0, 1], "match_over": false, "crawford": false} + 3 1 1 0 0 1 {"score": [2, 1], "match_over": false, "crawford": true} + 3 2 0 1 1 1 {"score": [2, 1], "match_over": false, "crawford": false} + 3 2 0 1 0 1 {"score": [3, 0], "match_over": true, "crawford": false} + 3 2 1 0 1 1 {"score": [2, 2], "match_over": false, "crawford": false} + 3 2 1 0 1 4 {"score": [2, 5], "match_over": true, "crawford": false} + 5 3 3 0 1 1 {"score": [3, 4], "match_over": false, "crawford": true} + 5 4 3 0 1 1 {"score": [4, 4], "match_over": false, "crawford": false} +EOF +grep -qF '{"score": [2, 0], "match_over": false, "crawford": false}' \ + <<<"$($cli aftergame "$libre_j0" 0 2)" || echouer "aftergame en partie libre" +attendre_erreur invalid_result aftergame "$libre_j0" 2 1 +attendre_erreur invalid_result aftergame "$libre_j0" 0 0 + +# L'ordinateur double (ComputerTurn de gnubg) : course gagnée, double et +# passe ; partie Crawford, videau inaccessible ; position perdue, hors de la +# fenêtre de marché. +grep -qF '"available": true, "market_window": true, "double": true, "decision": "DOUBLE_PASS"' \ + <<<"$($cli --level 2ply double "$course" "$libre_j0")" || echouer "double en course gagnée" +grep -qF '"available": false, "market_window": false, "double": false, "decision": null' \ + <<<"$($cli --level 2ply double "$depart" "$crawford")" || echouer "double en partie Crawford" +perdue="$(valeur swapped_position_id "$($cli position "$course" "$libre_j0")")" +grep -qF '"available": true, "market_window": false, "double": false, "decision": null' \ + <<<"$($cli --level beginner double "$perdue" "$libre_j0")" || echouer "double hors fenêtre de marché" +grep -qF '"double": false' <<<"$($cli --level 2ply double "$depart" "$libre_j0")" \ + || echouer "double à la position de départ" +# L'ordinateur répond au double : passe en course perdue, prend au départ. +grep -qF '"take": false, "decision": "DOUBLE_PASS"' \ + <<<"$($cli --level 2ply take "$course" "$libre_j0")" || echouer "take en course perdue" +grep -qF '"take": true' <<<"$($cli --level beginner take "$depart" "$libre_j0")" \ + || echouer "take au départ" +attendre_erreur usage double "$depart" "$libre_j0" + # Chance : même jet, même chance ; ordre des dés indifférent. [ "$($cli luck "$milieu" "$milieu_mid" 64 | sed 's/"dice": \[[0-9], [0-9]\]//')" \ = "$($cli luck "$milieu" "$milieu_mid" 46 | sed 's/"dice": \[[0-9], [0-9]\]//')" ] \ diff --git a/web/app.css b/web/app.css index a8ddcf9..f23d9fb 100644 --- a/web/app.css +++ b/web/app.css @@ -2260,6 +2260,251 @@ html.landscape .welcome { background: none; } +/* ------------------------------------------------------------ Match */ + +/* « Match en 5 points » sous la barre haute (portrait), hauteur fixe. */ +.match-line { + flex: none; + height: 20px; + text-align: center; +} + +/* Le score de chaque camp, dans son bandeau, juste avant son compte de + * points : une pastille carrée. */ +.strip .match-score { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 4px; + font-weight: 700; + font-variant-numeric: tabular-nums lining-nums; + border: 2px solid var(--border-strong); + border-radius: var(--radius-sm); +} + +/* Paysage : le score dans la colonne de commandes, sous le menu. */ +.match-box-frame { + fill: none; + stroke: var(--point-number); + stroke-opacity: 0.5; + stroke-width: 1.5; +} + +.match-box-score { + font-weight: 700; + fill: var(--point-number); +} + +.match-box-of { + fill: var(--point-number); +} + +/* ------------------------------------------------- Nouvelle partie */ + +/* La page remplit l'écran : la barre d'action reste collée en bas, même + * quand le contenu est plus court que l'écran. */ +.new-game { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + max-width: var(--page-width); + margin: 0 auto; +} + +.new-game .new-game-body { + flex: 1; +} + +.new-game-head { + display: flex; + gap: var(--space-2); + align-items: center; + min-height: 60px; +} + +.new-game-head h2 { + margin: 0; + font-size: 22px; + line-height: 28px; +} + +.new-game-body { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-bottom: var(--space-6); +} + +.new-game-body .overline { + margin-top: var(--space-4); +} + +/* Sélecteur à deux segments : fond creusé, segment choisi en relief. */ +.segmented { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-1); + padding: var(--space-1); + background: var(--surface-sunken); + border-radius: var(--radius-pill); +} + +.segmented .segment { + color: var(--text); + background: transparent; + border-color: transparent; +} + +.segmented .segment[aria-checked="true"] { + background: var(--surface); + box-shadow: var(--shadow-raised); +} + +.new-game-options { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.lengths { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} + +.lengths .length { + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + padding: 0; + color: var(--text); + background: transparent; + border-color: var(--border-strong); + border-radius: var(--radius-sm); +} + +.lengths .length[aria-checked="true"] { + color: var(--on-accent); + background: var(--accent); + border-color: var(--accent); +} + +.new-game-rule { + margin: 0; + font-size: 15px; + line-height: 21px; + color: var(--muted); +} + +/* Six barres de hauteur croissante ; celles jusqu'au niveau choisi sont en + * brique, le niveau choisi porte l'anneau de focus. */ +.level-bars { + display: flex; + gap: var(--space-2); + align-items: flex-end; +} + +.level-bars .level { + display: flex; + flex: 1; + align-items: flex-end; + min-width: 44px; + height: 84px; + padding: 4px; + background: transparent; + border: 0; + border-radius: var(--radius-sm); +} + +.level-bar { + display: block; + width: 100%; + background: var(--border); + border-radius: 4px; +} + +.level-1 .level-bar { height: 18px; } +.level-2 .level-bar { height: 28px; } +.level-3 .level-bar { height: 38px; } +.level-4 .level-bar { height: 50px; } +.level-5 .level-bar { height: 62px; } +.level-6 .level-bar { height: 76px; } + +.level-bars .level.reached .level-bar { + background: var(--accent); +} + +.level-bars .level[aria-checked="true"] { + outline: 3px solid var(--focus); + outline-offset: 0; +} + +.level-name { + margin: 0; + font-size: 22px; + line-height: 28px; + font-weight: 700; +} + +.level-text { + margin: 0; + color: var(--muted); +} + +.new-game-body .item { + display: flex; + gap: 14px; + align-items: center; + width: 100%; + min-height: 60px; + padding: 0; + font-weight: 500; + text-align: left; + color: var(--text); + background: transparent; + border: 0; + border-radius: 0; +} + +.new-game-body .item-label { + display: flex; + flex-direction: column; +} + +.new-game-body .item small { + font-size: 15px; + line-height: 20px; + font-weight: 400; + color: var(--muted); +} + +/* Barre d'action collée en bas : le seul bouton principal de l'écran. */ +.new-game-actions { + position: sticky; + bottom: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); + align-items: stretch; + padding: var(--space-3) 0 var(--space-4); + background: var(--background); + border-top: 1px solid var(--border); +} + +.new-game-kept { + margin: 0; + font-size: 13px; + line-height: 18px; + text-align: center; + color: var(--muted); +} + /* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la * hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions * dans une colonne à droite. */ diff --git a/web/index.html b/web/index.html index d2dc71b..f885694 100644 --- a/web/index.html +++ b/web/index.html @@ -39,10 +39,13 @@
+ +
+
@@ -57,6 +60,7 @@ +
@@ -89,6 +93,10 @@ + + +
- +