Compare commits

...
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 74693c4277 Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »
- Paysage : pendant la partie Crawford, la consigne de la bande centrale
  commence par « Partie Crawford · » ; la police se resserre si besoin.
- Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la
  barre d'action (type de partie et longueur à gauche, niveau et conseil
  du maître à droite), tout tient en 915x412 et en 844x320.
- Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes
  réglages (un match de même longueur après un match), « Partie suivante »
  dans un match qui continue, lien « Changer les réglages ».
- Recette automatique : aucun élément interactif visible sous une barre
  d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs).
- Scénario « Comment rejouer avec les mêmes réglages ? ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:03:59 +01:00
Pierre MARTINandClaude Opus 5.5 67bd207337 Fusion d'origin/main (MR !44) ; 1c faite, plan clos
Conflits : l'aide garde l'approche de main (défilement sur la carte du
terme) ; styles des deux côtés conservés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:14:58 +01:00
Pierre MARTINandClaude Opus 5.5 dd18a0cc21 Fusion de la MR !44 : accueil, tutoriel des règles, aide en questions, installation et mise à jour (0e.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:44 +01:00
Pierre MARTINandClaude Opus 5.5 1e9dfbb74b Aide : le terme ouvert s'arrête sous la barre ; test du match : attendre l'enregistrement avant de recharger
Après la fusion de main, l'aide amenait la carte du terme en haut alors
que la marge de défilement est posée sur son titre (dt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:03:17 +01:00
Pierre MARTINandClaude Opus 5.5 414dfa79f8 Plan 1c : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:36 +01:00
Pierre MARTINandClaude Opus 5.5 4357f7e3f2 Fusion d'origin/main dans match-et-reglages
Conflits résolus dans web/app.css (styles ajoutés des deux côtés) et
app/src/ui/help.ts (commentaire de main, fiche de la branche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:32:07 +01:00
Pierre MARTINandClaude Opus 5.5 85bdf4bb93 Interface : écran Nouvelle partie, score du match et partie Crawford
Écran #/nouvelle-partie (type de partie, niveau, conseil du maître,
choix gardés), score du match en portrait et en paysage, videau caché
pendant la partie Crawford, « Partie suivante » ; test navigateur d'un
match en 3 points jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:26 +01:00
Pierre MARTINandClaude Opus 5.5 809b5ec832 Fusion de la MR !47 : décisions de Pierre du 2026-11-09, journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:16 +01:00
Pierre MARTINandClaude Opus 5.5 1eeb6c95ae Décisions de Pierre du 2026-11-09 ; journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:12:54 +01:00
Pierre MARTINandClaude Opus 5.5 339d699488 Domaine : match, niveau de l'ordinateur, événements en version 2
Partie de match (score et Crawford dits par le moteur), niveau choisi
porté par GameStarted, ordinateur joué comme ComputerTurn de gnubg ;
migration des journaux de la version 1 ; scénarios, glossaire, ADR 0007
et 0013 amendés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:07:21 +01:00
Pierre MARTINandClaude Opus 5.5 c0f827c5ba Moteur : videau de l'ordinateur comme ComputerTurn, match après une partie, Match ID d'un match
Fenêtre de marché et double facultatif à 0 pli (play.c), réponse au
double, règle de Crawford automatique ; commandes double, take et
aftergame d'engine-cli, matchid étendu aux matchs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:35 +01:00
59 changed files with 3321 additions and 329 deletions
+40 -3
View File
@@ -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<CubeDecision>;
/** 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<boolean>;
/** 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<boolean>;
/** 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<EngineMatchAfter>;
/** 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<Luck>;
/** 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<MatchId>;
/** 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;
}
/**
+50 -4
View File
@@ -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;
}
/**
+132 -62
View File
@@ -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 { firstGameOf, 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,29 @@ 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 };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
@@ -182,7 +218,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 +250,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 +296,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 +386,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<void> {
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<void> {
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<void> {
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 +481,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<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
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 +510,7 @@ export class Game {
after: PositionId | null,
): Promise<void> {
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 +518,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<void> {
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<void> {
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 +635,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<GameState, "cube" | "match">, 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,
},
};
}
+50
View File
@@ -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<Record<ComputerLevel, Level>> = {
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;
}
+55
View File
@@ -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<Record<Side, number>>;
readonly crawford: boolean;
}
/** Le match après une partie, tel que le moteur le calcule. */
export interface MatchAfterGame {
readonly score: Readonly<Record<Side, number>>;
/** 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 };
}
+8 -11
View File
@@ -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<PositionLink> {
const onRoll: Side = state.turn ?? "player";
const cube: EngineCube =
state.cube.owner === null
? { value: state.cube.value, owner: "centered" }
: {
value: state.cube.value,
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
};
return { position: state.position, matchId: await engine.encodeMatch(cube) };
return {
position: state.position,
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
};
}
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
+30 -1
View File
@@ -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<Record<Side, number>>;
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,
},
}),
};
}
+10 -3
View File
@@ -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}]`,
+22 -1
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -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);
+41 -4
View File
@@ -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<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// 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,
+7 -10
View File
@@ -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),
+10 -14
View File
@@ -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<Game | null>;
/** 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<Game>;
}
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;
},
+175 -37
View File
@@ -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<unknown>;
/** 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<HTMLElement>(strip, ".who"),
lead: child<HTMLElement>(strip, ".lead"),
score: child<HTMLElement>(strip, ".match-score"),
count: child<HTMLButtonElement>(strip, ".count"),
};
};
@@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void {
menu: byId("menu"),
menuTitle: byId("menu-title"),
menuClose: byId<HTMLButtonElement>("menu-close"),
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
menuNewGame: byId<HTMLAnchorElement>("menu-new-game"),
newGameSection: byId("new-game"),
matchLine: byId("match-line"),
menuGames: byId<HTMLAnchorElement>("menu-games"),
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
@@ -231,6 +245,7 @@ export function startApp(options: AppOptions): void {
panelVeil: byId("panel-veil"),
panelText: byId("panel-text"),
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
banner: byId("banner"),
bannerText: byId("banner-text"),
@@ -304,7 +319,7 @@ export function startApp(options: AppOptions): void {
ui.confirm.textContent = TEXTS.confirm;
label(ui.cancel, TEXTS.cancel, "undo");
ui.skip.textContent = TEXTS.skip;
ui.again.textContent = TEXTS.newGame;
ui.again.textContent = MATCH_TEXTS.replay;
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -510,7 +525,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() ?? [])),
@@ -521,11 +537,16 @@ export function startApp(options: AppOptions): void {
diceAction: action,
canDouble: !busy && canDouble,
band:
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
landscape && !panelShown
? text === null
? TEXTS.gameOver
: bandText(text.short, state.match?.crawford === true)
: null,
controls: landscape ? controls() : null,
});
}
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 +582,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 +595,6 @@ export function startApp(options: AppOptions): void {
ui.skip,
ui.replay,
ui.again,
ui.menuNewGame,
ui.positionPlay,
]) {
button.disabled = busy;
@@ -624,7 +645,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 +666,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<Record<Side, number>>, turn: Side | null): void {
const lead = pipLead(pips);
@@ -681,7 +734,8 @@ 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.panelSettings.hidden = false;
ui.panelVeil.dataset.kind = "over";
} else if (ask) {
overline.textContent = "";
@@ -689,6 +743,7 @@ export function startApp(options: AppOptions): void {
detail.textContent = "";
ui.panelSecondary.textContent = TEXTS.drop;
ui.panelPrimary.textContent = TEXTS.take;
ui.panelSettings.hidden = true;
ui.panelVeil.dataset.kind = "doubled";
}
ui.panelPrimary.disabled = busy;
@@ -736,6 +791,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 +908,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 +993,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 +1022,16 @@ 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 « Rejouer », une nouvelle partie aux mêmes réglages (un match de
* même longueur après un match), tout de suite. */
function afterGame(): void {
if (game !== null) {
newGame(game.nextGame ?? game.rematch);
}
}
function showBanner(journal: GameJournal): void {
@@ -1101,8 +1171,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 +1199,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 +1231,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,9 +1305,16 @@ export function startApp(options: AppOptions): void {
if (ui.panelVeil.dataset.kind === "doubled") {
actions.take();
} else {
newGame();
afterGame();
}
};
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
// le panneau se ferme, le résultat reste sur l'écran de jeu.
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
ui.panelSettings.addEventListener("click", () => {
panelDismissed = true;
render();
});
ui.panelSecondary.onclick = () => {
if (ui.panelVeil.dataset.kind === "doubled") {
actions.drop();
@@ -1250,12 +1329,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 +1382,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 +1391,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 +1484,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 +1714,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 +2130,52 @@ 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 };
}
/** Paysage : la consigne de la bande centrale ; pendant la partie
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
function bandText(prompt: string, crawford: boolean): string {
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
}
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
* qui continue, sinon « Rejouer » (mêmes réglages). */
function afterGameLabel(game: Game): string {
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
}
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
* panneau fermé). */
function resultText(game: Game): string {
+67 -5
View File
@@ -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<Place>;
/** 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);
}
@@ -491,6 +502,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
const width = 6 * g.pointWidth - 24;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
// la police se resserre, sans descendre sous 13 px (charte). Largeur
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
// 0,45 em.
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
add(svg, "rect", {
class: "band-pill",
x,
@@ -506,13 +522,56 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
class: "band-text",
id: "band-text",
x: x + width / 2,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
y: middle + fontSize * 0.35,
"font-size": fontSize,
},
text,
);
}
/**
* 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 +616,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 {
+1
View File
@@ -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",
+99
View File
@@ -0,0 +1,99 @@
// 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<ComputerLevel, { name: string; text: string }>,
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" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** 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
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
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.",
};
+212
View File
@@ -0,0 +1,212 @@
// 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<Settings>) => 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<K extends keyof HTMLElementTagNameMap>(
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 });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(kindColumn, "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(kindColumn, "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(kindColumn, "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(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "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(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "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<HTMLElement>("[aria-checked='true']") ?? start).focus();
},
};
}
+6
View File
@@ -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/<Position ID>/<Match ID> (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
+38 -2
View File
@@ -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";
+5 -5
View File
@@ -52,13 +52,13 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
for (const [seed, level] of [
[1, "0ply"],
[7, "2ply"],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
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;
+209 -16
View File
@@ -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> = {}): 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,181 @@ 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);
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
// 0, au même niveau.
assert.deepEqual(crawford.rematch, {
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
});
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("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
assert.deepEqual(game.rematch, { level: "0ply" });
});
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));
});
+128
View File
@@ -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 });
});
+1
View File
@@ -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 },
+1 -1
View File
@@ -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 };
});
+324
View File
@@ -0,0 +1,324 @@
// É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 { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.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 { MATCH_TEXTS } from "../../src/ui/match-texts.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<void> {
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);
}
});
// ------------------------------------------------------------- Rejouer
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
function engineLevelNamed(name: string) {
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
return ENGINE_LEVELS[level];
}
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.phase, "over");
assert.equal(game.nextGame, null);
});
Given(
"un match en {int} points contre un ordinateur {word} vient de se terminer",
async (n: number, name: string) => {
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
// fini.
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.matchAfter?.over, true);
assert.equal(game.nextGame, null);
},
);
When("vous rejouez", () => {
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
});
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.position, START_POSITION);
assert.equal(game.state.match, null);
assert.equal(game.state.level, engineLevelNamed(name));
});
Then(
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
(n: number, name: string) => {
assert.equal(game.state.position, START_POSITION);
assert.deepEqual(game.state.match, firstGameOf(n));
assert.equal(game.state.level, engineLevelNamed(name));
},
);
// ------------------------------------------------------- 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);
});
+2 -2
View File
@@ -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", () => {
+2 -2
View File
@@ -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<void> {
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;
}
+5 -10
View File
@@ -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);
});
@@ -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");
});
+4 -8
View File
@@ -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);
+1 -1
View File
@@ -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();
+5 -5
View File
@@ -44,7 +44,7 @@ async function main(): Promise<void> {
[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<void> {
// 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<void> {
// 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<void> {
{
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<void> {
{
const game = Game.start(engine, {
dice: seededDice(17),
ply: 0,
level: "0ply",
setup: {
position: "t+0OAADg/wcAQA",
firstTurn: "player",
@@ -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.
+14
View File
@@ -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.
+21 -12
View File
@@ -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 |
@@ -36,21 +36,30 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
## Questions pour Pierre (ne bloquent pas le démarrage)
## Décisions de Pierre (2026-11-09)
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
les règles » qui oriente vers le tutoriel ou la partie.
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
l'étape 1.
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
(fait en 0e.5).
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
mauvais 0,06, très mauvais 0,12 d'équité, `arSkillLevel`), exposés par le
moteur depuis 1a ; à calibrer sur de vraies parties.
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
par un test, et le texte n'affirme rien que le moteur ne confirme.
- **Plein écran par défaut dans l'app installée** : non (pas de
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
des phrases du maître (section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md`).
## Ordre de réalisation
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
sous-étape a son plan dans `docs/plans/` et sa merge request.
+1 -1
View File
@@ -29,7 +29,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Étape | Contenu | État |
| --- | --- | --- |
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
+8 -1
View File
@@ -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)
+15
View File
@@ -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)
+8
View File
@@ -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)
+6 -1
View File
@@ -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)
+2 -1
View File
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
## Comportement
+35
View File
@@ -163,3 +163,38 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
Pierre sur le téléphone.
- Réglage en main des vibrations sur le Fairphone 4.
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
**Fait**
- Retours du premier test sur téléphone, traités et publiés :
- saisie du coup (MR !28) ;
- révision UX arbitrée par la session « identité » (MR !35) ;
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
- Fusionnés :
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
- Décisions techniques :
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
- « Grosse erreur » plutôt que « gaffe » ;
- pas d'indice en V1 ;
- pas de relecture préalable des leçons ;
- pas de plein écran par défaut ;
- seuils de gnubg ;
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
- Nouvelles demandes de Pierre, intégrées aux plans :
- une adresse pour chaque écran ;
- la mise à jour forcée de l'app (0e.5) ;
- un coach bienveillant, mis en avant mais discret.
**Reste à faire**
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
- Étape 1 :
- 1c en cours ;
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
- enfin le plan de test 1j.
+8 -4
View File
@@ -36,10 +36,14 @@ du moteur.
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
permet de le déterminer, sinon rien.
3. **Phrases du maître** : gabarits versionnés en français dans une source
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
elles ne citent que des faits du moteur (coup recommandé, rang, pions
frappés, écart) : aucune explication inventée. Première version à faire
valider par Pierre (ton).
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
le 2026-11-09 : la section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
exclamation, formules qui tournent pour les bons coups, exemples). Un test
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
phrases livrées passent par la session « identité » puis par Pierre pour
la relecture finale.
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
+1 -1
View File
@@ -18,7 +18,7 @@ Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque l
4. La course et le compte de points.
5. Sortir ses pions sans se faire frapper.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
@@ -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.
+1
View File
@@ -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 |
+76
View File
@@ -0,0 +1,76 @@
# 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, par « Changer les réglages ». 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. Le match fini, « Rejouer » en commence
un autre de même longueur.
É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: Comment rejouer avec les mêmes réglages ?
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
avec les mêmes réglages : le même niveau, et après un match, un nouveau
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
« Partie suivante » qui le continue. Pour changer de niveau ou de type
de partie, touchez « Changer les réglages », sous les boutons.
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
Quand vous rejouez
Alors une partie libre commence, contre un ordinateur Expert
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
Quand vous rejouez
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
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
+116 -18
View File
@@ -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["<n>"] (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)
+154
View File
@@ -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. */
+67 -1
View File
@@ -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). */
+23 -12
View File
@@ -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/<nom>.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 tests/browser/recette.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"
+3
View File
@@ -16,6 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
await page.setViewportSize({ width, height });
await sleep(250);
await settle();
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
captures++;
}
+446
View File
@@ -0,0 +1,446 @@
// 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 <adresse du site> <chromium|firefox> <dossier des captures>
// 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 <dossier des captures>/match/ : écran « Nouvelle
// partie » (aussi en 844×320), score du match et partie Crawford, en
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
// caché sous la barre d'action collée (recette.mjs).
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node match.mjs <adresse> <chromium|firefox> <dossier des captures>");
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 };
const SMALL_LANDSCAPE = { width: 844, height: 320 };
// 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 chaque taille (Chromium seulement),
* après la règle commune : rien d'interactif sous la barre d'action. */
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
if (!full) {
return;
}
for (const size of sizes) {
await page.setViewportSize(size);
await sleep(200);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
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");
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
// conseil du maître à droite.
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(200);
const where = `${size.width}x${size.height}`;
const layout = await page.evaluate(() => {
const box = (selector) => {
const r = document.querySelector(selector).getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
};
return {
scroll: document.scrollingElement.scrollHeight,
height: innerHeight,
bar: box(".new-game-actions"),
kinds: box(".segmented"),
lengths: box(".lengths"),
rule: box("#new-game-crawford"),
bars: box(".level-bars"),
name: box("#new-game-level-name"),
text: box("#new-game-level-text"),
advice: box("#new-game-advice"),
};
});
check(layout.scroll <= layout.height + 1,
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
}
for (const part of ["bars", "name", "text", "advice"]) {
check(layout[part].left >= layout.kinds.right,
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
}
}
await page.setViewportSize(PORTRAIT);
await sleep(200);
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
// 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}`);
const settingsLink = await page.evaluate(() => {
const a = document.getElementById("panel-settings");
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
if (primary === "Rejouer") {
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, « Changer les réglages » mène à l'écran des
// choix ; le fermer ramène au résultat, où « Rejouer » relance
// tout de suite un match de même longueur.
await page.click("#panel-settings");
await until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
await page.click("#new-game-close");
await until("retour au résultat du match", () => location.hash === "#/" &&
!document.getElementById("again").hidden);
check((await page.textContent("#again")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
!document.body.classList.contains("busy"));
const again = await shownMatch();
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
`« Rejouer » après le match : ${JSON.stringify(again)}`);
if (crawfordGames > 0 && scoreShot) {
break;
}
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
matches++;
games = 1;
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;
check(next.line === "Match en 3 points · partie Crawford",
`partie Crawford, portrait : « ${next.line} »`);
await page.setViewportSize(LANDSCAPE);
await sleep(200);
const band = await page.evaluate(() => {
const text = document.getElementById("band-text");
const pill = document.querySelector("#board .band-pill");
return {
text: text?.textContent ?? "",
fits: text !== null && pill !== null &&
text.getBBox().width <= pill.getBBox().width - 16,
};
});
check(band.text.startsWith("Partie Crawford · "),
`partie Crawford, paysage : « ${band.text} »`);
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
await page.setViewportSize(PORTRAIT);
await sleep(200);
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/` : ""}`);
+41 -12
View File
@@ -21,6 +21,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
const where = `${width}x${height} (${when})`;
const wrapped = await wrappedButtons(page);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
const seen = await page.evaluate(() => {
const visible = (el) => {
if (el.closest("[hidden]")) {
@@ -809,6 +812,8 @@ async function checkAddresses() {
const where = `${name} ${width}x${height}`;
const wrapped = await wrappedButtons(tab);
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const hidden = await hiddenUnderActionBar(tab);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
if (name === "question" || name === "terme") {
// La barre haute de l'aide reste en vue, et ce qu'on a
// ouvert s'arrête juste sous elle.
@@ -923,8 +928,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 +989,29 @@ 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");
}
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
* partie ». */
async function rematch() {
await page.click("#again");
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
"« Rejouer » ouvre un autre écran que 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 +1020,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")} »`);
@@ -1513,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
(await page.textContent("#panel-primary")) === "Nouvelle partie",
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
(await page.textContent("#panel-primary")) === "Rejouer" &&
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
await review("6-fin-de-partie");
await page.setViewportSize({ width: 844, height: 320 });
await sleep(150);
@@ -1545,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
log(`vibrations demandées : ${(await vibrations()).length}`);
await shot("4-fin-de-partie");
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
// barre d'actions.
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
// d'actions.
await page.click("#panel-secondary");
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
(await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
step("mes parties");
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
@@ -1557,7 +1586,7 @@ step("mes parties");
// recharger la page.
{
const finished = await savedJournal();
await page.click("#again");
await rematch();
await waitForPlayer();
const second = await savedJournal();
check(second.head !== finished.head || second.events.length !== finished.events.length,
@@ -1638,7 +1667,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 rematch();
await waitForPlayer();
await shot("6-sombre");
await page.emulateMedia({ colorScheme: "light" });
+74
View File
@@ -0,0 +1,74 @@
// recette.mjs -- règles de la recette design communes aux tests dans un
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
// capturé.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Les éléments interactifs visibles cachés, même en partie, sous une barre
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
* vraiment, au point du recouvrement.
*
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
*/
export async function hiddenUnderActionBar(target) {
return target.evaluate(() => {
const shown = (el) => {
if (el.closest("[hidden]")) {
return false;
}
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const bars = [...document.querySelectorAll("body *")].filter((el) => {
const style = getComputedStyle(el);
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
});
const describe = (el) =>
el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
} »`;
const hidden = [];
const interactive = document.querySelectorAll(
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
"[tabindex]:not([tabindex='-1'])",
);
for (const bar of bars) {
const b = bar.getBoundingClientRect();
for (const el of interactive) {
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
continue;
}
const r = el.getBoundingClientRect();
if (r.top >= innerHeight) {
continue;
}
const top = Math.max(r.top, b.top);
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
const left = Math.max(r.left, b.left);
const right = Math.min(r.right, b.right);
if (bottom - top <= 1 || right - left <= 1) {
continue;
}
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
if (hit !== null && bar.contains(hit)) {
hidden.push(
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
);
}
}
}
return hidden;
});
}
+56
View File
@@ -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]\]//')" ] \
+367
View File
@@ -835,6 +835,17 @@ button:disabled {
padding: 0 12px;
}
/* Fin de partie : « Changer les réglages », lien secondaire sous les
* boutons. */
.panel-link {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: var(--space-2);
font-weight: 700;
}
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
* barre de temps. */
.banner {
@@ -2260,6 +2271,362 @@ 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);
}
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
.new-game-column {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.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 (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
* deux colonnes au-dessus de la barre d'action, le type de partie à
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
* et la barre d'action tient sur une ligne, le bouton à droite sous le
* pouce. */
html.landscape .new-game {
max-width: 60rem;
}
html.landscape .new-game-head {
min-height: 44px;
}
html.landscape .new-game-head h2 {
font-size: 20px;
line-height: 24px;
}
html.landscape .new-game-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-6);
align-content: start;
padding-bottom: var(--space-2);
}
html.landscape .new-game-column {
gap: var(--space-2);
}
html.landscape .new-game-body .overline {
margin-top: 0;
}
html.landscape .new-game-body .during-game {
display: none;
}
html.landscape .level-bars .level {
height: 48px;
padding: 3px;
}
html.landscape .level-1 .level-bar { height: 10px; }
html.landscape .level-2 .level-bar { height: 17px; }
html.landscape .level-3 .level-bar { height: 24px; }
html.landscape .level-4 .level-bar { height: 30px; }
html.landscape .level-5 .level-bar { height: 36px; }
html.landscape .level-6 .level-bar { height: 42px; }
html.landscape .level-name {
font-size: 18px;
line-height: 22px;
}
html.landscape .level-text {
font-size: 15px;
line-height: 20px;
}
html.landscape .new-game-body .item {
min-height: 44px;
}
html.landscape .new-game-body .item-label {
line-height: 22px;
}
html.landscape .new-game-body .item small {
line-height: 18px;
}
html.landscape .new-game-actions {
flex-direction: row;
gap: var(--space-4);
align-items: center;
justify-content: flex-end;
padding: var(--space-2) 0;
}
html.landscape .new-game-kept {
order: -1;
text-align: right;
}
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
@media (min-height: 380px) {
html.landscape .new-game-column {
gap: var(--space-3);
}
html.landscape .level-bars .level {
height: 64px;
}
html.landscape .level-1 .level-bar { height: 14px; }
html.landscape .level-2 .level-bar { height: 22px; }
html.landscape .level-3 .level-bar { height: 30px; }
html.landscape .level-4 .level-bar { height: 39px; }
html.landscape .level-5 .level-bar { height: 48px; }
html.landscape .level-6 .level-bar { height: 58px; }
}
/* 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. */
+10 -1
View File
@@ -39,10 +39,13 @@
</header>
<main id="game" class="game">
<!-- Dans un match : « Match en 5 points », et la partie Crawford. -->
<p id="match-line" class="match-line overline" hidden></p>
<div id="strip-computer" class="strip">
<span class="mini mini-computer"></span>
<span class="who"></span>
<span class="lead"></span>
<span class="match-score" hidden></span>
<button type="button" class="count"></button>
</div>
<div class="board-box">
@@ -57,6 +60,7 @@
<span class="mini mini-player"></span>
<span class="who"></span>
<span class="lead"></span>
<span class="match-score" hidden></span>
<button type="button" class="count"></button>
</div>
<div class="infoline">
@@ -89,6 +93,10 @@
<section id="home" class="home page" hidden></section>
<section id="tutorial" class="tutorial page" hidden></section>
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
app/src/ui/new-game-view.ts. -->
<section id="new-game" class="new-game page" hidden></section>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
@@ -126,7 +134,7 @@
<h2 id="menu-title" class="sheet-title"></h2>
<button id="menu-close" type="button" class="icon-button"></button>
</div>
<button id="menu-new-game" type="button" class="item"></button>
<a id="menu-new-game" href="#/nouvelle-partie" class="item"></a>
<a id="menu-games" href="#/parties" class="item"></a>
<a id="menu-home" href="#/accueil" class="item"></a>
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
@@ -178,6 +186,7 @@
<button id="panel-secondary" type="button" class="secondary"></button>
<button id="panel-primary" type="button"></button>
</div>
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
</div>
</div>