Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+121
-62
@@ -1,4 +1,4 @@
|
||||
// Une partie libre contre l'ordinateur.
|
||||
// Une partie contre l'ordinateur : partie libre, ou une partie d'un match.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -6,11 +6,12 @@
|
||||
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
|
||||
// des événements au journal (events.ts), d'où l'état est recalculé
|
||||
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
|
||||
// l'ordinateur, videau, fin de partie et points.
|
||||
// l'ordinateur, videau (Crawford compris), fin de partie, points et score du
|
||||
// match.
|
||||
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
@@ -19,16 +20,18 @@ import {
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
} from "./events.js";
|
||||
import { type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
import { type GameState, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
|
||||
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
|
||||
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const STANDARD_PLY: Ply = 2;
|
||||
|
||||
@@ -62,12 +65,19 @@ export interface MoveSteps {
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Ce que l'on choisit pour une nouvelle partie. */
|
||||
export interface NewGame {
|
||||
/** Une position de départ particulière (sans jet d'ouverture). */
|
||||
readonly setup?: GameSetup;
|
||||
/** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */
|
||||
readonly level?: Level;
|
||||
/** Le match dont ce sera une partie ; absent : partie libre. */
|
||||
readonly match?: Match;
|
||||
}
|
||||
|
||||
export interface GameOptions extends NewGame {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
|
||||
readonly ply?: Ply;
|
||||
readonly setup?: GameSetup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +88,6 @@ export interface GameOptions {
|
||||
export class Game {
|
||||
readonly #engine: Engine;
|
||||
readonly #dice: DiceSource;
|
||||
readonly #ply: Ply;
|
||||
#journal: GameJournal = EMPTY_JOURNAL;
|
||||
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
|
||||
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
|
||||
@@ -88,11 +97,11 @@ export class Game {
|
||||
private constructor(engine: Engine, options: GameOptions) {
|
||||
this.#engine = engine;
|
||||
this.#dice = options.dice ?? randomDice;
|
||||
this.#ply = options.ply ?? STANDARD_PLY;
|
||||
}
|
||||
|
||||
/** Commence une partie : à la position de départ avec le jet d'ouverture,
|
||||
* ou à la position de options.setup. */
|
||||
* ou à la position de options.setup ; au niveau options.level, et dans
|
||||
* le match options.match s'il est donné. */
|
||||
static start(engine: Engine, options: GameOptions = {}): Game {
|
||||
const game = new Game(engine, options);
|
||||
const setup = options.setup;
|
||||
@@ -101,6 +110,8 @@ export class Game {
|
||||
position: setup?.position ?? START_POSITION,
|
||||
cube: setup?.cube ?? CENTERED_CUBE,
|
||||
firstTurn: setup?.firstTurn ?? null,
|
||||
...(options.level === undefined ? {} : { level: options.level }),
|
||||
...(options.match === undefined ? {} : { match: options.match }),
|
||||
});
|
||||
while (game.state.phase === "opening") {
|
||||
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
|
||||
@@ -112,11 +123,13 @@ export class Game {
|
||||
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
|
||||
* journal est d'un autre format ou incohérent.
|
||||
*/
|
||||
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = journal.events[0];
|
||||
if (first?.type !== "GameStarted" || journal.head === null) {
|
||||
static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = saved.events[0];
|
||||
if (first?.type !== "GameStarted" || saved.head === null) {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
// Un journal d'une version précédente passe à la version courante.
|
||||
const journal = migrateJournal(saved);
|
||||
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
|
||||
throw new Error("journal de partie d'une autre version");
|
||||
}
|
||||
@@ -141,6 +154,18 @@ export class Game {
|
||||
return replay(this.events);
|
||||
}
|
||||
|
||||
/** La partie finie appartient à un match qui continue : ce que sera la
|
||||
* partie suivante (score et Crawford dits par le moteur à la fin de
|
||||
* celle-ci) ; null sinon. */
|
||||
get nextGame(): NewGame | null {
|
||||
const state = this.state;
|
||||
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
|
||||
return null;
|
||||
}
|
||||
const match = nextGameOf(state.match, state.matchAfter);
|
||||
return match === null ? null : { level: state.level, match };
|
||||
}
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
@@ -182,7 +207,7 @@ export class Game {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +239,7 @@ export class Game {
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before)),
|
||||
);
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
@@ -260,10 +285,11 @@ export class Game {
|
||||
if (state.turn !== "player" || state.phase !== "rolling") {
|
||||
return false;
|
||||
}
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
|
||||
// moteur le refuse aussi pendant la partie Crawford.
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
engineCube(state, "player"),
|
||||
0,
|
||||
);
|
||||
return decision.available;
|
||||
@@ -349,62 +375,61 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
|
||||
* selon le moteur. */
|
||||
* à son niveau, selon le moteur. */
|
||||
async double(): Promise<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 +470,8 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<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 +499,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 +507,54 @@ export class Game {
|
||||
position: board.swappedPosition,
|
||||
});
|
||||
if (board.result !== null) {
|
||||
this.#append({
|
||||
type: "GameEnded",
|
||||
winner: board.result.winner === "onRoll" ? side : otherSide(side),
|
||||
kind: board.result.kind,
|
||||
points: board.result.points,
|
||||
});
|
||||
await this.#endGame(
|
||||
board.result.winner === "onRoll" ? side : otherSide(side),
|
||||
board.result.kind,
|
||||
board.result.points,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** taker prend ou passe le double proposé par l'autre camp. */
|
||||
#answerDouble(taker: Side, take: boolean): void {
|
||||
async #answerDouble(taker: Side, take: boolean): Promise<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 +624,21 @@ function occupiedPoints(side: BoardSide): number[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait, pour le moteur. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
if (cube.owner === null) {
|
||||
return { value: cube.value, owner: "centered" };
|
||||
/** Le videau et le match vus du camp au trait, pour le moteur. */
|
||||
export function engineCube(state: Pick<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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}]`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
+156
-35
@@ -23,11 +23,12 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
@@ -58,9 +60,18 @@ import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import {
|
||||
chosenGame,
|
||||
isFirstLaunch,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
} from "./settings.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
@@ -84,9 +95,9 @@ export interface CachedBoard {
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
|
||||
* une position donnée (setup). */
|
||||
readonly newGame: (setup?: GameSetup) => Game;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré) : niveau,
|
||||
* match, ou position de départ (setup). */
|
||||
readonly newGame: (newGame?: NewGame) => Game;
|
||||
/** Les liens vers une position (ADR 0013). */
|
||||
readonly positions: PositionsAccess;
|
||||
/** La partie enregistrée, ou null s'il n'y en a pas. */
|
||||
@@ -173,6 +184,7 @@ export function startApp(options: AppOptions): void {
|
||||
strip,
|
||||
who: child<HTMLElement>(strip, ".who"),
|
||||
lead: child<HTMLElement>(strip, ".lead"),
|
||||
score: child<HTMLElement>(strip, ".match-score"),
|
||||
count: child<HTMLButtonElement>(strip, ".count"),
|
||||
};
|
||||
};
|
||||
@@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void {
|
||||
menu: byId("menu"),
|
||||
menuTitle: byId("menu-title"),
|
||||
menuClose: byId<HTMLButtonElement>("menu-close"),
|
||||
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
|
||||
menuNewGame: byId<HTMLAnchorElement>("menu-new-game"),
|
||||
newGameSection: byId("new-game"),
|
||||
matchLine: byId("match-line"),
|
||||
menuGames: byId<HTMLAnchorElement>("menu-games"),
|
||||
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
|
||||
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
|
||||
@@ -510,7 +524,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, g, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
// Partie Crawford : pas de videau.
|
||||
cube: state.match?.crawford === true ? null : state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
@@ -526,6 +541,7 @@ export function startApp(options: AppOptions): void {
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
renderMatch(state);
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
@@ -561,6 +577,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -573,7 +590,6 @@ export function startApp(options: AppOptions): void {
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.menuNewGame,
|
||||
ui.positionPlay,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
@@ -624,7 +640,6 @@ export function startApp(options: AppOptions): void {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.moveHint.hidden = true;
|
||||
ui.menuNewGame.disabled = true;
|
||||
ui.menuCopyLink.disabled = true;
|
||||
ui.positionPlay.disabled = true;
|
||||
if (notice !== null) {
|
||||
@@ -646,9 +661,42 @@ export function startApp(options: AppOptions): void {
|
||||
back: replay ? "replay" : "undo",
|
||||
backEnabled: !busy && (replay || undoable),
|
||||
menuEnabled: true,
|
||||
match: game === null ? null : matchScore(game.state),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le match : la ligne « Match en N points » sous la barre haute, et le
|
||||
* score de chaque camp dans son bandeau (portrait). */
|
||||
function renderMatch(state: GameState): void {
|
||||
const score = matchScore(state);
|
||||
ui.matchLine.hidden = score === null;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const badge = ui.strips[side].score;
|
||||
badge.hidden = score === null;
|
||||
badge.textContent = score === null ? "" : String(score[side]);
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
score === null
|
||||
? ""
|
||||
: side === "player"
|
||||
? MATCH_TEXTS.playerScore(score.player)
|
||||
: MATCH_TEXTS.computerScore(score.computer),
|
||||
);
|
||||
}
|
||||
if (score === null || state.match === null) {
|
||||
ui.matchLine.textContent = "";
|
||||
ui.matchLine.removeAttribute("aria-label");
|
||||
return;
|
||||
}
|
||||
ui.matchLine.textContent = MATCH_TEXTS.matchLine(score.length, state.match.crawford);
|
||||
ui.matchLine.setAttribute(
|
||||
"aria-label",
|
||||
`${MATCH_TEXTS.scoreSpoken(score.length, score.computer, score.player)}${
|
||||
state.match.crawford ? ", partie Crawford" : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */
|
||||
function renderStrips(pips: Readonly<Record<Side, number>>, turn: Side | null): void {
|
||||
const lead = pipLead(pips);
|
||||
@@ -681,7 +729,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.panelText.textContent = end.title;
|
||||
detail.textContent = end.detail;
|
||||
ui.panelSecondary.textContent = TEXTS.reviewGame;
|
||||
ui.panelPrimary.textContent = TEXTS.newGame;
|
||||
ui.panelPrimary.textContent = afterGameLabel(g);
|
||||
ui.panelVeil.dataset.kind = "over";
|
||||
} else if (ask) {
|
||||
overline.textContent = "";
|
||||
@@ -736,6 +784,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
if (navigation.route.screen === "newGame") {
|
||||
newGameView.render(busy || game === null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
|
||||
@@ -850,7 +901,7 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const decor = {
|
||||
dice: shownDice(game, false),
|
||||
cube: game.state.cube,
|
||||
cube: game.state.match?.crawford === true ? null : game.state.cube,
|
||||
...NO_MARKS,
|
||||
band: null,
|
||||
controls: null,
|
||||
@@ -935,24 +986,27 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
function startNewGame(setup?: GameSetup): void {
|
||||
game = options.newGame(setup);
|
||||
/** Commence une partie : celle des choix de l'écran « Nouvelle partie »
|
||||
* si rien d'autre n'est demandé. */
|
||||
function startNewGame(chosen: NewGame = chosenGame(settings)): void {
|
||||
game = options.newGame(chosen);
|
||||
panelDismissed = false;
|
||||
announcedEnd = null;
|
||||
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
|
||||
const opening = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
info =
|
||||
setup !== undefined
|
||||
chosen.setup !== undefined
|
||||
? TEXTS.startedFromPosition
|
||||
: opening?.type === "DiceRolled"
|
||||
? TEXTS.opening(opening.roll[0], opening.roll[1])
|
||||
: "";
|
||||
}
|
||||
|
||||
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
|
||||
* position) : tout de suite, sans confirmation ; une partie en cours
|
||||
* reste reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(setup?: GameSetup): void {
|
||||
/** Une nouvelle partie (« Commencer » de l'écran « Nouvelle partie »,
|
||||
* « Jouer à partir d'ici » depuis une position, « Partie suivante » d'un
|
||||
* match) : tout de suite, sans confirmation ; une partie en cours reste
|
||||
* reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(chosen?: NewGame): void {
|
||||
closeOverlays();
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
@@ -961,7 +1015,19 @@ export function startApp(options: AppOptions): void {
|
||||
if (old !== null) {
|
||||
showBanner(old);
|
||||
}
|
||||
void act(() => startNewGame(setup));
|
||||
void act(() => startNewGame(chosen));
|
||||
}
|
||||
|
||||
/** Après une partie finie : la partie suivante du match s'il continue,
|
||||
* sinon l'écran « Nouvelle partie ». */
|
||||
function afterGame(): void {
|
||||
const next = game?.nextGame ?? null;
|
||||
if (next !== null) {
|
||||
newGame(next);
|
||||
} else {
|
||||
closeOverlays();
|
||||
navigation.go(routeHash({ screen: "newGame" }));
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(journal: GameJournal): void {
|
||||
@@ -1101,8 +1167,8 @@ export function startApp(options: AppOptions): void {
|
||||
drop: () => {
|
||||
const g = canAct();
|
||||
if (g?.state.phase === "doubled" && g.state.turn === "computer") {
|
||||
void act((g) => {
|
||||
g.drop();
|
||||
void act(async (g) => {
|
||||
await g.drop();
|
||||
info = "";
|
||||
});
|
||||
}
|
||||
@@ -1129,7 +1195,7 @@ export function startApp(options: AppOptions): void {
|
||||
selected = null;
|
||||
void settle(true);
|
||||
},
|
||||
again: () => newGame(),
|
||||
again: () => afterGame(),
|
||||
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
|
||||
// lien) ; le refermer, revenir à l'écran d'avant.
|
||||
menu: () => {
|
||||
@@ -1161,6 +1227,8 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave(isHelpScreen);
|
||||
} else if (route.screen === "position") {
|
||||
navigation.leave((r) => r.screen === "position");
|
||||
} else if (route.screen === "newGame") {
|
||||
navigation.leave((r) => r.screen === "newGame");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
}
|
||||
@@ -1233,7 +1301,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.take();
|
||||
} else {
|
||||
newGame();
|
||||
afterGame();
|
||||
}
|
||||
};
|
||||
ui.panelSecondary.onclick = () => {
|
||||
@@ -1250,12 +1318,12 @@ export function startApp(options: AppOptions): void {
|
||||
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
|
||||
// brancher. Les liens du menu remplacent son adresse : le retour du
|
||||
// téléphone ne rouvre pas le menu.
|
||||
replacingLink(ui.menuNewGame, navigation);
|
||||
replacingLink(ui.menuGames, navigation);
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuNewGame.onclick = () => newGame();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
closeOverlays();
|
||||
@@ -1303,7 +1371,8 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const page = help || position || home || tutorial;
|
||||
const choosing = below.screen === "newGame";
|
||||
const page = help || position || home || tutorial || choosing;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1311,9 +1380,16 @@ export function startApp(options: AppOptions): void {
|
||||
ui.position.hidden = !position;
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel et le premier lancement ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
}
|
||||
ui.root.dataset.screen = below.screen;
|
||||
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) {
|
||||
window.scrollTo(0, 0);
|
||||
@@ -1397,18 +1473,29 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const homeView = createHomeView({
|
||||
section: ui.home,
|
||||
newGame: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.go("#/");
|
||||
newGame();
|
||||
},
|
||||
// « Nouvelle partie » : l'écran des choix, qui la commence.
|
||||
newGame: () => navigation.go(routeHash({ screen: "newGame" })),
|
||||
install: () => void install(false),
|
||||
dismissInstall: () => saveSettings(dismissInstall(settings)),
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
change: (changes) => saveSettings(changes),
|
||||
start: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
|
||||
let progressLoading = false;
|
||||
function renderHome(): void {
|
||||
const playing = game === null ? null : game.state.phase !== "over";
|
||||
@@ -1616,7 +1703,7 @@ export function startApp(options: AppOptions): void {
|
||||
// La nouvelle partie est l'écran de jeu, à la place de la position
|
||||
// (le retour du téléphone ne rouvre pas la position).
|
||||
navigation.replace("#/");
|
||||
newGame(setupOf(shared));
|
||||
newGame({ level: chosenGame(settings).level, setup: setupOf(shared) });
|
||||
};
|
||||
ui.positionCopy.onclick = () => {
|
||||
if (shownPosition !== null) {
|
||||
@@ -2032,12 +2119,46 @@ function endParts(game: Game): { title: string; detail: string } {
|
||||
}
|
||||
const { winner, kind, points } = result;
|
||||
const dropped = game.events.some((e) => e.type === "CubeDropped");
|
||||
const { match, matchAfter } = game.state;
|
||||
if (match !== null && matchAfter !== null) {
|
||||
const { player, computer } = matchAfter.score;
|
||||
const detail = MATCH_TEXTS.matchDetail(
|
||||
winner,
|
||||
points,
|
||||
match.length,
|
||||
player,
|
||||
computer,
|
||||
matchAfter.over,
|
||||
);
|
||||
return {
|
||||
title: matchAfter.over
|
||||
? MATCH_TEXTS.matchWonTitle(winner)
|
||||
: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: TEXTS.endDetail(winner, points, game.state.cube.value),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le score du match à montrer : avant la partie, puis après une fois
|
||||
* qu'elle est finie (null en partie libre). */
|
||||
function matchScore(state: GameState): MatchScore | null {
|
||||
if (state.match === null) {
|
||||
return null;
|
||||
}
|
||||
const score = state.matchAfter?.score ?? state.match.score;
|
||||
return { length: state.match.length, computer: score.computer, player: score.player };
|
||||
}
|
||||
|
||||
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
|
||||
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
|
||||
function afterGameLabel(game: Game): string {
|
||||
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
|
||||
* panneau fermé). */
|
||||
function resultText(game: Game): string {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./board-model.js";
|
||||
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
|
||||
import { ICON_PATHS, type IconName } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
@@ -49,12 +50,22 @@ export interface BoardControls {
|
||||
readonly back: "undo" | "replay";
|
||||
readonly backEnabled: boolean;
|
||||
readonly menuEnabled: boolean;
|
||||
/** Le score du match, sous le menu (null : partie libre). */
|
||||
readonly match: MatchScore | null;
|
||||
}
|
||||
|
||||
/** Le score d'un match, à afficher. */
|
||||
export interface MatchScore {
|
||||
readonly length: number;
|
||||
readonly computer: number;
|
||||
readonly player: number;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
readonly dice: readonly ShownDie[];
|
||||
readonly cube: Cube;
|
||||
/** null : pas de videau (partie Crawford). */
|
||||
readonly cube: Cube | null;
|
||||
/** Les pions que le joueur peut déplacer. */
|
||||
readonly movable: ReadonlySet<Place>;
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
@@ -181,7 +192,7 @@ export function renderBoard(
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = drawCube(svg, g, decor.cube);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
@@ -197,7 +208,7 @@ export function renderBoard(
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
if (decor.canDouble && cube !== null && decor.cube !== null) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
@@ -513,6 +524,49 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
|
||||
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
|
||||
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
|
||||
*/
|
||||
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
|
||||
const line = g.chipFont * 1.2;
|
||||
const width = g.left - 12;
|
||||
const group = add(svg, "g", {
|
||||
class: "match-box",
|
||||
role: "img",
|
||||
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
|
||||
});
|
||||
add(group, "rect", {
|
||||
class: "match-box-frame",
|
||||
x: x - width / 2,
|
||||
y: top,
|
||||
width,
|
||||
height: 3 * line + 8,
|
||||
rx: 6,
|
||||
});
|
||||
const texts: [string, string][] = [
|
||||
[String(match.computer), "match-box-score"],
|
||||
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
|
||||
[String(match.player), "match-box-score"],
|
||||
];
|
||||
for (const [i, [text, cssClass]] of texts.entries()) {
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: cssClass,
|
||||
x,
|
||||
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
"text-anchor": "middle",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
@@ -557,6 +611,9 @@ function drawControls(
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
if (controls.match !== null) {
|
||||
drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match);
|
||||
}
|
||||
if (controls.back === "replay") {
|
||||
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
|
||||
} else {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
|
||||
// descriptions des niveaux sont ceux de la charte ; le reste est une
|
||||
// première version, à valider par Pierre.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ComputerLevel } from "../domain/game/level.js";
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const MATCH_TEXTS = {
|
||||
// Écran « Nouvelle partie »
|
||||
title: "Nouvelle partie",
|
||||
close: "Fermer sans rien changer",
|
||||
kindLabel: "Type de partie",
|
||||
free: "Partie libre",
|
||||
match: "Match",
|
||||
/** « Le premier à [3] [5] [7] [11] points ». */
|
||||
lengthBefore: "Le premier à",
|
||||
lengthAfter: "points",
|
||||
lengthLabel: (n: number) => `Match en ${n} points`,
|
||||
crawfordRule:
|
||||
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
|
||||
crawfordLink: "règle de Crawford",
|
||||
jacobyRule:
|
||||
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
|
||||
jacobyLink: "règle de Jacoby",
|
||||
levelTitle: "Niveau de l'ordinateur",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Débutant",
|
||||
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
|
||||
},
|
||||
amateur: {
|
||||
name: "Amateur",
|
||||
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
|
||||
},
|
||||
intermediate: {
|
||||
name: "Intermédiaire",
|
||||
text: "Un habitué de la table : solide, sans être implacable.",
|
||||
},
|
||||
advanced: {
|
||||
name: "Avancé",
|
||||
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
|
||||
},
|
||||
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
|
||||
champion: {
|
||||
name: "Champion",
|
||||
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
|
||||
},
|
||||
} satisfies Record<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" : ""}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
/** Paysage : « sur 5 ». */
|
||||
outOf: (length: number) => `sur ${length}`,
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie de match
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
|
||||
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
|
||||
matchDetail: (
|
||||
winner: "player" | "computer",
|
||||
n: number,
|
||||
length: number,
|
||||
player: number,
|
||||
computer: number,
|
||||
over: boolean,
|
||||
) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
|
||||
over ? "Score final" : `Match en ${length} points`
|
||||
} : vous ${player}, l'ordinateur ${computer}.`,
|
||||
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
|
||||
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
|
||||
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
|
||||
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
|
||||
// en cours ; seul « Commencer » en lance une.
|
||||
|
||||
import { COMPUTER_LEVELS } from "../domain/game/level.js";
|
||||
import { MATCH_LENGTHS } from "../domain/game/match.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
import type { Settings } from "./settings.js";
|
||||
|
||||
export interface NewGameViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly settings: () => Settings;
|
||||
/** Un choix a changé : le garder. */
|
||||
readonly change: (changes: Partial<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 });
|
||||
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(body, "div", { className: "segmented" });
|
||||
kinds.setAttribute("role", "radiogroup");
|
||||
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
|
||||
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
|
||||
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(body, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
const lengths = element(matchOptions, "div", { className: "lengths" });
|
||||
lengths.setAttribute("role", "radiogroup");
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
|
||||
const lengthButtons = MATCH_LENGTHS.map((n) => {
|
||||
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
|
||||
button.addEventListener("click", () => options.change({ matchLength: n }));
|
||||
return button;
|
||||
});
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
|
||||
rule(
|
||||
matchOptions,
|
||||
"new-game-crawford",
|
||||
MATCH_TEXTS.crawfordRule,
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(body, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
rule(
|
||||
freeOptions,
|
||||
"new-game-jacoby",
|
||||
MATCH_TEXTS.jacobyRule,
|
||||
MATCH_TEXTS.jacobyLink,
|
||||
"regle-de-jacoby",
|
||||
);
|
||||
|
||||
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(body, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
|
||||
button.dataset.level = level;
|
||||
element(button, "span", { className: "level-bar" });
|
||||
button.addEventListener("click", () => options.change({ level }));
|
||||
return button;
|
||||
});
|
||||
const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" });
|
||||
const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" });
|
||||
|
||||
// 3. Pendant la partie.
|
||||
element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame });
|
||||
const advice = element(body, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
|
||||
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
|
||||
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
|
||||
advice.addEventListener("click", () =>
|
||||
options.change({ masterAdvice: !options.settings().masterAdvice }),
|
||||
);
|
||||
|
||||
// 4. Barre d'action collée en bas.
|
||||
const actions = element(section, "div", { className: "new-game-actions" });
|
||||
const start = element(actions, "button", { id: "new-game-start" });
|
||||
start.type = "button";
|
||||
start.addEventListener("click", () => options.start());
|
||||
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
|
||||
|
||||
return {
|
||||
closeLink,
|
||||
render(busy) {
|
||||
const s = options.settings();
|
||||
const isMatch = s.gameKind === "match";
|
||||
free.setAttribute("aria-checked", String(!isMatch));
|
||||
match.setAttribute("aria-checked", String(isMatch));
|
||||
matchOptions.hidden = !isMatch;
|
||||
freeOptions.hidden = isMatch;
|
||||
for (const [i, button] of lengthButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
|
||||
}
|
||||
const chosen = COMPUTER_LEVELS.indexOf(s.level);
|
||||
for (const [i, button] of levelButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(i === chosen));
|
||||
button.classList.toggle("reached", i <= chosen);
|
||||
}
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
+198
-16
@@ -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,170 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () =
|
||||
assert.equal(swapped.player.pipCount, 2);
|
||||
assert.equal(swapped.computer.pipCount, 1);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Match (1c)
|
||||
|
||||
test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => {
|
||||
const cubes: EngineCube[] = [];
|
||||
const levels: Level[] = [];
|
||||
const engine = fakeEngine({
|
||||
levelDouble: async (_position, cube, level) => {
|
||||
cubes.push(cube);
|
||||
levels.push(level);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false };
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1, 4, 2]),
|
||||
level: "intermediate",
|
||||
match,
|
||||
});
|
||||
const start = game.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "intermediate");
|
||||
assert.deepEqual(start.match, match);
|
||||
assert.equal(game.state.level, "intermediate");
|
||||
// L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite
|
||||
// l'ordinateur pense à doubler, et le match lui est donné vu de lui.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.deepEqual(levels, ["intermediate"]);
|
||||
assert.deepEqual(cubes[0]?.match, {
|
||||
length: 5,
|
||||
score: { onRoll: 3, opponent: 1 },
|
||||
crawford: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => {
|
||||
const winning: Board = {
|
||||
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
|
||||
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
|
||||
swappedPosition: "SWAPPED",
|
||||
result: { winner: "onRoll", kind: "gammon", points: 2 },
|
||||
};
|
||||
const engine = fakeEngine({ board: async () => winning });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([5, 2]),
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
assert.equal(game.nextGame, null);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const end = game.events.at(-1);
|
||||
assert.ok(end?.type === "GameEnded");
|
||||
assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true });
|
||||
const next = game.nextGame;
|
||||
assert.deepEqual(next, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 2, computer: 0 }, crawford: true },
|
||||
});
|
||||
|
||||
// La partie Crawford, gagnée : le match est fini, plus de partie suivante.
|
||||
const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) });
|
||||
await crawford.playHalfMove({ from: 8, to: 5 });
|
||||
await crawford.playHalfMove({ from: 6, to: 5 });
|
||||
await crawford.confirmMove();
|
||||
assert.deepEqual(crawford.state.matchAfter, {
|
||||
score: { player: 4, computer: 0 },
|
||||
over: true,
|
||||
crawford: false,
|
||||
});
|
||||
assert.equal(crawford.nextGame, null);
|
||||
});
|
||||
|
||||
test("passer un double dans un match : le moteur compte le match", async () => {
|
||||
const engine = fakeEngine({ levelDouble: async () => true });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1]),
|
||||
match: { length: 3, score: { player: 0, computer: 1 }, crawford: false },
|
||||
});
|
||||
// L'ordinateur joue son jet d'ouverture, le joueur son coup, puis
|
||||
// l'ordinateur double.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled");
|
||||
await game.drop();
|
||||
assert.deepEqual(game.state.matchAfter, {
|
||||
score: { player: 0, computer: 2 },
|
||||
over: false,
|
||||
crawford: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie libre n'a ni match ni partie suivante", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, DEFAULT_LEVEL);
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
|
||||
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
|
||||
// sans level ni match, un coup joué, une branche.
|
||||
const v1 = {
|
||||
events: [
|
||||
{
|
||||
id: "1",
|
||||
parent: null,
|
||||
version: 1,
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
},
|
||||
{ id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] },
|
||||
{
|
||||
id: "3",
|
||||
parent: "2",
|
||||
version: 1,
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 6, to: 5 },
|
||||
],
|
||||
position: "SWAPPED",
|
||||
},
|
||||
{ id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] },
|
||||
],
|
||||
head: "3",
|
||||
} as unknown as GameJournal;
|
||||
const before = replay(currentLine(v1));
|
||||
const migrated = migrateJournal(v1);
|
||||
assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION));
|
||||
assert.equal(migrated.head, "3");
|
||||
const start = migrated.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "2ply");
|
||||
assert.equal(start.match, undefined);
|
||||
// Rien d'autre ne change : mêmes événements, même arbre, même état.
|
||||
const strip = (journal: GameJournal) =>
|
||||
journal.events.map(({ version: _, ...event }) => {
|
||||
const { level: _level, ...rest } = event as typeof event & { level?: unknown };
|
||||
return rest;
|
||||
});
|
||||
assert.deepEqual(strip(migrated), strip(v1));
|
||||
assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" });
|
||||
// Une version courante passe telle quelle.
|
||||
assert.equal(migrateJournal(migrated), migrated);
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1)));
|
||||
assert.equal(resumed.state.level, "2ply");
|
||||
assert.equal(resumed.state.match, null);
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
// La partie continue en version 2.
|
||||
await resumed.playComputerTurn();
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
|
||||
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
|
||||
// (settings.ts) et la session d'enregistrement ; les parties et le match,
|
||||
// par le domaine branché sur le vrai moteur, avec des dés imposés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
|
||||
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
|
||||
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
|
||||
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
|
||||
// 6-5, le joueur au trait gagne un gammon.
|
||||
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
|
||||
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
|
||||
// gagne une partie simple avec 6-5.
|
||||
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let session: GameSession;
|
||||
let game: Game;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
|
||||
async function playFirstMove(g: Game): Promise<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);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,10 @@ BeforeAll(async () => {
|
||||
|
||||
Before(() => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: (setup) =>
|
||||
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
|
||||
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
|
||||
},
|
||||
"0ply",
|
||||
);
|
||||
session = createGameSession(library, {
|
||||
start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }),
|
||||
resume: (journal) => Game.resume(engine, journal, { level: "0ply" }),
|
||||
});
|
||||
shared = null;
|
||||
failure = null;
|
||||
});
|
||||
@@ -131,7 +126,7 @@ Then("votre partie n'a pas changé", () => {
|
||||
|
||||
When("vous choisissez « Jouer à partir d'ici »", () => {
|
||||
assert.ok(shared !== null);
|
||||
game = session.newGame(setupOf(shared));
|
||||
game = session.newGame({ setup: setupOf(shared) });
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,14 +48,10 @@ let oldId: string | null;
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
|
||||
session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: () => Game.start(engine, { dice }),
|
||||
resume: (journal) => Game.resume(engine, journal, { dice }),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
session = createGameSession(library, {
|
||||
start: () => Game.start(engine, { dice }),
|
||||
resume: (journal) => Game.resume(engine, journal, { dice }),
|
||||
});
|
||||
game = session.newGame();
|
||||
dice = () => 1 + Math.floor(Math.random() * 6);
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
@@ -185,7 +181,7 @@ const shownNow = (otherBanner = false) =>
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 });
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| --- | --- | --- |
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Choisir sa partie et jouer un match
|
||||
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin
|
||||
d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
|
||||
Scénario: Comment choisir le niveau de l'ordinateur ?
|
||||
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
|
||||
GNU Backgammon à son meilleur niveau sur ce téléphone. La première fois,
|
||||
c'est une partie libre contre un ordinateur Intermédiaire, avec le
|
||||
conseil du maître. L'ordinateur garde le niveau choisi toute la partie,
|
||||
même si vous fermez l'application.
|
||||
|
||||
Étant donné que vous ouvrez l'écran « Nouvelle partie » pour la première fois
|
||||
Alors une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée
|
||||
Quand vous choisissez le niveau Champion et commencez la partie
|
||||
Alors l'ordinateur joue au niveau 2 plis de GNU Backgammon
|
||||
Et la partie reprise garde ce niveau
|
||||
Et vos choix sont proposés à la prochaine nouvelle partie
|
||||
|
||||
Scénario: Comment jouer un match ?
|
||||
Un match se joue en plusieurs parties : chacune rapporte ses points au
|
||||
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
|
||||
valeur du videau), et le premier qui atteint le nombre de points du
|
||||
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
|
||||
suivante, avec le score du match.
|
||||
|
||||
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
|
||||
Quand le match se joue jusqu'au bout, partie après partie
|
||||
Alors le match se termine dès qu'un joueur atteint 3 points
|
||||
Et chaque partie commence avec le score laissé par la précédente
|
||||
Et la partie Crawford s'est jouée sans videau
|
||||
|
||||
Scénario: Qu'est-ce que la partie Crawford ?
|
||||
Dans un match, quand un joueur arrive à un point de la victoire, la
|
||||
partie suivante se joue sans videau : personne ne peut doubler. C'est la
|
||||
règle de Crawford ; l'écran l'annonce par « partie Crawford ». Dans les
|
||||
parties d'après, celui qui est mené peut de nouveau doubler.
|
||||
|
||||
Étant donné qu'un match en 3 points commence et que l'ordinateur va gagner un gammon
|
||||
Quand l'ordinateur gagne la partie
|
||||
Alors l'ordinateur mène le match 2 à 0
|
||||
Et la partie suivante est la partie Crawford
|
||||
Quand la partie Crawford commence
|
||||
Alors personne ne peut doubler pendant cette partie
|
||||
Quand vous gagnez la partie Crawford
|
||||
Alors vous pouvez de nouveau doubler dans la partie suivante
|
||||
|
||||
Scénario: Pourquoi mon gammon ne m'a-t-il rapporté qu'un point ?
|
||||
En partie libre, la règle de Jacoby s'applique : tant que personne n'a
|
||||
doublé, un gammon ou un backgammon ne rapporte qu'un point. Elle
|
||||
encourage à doubler plutôt qu'à jouer le gammon sans risque. En match,
|
||||
elle ne s'applique pas : le gammon compte toujours double.
|
||||
|
||||
Étant donné que vous allez gagner un gammon sans que personne ait doublé
|
||||
Quand vous gagnez une partie libre
|
||||
Alors vous marquez 1 point
|
||||
Quand vous gagnez la même partie dans un match
|
||||
Alors vous marquez 2 points
|
||||
+116
-18
@@ -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
@@ -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
@@ -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
@@ -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 "$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"
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué
|
||||
// jusqu'au bout par l'interface, dans un vrai navigateur sans écran
|
||||
// (Playwright), sur le site servi par l'image de production
|
||||
// (docs/plans/2026-11-03-match-et-reglages.md, point 6).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node match.mjs <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 », score du match et partie Crawford, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node match.mjs <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 };
|
||||
// La graine des dés : un match qui passe par une partie Crawford.
|
||||
const SEED = 7;
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
// Sans animation : le match va plus vite, rien d'autre ne change.
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript((seed) => {
|
||||
let state = seed;
|
||||
Math.random = () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
}, SEED);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 60_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */
|
||||
async function todo() {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const what = await page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const panel = document.getElementById("panel-veil");
|
||||
if (!panel.hidden) {
|
||||
return panel.dataset.kind;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||||
});
|
||||
if (what !== null) {
|
||||
return what;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`);
|
||||
}
|
||||
|
||||
/** Le match tel que l'écran le montre (portrait). */
|
||||
async function shownMatch() {
|
||||
return page.evaluate(() => ({
|
||||
line: document.getElementById("match-line").textContent,
|
||||
lineShown: !document.getElementById("match-line").hidden,
|
||||
spoken: document.getElementById("match-line").getAttribute("aria-label"),
|
||||
computer: document.querySelector("#strip-computer .match-score").textContent,
|
||||
player: document.querySelector("#strip-player .match-score").textContent,
|
||||
cube: document.querySelectorAll("#board .cube").length,
|
||||
double: !document.getElementById("double").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Écran « Nouvelle partie »
|
||||
|
||||
await page.goto(`${base}#/nouvelle-partie`);
|
||||
await until("moteur chargé", () => !document.getElementById("new-game-start").disabled);
|
||||
const screen = () =>
|
||||
page.evaluate(() => ({
|
||||
free: document.getElementById("new-game-free").getAttribute("aria-checked"),
|
||||
match: document.getElementById("new-game-match").getAttribute("aria-checked"),
|
||||
lengthsShown: !document.getElementById("new-game-match-options").hidden,
|
||||
jacobyShown: !document.getElementById("new-game-free-options").hidden,
|
||||
level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level,
|
||||
reached: document.querySelectorAll(".level-bars .reached").length,
|
||||
name: document.getElementById("new-game-level-name").textContent,
|
||||
advice: document.getElementById("new-game-advice").getAttribute("aria-checked"),
|
||||
start: document.getElementById("new-game-start").textContent,
|
||||
topbar: document.querySelector(".topbar").getClientRects().length > 0,
|
||||
game: !document.getElementById("game").hidden,
|
||||
}));
|
||||
let s = await screen();
|
||||
check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown,
|
||||
`par défaut, partie libre : ${JSON.stringify(s)}`);
|
||||
check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire",
|
||||
`par défaut, Intermédiaire : ${JSON.stringify(s)}`);
|
||||
check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`);
|
||||
check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie");
|
||||
check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby",
|
||||
"lien vers la règle de Jacoby");
|
||||
// Les cibles tactiles : 44 px au moins.
|
||||
const small = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#new-game button, #new-game a")]
|
||||
.filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null)
|
||||
.map((el) => el.getBoundingClientRect())
|
||||
.filter((r) => r.width < 44 || r.height < 44).length,
|
||||
);
|
||||
check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`);
|
||||
|
||||
await page.click("#new-game-match");
|
||||
await page.click("#new-game-length-3");
|
||||
await page.click("#new-game-level-beginner");
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
|
||||
`match en 3 points : ${JSON.stringify(s)}`);
|
||||
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
|
||||
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
|
||||
"lien vers la règle de Crawford");
|
||||
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
|
||||
"description du niveau Débutant");
|
||||
await capture("1-nouvelle-partie");
|
||||
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
|
||||
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
|
||||
await page.click("#new-game-close");
|
||||
await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden);
|
||||
const settings = JSON.parse(before);
|
||||
check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner",
|
||||
`choix gardés : ${before}`);
|
||||
// Le menu y ramène, avec les choix de la dernière fois.
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-new-game");
|
||||
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden);
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
|
||||
`choix retrouvés : ${JSON.stringify(s)}`);
|
||||
await page.click("#new-game-start");
|
||||
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
|
||||
log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés");
|
||||
|
||||
// ------------------------------------------------------------- Le match
|
||||
|
||||
let m = await shownMatch();
|
||||
check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0",
|
||||
`début du match : ${JSON.stringify(m)}`);
|
||||
check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`);
|
||||
|
||||
// Un match ne passe pas toujours par une partie Crawford (un gammon peut
|
||||
// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les
|
||||
// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus).
|
||||
let games = 1;
|
||||
let crawfordGames = 0;
|
||||
let scoreShot = false;
|
||||
let crawfordShot = false;
|
||||
let reloaded = false;
|
||||
let actions = 0;
|
||||
let matches = 1;
|
||||
for (;;) {
|
||||
check(actions++ < 20000, "le match ne finit pas");
|
||||
const what = await todo();
|
||||
const now = await shownMatch();
|
||||
const crawford = now.line.endsWith("· partie Crawford");
|
||||
if (crawford && what !== "over") {
|
||||
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
|
||||
}
|
||||
if (what === "over") {
|
||||
const title = await page.textContent("#panel-text");
|
||||
const detail = await page.textContent("#panel-detail");
|
||||
const primary = await page.textContent("#panel-primary");
|
||||
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
|
||||
if (primary === "Nouvelle partie") {
|
||||
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
|
||||
`fin du match : ${title} ; ${detail}`);
|
||||
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
|
||||
check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`);
|
||||
// Paysage : le score dans la colonne de commandes.
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const box = await page.evaluate(() =>
|
||||
document.querySelector("#board .match-box")?.getAttribute("aria-label"));
|
||||
check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""),
|
||||
`score en paysage : « ${box} »`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
// Après le match, « Nouvelle partie » mène à l'écran des choix.
|
||||
await page.click("#panel-primary");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie");
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
if (crawfordGames > 0 && scoreShot) {
|
||||
break;
|
||||
}
|
||||
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
|
||||
matches++;
|
||||
games = 1;
|
||||
await page.click("#new-game-start");
|
||||
await until("match suivant", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
continue;
|
||||
}
|
||||
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
|
||||
`fin de partie dans un match : ${primary} ; ${detail}`);
|
||||
await page.click("#panel-primary");
|
||||
games++;
|
||||
await until("partie suivante", () => !document.body.classList.contains("busy"));
|
||||
const next = await shownMatch();
|
||||
if (next.line.endsWith("· partie Crawford")) {
|
||||
crawfordGames++;
|
||||
check(next.cube === 0, "partie Crawford : le videau est dessiné");
|
||||
if (!crawfordShot) {
|
||||
crawfordShot = true;
|
||||
await capture("3-partie-crawford");
|
||||
}
|
||||
} else if (!scoreShot) {
|
||||
scoreShot = true;
|
||||
await capture("2-score-du-match");
|
||||
}
|
||||
if (!reloaded) {
|
||||
// Le match continue après un rechargement de la page.
|
||||
reloaded = true;
|
||||
// L'application enregistre sans attendre : on laisse IndexedDB
|
||||
// finir d'écrire la nouvelle partie (Firefox peut être lent).
|
||||
await until("partie suivante enregistrée", (score) => new Promise((resolve) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onerror = () => resolve(false);
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction(["settings", "games"]);
|
||||
const current = tx.objectStore("settings").get("current");
|
||||
current.onsuccess = () => {
|
||||
const id = current.result?.value;
|
||||
if (typeof id !== "string") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const summary = tx.objectStore("games").get(id);
|
||||
summary.onsuccess = () => {
|
||||
const s = summary.result;
|
||||
resolve(s?.status === "playing" &&
|
||||
`${s.match?.score.computer}-${s.match?.score.player}` === score);
|
||||
open.result.close();
|
||||
};
|
||||
};
|
||||
};
|
||||
}), `${next.computer}-${next.player}`).catch(async (error) => {
|
||||
const stored = await page.evaluate(() => new Promise((resolve) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onerror = () => resolve(`IndexedDB : ${open.error}`);
|
||||
open.onsuccess = () => {
|
||||
const all = open.result.transaction(["settings", "games"]);
|
||||
const current = all.objectStore("settings").get("current");
|
||||
const games = all.objectStore("games").getAll();
|
||||
games.onsuccess = () => resolve(JSON.stringify({
|
||||
current: current.result,
|
||||
games: games.result.map((g) => [g.id, g.status, g.match, g.updatedAt]),
|
||||
local: Object.keys(localStorage),
|
||||
}));
|
||||
};
|
||||
}));
|
||||
throw new Error(`${error.message} ; enregistré : ${stored}`);
|
||||
});
|
||||
await page.reload();
|
||||
await until("partie reprise", () => !document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === next.line && again.player === next.player && again.computer === next.computer,
|
||||
`après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Les doubles de l'ordinateur sont passés : le match avance d'un point
|
||||
// à la fois, ce qui rend la partie Crawford probable.
|
||||
if (what === "doubled") {
|
||||
await page.click("#panel-secondary");
|
||||
} else if (what === "take") {
|
||||
await page.click("#drop");
|
||||
} else if (what === "skip" || what === "confirm" || what === "roll") {
|
||||
await page.click(`#${what}`);
|
||||
} else if (what === "move") {
|
||||
await page.click(".hit.can-move");
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
await page.click(".hit.can-land");
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` +
|
||||
`${actions} actions`);
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`);
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`);
|
||||
+18
-4
@@ -923,8 +923,7 @@ async function checkAddresses() {
|
||||
for (const selector of ["#menu-games", "#menu-help"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
|
||||
"« Nouvelle partie » n'est plus un bouton");
|
||||
check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : menu fermé", playing);
|
||||
await tab.click("#menu-button");
|
||||
@@ -985,8 +984,20 @@ async function openMenu() {
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
|
||||
* le journal de la partie quittée. */
|
||||
/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie
|
||||
* choisie, et l'adresse redevient celle de la partie. */
|
||||
async function startChosenGame() {
|
||||
await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas");
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`);
|
||||
await page.click("#new-game-start");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
|
||||
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
const before = await savedJournal();
|
||||
const started = () =>
|
||||
@@ -995,6 +1006,7 @@ async function newGameFromMenu() {
|
||||
await openMenu();
|
||||
const start = Date.now();
|
||||
await page.click("#menu-new-game");
|
||||
await startChosenGame();
|
||||
check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »");
|
||||
check((await page.textContent("#banner")).includes("Reprendre l'ancienne"),
|
||||
`bandeau : « ${await page.textContent("#banner")} »`);
|
||||
@@ -1558,6 +1570,7 @@ step("mes parties");
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1639,6 +1652,7 @@ log("compte de points : bulle avec la fiche du glossaire");
|
||||
step("thème sombre");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
@@ -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]\]//')" ] \
|
||||
|
||||
+245
@@ -2260,6 +2260,251 @@ html.landscape .welcome {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Match */
|
||||
|
||||
/* « Match en 5 points » sous la barre haute (portrait), hauteur fixe. */
|
||||
.match-line {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Le score de chaque camp, dans son bandeau, juste avant son compte de
|
||||
* points : une pastille carrée. */
|
||||
.strip .match-score {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Paysage : le score dans la colonne de commandes, sous le menu. */
|
||||
.match-box-frame {
|
||||
fill: none;
|
||||
stroke: var(--point-number);
|
||||
stroke-opacity: 0.5;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.match-box-score {
|
||||
font-weight: 700;
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
.match-box-of {
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Nouvelle partie */
|
||||
|
||||
/* La page remplit l'écran : la barre d'action reste collée en bas, même
|
||||
* quand le contenu est plus court que l'écran. */
|
||||
.new-game {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.new-game .new-game-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-game-head {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.new-game-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.new-game-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.new-game-body .overline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Sélecteur à deux segments : fond creusé, segment choisi en relief. */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.segmented .segment {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.segmented .segment[aria-checked="true"] {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.new-game-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lengths {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lengths .length {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lengths .length[aria-checked="true"] {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.new-game-rule {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Six barres de hauteur croissante ; celles jusqu'au niveau choisi sont en
|
||||
* brique, le niveau choisi porte l'anneau de focus. */
|
||||
.level-bars {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.level-bars .level {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
min-width: 44px;
|
||||
height: 84px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.level-bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.level-1 .level-bar { height: 18px; }
|
||||
.level-2 .level-bar { height: 28px; }
|
||||
.level-3 .level-bar { height: 38px; }
|
||||
.level-4 .level-bar { height: 50px; }
|
||||
.level-5 .level-bar { height: 62px; }
|
||||
.level-6 .level-bar { height: 76px; }
|
||||
|
||||
.level-bars .level.reached .level-bar {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.level-bars .level[aria-checked="true"] {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.level-name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.new-game-body .item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.new-game-body .item-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.new-game-body .item small {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Barre d'action collée en bas : le seul bouton principal de l'écran. */
|
||||
.new-game-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
padding: var(--space-3) 0 var(--space-4);
|
||||
background: var(--background);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.new-game-kept {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
|
||||
+9
-1
@@ -39,10 +39,13 @@
|
||||
</header>
|
||||
|
||||
<main id="game" class="game">
|
||||
<!-- Dans un match : « Match en 5 points », et la partie Crawford. -->
|
||||
<p id="match-line" class="match-line overline" hidden></p>
|
||||
<div id="strip-computer" class="strip">
|
||||
<span class="mini mini-computer"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="board-box">
|
||||
@@ -57,6 +60,7 @@
|
||||
<span class="mini mini-player"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="infoline">
|
||||
@@ -89,6 +93,10 @@
|
||||
<section id="home" class="home page" hidden></section>
|
||||
<section id="tutorial" class="tutorial page" hidden></section>
|
||||
|
||||
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
|
||||
app/src/ui/new-game-view.ts. -->
|
||||
<section id="new-game" class="new-game page" hidden></section>
|
||||
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
|
||||
@@ -126,7 +134,7 @@
|
||||
<h2 id="menu-title" class="sheet-title"></h2>
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<a id="menu-new-game" href="#/nouvelle-partie" class="item"></a>
|
||||
<a id="menu-games" href="#/parties" class="item"></a>
|
||||
<a id="menu-home" href="#/accueil" class="item"></a>
|
||||
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
|
||||
|
||||
Reference in New Issue
Block a user