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 86c0ebfc36 Recette design : tutoriel en paysage, sens du jeu, bandeau de mise à jour
- Sens du jeu (étape 2) : le tracé passe sous les pions et sous les
  numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran.
- Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie
  du jeu en paysage, colonne gauche du cadre), colonne à droite avec la
  barre de progression, le titre, le texte, la consigne et les boutons ;
  seul le texte peut défiler. Tient en 915x412 et 844x320.
- Recette automatique étendue au tutoriel : numéros d'au moins 12 px,
  page sans défilement et boutons en vue en paysage, captures en 844x320.
- Boutons du videau « Prendre » et « Passer », lien « Quitter ».
- Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre
  d'actions, seulement entre deux tours (betweenTurns, testé).
- Test des adresses : le lien Aide est lu au moment du toucher, la carte
  du terme ouvert est mesurée sous la barre de l'aide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 7c10c688c3 Tutoriel : étape 7 réécrite, « Prendre », « Passer » et « Quitter »
Texte de l'étape 7 remplacé par la version de la recette design (gammon,
backgammon et règle de Jacoby en un paragraphe), légende du plateau
gardée. Le texte du tutoriel est validé par Pierre (9 novembre 2026).
Scénario : les réponses au double sont « Prendre » et « Passer », le lien
du haut est « Quitter ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +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 d1e234bb11 Fusion de main (MR !43) dans accueil-tutoriel-aide
Conflits résolus en gardant les deux apports : barre de l'aide collée
(scroll-margin sur la carte du terme) et position partagée en paysage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:15 +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 65a4638475 Fusion de la MR !43 : une adresse pour chaque écran, et un lien pour chaque position (0e.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:03:28 +01:00
Pierre MARTINandClaude Opus 5.5 2b8fa1a0b0 Recette design des adresses : barre de l'aide collée, boutons sur une ligne, position en paysage
Barre haute de l'aide en position sticky ; question ou terme ouvert par
son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s.
Boutons en white-space: nowrap ; actions de la position empilées
(Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la
hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler),
colonne de 288 px à droite. Test navigateur : aucun bouton sur deux
lignes, barre de l'aide en vue, actions visibles sans défiler, videau
entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:52:50 +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
pierre 8e5be8e339 Fusion de la MR !46 : tutoriel, Jacoby 2026-11-09 17:38:55 +00:00
Pierre MARTINandClaude Opus 5.5 e724368f09 Charte : tutoriel, étape 7 avec la règle de Jacoby, lien « Quitter »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:38:53 +01:00
Pierre MARTINandClaude Opus 5.5 2352a5317f Fusion de la MR !45 : « Grosse erreur » plutôt que « gaffe »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:41 +01:00
Pierre MARTINandClaude Opus 5.5 0a88e835d2 Vocabulaire : « Grosse erreur » plutôt que « gaffe » (choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:29 +01:00
67 changed files with 3913 additions and 450 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;
},
+195 -47
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,13 +60,22 @@ 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";
import { type Updates, updateBannerShown } from "./updates.js";
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
* (interaction.md : on suit le coup sans l'attendre). */
@@ -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;
@@ -718,18 +773,17 @@ export function startApp(options: AppOptions): void {
ui.helpButton.href = helpLink;
ui.menuHelp.href = helpLink;
ui.menuInstall.hidden = !installMenuShown(settings, installer.installed);
const moveInProgress =
busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden);
// Le bandeau de mise à jour attend la fin du tour, même hors de
// l'écran de jeu : la partie enregistrée reprend alors sans perte.
const quiet =
!busy &&
(game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length));
if (updateMessage !== null) {
ui.updateBanner.hidden = false;
ui.updateText.textContent = updateMessage;
ui.updateApply.hidden = true;
} else {
const shown = updateBannerShown(
updates?.available ?? false,
moveInProgress,
!ui.banner.hidden,
);
const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden);
ui.updateBanner.hidden = !shown;
ui.updateText.textContent = HOME_TEXTS.updateAvailable;
ui.updateApply.hidden = false;
@@ -737,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. */
@@ -851,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,
@@ -936,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;
@@ -962,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 {
@@ -1102,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 = "";
});
}
@@ -1130,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: () => {
@@ -1162,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");
}
@@ -1234,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();
@@ -1251,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();
@@ -1304,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;
@@ -1312,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);
@@ -1398,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";
@@ -1455,6 +1552,7 @@ export function startApp(options: AppOptions): void {
},
placed: () => haptic("checkerPlaced"),
reducedMotion,
landscape: () => layout() === "landscape",
replace: (hash) => navigation.replace(hash),
});
@@ -1565,6 +1663,7 @@ export function startApp(options: AppOptions): void {
? TEXTS.unreadablePosition
: "";
ui.positionBoard.toggleAttribute("hidden", !shown);
ui.position.classList.toggle("with-board", shown);
ui.positionDetail.hidden = !shown;
ui.positionCopy.hidden = !shown;
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
@@ -1572,11 +1671,20 @@ export function startApp(options: AppOptions): void {
return;
}
const shared = state.shared;
const width = ui.positionBoard.clientWidth || Math.min(window.innerWidth, 640);
// En paysage, le plateau occupe toute la hauteur, à gauche de la
// colonne du titre et des actions (app.css), avec la géométrie du
// jeu : la colonne gauche du cadre garde le videau, sans menu ni
// bouton annuler (il ne se joue pas).
const landscape = layout() === "landscape";
const box = ui.positionBoard.getBoundingClientRect();
const width = box.width || Math.min(window.innerWidth, 640);
const height = box.height || window.innerHeight;
const model = boardModel(shared.sides);
renderBoard(
ui.positionBoard,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
landscape
? boardGeometry("landscape", width / height, height / 520)
: boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
model,
{
dice: [],
@@ -1606,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) {
@@ -2022,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 {
+68 -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. */
@@ -166,6 +177,7 @@ export function renderBoard(
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
pieces.setAttribute("class", "pieces");
for (const point of board.points) {
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
@@ -180,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);
}
@@ -196,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);
}
@@ -490,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,
@@ -505,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,
@@ -556,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 {
+3
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",
@@ -322,6 +323,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
+5 -5
View File
@@ -68,7 +68,7 @@ export const HOME_TEXTS = {
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version disponible.",
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
@@ -80,8 +80,8 @@ export const HOME_TEXTS = {
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Passer",
tutorialSkipLabel: "Passer le tutoriel",
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
@@ -90,8 +90,8 @@ export const HOME_TEXTS = {
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Je prends",
drop: "Je passe",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
+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";
+38 -14
View File
@@ -95,6 +95,8 @@ export interface TutorialViewOptions {
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
@@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */
function drawPath(svg: SVGSVGElement, g: Geometry): void {
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
@@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const width = g.pointWidth * 0.22;
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", width * 2.4],
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
@@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void {
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`);
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = width * 2.2;
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(width * 1.2));
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les zones à toucher, toujours dessinées en dernier.
svg.insertBefore(group, svg.lastChild);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
}
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
@@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(section, "p", {
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
@@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const width = svg.clientWidth || Math.min(window.innerWidth, 640);
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
@@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g);
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
@@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
@@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
+20 -9
View File
@@ -1,8 +1,9 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle
// version disponible » ; « Mettre à jour » active la nouvelle version et
// recharge la page, qui reprend la partie enregistrée.
// l'accord du joueur. L'application affiche alors, entre deux tours, le
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
// active la nouvelle version et recharge la page, qui reprend la partie
// enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -25,17 +26,27 @@ export interface Updates {
export const SKIP_WAITING = "skip-waiting";
/**
* Le bandeau « Nouvelle version disponible » est-il montré ? Quand une
* version attend, jamais pendant un coup en cours (des pions déjà déplacés,
* ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre
* l'ancienne ». Fonction pure, testée sous Node.
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
* pure, testée sous Node.
*/
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
}
/**
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
moveInProgress: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && !moveInProgress && !otherBanner;
return available && betweenTwoTurns && !otherBanner;
}
/**
+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 };
});
@@ -113,11 +113,11 @@ Then("le tutoriel salue votre coup", () => {
assert.ok(screen.right.length > 0);
});
When("vous répondez « Je passe »", async () => {
When("vous répondez « Passer »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === false;
});
When("vous répondez « Je prends »", async () => {
When("vous répondez « Prendre »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === true;
});
+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);
});
+23 -16
View File
@@ -20,7 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js";
import { BANNER_MS } from "../../src/ui/app.js";
import { createHaptics } from "../../src/ui/haptics.js";
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
import { updateBannerShown } from "../../src/ui/updates.js";
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
@@ -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")) {
@@ -168,23 +164,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
// ------------------------------------------------------------ Mise à jour
// Le bandeau dépend de deux choses : une version attend (le service worker
// l'a installée), et aucun coup n'est en cours de saisie. La partie est
// celle du domaine, sur le vrai moteur.
// l'a installée), et la partie est entre deux tours. La partie est celle du
// domaine, sur le vrai moteur.
let updateAvailable = false;
Given("une nouvelle version d'Ustagammon est disponible", () => {
updateAvailable = true;
});
Given("vous avez déjà déplacé un pion de votre coup", async () => {
const shownNow = (otherBanner = false) =>
updateBannerShown(
updateAvailable,
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
otherBanner,
);
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");
});
When("vous déplacez un pion de votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
});
Then("l'application ne vous propose pas encore de mettre à jour", () => {
assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
assert.ok(!shownNow());
});
When("vous avez joué votre coup", async () => {
@@ -193,7 +200,7 @@ When("vous avez joué votre coup", async () => {
});
Then("l'application vous propose de mettre à jour", () => {
assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false));
assert.ok(shownNow());
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
assert.ok(!updateBannerShown(updateAvailable, false, true));
assert.ok(!shownNow(true));
});
+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.
+22 -13
View File
@@ -19,8 +19,8 @@ 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 |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) 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 |
| 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 |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
@@ -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)
@@ -67,7 +67,8 @@ interrompre.
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
- En haut : une barre de progression en huit segments (`accent` pour les
étapes faites) et « Passer » à droite. En bas : « Retour » et
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
« Continuer ».
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
plateau est le composant de l'app, non jouable sauf sur les écrans
@@ -113,9 +114,10 @@ interrompre.
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon :
2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
c'est un backgammon : 3 points. »
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
+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.
+9 -5
View File
@@ -29,17 +29,21 @@ du moteur.
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
au bilan (1e) sans recalcul.
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
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 |
+3 -3
View File
@@ -10,7 +10,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel
Chaque étape tient en un écran court : un titre, le plateau, deux ou
trois phrases. Le coup montré se joue tout seul après une seconde ;
« Revoir » le rejoue. La barre du haut montre où vous en êtes et vous
ramène aux étapes déjà vues. « Passer » vous emmène jouer.
ramène aux étapes déjà vues. « Quitter » vous emmène jouer.
Étant donné que vous commencez le tutoriel
Alors il compte huit étapes, dont quatre où c'est à vous de jouer
@@ -36,7 +36,7 @@ Fonctionnalité: Apprendre les règles avec le tutoriel
passez : le maître vous dit ensuite ce qu'il aurait fait, et pourquoi.
Étant donné que vous êtes à l'écran « à vous » de l'étape 8
Quand vous répondez « Je passe »
Quand vous répondez « Passer »
Alors le maître dit qu'il aurait pris
Quand vous répondez « Je prends »
Quand vous répondez « Prendre »
Alors le tutoriel salue votre réponse
+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
+7 -4
View File
@@ -52,13 +52,16 @@ Fonctionnalité: Régler l'application
Scénario: Comment mettre l'application à jour ?
Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la
télécharge d'elle-même, puis affiche « Nouvelle version disponible »,
jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et
vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à
télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle
version prête. », seulement entre deux tours, jamais pendant que vous
jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en
étiez. Dans le menu, « Rechercher une mise à
jour » vérifie tout de suite s'il y en a une.
Étant donné qu'une nouvelle version d'Ustagammon est disponible
Et que vous avez déjà déplacé un pion de votre coup
Et que vous avez des dés à jouer
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous déplacez un pion de votre coup
Alors l'application ne vous propose pas encore de mettre à jour
Quand vous avez joué votre coup
Alors l'application vous propose de mettre à jour
+2 -4
View File
@@ -1,7 +1,7 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
première version à valider par Pierre. Format : voir README.md.
validé par Pierre le 9 novembre 2026. Format : voir README.md.
## 1. Le but du jeu
@@ -101,9 +101,7 @@ Vous avez 6 et 3 : sortez deux pions.
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion quand vous finissez, c'est un [gammon](gammon.md) : 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points.
Dans une partie libre contre l'ordinateur, le gammon et le backgammon ne comptent double ou triple qu'une fois le videau doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
+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"
+136 -15
View File
@@ -11,10 +11,12 @@
// Lancé par tests/browser.sh. Le second site sert la même chose depuis un
// dossier que ce script modifie : il y publie une deuxième version pour
// essayer la mise à jour. Captures de la recette design (Chromium) dans
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412.
// <dossier des captures>/accueil-tutoriel/, en 412×915 et 915×412, et aussi
// en 844×320 pour le tutoriel.
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];
@@ -29,6 +31,8 @@ const SIZES = [
[412, 915],
[915, 412],
];
// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette.
const TUTORIAL_SIZES = [...SIZES, [844, 320]];
const browser = await browserType.launch();
const problems = [];
@@ -102,15 +106,17 @@ async function until(page, what, test, arg, ms = 30_000) {
throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`);
}
/** Range une capture de l'écran courant dans les deux tailles (Chromium). */
async function capture(page, name, settle = async () => {}) {
/** Range une capture de l'écran courant dans chaque taille (Chromium). */
async function capture(page, name, settle = async () => {}, sizes = SIZES) {
if (!full) {
return;
}
for (const [width, height] of SIZES) {
for (const [width, height] of 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++;
}
@@ -139,6 +145,60 @@ async function tutorialReady(page, hash) {
}, hash);
}
/** Recette du tutoriel dans la taille courante : numéros des flèches de
* 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */
async function checkTutorialView(page, name) {
const view = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const numbers = [...svg.querySelectorAll(".point-number")]
.map((t) => Number(t.getAttribute("font-size")) * scale);
const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"]
.map((id) => document.getElementById(id))
.filter((el) => el !== null && el.closest("[hidden]") === null)
.map((el) => {
const r = el.getBoundingClientRect();
return { id: el.id, top: r.top, bottom: r.bottom };
});
return {
landscape: document.documentElement.classList.contains("landscape"),
minNumber: numbers.length === 0 ? 0 : Math.min(...numbers),
scroll: document.scrollingElement.scrollHeight - innerHeight,
body: (() => {
const b = document.querySelector("#tutorial .tutorial-body");
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
buttons,
};
});
const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`;
check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`);
if (view.landscape) {
check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`);
check(view.body <= 1, `${size} : le texte défile de ${view.body} px`);
if (view.body > tutorialBodyScroll.px) {
tutorialBodyScroll = { px: view.body, where: size };
}
for (const b of view.buttons) {
check(b.top >= 0 && b.bottom <= view.height + 0.5,
`${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`);
}
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
}
let tutorialViews = 0;
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */
async function tutorialCapture(page, name) {
await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES);
}
/** Touche une case du plateau du tutoriel. */
async function touch(page, place) {
await page.click(`#tutorial-board [data-place="${place}"]`);
@@ -210,8 +270,17 @@ async function playTouches(page, touches) {
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
}
await capture(page, `tutoriel-${step}`);
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
await page.click("#tutorial-next");
const practice = await page.evaluate(() => location.hash.endsWith("/a-vous"));
@@ -222,11 +291,11 @@ async function playTouches(page, touches) {
await tutorialReady(page, avous);
check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")),
`étape ${step}, à vous : « Continuer » principal avant le geste`);
await capture(page, `tutoriel-${step}-a-vous`);
await tutorialCapture(page, `tutoriel-${step}-a-vous`);
if (step === 8) {
await page.click("#tutorial-drop");
check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté");
await capture(page, "tutoriel-8-a-vous-refuse");
check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté");
await tutorialCapture(page, "tutoriel-8-a-vous-refuse");
await page.click("#tutorial-take");
await until(page, "étape 8 juste", () =>
document.getElementById("tutorial-feedback").dataset.verdict === "right");
@@ -238,7 +307,7 @@ async function playTouches(page, touches) {
await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0);
const words = await page.textContent("#tutorial-feedback");
check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`);
await capture(page, `tutoriel-${step}-a-vous-refuse`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`);
await playTouches(page, moves.right);
check(await verdict(page) === "right", `étape ${step} : bon coup refusé`);
check(await page.locator("#tutorial-board .hit.can-move").count() === 0,
@@ -248,13 +317,13 @@ async function playTouches(page, touches) {
`étape ${step} : « Continuer » pas principal après le bon geste`);
const vibrated = await page.evaluate(() => window.__vibrations.at(-1));
check(vibrated === "12", `étape ${step} : vibration ${vibrated}`);
await capture(page, `tutoriel-${step}-a-vous-reussi`);
await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`);
screens++;
await page.click("#tutorial-next");
}
await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" &&
document.getElementById("tutorial").dataset.ready !== undefined);
await capture(page, "tutoriel-fin");
await tutorialCapture(page, "tutoriel-fin");
// Le retour du téléphone : l'écran d'avant.
await page.goBack();
await tutorialReady(page, "#/tutoriel/8/a-vous");
@@ -262,7 +331,9 @@ async function playTouches(page, touches) {
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`);
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
// Le choix ne revient plus : l'accueil, ensuite.
await page.goto(base);
@@ -391,8 +462,27 @@ if (browserName === "chromium") {
};
open.onerror = () => resolve(null);
}));
const gameBefore = await journal();
check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version");
// Si vous avez la main (jet d'ouverture), le bandeau attend la fin du
// tour : on publie la nouvelle version d'abord, puis on joue le coup.
const toPlay = () => page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return "busy";
}
const usable = (id) => {
const b = document.getElementById(id);
return b !== null && !b.hidden && !b.disabled;
};
for (const id of ["roll", "again", "confirm", "skip", "take"]) {
if (usable(id)) {
return id;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : "busy";
});
// Une deuxième version publiée : autre version.json, autre sw.js.
writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n');
const sw = readFileSync(`${updateDir}/sw.js`, "utf8");
@@ -400,9 +490,40 @@ if (browserName === "chromium") {
await page.click("#menu-button");
await page.waitForSelector("#menu", { state: "visible" });
await page.click("#menu-update");
await until(page, "nouvelle version trouvée", () =>
document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000);
// Pendant votre coup, jamais de bandeau : seulement entre deux tours.
let waited = false;
for (let i = 0; i < 40; i++) {
const todo = await toPlay();
if (todo === "roll" || todo === "again") {
break;
}
if (todo === "busy") {
await sleep(100);
continue;
}
if (todo === "move" || todo === "land" || todo === "confirm") {
await sleep(400);
check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`);
waited = true;
}
await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`);
await sleep(200);
}
await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000);
check((await page.textContent("#update-text")) === "Nouvelle version disponible.",
check((await page.textContent("#update-text")) === "Nouvelle version prête.",
`bandeau : « ${await page.textContent("#update-text")} »`);
// En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ».
const place = await page.evaluate(() => {
const banner = document.getElementById("update-banner").getBoundingClientRect();
const actions = document.getElementById("actions").getBoundingClientRect();
const top = document.querySelector(".topbar").getBoundingClientRect();
return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom };
});
check(place.bottom <= place.actions && place.top > place.bar,
`bandeau de mise à jour mal placé : ${JSON.stringify(place)}`);
const gameBefore = await journal();
await capture(page, "09-mise-a-jour");
await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]);
await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000);
@@ -410,7 +531,7 @@ if (browserName === "chromium") {
await page.goto(`${updateBase}#/a-propos`);
await until(page, "nouvelle version", () =>
document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab.");
log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version");
log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`);
// Rien de neuf : « Vous avez la dernière version ».
await page.goto(`${updateBase}#/`);
await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000);
+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/` : ""}`);
+147 -14
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];
@@ -300,10 +301,51 @@ async function checkStable(when) {
stableChecks++;
}
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
async function wrappedButtons(target) {
return target.evaluate(() => {
const wrapped = [];
for (const el of document.querySelectorAll("button, a.button")) {
const r = el.getBoundingClientRect();
if (el.closest("[hidden]") || r.width === 0 || r.height === 0) {
continue;
}
const name = el.id ? `#${el.id}` : el.textContent.trim();
const tops = new Set();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
if (node.textContent.trim() === "") {
continue;
}
const range = document.createRange();
range.selectNodeContents(node);
for (const line of range.getClientRects()) {
if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) {
tops.add(line.top);
}
}
}
if (r.height > 48.5 && tops.size > 1) {
wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`);
} else if (el.scrollWidth > el.clientWidth + 1) {
wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`);
}
}
return wrapped;
});
}
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
* mesures pour le compte rendu. */
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]")) {
@@ -767,6 +809,67 @@ async function checkAddresses() {
await expectScreen(tab, `capture ${name}`, expected);
await sleep(300);
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
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.
const seen = await tab.evaluate(() => {
const bar = document.getElementById("help-bar").getBoundingClientRect();
const target = document.querySelector(
"#help-play .question.open, #help-glossary .term-card.current",
).getBoundingClientRect();
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
});
check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1,
`${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`);
check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2,
`${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`);
}
if (name === "position") {
// Les trois actions, dans l'ordre, visibles sans défiler.
const seen = await tab.evaluate(() => ({
scrolled: window.scrollY,
order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id),
boxes: ["position-play", "position-copy", "position-back"].map((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return [r.left, r.top, r.right, r.bottom];
}),
board: (() => {
const r = document.getElementById("position-board").getBoundingClientRect();
return [r.left, r.top, r.width, r.height];
})(),
cube: (() => {
const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect();
return r === undefined ? null : [r.left, r.top, r.right, r.bottom];
})(),
controls: document.querySelectorAll("#position-board [data-action]").length,
}));
check(seen.order.join() === "position-play,position-copy,position-back",
`${where} : actions dans l'ordre ${seen.order}`);
const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height);
if (width > height) {
check(seen.scrolled === 0 && inView,
`${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`);
check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1,
`${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`);
// Le videau, dans la colonne gauche du cadre, entier à
// l'écran ; ni menu ni annuler : la position ne se joue pas.
const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1];
check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height,
`${where} : videau coupé : ${JSON.stringify(seen.cube)}`);
check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`);
} else {
// Empilées sur toute la largeur.
const [first] = seen.boxes;
check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 &&
Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])),
`${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`);
}
}
await tab.close();
}
}
@@ -784,9 +887,15 @@ async function checkAddresses() {
}
// Le bouton « Aide » ouvre la question du moment de la partie
// (help-context.ts) ; ici, l'onglet « Jouer » sans question.
const helpHref = await tab.getAttribute("#help-button", "href");
// L'adresse est lue au moment du toucher : la partie qui vient de
// s'ouvrir peut encore changer de moment (jet d'ouverture).
const helpHref = await tab.evaluate(() => {
const button = document.getElementById("help-button");
const href = button.getAttribute("href");
button.click();
return href;
});
check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`);
await tab.click("#help-button");
await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play);
if (helpHref !== "#/aide") {
await tab.goBack();
@@ -819,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");
@@ -881,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 = () =>
@@ -891,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")} »`);
@@ -1409,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);
@@ -1441,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,
@@ -1453,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,
@@ -1534,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]\]//')" ] \
+579 -30
View File
@@ -363,6 +363,8 @@ button {
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
cursor: pointer;
/* Un bouton tient sur une ligne (charte). */
white-space: nowrap;
/* Pas de délai ni de zoom sur un double toucher. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@@ -833,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 {
@@ -1291,7 +1304,7 @@ html.rotated .banner {
box-shadow: var(--shadow-raised);
}
.glossary dt {
.glossary .term-card {
padding-top: var(--space-3);
font-weight: 700;
}
@@ -1317,16 +1330,36 @@ html.rotated .banner {
-webkit-user-select: text;
}
#help-back {
margin-top: var(--space-4);
/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la
* page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une
* question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */
.help {
--help-bar-height: 113px;
}
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique. */
.help-bar {
position: sticky;
top: env(safe-area-inset-top);
z-index: 3;
height: var(--help-bar-height);
margin-inline: calc(-1 * var(--space-4));
padding: var(--space-3) var(--space-4) 0;
background: var(--background);
}
.help .question,
.glossary .term-card {
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
}
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ;
* sur une ligne, qui défile si l'écran est trop étroit. */
.help-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-4);
overflow-x: auto;
white-space: nowrap;
margin-top: var(--space-2);
border-bottom: 1px solid var(--border);
}
@@ -1402,6 +1435,12 @@ html.rotated .banner {
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
background: var(--accent-soft);
/* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */
animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards;
}
@keyframes term-fade {
to { background-color: transparent; }
}
/* -------------------------------------------------- Liens et boutons */
@@ -1420,6 +1459,7 @@ a.button {
background: var(--accent);
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
white-space: nowrap;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
@@ -1483,14 +1523,6 @@ a.link-button {
margin: var(--space-2) 0;
}
/* En paysage, le plateau tient dans la hauteur de l'écran. */
html.landscape .position-board {
width: min(100%, calc(80vh * 640 / 520));
height: auto;
margin-inline: auto;
aspect-ratio: 640 / 520;
}
.position-detail {
margin: var(--space-2) 0 var(--space-4);
font-size: 15px;
@@ -1498,26 +1530,25 @@ html.landscape .position-board {
color: var(--muted);
}
/* Les actions empilées sur toute la largeur, la principale en premier. */
.position-actions {
display: grid;
grid-template-columns: 1fr 1.6fr;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.position-actions > * {
min-width: 0;
width: 100%;
}
.position-copy {
#position-copy {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
width: 100%;
margin-top: var(--space-3);
}
.position-copy .icon {
#position-copy .icon {
width: 20px;
height: 20px;
}
@@ -1855,13 +1886,6 @@ html.bare .topbar {
width: 100%;
}
/* Bandeau de mise à jour : en haut, discret, jamais sur la barre
* d'actions ni sur « Reprendre l'ancienne ». */
.update-banner {
top: var(--space-2);
bottom: auto;
}
/* Tutoriel. */
.tutorial-head {
display: flex;
@@ -2019,8 +2043,132 @@ html.bare .topbar {
stroke: var(--brand-noyer);
}
/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */
html.landscape .tutorial-board,
/* En paysage, comme la position partagée : le plateau à gauche sur toute
* la hauteur (avec la colonne gauche du cadre), et à droite une colonne
* avec la barre de progression, le titre, le texte, la consigne « à vous »
* et les boutons, le tout sans défilement de la page. Si le texte est trop
* long pour l'écran, lui seul défile : les boutons restent en vue. */
html.landscape .tutorial {
position: fixed;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px);
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
max-width: none;
margin: 0;
padding: 0;
overflow: hidden;
background: var(--background);
}
html.landscape .tutorial > * {
grid-column: 2;
margin-inline: 0;
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
}
html.landscape .tutorial .overline {
display: none;
}
html.landscape .tutorial-head {
grid-row: 1;
min-height: 44px;
padding-top: 0;
}
html.landscape .tutorial-title {
grid-row: 2;
margin: 0 0 var(--space-1);
padding-right: calc(44px + var(--space-4));
font-size: 20px;
line-height: 26px;
}
/* La boîte du plateau s'efface : le plateau et « Revoir » se placent
* eux-mêmes dans la grille. */
html.landscape .tutorial-board-box {
display: contents;
}
html.landscape .tutorial-board {
grid-row: 1 / -1;
grid-column: 1;
width: 100%;
height: 100%;
margin: 0;
aspect-ratio: auto;
background: var(--frame);
}
html.landscape #tutorial-replay {
position: static;
grid-row: 2;
grid-column: 2;
justify-self: end;
align-self: start;
margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0;
padding: 0;
}
html.landscape .tutorial-body {
grid-row: 3;
min-height: 0;
overflow-y: auto;
font-size: 15px;
line-height: 20px;
}
html.landscape .tutorial-text {
margin: 0;
padding-right: 0;
}
html.landscape .tutorial-text p {
margin-bottom: var(--space-2);
}
html.landscape .tutorial-task {
margin: 0;
padding: var(--space-2) 12px;
}
html.landscape .tutorial-feedback {
min-height: 0;
margin: var(--space-2) 0 0;
}
html.landscape .tutorial-cube {
grid-row: 4;
margin-top: var(--space-2);
}
/* La réponse au double donnée et saluée, les deux boutons n'ont plus
* d'usage : en paysage, leur place revient au texte. */
html.landscape .tutorial[data-solved] .tutorial-cube {
display: none;
}
/* Écran très bas (844 × 320) : texte un cran plus petit. */
@media (max-height: 360px) {
html.landscape .tutorial-body {
font-size: 14px;
line-height: 18px;
}
html.landscape .tutorial-title {
font-size: 18px;
line-height: 24px;
}
}
html.landscape .tutorial-nav {
grid-row: 5;
margin: 0;
padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
}
/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */
html.landscape .welcome-board {
width: min(100%, calc(70vh * 640 / 520));
height: auto;
@@ -2122,3 +2270,404 @@ html.landscape .welcome {
padding-inline: 0;
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. */
html.landscape .position-view.with-board {
position: fixed;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 288px;
grid-template-rows: auto auto auto auto 1fr;
max-width: none;
margin: 0;
padding: 0;
overflow-y: auto;
background: var(--background);
}
html.landscape .position-view.with-board > * {
grid-column: 2;
margin-inline: 0;
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
}
html.landscape .position-view.with-board .position-title {
margin-top: var(--space-4);
}
html.landscape .position-view.with-board .position-board {
grid-row: 1 / -1;
grid-column: 1;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
aspect-ratio: auto;
background: var(--frame);
}
html.landscape .position-view.with-board .position-actions {
padding-bottom: var(--space-4);
}
html.landscape .position-view.with-board .position-actions > * {
padding-inline: 16px;
}
+25 -11
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,29 +93,37 @@
<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>
<a id="help-back" href="#/" class="button secondary"></a>
<nav id="help-tabs" class="help-tabs">
<a href="#/aide" data-screen="help"></a>
<a href="#/glossaire" data-screen="glossary"></a>
<a href="#/a-propos" data-screen="about"></a>
</nav>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
<div id="help-bar" class="help-bar">
<a id="help-back" href="#/" class="button secondary"></a>
<nav id="help-tabs" class="help-tabs">
<a href="#/aide" data-screen="help"></a>
<a href="#/glossaire" data-screen="glossary"></a>
<a href="#/a-propos" data-screen="about"></a>
</nav>
</div>
<div id="help-content"></div>
</section>
<!-- Une position partagée (#/position/<Position ID>/<Match ID>), non
jouable. -->
jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
sur toute la hauteur, le reste dans une colonne à droite. -->
<section id="position-view" class="position-view page" aria-labelledby="position-title" hidden>
<h2 id="position-title" class="position-title"></h2>
<p id="position-message" class="position-message" role="status"></p>
<svg id="position-board" class="board position-board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
<p id="position-detail" class="position-detail"></p>
<div class="position-actions">
<a id="position-back" href="#/" class="button secondary"></a>
<button id="position-play" type="button"></button>
<button id="position-copy" type="button" class="secondary"></button>
<a id="position-back" href="#/" class="button secondary"></a>
</div>
<button id="position-copy" type="button" class="secondary position-copy"></button>
</section>
<!-- Menu : une feuille qui monte du bas. -->
@@ -122,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>
@@ -174,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>
@@ -184,7 +197,8 @@
<span class="banner-timer" aria-hidden="true"></span>
</div>
<!-- « Nouvelle version disponible » : jamais pendant un coup en cours. -->
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
seulement entre deux tours. -->
<div id="update-banner" class="banner update-banner" role="status" hidden>
<span id="update-text"></span>
<button id="update-apply" type="button" class="secondary"></button>