Compare commits
111
Commits
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
|
||||
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
|
||||
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
|
||||
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
|
||||
REFERENCE_IMAGE
|
||||
|
||||
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
|
||||
# build/ lui appartienne.
|
||||
@@ -107,8 +108,6 @@ lint: ## Vérifie le style et les erreurs courantes
|
||||
deploy: site ## Sert l'application sur le réseau local (Ctrl+C pour arrêter)
|
||||
@ip="$$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($$i == "src") print $$(i + 1) }')"; \
|
||||
echo "Application : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/"; \
|
||||
echo "Test du moteur : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/"; \
|
||||
echo "Page de mesure : http://$${ip:-ADRESSE-DU-PC}:$(WEB_PORT)/moteur/bench.html"; \
|
||||
echo "À ouvrir depuis le téléphone, sur le même Wi-Fi. Ctrl+C pour arrêter."
|
||||
@docker run --rm --init -p $(WEB_PORT):8080 -v "$(CURDIR)/$(BUILD_DIR)/site:/site:ro" \
|
||||
$(BUILD_C_IMAGE) python3 -m http.server 8080 --directory /site
|
||||
@@ -123,13 +122,14 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
|
||||
|
||||
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
|
||||
# - web/ à la racine : la page de l'application, son style, sa police, le
|
||||
# manifeste, le Web Worker du moteur, et les pages de test sous moteur/ ;
|
||||
# manifeste et le Web Worker du moteur ;
|
||||
# - sous icons/, le logo et les icônes de la charte, copiés depuis
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - l'application compilée (build/web/src/) sous app/ ;
|
||||
# - le moteur sous wasm/ ;
|
||||
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
|
||||
# l'application (ADR 0008 : jamais commitées ailleurs) ;
|
||||
# - les copies du glossaire, des scénarios et du tutoriel sous docs/, lues par
|
||||
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
|
||||
site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
@@ -141,7 +141,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel $(BUILD_DIR)/site/docs/
|
||||
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
|
||||
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
|
||||
scripts/service-worker.sh $(BUILD_DIR)/site
|
||||
@@ -165,8 +165,8 @@ reference-image: ## Construit l'image Docker du gnubg de référence (cache Dock
|
||||
docker build -t $(REFERENCE_IMAGE) docker/gnubg-reference
|
||||
@echo "Image $(REFERENCE_IMAGE) prête."
|
||||
|
||||
reference: reference-image ## Régénère tests/reference/results/ avec le gnubg de référence
|
||||
rm -rf tests/reference/results
|
||||
reference: reference-image ## Régénère tests/reference/results/ et analysis/ avec le gnubg de référence
|
||||
rm -rf tests/reference/results tests/reference/analysis
|
||||
docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp \
|
||||
-v "$(CURDIR)/tests/reference:/reference" $(REFERENCE_IMAGE) \
|
||||
gnubg --tty --quiet --no-rc --python=/reference/generate.py
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Les branches d'une partie : les lignes de jeu de son journal.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
|
||||
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
|
||||
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
|
||||
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
|
||||
// deux enfants d'un même événement, le plus ancien a le plus petit indice
|
||||
// dans le journal, et c'est lui qui continue la ligne d'origine.
|
||||
|
||||
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Une branche de la partie : la suite d'événements du début jusqu'à head.
|
||||
*
|
||||
* Glossaire : docs/glossaire/branche.md
|
||||
*/
|
||||
export interface Branch {
|
||||
/** Le dernier événement de la branche (une feuille de l'arbre). */
|
||||
readonly head: string;
|
||||
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
|
||||
* null pour la ligne d'origine de la partie. */
|
||||
readonly forkedFrom: string | null;
|
||||
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
|
||||
* la ligne d'origine. */
|
||||
readonly forkMoveCount: number;
|
||||
/** Les coups joués sur toute la branche, depuis le début. */
|
||||
readonly moveCount: number;
|
||||
/** La partie est finie au bout de cette branche. */
|
||||
readonly over: boolean;
|
||||
/** L'événement courant de la partie est sur cette branche. */
|
||||
readonly containsHead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les branches de la partie, dans l'ordre de leur création (celui de leur
|
||||
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
|
||||
* journal vide.
|
||||
*/
|
||||
export function branches(journal: GameJournal): Branch[] {
|
||||
const order = new Map(journal.events.map((event, index) => [event.id, index]));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
const leaves = journal.events.filter((event) => !children.has(event.id));
|
||||
return leaves
|
||||
.map((leaf) => {
|
||||
const line = currentLine({ events: journal.events, head: leaf.id });
|
||||
// Le point de départ de la branche : le parent de l'événement le
|
||||
// plus récent du chemin qui n'est pas le premier enfant de son
|
||||
// parent (la ligne d'origine n'en a aucun).
|
||||
let forkIndex = -1;
|
||||
for (let i = line.length - 1; i > 0; i--) {
|
||||
const event = line[i] as GameEvent;
|
||||
const first = children.get(event.parent as string)?.[0];
|
||||
if (first !== undefined && first.id !== event.id) {
|
||||
forkIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const moves = (events: readonly GameEvent[]) =>
|
||||
events.filter((event) => event.type === "MovePlayed").length;
|
||||
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
|
||||
const start = line[forkIndex + 1] as GameEvent;
|
||||
return {
|
||||
created: order.get(start.id) ?? 0,
|
||||
head: leaf.id,
|
||||
forkedFrom: fork?.id ?? null,
|
||||
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
|
||||
moveCount: moves(line),
|
||||
over: replay(line).phase === "over",
|
||||
containsHead: line.some((event) => event.id === journal.head),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.created - b.created)
|
||||
.map(({ created: _, ...branch }) => branch);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
|
||||
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
|
||||
if (!branches(journal).some((branch) => branch.head === head)) {
|
||||
throw new Error(`branche introuvable : ${head}`);
|
||||
}
|
||||
return goBackTo(journal, head);
|
||||
}
|
||||
@@ -38,6 +38,189 @@ export interface Engine {
|
||||
|
||||
/** Le plateau d'une position, et le résultat si la partie y est finie. */
|
||||
board(position: PositionId, cube: EngineCube): Promise<Board>;
|
||||
|
||||
/** Le coup que joue l'ordinateur de gnubg à ce niveau (bruit compris,
|
||||
* reproductible), ou null si aucun coup n'est possible. */
|
||||
levelMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
level: Level,
|
||||
): Promise<Move | null>;
|
||||
|
||||
/** 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. */
|
||||
analyseMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
played: PositionId,
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** L'analyse d'une décision de videau, position vue du joueur au trait
|
||||
* (celui qui double ou non ; take et pass sont la réponse de son
|
||||
* adversaire). */
|
||||
analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
action: CubeAction,
|
||||
ply: Ply,
|
||||
): Promise<CubeAnalysis>;
|
||||
|
||||
/** La chance d'un jet pour le joueur au trait. */
|
||||
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
|
||||
|
||||
/** 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. */
|
||||
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur
|
||||
* du match, videau, joueur au trait. Le domaine ne le décode jamais
|
||||
* lui-même : il le passe au moteur.
|
||||
*
|
||||
* Glossaire : docs/glossaire/identifiant-de-match.md
|
||||
*/
|
||||
export type MatchId = string;
|
||||
|
||||
/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1,
|
||||
* comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */
|
||||
export interface MatchDescription {
|
||||
/** 0 : partie libre. */
|
||||
readonly matchLength: number;
|
||||
readonly score: readonly [number, number];
|
||||
readonly cubeValue: number;
|
||||
/** -1 : videau au centre ; sinon le joueur qui le possède. */
|
||||
readonly cubeOwner: -1 | 0 | 1;
|
||||
readonly playerOnRoll: 0 | 1;
|
||||
readonly crawford: boolean;
|
||||
readonly jacoby: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Niveau de jeu de l'ordinateur : un réglage prédéfini de GNU Backgammon
|
||||
* (aecSettings dans eval.c ; voir engine/include/engine.h). Les quatre
|
||||
* premiers jouent à 0 pli avec un bruit qui leur fait commettre des erreurs ;
|
||||
* les suivants sont sans bruit, de 0 à 4 plis.
|
||||
*
|
||||
* Glossaire : docs/glossaire/niveau-de-jeu.md
|
||||
*/
|
||||
export type Level =
|
||||
| "beginner"
|
||||
| "casual"
|
||||
| "intermediate"
|
||||
| "advanced"
|
||||
| "0ply"
|
||||
| "1ply"
|
||||
| "2ply"
|
||||
| "3ply"
|
||||
| "4ply";
|
||||
|
||||
/**
|
||||
* Appréciation d'un coup ou d'une décision de videau par le moteur, selon
|
||||
* l'équité perdue : rien à signaler, douteux, mauvais, très mauvais.
|
||||
*
|
||||
* Glossaire : docs/glossaire/appreciation.md
|
||||
*/
|
||||
export type Skill = "none" | "doubtful" | "bad" | "veryBad";
|
||||
|
||||
/**
|
||||
* Seuils de GNU Backgammon (arSkillLevel dans gnubg.c) : une perte
|
||||
* d'équité strictement plus grande fait passer à l'appréciation. Le moteur
|
||||
* classe lui-même ; ces valeurs servent à l'expliquer.
|
||||
*/
|
||||
export const SKILL_THRESHOLDS = { doubtful: 0.03, bad: 0.06, veryBad: 0.12 } as const;
|
||||
|
||||
/** Catégorie de chance d'un jet, de très malchanceux à très chanceux. */
|
||||
export type LuckRating = "veryUnlucky" | "unlucky" | "none" | "lucky" | "veryLucky";
|
||||
|
||||
/**
|
||||
* Seuils de GNU Backgammon (arLuckLevel dans gnubg.c) sur la chance d'un
|
||||
* jet, dans un sens ou dans l'autre.
|
||||
*/
|
||||
export const LUCK_THRESHOLDS = { lucky: 0.3, veryLucky: 0.6 } as const;
|
||||
|
||||
/**
|
||||
* La chance d'un jet : l'équité qu'il fait gagner (positive) ou perdre au
|
||||
* joueur par rapport à la moyenne des jets.
|
||||
*
|
||||
* Glossaire : docs/glossaire/chance.md
|
||||
*/
|
||||
export interface Luck {
|
||||
readonly value: number;
|
||||
readonly rating: LuckRating;
|
||||
}
|
||||
|
||||
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
|
||||
export interface EvaluatedMove extends Move {
|
||||
readonly equity: number;
|
||||
/** Niveau auquel le coup a réellement été évalué. */
|
||||
readonly ply: Ply;
|
||||
}
|
||||
|
||||
/** Ce que le moteur dit d'un coup joué, comme l'analyse de partie de gnubg. */
|
||||
export interface MoveAnalysis {
|
||||
/** Nombre de coups légaux. */
|
||||
readonly moveCount: number;
|
||||
/** Place du coup joué dans le classement, à partir de 1. */
|
||||
readonly rank: number;
|
||||
readonly played: EvaluatedMove;
|
||||
readonly best: EvaluatedMove;
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
|
||||
* (son adversaire). */
|
||||
export type CubeAction = "noDouble" | "double" | "take" | "pass";
|
||||
|
||||
/** Ce que le moteur dit d'une décision de videau. */
|
||||
export interface CubeAnalysis {
|
||||
/** gnubg analyse-t-il cette décision ? Pas quand le joueur au trait ne
|
||||
* peut pas doubler, ni à la position de départ. */
|
||||
readonly analysed: boolean;
|
||||
/** Équités du joueur au trait, normalisées pour un videau de 1 (null si
|
||||
* non analysée). */
|
||||
readonly equities: CubeEquities | null;
|
||||
/** Équité perdue par celui qui décide : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
export interface CubeEquities {
|
||||
readonly noDouble: number;
|
||||
readonly doubleTake: number;
|
||||
readonly doublePass: number;
|
||||
/** Après les meilleures décisions des deux joueurs. */
|
||||
readonly optimal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +230,29 @@ export interface Engine {
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+259
-86
@@ -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,10 +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,
|
||||
@@ -18,16 +20,18 @@ import {
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
} from "./events.js";
|
||||
import { firstGameOf, type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
import { type GameState, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
|
||||
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
|
||||
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const STANDARD_PLY: Ply = 2;
|
||||
|
||||
@@ -61,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,20 +88,20 @@ 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.
|
||||
#pending: HalfMove[] = [];
|
||||
#pendingPosition: PositionId | null = null;
|
||||
// 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
|
||||
// position après lui, et si le coup est alors complet, selon le moteur.
|
||||
#pending: PendingHalfMove[] = [];
|
||||
|
||||
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;
|
||||
@@ -99,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) });
|
||||
@@ -110,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");
|
||||
}
|
||||
@@ -139,14 +154,62 @@ export class Game {
|
||||
return replay(this.events);
|
||||
}
|
||||
|
||||
/** La partie finie appartient à un match qui continue : ce que sera la
|
||||
* partie suivante (score et Crawford dits par le moteur à la fin de
|
||||
* celle-ci) ; null sinon. */
|
||||
get nextGame(): NewGame | null {
|
||||
const state = this.state;
|
||||
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
|
||||
return null;
|
||||
}
|
||||
const match = nextGameOf(state.match, state.matchAfter);
|
||||
return match === null ? null : { level: state.level, match };
|
||||
}
|
||||
|
||||
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
|
||||
* au même niveau ; pour un match, un nouveau match de même longueur,
|
||||
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
|
||||
* la position de départ. */
|
||||
get rematch(): NewGame {
|
||||
const state = this.state;
|
||||
return state.match === null
|
||||
? { level: state.level }
|
||||
: { level: state.level, match: firstGameOf(state.match.length) };
|
||||
}
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending;
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
}
|
||||
|
||||
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
|
||||
* n'attend plus que d'être validé (confirmMove) ou défait
|
||||
* (undoLastHalfMove). */
|
||||
get moveComplete(): boolean {
|
||||
return this.#pending.at(-1)?.complete === true;
|
||||
}
|
||||
|
||||
/** La position à afficher : après les demi-coups déjà saisis. */
|
||||
get displayedPosition(): PositionId {
|
||||
return this.#pendingPosition ?? this.state.position;
|
||||
return this.#pending.at(-1)?.position ?? this.state.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les dés du jet à jouer (quatre pour un double), et pour chacun s'il est
|
||||
* déjà utilisé par un demi-coup saisi ; liste vide hors de la saisie d'un
|
||||
* coup du joueur. Un demi-coup utilise le dé qui l'a produit dans
|
||||
* nextHalfMoves (de from vers from - dé, ou vers la sortie) : aucune
|
||||
* règle n'est jugée ici, la légalité vient du moteur.
|
||||
*/
|
||||
get diceToPlay(): DieToPlay[] {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving" || state.roll === null) {
|
||||
return [];
|
||||
}
|
||||
const [a, b] = state.roll;
|
||||
const dice = a === b ? [a, a, a, a] : [a, b];
|
||||
const used = assignDice(dice, this.pendingHalfMoves);
|
||||
return dice.map((value, i) => ({ value, used: used[i] === true }));
|
||||
}
|
||||
|
||||
/** Le plateau affiché (après les demi-coups déjà saisis), décodé par le
|
||||
@@ -155,7 +218,7 @@ export class Game {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,7 +250,7 @@ export class Game {
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before)),
|
||||
);
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
@@ -205,7 +268,7 @@ export class Game {
|
||||
*/
|
||||
async nextHalfMoves(): Promise<HalfMove[]> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
|
||||
return [];
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
@@ -220,7 +283,7 @@ export class Game {
|
||||
const moves = [...candidates.values()];
|
||||
const checks = await Promise.all(
|
||||
moves.map((h) =>
|
||||
this.#engine.checkHalfMoves(state.position, roll, [...this.#pending, h]),
|
||||
this.#engine.checkHalfMoves(state.position, roll, [...this.pendingHalfMoves, h]),
|
||||
),
|
||||
);
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
@@ -233,10 +296,11 @@ export class Game {
|
||||
if (state.turn !== "player" || state.phase !== "rolling") {
|
||||
return false;
|
||||
}
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
|
||||
// moteur le refuse aussi pendant la partie Crawford.
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
engineCube(state, "player"),
|
||||
0,
|
||||
);
|
||||
return decision.available;
|
||||
@@ -274,93 +338,109 @@ export class Game {
|
||||
|
||||
/**
|
||||
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
|
||||
* mène à aucun coup légal : il est refusé et rien ne change. Quand les
|
||||
* demi-coups forment un coup complet, le coup est joué.
|
||||
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
|
||||
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
|
||||
* complet (moveComplete), il attend d'être validé (confirmMove).
|
||||
*/
|
||||
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
|
||||
const state = this.#expect("player", "moving");
|
||||
const halfMoves = [...this.#pending, halfMove];
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
state.position,
|
||||
rollToPlay(state),
|
||||
halfMoves,
|
||||
);
|
||||
if (this.moveComplete) {
|
||||
return false;
|
||||
}
|
||||
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
|
||||
...this.pendingHalfMoves,
|
||||
halfMove,
|
||||
]);
|
||||
if (!check.valid || check.position === null) {
|
||||
return false;
|
||||
}
|
||||
if (check.complete) {
|
||||
this.cancelHalfMoves();
|
||||
await this.#playMove("player", halfMoves, check.position);
|
||||
} else {
|
||||
this.#pending = halfMoves;
|
||||
this.#pendingPosition = check.position;
|
||||
}
|
||||
this.#pending = [
|
||||
...this.#pending,
|
||||
{ halfMove, position: check.position, complete: check.complete },
|
||||
];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Oublie les demi-coups saisis du coup en cours. */
|
||||
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
|
||||
* pas) : il n'a jamais été dans le journal. */
|
||||
undoLastHalfMove(): void {
|
||||
this.#pending = this.#pending.slice(0, -1);
|
||||
}
|
||||
|
||||
/** Oublie tous les demi-coups saisis du coup en cours. */
|
||||
cancelHalfMoves(): void {
|
||||
this.#pending = [];
|
||||
this.#pendingPosition = null;
|
||||
}
|
||||
|
||||
/** Le joueur valide son coup complet : il entre dans le journal
|
||||
* (MovePlayed) et le tour passe. Erreur si le coup n'est pas complet. */
|
||||
async confirmMove(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
const last = this.#pending.at(-1);
|
||||
if (last?.complete !== true) {
|
||||
throw new Error("le coup n'est pas complet : jouez encore");
|
||||
}
|
||||
const halfMoves = this.pendingHalfMoves;
|
||||
this.cancelHalfMoves();
|
||||
await this.#playMove("player", halfMoves, last.position);
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -372,6 +452,19 @@ export class Game {
|
||||
this.#journal = goBackTo(this.#journal, eventId);
|
||||
}
|
||||
|
||||
/** Les branches de la partie (voir branches.ts), la ligne d'origine
|
||||
* d'abord. */
|
||||
get branches(): Branch[] {
|
||||
return branches(this.#journal);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche, désignée par son dernier événement : la
|
||||
* partie reprend au bout de celle-ci ; les autres restent intactes. */
|
||||
switchBranch(head: string): void {
|
||||
this.cancelHalfMoves();
|
||||
this.#journal = switchToBranch(this.#journal, head);
|
||||
}
|
||||
|
||||
/** Annule le dernier événement : la partie repart de son parent. */
|
||||
undo(): void {
|
||||
const last = this.events.at(-1);
|
||||
@@ -388,8 +481,8 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(cube, onRoll));
|
||||
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(state, onRoll));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
@@ -417,10 +510,7 @@ export class Game {
|
||||
after: PositionId | null,
|
||||
): Promise<void> {
|
||||
const state = this.state;
|
||||
const board = await this.#engine.board(
|
||||
after ?? state.position,
|
||||
engineCube(state.cube, side),
|
||||
);
|
||||
const board = await this.#engine.board(after ?? state.position, engineCube(state, side));
|
||||
this.#append({
|
||||
type: "MovePlayed",
|
||||
side,
|
||||
@@ -428,32 +518,104 @@ 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Un dé du jet à jouer, et s'il est déjà utilisé par un demi-coup saisi. */
|
||||
export interface DieToPlay {
|
||||
readonly value: number;
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Un demi-coup saisi, pas encore validé. */
|
||||
interface PendingHalfMove {
|
||||
readonly halfMove: HalfMove;
|
||||
/** La position après lui, vue du joueur. */
|
||||
readonly position: PositionId;
|
||||
/** Les demi-coups saisis jusqu'à lui forment un coup complet. */
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quels dés les demi-coups ont utilisés : chaque demi-coup reçoit un dé
|
||||
* différent qui le produit (de from vers from - dé, ou vers la sortie si le
|
||||
* dé dépasse), comme les candidats de nextHalfMoves. Les dés sont essayés du
|
||||
* plus petit au plus grand : un pion sorti l'est avec le plus petit dé
|
||||
* suffisant. Si aucune répartition ne convient, les premiers dés sont
|
||||
* marqués, un par demi-coup.
|
||||
*/
|
||||
function assignDice(dice: readonly number[], halfMoves: readonly HalfMove[]): boolean[] {
|
||||
const order = dice.map((_, i) => i).sort((i, j) => (dice[i] ?? 0) - (dice[j] ?? 0));
|
||||
const used = dice.map(() => false);
|
||||
const fits = (h: HalfMove, die: number) => Math.max(h.from - die, OFF) === h.to;
|
||||
const place = (k: number): boolean => {
|
||||
const h = halfMoves[k];
|
||||
if (h === undefined) {
|
||||
return true;
|
||||
}
|
||||
for (const i of order) {
|
||||
if (!used[i] && fits(h, dice[i] ?? 0)) {
|
||||
used[i] = true;
|
||||
if (place(k + 1)) {
|
||||
return true;
|
||||
}
|
||||
used[i] = false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return place(0) ? used : dice.map((_, i) => i < halfMoves.length);
|
||||
}
|
||||
|
||||
/** Le jet à jouer, en phase moving. */
|
||||
function rollToPlay(state: GameState): Roll {
|
||||
if (state.roll === null) {
|
||||
@@ -473,10 +635,21 @@ function occupiedPoints(side: BoardSide): number[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait, pour le moteur. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
if (cube.owner === null) {
|
||||
return { value: cube.value, owner: "centered" };
|
||||
/** Le videau et le match vus du camp au trait, pour le moteur. */
|
||||
export function engineCube(state: Pick<GameState, "cube" | "match">, onRoll: Side): EngineCube {
|
||||
const { cube, match } = state;
|
||||
const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent";
|
||||
if (match === null) {
|
||||
return { value: cube.value, owner };
|
||||
}
|
||||
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
|
||||
const opponent = otherSide(onRoll);
|
||||
return {
|
||||
value: cube.value,
|
||||
owner,
|
||||
match: {
|
||||
length: match.length,
|
||||
score: { onRoll: match.score[onRoll], opponent: match.score[opponent] },
|
||||
crawford: match.crawford,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Une position du plateau dans un lien (ADR 0013) : le Position ID et le
|
||||
// Match ID de GNU Backgammon, lus et écrits par le moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un lien décrit la position du point de vue du joueur au trait : c'est lui
|
||||
// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à
|
||||
// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux
|
||||
// identifiants (aucune règle de représentation n'est réimplémentée).
|
||||
|
||||
import type { Cube } from "./cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.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";
|
||||
|
||||
/**
|
||||
* Ce que porte l'adresse d'une position : son Position ID, et son Match ID
|
||||
* s'il est donné (sans lui : partie libre, videau au centre).
|
||||
*
|
||||
* Glossaire : docs/glossaire/lien-de-position.md
|
||||
*/
|
||||
export interface PositionLink {
|
||||
readonly position: PositionId;
|
||||
readonly matchId: MatchId | null;
|
||||
}
|
||||
|
||||
/** Une position partagée, lue par le moteur. Le camp « player » est celui
|
||||
* au trait. */
|
||||
export interface SharedPosition {
|
||||
readonly link: PositionLink;
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
/** null en partie libre ; sinon la longueur du match et le score. */
|
||||
readonly match: {
|
||||
readonly length: number;
|
||||
readonly score: Readonly<Record<Side, number>>;
|
||||
} | null;
|
||||
/** La partie est déjà finie dans cette position (null sinon). */
|
||||
readonly result: BoardResult | null;
|
||||
}
|
||||
|
||||
/** Un lien que le moteur ne sait pas lire. */
|
||||
export class UnreadableLinkError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = "UnreadableLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une position partagée : le moteur décode le Match ID (s'il est
|
||||
* donné), puis la position avec son videau. Erreur UnreadableLinkError si
|
||||
* l'un des deux est illisible.
|
||||
*/
|
||||
export async function readPosition(engine: Engine, link: PositionLink): Promise<SharedPosition> {
|
||||
let cube: EngineCube = { value: 1, owner: "centered" };
|
||||
let match: SharedPosition["match"] = null;
|
||||
if (link.matchId !== null) {
|
||||
const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => {
|
||||
throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`);
|
||||
});
|
||||
const onRoll = description.playerOnRoll;
|
||||
cube = {
|
||||
value: description.cubeValue,
|
||||
owner:
|
||||
description.cubeOwner === -1
|
||||
? "centered"
|
||||
: description.cubeOwner === onRoll
|
||||
? "onRoll"
|
||||
: "opponent",
|
||||
};
|
||||
if (description.matchLength > 0) {
|
||||
match = {
|
||||
length: description.matchLength,
|
||||
score: {
|
||||
player: description.score[onRoll],
|
||||
computer: description.score[onRoll === 0 ? 1 : 0],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const board = await engine.board(link.position, cube).catch((error: unknown) => {
|
||||
throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`);
|
||||
});
|
||||
return {
|
||||
link,
|
||||
sides: { player: board.onRoll, computer: board.opponent },
|
||||
cube: {
|
||||
value: cube.value,
|
||||
owner:
|
||||
cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer",
|
||||
},
|
||||
match,
|
||||
result: board.result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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";
|
||||
return {
|
||||
position: state.position,
|
||||
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
|
||||
};
|
||||
}
|
||||
|
||||
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
|
||||
* place du camp au trait, avec le même videau ; partie libre. */
|
||||
export function setupOf(shared: SharedPosition): GameSetup {
|
||||
return { position: shared.link.position, firstTurn: "player", cube: shared.cube };
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
|
||||
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
|
||||
// relire tous les journaux.
|
||||
|
||||
import { branches } from "./branches.js";
|
||||
import type { Level } from "./engine.js";
|
||||
import { currentLine, type GameJournal } from "./events.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Le résumé d'une partie enregistrée.
|
||||
*
|
||||
* Glossaire : docs/glossaire/partie-enregistree.md
|
||||
*/
|
||||
export interface SavedGameSummary {
|
||||
/** Identifiant de la partie, unique sur l'appareil. */
|
||||
readonly id: string;
|
||||
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
|
||||
readonly startedAt: number;
|
||||
readonly updatedAt: number;
|
||||
/** Niveau de l'ordinateur, l'adversaire. */
|
||||
readonly level: Level;
|
||||
/** En cours ou finie, sur la branche courante. */
|
||||
readonly status: "playing" | "over";
|
||||
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
|
||||
readonly result: GameResult | null;
|
||||
/** Coups joués sur la branche courante. */
|
||||
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
|
||||
* journal. */
|
||||
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(
|
||||
id: string,
|
||||
journal: GameJournal,
|
||||
details: SavedGameDetails,
|
||||
times: { readonly startedAt: number; readonly updatedAt: number },
|
||||
): SavedGameSummary {
|
||||
const line = currentLine(journal);
|
||||
const state = replay(line);
|
||||
return {
|
||||
id,
|
||||
startedAt: times.startedAt,
|
||||
updatedAt: times.updatedAt,
|
||||
level: details.level,
|
||||
status: state.phase === "over" ? "over" : "playing",
|
||||
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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Export d'une partie au format SGF de GNU Backgammon.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
|
||||
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
|
||||
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
|
||||
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
|
||||
// devient une variante. GNU Backgammon ne lit que la première variante de
|
||||
// chaque embranchement ; la branche courante de la partie vient donc en
|
||||
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
|
||||
//
|
||||
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
|
||||
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
|
||||
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
|
||||
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
|
||||
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
|
||||
// - [double], [take], [drop] pour le videau ;
|
||||
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
|
||||
// PL, le videau par CV et CP.
|
||||
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
|
||||
// et une position de départ particulière est décodée par le moteur.
|
||||
|
||||
import type { Engine } from "./engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
|
||||
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
export interface SgfOptions {
|
||||
/** Nom du joueur (PB). */
|
||||
readonly playerName: string;
|
||||
/** Nom de l'ordinateur (PW). */
|
||||
readonly computerName: string;
|
||||
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
|
||||
readonly date?: string;
|
||||
}
|
||||
|
||||
/** Un nœud SGF et les variantes qui le suivent. */
|
||||
interface SgfNode {
|
||||
readonly properties: string;
|
||||
readonly children: SgfNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* La partie au format SGF de GNU Backgammon, branches comprises (en
|
||||
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
|
||||
* une position de départ particulière. (Le format : docs/glossaire/sgf.md.)
|
||||
*/
|
||||
export async function exportSgf(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
options: SgfOptions,
|
||||
): Promise<string> {
|
||||
const start = journal.events[0];
|
||||
if (start?.type !== "GameStarted") {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
const byId = new Map(journal.events.map((event) => [event.id, event]));
|
||||
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
// La branche courante d'abord, puis les autres dans l'ordre du journal.
|
||||
for (const list of children.values()) {
|
||||
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
|
||||
}
|
||||
|
||||
/** Les variantes qui suivent un événement. */
|
||||
const variations = (id: string): SgfNode[] =>
|
||||
(children.get(id) ?? []).flatMap((child) => {
|
||||
const properties = nodeProperties(child, byId, children.has(child.id));
|
||||
const below = variations(child.id);
|
||||
return properties === null ? below : [{ properties, children: below }];
|
||||
});
|
||||
|
||||
// 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]",
|
||||
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}]`,
|
||||
].join("");
|
||||
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
|
||||
const setup = await setupProperties(engine, start);
|
||||
const tree = setup.reduceRight<SgfNode[]>(
|
||||
(below, properties) => [{ properties, children: below }],
|
||||
variations(start.id),
|
||||
);
|
||||
return `(;${root}${sequence(tree)})\n`;
|
||||
}
|
||||
|
||||
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
|
||||
* chaque variante entre parenthèses. */
|
||||
function sequence(nodes: readonly SgfNode[]): string {
|
||||
if (nodes.length === 1) {
|
||||
const [node] = nodes as [SgfNode];
|
||||
return `\n;${node.properties}${sequence(node.children)}`;
|
||||
}
|
||||
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
|
||||
}
|
||||
|
||||
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
|
||||
* (début, dés suivis d'un coup, fin de partie). */
|
||||
function nodeProperties(
|
||||
event: GameEvent,
|
||||
byId: ReadonlyMap<string, GameEvent>,
|
||||
hasChildren: boolean,
|
||||
): string | null {
|
||||
switch (event.type) {
|
||||
case "MovePlayed": {
|
||||
const rolled = event.parent === null ? undefined : byId.get(event.parent);
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
throw new Error(`coup sans jet de dés : ${event.id}`);
|
||||
}
|
||||
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
|
||||
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
|
||||
}
|
||||
case "CubeOffered":
|
||||
return `${letter(event.side)}[double]`;
|
||||
case "CubeTaken":
|
||||
return `${letter(event.side)}[take]`;
|
||||
case "CubeDropped":
|
||||
return `${letter(event.side)}[drop]`;
|
||||
case "DiceRolled": {
|
||||
// Des dés pas encore joués, au bout d'une branche.
|
||||
if (hasChildren) {
|
||||
return null;
|
||||
}
|
||||
const [a, b] = event.roll;
|
||||
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
|
||||
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
|
||||
}
|
||||
case "GameStarted":
|
||||
case "GameEnded":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Les propriétés des nœuds qui posent une position de départ particulière
|
||||
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
|
||||
* faire). */
|
||||
async function setupProperties(
|
||||
engine: Engine,
|
||||
start: Extract<GameEvent, { type: "GameStarted" }>,
|
||||
): Promise<string[]> {
|
||||
const usual =
|
||||
start.position === START_POSITION &&
|
||||
start.firstTurn === null &&
|
||||
start.cube.value === 1 &&
|
||||
start.cube.owner === null;
|
||||
if (usual) {
|
||||
return [];
|
||||
}
|
||||
const onRoll: Side = start.firstTurn ?? "player";
|
||||
const owner =
|
||||
start.cube.owner === null
|
||||
? "centered"
|
||||
: start.cube.owner === onRoll
|
||||
? "onRoll"
|
||||
: "opponent";
|
||||
const board = await engine.board(start.position, { value: start.cube.value, owner });
|
||||
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
|
||||
// 'x' - (c - 1), la barre en 'y' (comme les coups).
|
||||
const stones = (side: Side, points: readonly number[], bar: number) => {
|
||||
const out: string[] = [];
|
||||
points.forEach((count, i) => {
|
||||
for (let k = 0; k < count; k++) {
|
||||
out.push(`[${pointLetter(i + 1, side)}]`);
|
||||
}
|
||||
});
|
||||
for (let k = 0; k < bar; k++) {
|
||||
out.push("[y]");
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
const other: Side = onRoll === "player" ? "computer" : "player";
|
||||
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
|
||||
Side,
|
||||
typeof board.onRoll
|
||||
>;
|
||||
const white = stones("computer", sides.computer.points, sides.computer.bar);
|
||||
const black = stones("player", sides.player.points, sides.player.bar);
|
||||
const nodes = [
|
||||
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
|
||||
];
|
||||
if (start.cube.value !== 1) {
|
||||
nodes.push(`CV[${start.cube.value}]`);
|
||||
}
|
||||
if (start.cube.owner !== null) {
|
||||
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function letter(side: Side): "B" | "W" {
|
||||
return side === "player" ? "B" : "W";
|
||||
}
|
||||
|
||||
function halfMove(h: HalfMove, side: Side): string {
|
||||
return pointLetter(h.from, side) + pointLetter(h.to, side);
|
||||
}
|
||||
|
||||
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
|
||||
function pointLetter(point: number, side: Side): string {
|
||||
if (point === BAR) {
|
||||
return "y";
|
||||
}
|
||||
if (point === OFF) {
|
||||
return "z";
|
||||
}
|
||||
const code =
|
||||
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
|
||||
/** Échappe un texte libre dans une valeur SGF. */
|
||||
function escapeText(text: string): string {
|
||||
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Le tutoriel des règles, côté domaine : les positions illustrées et les
|
||||
// écrans « à vous », vérifiés par le moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
|
||||
// source) ; il donne pour chaque écran une position par son identifiant
|
||||
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
|
||||
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
|
||||
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
|
||||
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
|
||||
// reconnus.
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
|
||||
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
|
||||
import type { HalfMove, PositionId } from "../game/position.js";
|
||||
import { fixedDice, type Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
|
||||
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
|
||||
* partie y est finie (vu du camp « player »). */
|
||||
export interface TutorialBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
return {
|
||||
value: cube.value,
|
||||
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
|
||||
};
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
|
||||
* l'identifiant est illisible). */
|
||||
export async function tutorialBoard(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
onRoll: Side = "player",
|
||||
cube: Cube = CENTERED_CUBE,
|
||||
): Promise<TutorialBoard> {
|
||||
const board = await engine.board(position, engineCube(cube, onRoll));
|
||||
const sides =
|
||||
onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const result =
|
||||
board.result === null
|
||||
? null
|
||||
: {
|
||||
winner:
|
||||
(board.result.winner === "onRoll") === (onRoll === "player")
|
||||
? ("player" as const)
|
||||
: ("computer" as const),
|
||||
kind: board.result.kind,
|
||||
};
|
||||
return { sides, result };
|
||||
}
|
||||
|
||||
/** Les coups du jet que le moteur déclare légaux et dont la notation est
|
||||
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
|
||||
* légal : le texte du tutoriel ne correspond plus au moteur. */
|
||||
export async function expectedMoves(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<Move[]> {
|
||||
const legal = await engine.legalMoves(position, roll);
|
||||
return notations.map((notation) => {
|
||||
const move = legal.find((m) => m.notation === notation);
|
||||
if (move === undefined) {
|
||||
throw new Error(
|
||||
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
|
||||
);
|
||||
}
|
||||
return move;
|
||||
});
|
||||
}
|
||||
|
||||
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
|
||||
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
|
||||
* finit la partie. */
|
||||
export async function illustratedMove(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notation: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
|
||||
const [move] = await expectedMoves(engine, position, roll, [notation]);
|
||||
if (move === undefined) {
|
||||
throw new Error(`coup illustré introuvable : ${notation}`);
|
||||
}
|
||||
const positions = [position];
|
||||
for (let i = 1; i <= move.halfMoves.length; i++) {
|
||||
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
|
||||
if (check.position === null) {
|
||||
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
|
||||
const hits = move.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
|
||||
);
|
||||
const after = boards.at(-1) ?? boards[0];
|
||||
if (after === undefined) {
|
||||
throw new Error("aucun plateau");
|
||||
}
|
||||
return {
|
||||
steps: {
|
||||
side: "player",
|
||||
halfMoves: move.halfMoves,
|
||||
boards: boards.map((b) => b.sides),
|
||||
hits,
|
||||
},
|
||||
after,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
|
||||
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
|
||||
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
|
||||
* position (verdict de MovePractice).
|
||||
*/
|
||||
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
|
||||
return expected.some((move) => {
|
||||
const left = [...move.halfMoves];
|
||||
return played.every((h) => {
|
||||
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
|
||||
if (i < 0) {
|
||||
return false;
|
||||
}
|
||||
left.splice(i, 1);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce que devient un écran « à vous » après un demi-coup. */
|
||||
export type PracticeVerdict = "continue" | "right" | "wrong";
|
||||
|
||||
/**
|
||||
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
|
||||
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
|
||||
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
|
||||
* coup est juste s'il mène à la position de l'un des coups attendus.
|
||||
*/
|
||||
export class MovePractice {
|
||||
readonly #game: Game;
|
||||
readonly #expected: readonly Move[];
|
||||
readonly roll: Roll;
|
||||
|
||||
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
|
||||
this.#game = game;
|
||||
this.#expected = expected;
|
||||
this.roll = roll;
|
||||
}
|
||||
|
||||
/** Prépare l'écran : la position, le jet, et les coups attendus,
|
||||
* reconnus par le moteur (erreur sinon). */
|
||||
static async start(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<MovePractice> {
|
||||
const expected = await expectedMoves(engine, position, roll, notations);
|
||||
const game = Game.start(engine, {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(roll),
|
||||
level: "0ply",
|
||||
});
|
||||
await game.roll();
|
||||
return new MovePractice(game, expected, roll);
|
||||
}
|
||||
|
||||
/** Le plateau affiché, demi-coups saisis compris. */
|
||||
board(): Promise<SideBoards> {
|
||||
return this.#game.displayedBoard();
|
||||
}
|
||||
|
||||
/** Les demi-coups permis maintenant (le moteur le dit). */
|
||||
nextHalfMoves(): Promise<HalfMove[]> {
|
||||
return this.#game.nextHalfMoves();
|
||||
}
|
||||
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#game.pendingHalfMoves;
|
||||
}
|
||||
|
||||
/** Les dés du jet, et ceux déjà utilisés. */
|
||||
get diceToPlay(): { value: number; used: boolean }[] {
|
||||
return this.#game.diceToPlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
|
||||
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
|
||||
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
|
||||
* arrière (reset). « right » quand le coup complet est l'un des coups
|
||||
* attendus.
|
||||
*/
|
||||
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
|
||||
if (!(await this.#game.playHalfMove(halfMove))) {
|
||||
return "wrong";
|
||||
}
|
||||
if (this.#game.moveComplete) {
|
||||
const position = this.#game.displayedPosition;
|
||||
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
|
||||
}
|
||||
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
|
||||
}
|
||||
|
||||
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
|
||||
reset(): void {
|
||||
this.#game.cancelHalfMoves();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
|
||||
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
|
||||
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
|
||||
* peut pas doubler dans cette position.
|
||||
*/
|
||||
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
|
||||
const decision = await engine.cubeDecision(
|
||||
position,
|
||||
{ value: 1, owner: "centered" },
|
||||
STANDARD_PLY,
|
||||
);
|
||||
if (!decision.available) {
|
||||
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
|
||||
}
|
||||
return decision.take;
|
||||
}
|
||||
@@ -12,14 +12,30 @@ import type {
|
||||
Board,
|
||||
BoardResult,
|
||||
BoardSide,
|
||||
CubeAction,
|
||||
CubeAnalysis,
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
EngineMatch,
|
||||
EvaluatedMove,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Luck,
|
||||
LuckRating,
|
||||
MatchDescription,
|
||||
MatchId,
|
||||
Move,
|
||||
MoveAnalysis,
|
||||
Ply,
|
||||
Skill,
|
||||
} from "../domain/game/engine.js";
|
||||
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
|
||||
import {
|
||||
formatHalfMoves,
|
||||
type HalfMove,
|
||||
type PositionId,
|
||||
START_POSITION,
|
||||
} from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
|
||||
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
|
||||
@@ -32,6 +48,16 @@ interface CliMove {
|
||||
position_id: string;
|
||||
half_moves: [number, number][];
|
||||
}
|
||||
interface CliEvaluatedMove extends CliMove {
|
||||
ply: number;
|
||||
equity: number;
|
||||
}
|
||||
type CliSkill = "none" | "doubtful" | "bad" | "very_bad";
|
||||
interface CliCubeDecision {
|
||||
available: boolean;
|
||||
double: boolean;
|
||||
take: boolean;
|
||||
}
|
||||
interface CliSide {
|
||||
points: number[];
|
||||
bar: number;
|
||||
@@ -50,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;
|
||||
@@ -86,8 +122,163 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
ply: Ply,
|
||||
): Promise<CubeDecision> {
|
||||
const args = ["--ply", String(ply), "cube", position, await matchId(cube)];
|
||||
const answer = (await run(args)) as CubeDecision;
|
||||
return { available: answer.available, double: answer.double, take: answer.take };
|
||||
return toCubeDecision((await run(args)) as CliCubeDecision);
|
||||
},
|
||||
|
||||
async levelMove(position: PositionId, cube: EngineCube, roll: Roll, level: Level) {
|
||||
const args = ["--level", level, "bestmove", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null };
|
||||
return answer.best_move === null ? null : toMove(answer.best_move);
|
||||
},
|
||||
|
||||
async levelCubeDecision(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
level: Level,
|
||||
): Promise<CubeDecision> {
|
||||
const args = ["--level", level, "cube", position, await matchId(cube)];
|
||||
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,
|
||||
roll: Roll,
|
||||
played: PositionId,
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null> {
|
||||
const args = ["--ply", String(ply), "analysemove", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll), played])) as {
|
||||
move_count: number;
|
||||
rank: number | null;
|
||||
played: CliEvaluatedMove | null;
|
||||
best: CliEvaluatedMove | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
};
|
||||
if (answer.rank === null || answer.played === null || answer.best === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
moveCount: answer.move_count,
|
||||
rank: answer.rank,
|
||||
played: toEvaluatedMove(answer.played),
|
||||
best: toEvaluatedMove(answer.best),
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
};
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
action: CubeAction,
|
||||
ply: Ply,
|
||||
): Promise<CubeAnalysis> {
|
||||
const cliAction = {
|
||||
noDouble: "no_double",
|
||||
double: "double",
|
||||
take: "take",
|
||||
pass: "pass",
|
||||
}[action];
|
||||
const args = ["--ply", String(ply), "analysecube", position, await matchId(cube)];
|
||||
const answer = (await run([...args, cliAction])) as {
|
||||
analysed: boolean;
|
||||
cube: {
|
||||
no_double: number;
|
||||
double_take: number;
|
||||
double_pass: number;
|
||||
optimal: number;
|
||||
} | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
};
|
||||
return {
|
||||
analysed: answer.analysed,
|
||||
equities:
|
||||
answer.cube === null
|
||||
? null
|
||||
: {
|
||||
noDouble: answer.cube.no_double,
|
||||
doubleTake: answer.cube.double_take,
|
||||
doublePass: answer.cube.double_pass,
|
||||
optimal: answer.cube.optimal,
|
||||
},
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
};
|
||||
},
|
||||
|
||||
async luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck> {
|
||||
const args = ["luck", position, await matchId(cube), diceText(roll)];
|
||||
const answer = (await run(args)) as { luck: number; rating: string };
|
||||
const ratings: Record<string, LuckRating> = {
|
||||
very_unlucky: "veryUnlucky",
|
||||
unlucky: "unlucky",
|
||||
none: "none",
|
||||
lucky: "lucky",
|
||||
very_lucky: "veryLucky",
|
||||
};
|
||||
const rating = ratings[answer.rating];
|
||||
if (rating === undefined) {
|
||||
throw new Error(`chance inconnue : ${answer.rating}`);
|
||||
}
|
||||
return { value: answer.luck, rating };
|
||||
},
|
||||
|
||||
encodeMatch: matchId,
|
||||
|
||||
async decodeMatch(id: MatchId): Promise<MatchDescription> {
|
||||
// engine-cli n'a pas de commande qui décode seule un Match ID :
|
||||
// l'évaluation à 0 pli (quelques millisecondes) le décode, le
|
||||
// valide (SetCubeInfo de gnubg) et le renvoie lisible.
|
||||
const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as {
|
||||
match: {
|
||||
match_length: number;
|
||||
score: [number, number];
|
||||
cube_value: number;
|
||||
cube_owner: number;
|
||||
player_on_roll: number;
|
||||
crawford: boolean;
|
||||
jacoby: boolean;
|
||||
};
|
||||
};
|
||||
const m = answer.match;
|
||||
return {
|
||||
matchLength: m.match_length,
|
||||
score: m.score,
|
||||
cubeValue: m.cube_value,
|
||||
cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1,
|
||||
playerOnRoll: m.player_on_roll === 1 ? 1 : 0,
|
||||
crawford: m.crawford,
|
||||
jacoby: m.jacoby,
|
||||
};
|
||||
},
|
||||
|
||||
async checkHalfMoves(
|
||||
@@ -139,6 +330,18 @@ function toMove(move: CliMove): Move {
|
||||
};
|
||||
}
|
||||
|
||||
function toEvaluatedMove(move: CliEvaluatedMove): EvaluatedMove {
|
||||
return { ...toMove(move), equity: move.equity, ply: move.ply };
|
||||
}
|
||||
|
||||
function toCubeDecision(answer: CliCubeDecision): CubeDecision {
|
||||
return { available: answer.available, double: answer.double, take: answer.take };
|
||||
}
|
||||
|
||||
function toSkill(skill: CliSkill): Skill {
|
||||
return skill === "very_bad" ? "veryBad" : skill;
|
||||
}
|
||||
|
||||
function toSide(side: CliSide): BoardSide {
|
||||
return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count };
|
||||
}
|
||||
|
||||
+109
-26
@@ -6,43 +6,126 @@
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
import {
|
||||
illustratedMove,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
import { startUpdates } from "./ui/updates.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
|
||||
// Chaque accès au stockage du navigateur peut lever une exception (stockage
|
||||
// interdit) : on fait alors sans.
|
||||
const localStorage = attempt(() => window.localStorage);
|
||||
const settings = createSettingsStore(localStorage ?? memoryStorage());
|
||||
|
||||
// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes
|
||||
// pendant que le moteur se charge : le plateau est dessiné sans les
|
||||
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
|
||||
const opening = (async () => {
|
||||
const { library } = await openGameLibrary({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
// 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 };
|
||||
})();
|
||||
let stored: Awaited<typeof opening> | null = null;
|
||||
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
|
||||
function storage(): Awaited<typeof opening> {
|
||||
if (stored === null) {
|
||||
throw new Error("stockage pas encore ouvert");
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => Game.start(engine),
|
||||
savedGame: () => {
|
||||
const journal = store.load();
|
||||
if (journal === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
engineReady: Promise.all([
|
||||
worker.ready,
|
||||
opening.then((opened) => {
|
||||
stored = opened;
|
||||
}),
|
||||
]),
|
||||
newGame: (newGame) => storage().session.newGame(newGame),
|
||||
positions: {
|
||||
read: (link) => readPosition(engine, link),
|
||||
linkOf: (game) => linkOf(engine, game.state),
|
||||
},
|
||||
saveGame: (journal) => store.save(journal),
|
||||
confirm: (question) => window.confirm(question),
|
||||
savedGame: () => storage().saved,
|
||||
resumePrevious: () => storage().session.resumePrevious(),
|
||||
saveGame: (journal) => storage().session.save(journal),
|
||||
games: {
|
||||
list: () => storage().library.list(),
|
||||
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
|
||||
shownId: () => storage().session.shownId,
|
||||
open: (id, branchHead) => storage().session.open(id, branchHead),
|
||||
remove: (id) => storage().library.remove(id),
|
||||
exportSgf: async (id) => {
|
||||
const game = await storage().library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const started = new Date(game.summary.startedAt);
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`;
|
||||
const text = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
date,
|
||||
});
|
||||
const time = `${two(started.getHours())}${two(started.getMinutes())}`;
|
||||
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
|
||||
},
|
||||
},
|
||||
cachedBoard: () => settings.loadBoard(),
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
// Le tutoriel : positions, coups illustrés et gestes jugés par le
|
||||
// domaine, sur le moteur.
|
||||
tutorial: {
|
||||
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
|
||||
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
|
||||
practice: (position, roll, expected) =>
|
||||
MovePractice.start(engine, position, roll, expected),
|
||||
shouldTake: (position) => shouldTake(engine, position),
|
||||
},
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur
|
||||
// le refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
|
||||
// Chromium fait échouer l'installation (« Cache.add() encountered a
|
||||
// network error »), et l'application n'est pas disponible hors ligne.
|
||||
// Cela évite aussi de télécharger deux fois ces données à la première
|
||||
// visite.
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
|
||||
// fait échouer l'installation (« Cache.add() encountered a network error »),
|
||||
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
|
||||
// télécharger deux fois ces données à la première visite.
|
||||
if ("serviceWorker" in navigator) {
|
||||
const sw = new URL("../sw.js", import.meta.url);
|
||||
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
|
||||
/** La valeur, ou undefined si l'obtenir lève une exception. */
|
||||
function attempt<T>(get: () => T): T | undefined {
|
||||
try {
|
||||
return get();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
|
||||
// d'événements, et laquelle est la partie en cours.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Deux stockages derrière la même interface (GameLibrary) :
|
||||
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
|
||||
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
|
||||
// indisponible, par exemple en navigation privée de certains navigateurs.
|
||||
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
|
||||
// appels : un enregistrement lancé sans être attendu est donc toujours fait
|
||||
// avant la lecture qui suit.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import {
|
||||
type SavedGameDetails,
|
||||
type SavedGameSummary,
|
||||
summarizeGame,
|
||||
} from "../domain/game/saved-game.js";
|
||||
|
||||
/** Une partie enregistrée : son résumé et son journal. */
|
||||
export interface SavedGame {
|
||||
readonly summary: SavedGameSummary;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
export interface GameLibrary {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
list(): Promise<SavedGameSummary[]>;
|
||||
/** Une partie, ou null si elle n'existe pas ou est illisible. */
|
||||
load(id: string): Promise<SavedGame | null>;
|
||||
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
|
||||
* cours. Renvoie son résumé. */
|
||||
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
|
||||
/** Supprime une partie (rien si elle n'existe pas). */
|
||||
remove(id: string): Promise<void>;
|
||||
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
|
||||
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
|
||||
* aucune. */
|
||||
current(): Promise<SavedGame | null>;
|
||||
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
|
||||
* (pour la reprendre). */
|
||||
setCurrent(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
|
||||
* opérations, les dates et la validation. */
|
||||
export interface LibraryBackend {
|
||||
summaries(): Promise<SavedGameSummary[]>;
|
||||
summary(id: string): Promise<SavedGameSummary | undefined>;
|
||||
journal(id: string): Promise<unknown>;
|
||||
/** Écrit le résumé et le journal ensemble. */
|
||||
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
currentId(): Promise<string | undefined>;
|
||||
setCurrentId(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
/** L'horloge, en millisecondes ; Date.now par défaut. */
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
/** Un identifiant de partie : la date et du hasard, sans dépendre de
|
||||
* crypto.randomUUID, absent hors https. */
|
||||
export function newGameId(now: number = Date.now()): string {
|
||||
const random = Math.floor(Math.random() * 36 ** 6)
|
||||
.toString(36)
|
||||
.padStart(6, "0");
|
||||
return `${now.toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
|
||||
* domaine le vérifie en reprenant la partie). */
|
||||
export function isJournal(value: unknown): value is GameJournal {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const journal = value as { events?: unknown; head?: unknown };
|
||||
return (
|
||||
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function createGameLibrary(
|
||||
backend: LibraryBackend,
|
||||
options: LibraryOptions = {},
|
||||
): GameLibrary {
|
||||
const now = options.now ?? Date.now;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
/** Exécute op après toutes les opérations déjà demandées. */
|
||||
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
|
||||
const result = queue.then(op, op);
|
||||
queue = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
|
||||
const load = async (id: string): Promise<SavedGame | null> => {
|
||||
const summary = await backend.summary(id);
|
||||
const journal = await backend.journal(id);
|
||||
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
|
||||
};
|
||||
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return {
|
||||
list: () => enqueue(list),
|
||||
load: (id) => enqueue(() => load(id)),
|
||||
save: (id, journal, details) =>
|
||||
enqueue(async () => {
|
||||
const time = now();
|
||||
const startedAt = (await backend.summary(id))?.startedAt ?? time;
|
||||
const summary = summarizeGame(id, journal, details, {
|
||||
startedAt,
|
||||
updatedAt: Math.max(time, startedAt),
|
||||
});
|
||||
await backend.write(summary, journal);
|
||||
await backend.setCurrentId(id);
|
||||
return summary;
|
||||
}),
|
||||
remove: (id) => enqueue(() => backend.remove(id)),
|
||||
current: () =>
|
||||
enqueue(async () => {
|
||||
const id = await backend.currentId();
|
||||
const chosen = id === undefined ? null : await load(id);
|
||||
if (chosen !== null) {
|
||||
return chosen;
|
||||
}
|
||||
const latest = (await list())[0];
|
||||
return latest === undefined ? null : load(latest.id);
|
||||
}),
|
||||
setCurrent: (id) =>
|
||||
enqueue(async () => {
|
||||
if ((await backend.summary(id)) === undefined) {
|
||||
throw new Error(`partie introuvable : ${id}`);
|
||||
}
|
||||
await backend.setCurrentId(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// La partie à l'écran et son enregistrement : quel identifiant elle porte,
|
||||
// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la
|
||||
// reprise d'une partie enregistrée ou de l'une de ses branches.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle
|
||||
// quelle, sur un stockage en mémoire.
|
||||
|
||||
import { switchToBranch } from "../domain/game/branches.js";
|
||||
import type { GameJournal } from "../domain/game/events.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: (newGame?: NewGame) => Game;
|
||||
readonly resume: (journal: GameJournal) => Game;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
/** L'identifiant de la partie à l'écran (null avant la première). */
|
||||
readonly shownId: string | null;
|
||||
/** 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 (niveau, match, position de départ éventuels) ;
|
||||
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
|
||||
* prochain changement de partie. */
|
||||
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;
|
||||
/** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans
|
||||
* le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */
|
||||
resumePrevious(): Promise<Game | null>;
|
||||
/** Reprend une partie enregistrée, au bout de la branche branchHead si
|
||||
* elle est donnée. Erreur si elle n'existe pas ou est illisible. */
|
||||
open(id: string, branchHead?: string): Promise<Game>;
|
||||
}
|
||||
|
||||
export function createGameSession(library: GameLibrary, games: GameFactory): GameSession {
|
||||
let shownId: string | null = null;
|
||||
let previousId: string | null = null;
|
||||
|
||||
/** Une partie enregistrée, reprise ; null si absente ou illisible. */
|
||||
const resume = async (id: string): Promise<Game | null> => {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return games.resume(saved.journal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get shownId() {
|
||||
return shownId;
|
||||
},
|
||||
async restore() {
|
||||
const saved = await library.current().catch(() => null);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const game = games.resume(saved.journal);
|
||||
shownId = saved.summary.id;
|
||||
return game;
|
||||
} catch {
|
||||
return null; // partie illisible : on en commencera une nouvelle
|
||||
}
|
||||
},
|
||||
newGame(newGame) {
|
||||
previousId = shownId;
|
||||
shownId = newGameId();
|
||||
return games.start(newGame);
|
||||
},
|
||||
save(journal) {
|
||||
if (shownId !== null) {
|
||||
library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {});
|
||||
}
|
||||
},
|
||||
async resumePrevious() {
|
||||
const id = previousId;
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
const resumed = await resume(id);
|
||||
if (resumed === null) {
|
||||
return null;
|
||||
}
|
||||
const abandoned = shownId;
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
await library.setCurrent(id);
|
||||
if (abandoned !== null) {
|
||||
await library.remove(abandoned);
|
||||
}
|
||||
return resumed;
|
||||
},
|
||||
async open(id, branchHead) {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const journal =
|
||||
branchHead === undefined
|
||||
? saved.journal
|
||||
: switchToBranch(saved.journal, branchHead);
|
||||
const game = games.resume(journal);
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
await library.save(id, journal, { level: levelOf(journal) });
|
||||
}
|
||||
return game;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
|
||||
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
|
||||
// (localStorage dans le navigateur).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
|
||||
load(): GameJournal | null;
|
||||
save(journal: GameJournal): void;
|
||||
}
|
||||
|
||||
const KEY = "ustagammon.current-game";
|
||||
|
||||
export function createGameStore(storage: KeyValueStorage): GameStore {
|
||||
return {
|
||||
load() {
|
||||
try {
|
||||
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
|
||||
const valid =
|
||||
journal !== null &&
|
||||
Array.isArray(journal.events) &&
|
||||
(journal.head === null || typeof journal.head === "string");
|
||||
return valid ? journal : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save(journal) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(journal));
|
||||
} catch {
|
||||
// Stockage plein ou interdit (navigation privée) : la partie
|
||||
// continue, elle ne pourra seulement pas être reprise.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Les parties dans IndexedDB, le stockage de données du navigateur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Base « ustagammon », trois magasins d'objets :
|
||||
// - games : les résumés des parties (SavedGameSummary), clé id ;
|
||||
// - journals : leurs journaux, { id, journal }, clé id ;
|
||||
// - settings : { key, value }, dont la partie en cours (key « current »).
|
||||
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
|
||||
// L'API IndexedDB est utilisée directement, sans bibliothèque.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
const DATABASE = "ustagammon";
|
||||
const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
|
||||
function committed(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
|
||||
* indisponible ou refusé. */
|
||||
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
|
||||
const request = factory.open(DATABASE, VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
db.createObjectStore(GAMES, { keyPath: "id" });
|
||||
db.createObjectStore(JOURNALS, { keyPath: "id" });
|
||||
db.createObjectStore(SETTINGS, { keyPath: "key" });
|
||||
};
|
||||
const db = await done(request);
|
||||
|
||||
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
|
||||
done(db.transaction(store).objectStore(store).get(key));
|
||||
|
||||
return {
|
||||
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
|
||||
summary: (id) => get<SavedGameSummary>(GAMES, id),
|
||||
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
|
||||
write: async (summary: SavedGameSummary, journal: GameJournal) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
|
||||
transaction.objectStore(GAMES).put(summary);
|
||||
await committed(transaction);
|
||||
},
|
||||
remove: async (id) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(GAMES).delete(id);
|
||||
transaction.objectStore(JOURNALS).delete(id);
|
||||
await committed(transaction);
|
||||
},
|
||||
currentId: async () => {
|
||||
const setting = await get<{ value: unknown }>(SETTINGS, "current");
|
||||
return typeof setting?.value === "string" ? setting.value : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
|
||||
// repli quand IndexedDB est indisponible, et la lecture de la partie
|
||||
// enregistrée par les versions précédentes, pour la migrer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
|
||||
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
|
||||
* stockage est interdit. */
|
||||
function read(storage: KeyValueStorage, key: string): unknown {
|
||||
try {
|
||||
const text = storage.getItem(key);
|
||||
return text === null ? undefined : JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
|
||||
const summaries = (): SavedGameSummary[] => {
|
||||
const value = read(storage, SUMMARIES);
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
const writeSummaries = (list: readonly SavedGameSummary[]) =>
|
||||
storage.setItem(SUMMARIES, JSON.stringify(list));
|
||||
return {
|
||||
summaries: async () => summaries(),
|
||||
summary: async (id) => summaries().find((s) => s.id === id),
|
||||
journal: async (id) => read(storage, journalKey(id)),
|
||||
write: async (summary, journal) => {
|
||||
// Le journal d'abord : un résumé ne désigne jamais un journal
|
||||
// absent.
|
||||
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
|
||||
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
|
||||
},
|
||||
remove: async (id) => {
|
||||
writeSummaries(summaries().filter((s) => s.id !== id));
|
||||
storage.removeItem(journalKey(id));
|
||||
},
|
||||
currentId: async () => {
|
||||
const id = read(storage, CURRENT);
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend dans library la partie enregistrée par les versions précédentes
|
||||
* (une seule partie, sous une clé de localStorage), qui devient la partie
|
||||
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
|
||||
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
|
||||
*/
|
||||
export async function migrateLegacyGame(
|
||||
storage: KeyValueStorage,
|
||||
library: GameLibrary,
|
||||
now: number = Date.now(),
|
||||
): Promise<string | null> {
|
||||
let present: boolean;
|
||||
try {
|
||||
present = storage.getItem(LEGACY_GAME) !== null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!present) {
|
||||
return null;
|
||||
}
|
||||
const journal: unknown = read(storage, LEGACY_GAME);
|
||||
let id: string | null = null;
|
||||
if (isJournal(journal) && journal.events.length > 0) {
|
||||
id = newGameId(now);
|
||||
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
|
||||
await library.save(id, journal as GameJournal, { level: "2ply" });
|
||||
}
|
||||
try {
|
||||
storage.removeItem(LEGACY_GAME);
|
||||
} catch {
|
||||
// Stockage interdit : la migration sera refaite, sans dommage.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
|
||||
// sinon localStorage, puis migration de la partie des versions précédentes.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
readonly indexedDB?: IDBFactory;
|
||||
readonly localStorage?: KeyValueStorage;
|
||||
}
|
||||
|
||||
export interface OpenedLibrary {
|
||||
readonly library: GameLibrary;
|
||||
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
|
||||
* mémoire de la page (rien n'est gardé après sa fermeture). */
|
||||
readonly kind: "indexeddb" | "localStorage" | "memory";
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
* doit pas empêcher de jouer). */
|
||||
const OPEN_TIMEOUT_MS = 5000;
|
||||
|
||||
export async function openGameLibrary(
|
||||
storage: BrowserStorage,
|
||||
options: LibraryOptions = {},
|
||||
): Promise<OpenedLibrary> {
|
||||
const opened = await open(storage, options);
|
||||
if (storage.localStorage !== undefined) {
|
||||
try {
|
||||
await migrateLegacyGame(storage.localStorage, opened.library);
|
||||
} catch {
|
||||
// Migration impossible (stockage plein ou refusé) : l'ancienne
|
||||
// partie reste où elle est, on la retentera au prochain démarrage.
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
|
||||
if (storage.indexedDB !== undefined) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const backend = await Promise.race([
|
||||
openIndexedDbBackend(storage.indexedDB),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
}
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage peut exister et refuser toute écriture. */
|
||||
function usable(storage: KeyValueStorage): boolean {
|
||||
try {
|
||||
storage.setItem("ustagammon.probe", "1");
|
||||
storage.removeItem("ustagammon.probe");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un stockage clé-valeur en mémoire. */
|
||||
export function memoryStorage(): KeyValueStorage {
|
||||
const items = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => items.get(key) ?? null,
|
||||
setItem: (key, value) => void items.set(key, value),
|
||||
removeItem: (key) => void items.delete(key),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Ce que l'application garde en plus de la partie, dans le même stockage
|
||||
// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier
|
||||
// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que
|
||||
// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import type { KeyValueStorage } from "./key-value-backend.js";
|
||||
|
||||
const SETTINGS_KEY = "ustagammon.settings";
|
||||
const BOARD_KEY = "ustagammon.last-board";
|
||||
|
||||
/** Le dernier plateau affiché, tel que le moteur l'a décodé. */
|
||||
export interface CachedBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
}
|
||||
|
||||
export interface SettingsStore {
|
||||
/** Le texte JSON des réglages (null : jamais enregistrés). */
|
||||
loadSettings(): string | null;
|
||||
saveSettings(json: string): void;
|
||||
loadBoard(): CachedBoard | null;
|
||||
saveBoard(board: CachedBoard): void;
|
||||
}
|
||||
|
||||
function isSide(value: unknown): boolean {
|
||||
const side = value as Record<string, unknown> | null;
|
||||
return (
|
||||
side !== null &&
|
||||
typeof side === "object" &&
|
||||
Array.isArray(side.points) &&
|
||||
side.points.length === 24 &&
|
||||
side.points.every((n) => Number.isInteger(n) && n >= 0) &&
|
||||
Number.isInteger(side.bar) &&
|
||||
Number.isInteger(side.off) &&
|
||||
Number.isInteger(side.pipCount)
|
||||
);
|
||||
}
|
||||
|
||||
export function createSettingsStore(
|
||||
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
|
||||
): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
// Stockage plein ou interdit : l'application marche sans.
|
||||
}
|
||||
};
|
||||
return {
|
||||
loadSettings() {
|
||||
try {
|
||||
return storage.getItem(SETTINGS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveSettings(json) {
|
||||
write(SETTINGS_KEY, json);
|
||||
},
|
||||
loadBoard() {
|
||||
try {
|
||||
const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null");
|
||||
const valid =
|
||||
board !== null &&
|
||||
isSide(board.sides?.player) &&
|
||||
isSide(board.sides?.computer) &&
|
||||
Number.isInteger(board.cube?.value) &&
|
||||
[null, "player", "computer"].includes(board.cube?.owner);
|
||||
return valid ? board : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveBoard(board) {
|
||||
write(BOARD_KEY, JSON.stringify(board));
|
||||
},
|
||||
};
|
||||
}
|
||||
+1862
-174
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,19 @@ export function moveTargets(halfMoves: readonly HalfMove[]): Map<Place, Map<Plac
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** Le seul demi-coup possible depuis place, s'il n'a qu'une case d'arrivée
|
||||
* (le pion y va dès qu'on le touche) ; null sinon. */
|
||||
export function onlyHalfMove(
|
||||
targets: ReadonlyMap<Place, ReadonlyMap<Place, HalfMove>>,
|
||||
place: Place,
|
||||
): HalfMove | null {
|
||||
const tos = targets.get(place);
|
||||
if (tos === undefined || tos.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
return [...tos.values()][0] ?? null;
|
||||
}
|
||||
|
||||
/** Ce qui reste dessiné d'un coup une fois joué. */
|
||||
export interface MoveTrace {
|
||||
/** Le camp qui a joué. */
|
||||
@@ -126,6 +139,14 @@ export function moveTrace(
|
||||
return { side, arrows, landed, left, hits: hits.filter(Boolean).length };
|
||||
}
|
||||
|
||||
/** La couleur d'une flèche (case), d'après son numéro affiché (1 à 24,
|
||||
* pour le joueur) : impaire en point-a, paire en point-b (charte). Jamais
|
||||
* d'après sa colonne : les cases 12 et 13, l'une au-dessus de l'autre,
|
||||
* n'ont pas la même couleur. */
|
||||
export function pointColor(number: number): "point-a" | "point-b" {
|
||||
return number % 2 === 1 ? "point-a" : "point-b";
|
||||
}
|
||||
|
||||
/** Où se trouve une case sur le dessin : rangée du haut ou du bas, et
|
||||
* colonne de 0 (à gauche) à 11 (à droite), sans compter la barre. */
|
||||
export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
@@ -134,3 +155,26 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
}
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
* comme dans le dessin de référence de la charte (board.js), pas une règle. */
|
||||
export const OPENING_BOARDS: SideBoards = (() => {
|
||||
const points = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0);
|
||||
const side = { points, bar: 0, off: 0, pipCount: 167 };
|
||||
return { player: side, computer: side };
|
||||
})();
|
||||
|
||||
/** Qui mène la course, et de combien : le plus petit compte de points
|
||||
* (leader null : égalité). */
|
||||
export function pipLead(pipCount: Readonly<Record<Side, number>>): {
|
||||
leader: Side | null;
|
||||
by: number;
|
||||
} {
|
||||
const by = Math.abs(pipCount.player - pipCount.computer);
|
||||
if (by === 0) {
|
||||
return { leader: null, by };
|
||||
}
|
||||
return { leader: pipCount.player < pipCount.computer ? "player" : "computer", by };
|
||||
}
|
||||
|
||||
+523
-173
@@ -7,11 +7,27 @@
|
||||
// il n'y a aucun état caché dans le dessin. Les couleurs viennent des
|
||||
// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque
|
||||
// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus
|
||||
// le reste, qui porte son nom dans data-place.
|
||||
// le reste, qui porte son nom dans data-place (une case, la barre, la
|
||||
// sortie) ou data-action (dés, videau, commandes).
|
||||
//
|
||||
// Les mesures viennent de la géométrie (geometry.ts), calculée d'après la
|
||||
// forme de l'écran. En paysage, le plateau porte aussi les commandes : la
|
||||
// colonne de gauche (menu, videau, annuler ou revoir), les comptes de
|
||||
// points en pastilles aux bouts de la barre et la consigne dans la bande
|
||||
// centrale.
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import { type BoardModel, type MoveTrace, type Place, pointSlot } from "./board-model.js";
|
||||
import {
|
||||
type BoardModel,
|
||||
type MoveTrace,
|
||||
type Place,
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} 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é. */
|
||||
@@ -20,12 +36,36 @@ export interface ShownDie {
|
||||
readonly side: Side;
|
||||
/** Le jet ne permet aucun coup : le dé est estompé et barré. */
|
||||
readonly blocked: boolean;
|
||||
/** Le dé est déjà utilisé par un demi-coup saisi : il est estompé. */
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer, jouer le coup saisi, ou
|
||||
* passer son tour quand aucun coup n'est possible. */
|
||||
export type DiceAction = "roll" | "confirm" | "skip";
|
||||
|
||||
/** Les commandes de la colonne de gauche, en paysage. */
|
||||
export interface BoardControls {
|
||||
/** En bas : annuler le demi-coup, ou revoir le coup de l'ordinateur. */
|
||||
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. */
|
||||
@@ -33,10 +73,16 @@ export interface BoardDecor {
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** Le joueur peut lancer les dés : ses dés en attente se touchent. */
|
||||
readonly canRoll: boolean;
|
||||
/** Ce que fait un toucher sur la bande centrale, d'un côté ou de
|
||||
* l'autre du plateau (null : rien). En portrait, pour « roll », les dés
|
||||
* du joueur en attente sont dessinés. */
|
||||
readonly diceAction: DiceAction | null;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
/** Paysage : la consigne courte, dans la bande centrale. */
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -45,34 +91,11 @@ export const NO_MARKS = {
|
||||
selected: null,
|
||||
targets: new Set<Place>(),
|
||||
trace: null,
|
||||
canRoll: false,
|
||||
diceAction: null,
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
|
||||
const WIDTH = 640;
|
||||
const HEIGHT = 520;
|
||||
const FRAME = 8;
|
||||
const POINT_WIDTH = 44;
|
||||
const HALF_WIDTH = 6 * POINT_WIDTH;
|
||||
const BAR_LEFT = FRAME + HALF_WIDTH;
|
||||
const BAR_WIDTH = 40;
|
||||
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
|
||||
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
|
||||
const TRAY_WIDTH = 48;
|
||||
const POINT_HEIGHT = 190;
|
||||
const MIDDLE = HEIGHT / 2;
|
||||
const RADIUS = 20;
|
||||
const STEP = 42; // d'un pion au suivant dans une pile
|
||||
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
|
||||
const OFF_SLAB = 12; // épaisseur d'un pion sorti
|
||||
const DIE = 36;
|
||||
const CUBE = 34;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -90,11 +113,6 @@ function add(parent: Element, name: string, attributes: Attributes, text?: strin
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne (0 à 11). */
|
||||
function columnLeft(column: number): number {
|
||||
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
|
||||
}
|
||||
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
@@ -107,27 +125,24 @@ export interface Spot {
|
||||
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
|
||||
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
|
||||
*/
|
||||
function slotCenter(place: Place, index: number): Spot {
|
||||
function slotCenter(g: Geometry, place: Place, index: number): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
x: g.barLeft + g.bar / 2,
|
||||
y: place === "bar-player" ? middle - offset : middle + offset,
|
||||
};
|
||||
}
|
||||
if (place === "off-player" || place === "off-computer") {
|
||||
const y =
|
||||
place === "off-computer"
|
||||
? FRAME + index * OFF_SLAB
|
||||
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
|
||||
? g.edge + index * OFF_SLAB
|
||||
: g.height - g.edge - (index + 1) * OFF_SLAB;
|
||||
return { x: g.trayLeft + g.tray / 2, y: y + OFF_SLAB / 2 };
|
||||
}
|
||||
const { top, column } = pointSlot(Number(place.slice(1)));
|
||||
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: columnLeft(column) + POINT_WIDTH / 2,
|
||||
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
|
||||
};
|
||||
return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) };
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
@@ -142,56 +157,111 @@ function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** Le nombre de pions dessinés au plus à un endroit. */
|
||||
function stackLimit(g: Geometry, place: Place): number {
|
||||
return place.startsWith("bar-") ? g.barStack : MAX_STACK;
|
||||
}
|
||||
|
||||
/** Redessine tout le plateau dans svg. */
|
||||
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
|
||||
export function renderBoard(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
svg.replaceChildren();
|
||||
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
|
||||
drawFrame(svg);
|
||||
svg.setAttribute("viewBox", `0 0 ${g.width} ${g.height}`);
|
||||
svg.dataset.layout = g.layout;
|
||||
drawFrame(svg, g);
|
||||
const hits = add(svg, "g", {});
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
pieces.setAttribute("class", "pieces");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, hits, board, point.number, decor);
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
drawBar(svg, hits, board, decor);
|
||||
drawTray(svg, hits, board, decor);
|
||||
drawDice(svg, decor.dice);
|
||||
const cube = drawCube(svg, decor.cube);
|
||||
drawBar(svg, pieces, g, hits, board, decor);
|
||||
drawTray(svg, pieces, g, hits, board, decor);
|
||||
if (decor.trace !== null) {
|
||||
drawTrace(svg, board, decor.trace);
|
||||
drawTraceArrows(svg, g, board, decor.trace);
|
||||
}
|
||||
if (decor.canRoll) {
|
||||
const center = waitingDice(svg);
|
||||
addAction(hits, "roll", TEXTS.roll, center, 2 * DIE + 8 + MIN_TARGET / 2);
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
if (decor.diceAction !== null) {
|
||||
if (decor.diceAction === "roll" && g.layout === "portrait") {
|
||||
waitingDice(svg, g);
|
||||
}
|
||||
// Une zone sur chaque moitié de la bande centrale : les dés en
|
||||
// attente (à gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = TEXTS.diceActions[decor.diceAction];
|
||||
const width = 6 * g.pointWidth - 2 * g.frame;
|
||||
for (const x of [g.x0 + 3 * g.pointWidth, g.rightHalf + 3 * g.pointWidth]) {
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble && cube !== null && decor.cube !== null) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
|
||||
function drawFrame(svg: SVGSVGElement): void {
|
||||
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
|
||||
for (const left of [FRAME, RIGHT_HALF]) {
|
||||
function drawFrame(svg: SVGSVGElement, g: Geometry): void {
|
||||
const landscape = g.layout === "landscape";
|
||||
add(svg, "rect", {
|
||||
class: "board-frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: g.width,
|
||||
height: g.height,
|
||||
rx: landscape ? 0 : 6,
|
||||
});
|
||||
for (const left of [g.x0, g.rightHalf]) {
|
||||
add(svg, "rect", {
|
||||
class: "board-bed",
|
||||
x: left,
|
||||
y: FRAME,
|
||||
width: HALF_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
y: g.edge,
|
||||
width: 6 * g.pointWidth,
|
||||
height: g.height - 2 * g.edge,
|
||||
});
|
||||
}
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: TRAY_LEFT,
|
||||
y: FRAME,
|
||||
width: TRAY_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
x: g.trayLeft,
|
||||
y: g.edge,
|
||||
width: g.tray,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: landscape ? 4 : 0,
|
||||
});
|
||||
if (g.left > 0) {
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: g.frame,
|
||||
y: g.edge,
|
||||
width: g.left - 4,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawPoint(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
number: number,
|
||||
@@ -202,38 +272,54 @@ function drawPoint(
|
||||
return;
|
||||
}
|
||||
const { top, column } = pointSlot(number);
|
||||
const left = columnLeft(column);
|
||||
const center = left + POINT_WIDTH / 2;
|
||||
const base = top ? FRAME : HEIGHT - FRAME;
|
||||
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
|
||||
const left = columnLeft(g, column);
|
||||
const center = left + g.pointWidth / 2;
|
||||
const middle = g.height / 2;
|
||||
const base = top ? g.edge : g.height - g.edge;
|
||||
const length = middle - g.edge - g.band / 2 + 6;
|
||||
const tip = top ? base + length : base - length;
|
||||
add(svg, "polygon", {
|
||||
class: column % 2 === 0 ? "point-a" : "point-b",
|
||||
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
|
||||
class: pointColor(number),
|
||||
"data-point": number,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
// Numéros : sur le cadre en paysage, dans la bande centrale en
|
||||
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// du dessin : c'est une mesure du dessin, comme x et y.
|
||||
const size = g.numberSize;
|
||||
let y: number;
|
||||
if (g.layout === "landscape") {
|
||||
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
|
||||
} else {
|
||||
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
|
||||
}
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
|
||||
{
|
||||
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
|
||||
x: center,
|
||||
y,
|
||||
"font-size": size,
|
||||
},
|
||||
String(number),
|
||||
);
|
||||
// Case d'arrivée possible : la flèche seule est voilée (son triangle) ;
|
||||
// l'endroit où le pion se posera est marqué plus loin (drawLandings).
|
||||
if (decor.targets.has(point.place)) {
|
||||
add(svg, "rect", {
|
||||
add(svg, "polygon", {
|
||||
class: "target-zone",
|
||||
x: left + 2,
|
||||
y: top ? FRAME : MIDDLE,
|
||||
width: POINT_WIDTH - 4,
|
||||
height: HEIGHT / 2 - FRAME,
|
||||
rx: 4,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
}
|
||||
if (point.owner !== null) {
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
drawStack(pieces, g, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
y: top ? 0 : MIDDLE,
|
||||
width: POINT_WIDTH,
|
||||
height: MIDDLE,
|
||||
y: top ? 0 : middle,
|
||||
width: g.pointWidth,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,13 +327,15 @@ function drawPoint(
|
||||
* pour les montrer tous. Le pion du dessus porte son endroit dans
|
||||
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
|
||||
function drawStack(
|
||||
svg: SVGSVGElement,
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
side: Side,
|
||||
count: number,
|
||||
place: Place,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
const shown = Math.min(count, MAX_STACK);
|
||||
const limit = stackLimit(g, place);
|
||||
const shown = Math.min(count, limit);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const last = i === shown - 1;
|
||||
const classes = [`checker checker-${side}`];
|
||||
@@ -256,119 +344,340 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
x,
|
||||
y: y + g.countFont * 0.35,
|
||||
"font-size": g.countFont,
|
||||
},
|
||||
String(count),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
add(svg, "rect", {
|
||||
class: "board-bar",
|
||||
x: BAR_LEFT,
|
||||
y: 0,
|
||||
width: BAR_WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
function drawBar(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
add(svg, "rect", { class: "board-bar", x: g.barLeft, y: 0, width: g.bar, height: g.height });
|
||||
// Les pions du joueur sur la barre entrent en haut à droite : on les met
|
||||
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
|
||||
drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
|
||||
if (g.layout === "landscape") {
|
||||
drawChips(svg, g, board);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, {
|
||||
x: g.barLeft,
|
||||
y: 0,
|
||||
width: g.bar,
|
||||
height: g.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
/** Paysage : les comptes de points en pastilles aux deux bouts de la barre,
|
||||
* aux couleurs du camp (l'ordinateur en haut, vous en bas). */
|
||||
function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void {
|
||||
const width = g.bar - 6;
|
||||
const h = g.chipHeight;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const y = side === "computer" ? g.edge + 4 : g.height - g.edge - 4 - h;
|
||||
add(svg, "rect", {
|
||||
class: `pip-chip chip-${side}`,
|
||||
x: g.barLeft + 3,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
rx: h / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `pip-chip-text chip-text-${side}`,
|
||||
"data-pips": side,
|
||||
x: g.barLeft + g.bar / 2,
|
||||
y: y + h / 2 + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
},
|
||||
String(board.pipCount[side]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTray(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
const slab = addSlab(
|
||||
pieces,
|
||||
g,
|
||||
`checker checker-${side}`,
|
||||
slotCenter(g, `off-${side}`, i),
|
||||
);
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const middle = g.height / 2;
|
||||
if (decor.targets.has("off-player")) {
|
||||
add(svg, "rect", {
|
||||
class: "target-zone",
|
||||
x: TRAY_LEFT + 2,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH - 4,
|
||||
height: MIDDLE - FRAME,
|
||||
x: g.trayLeft + 2,
|
||||
y: middle,
|
||||
width: g.tray - 4,
|
||||
height: middle - g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH,
|
||||
height: MIDDLE,
|
||||
x: g.trayLeft,
|
||||
y: middle,
|
||||
width: g.width - g.trayLeft,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
x: g.trayLeft + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
width: g.tray - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
|
||||
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
|
||||
function addRing(
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
cssClass: string,
|
||||
place: Place,
|
||||
spot: Spot,
|
||||
r: number,
|
||||
): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
addSlab(svg, g, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la consigne courte, dans la moitié gauche de la bande
|
||||
* centrale, sur une pastille accent-soft. */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
|
||||
// la police se resserre, sans descendre sous 13 px (charte). Largeur
|
||||
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
|
||||
// 0,45 em.
|
||||
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + fontSize * 0.35,
|
||||
"font-size": fontSize,
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
|
||||
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
|
||||
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
|
||||
*/
|
||||
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
|
||||
const line = g.chipFont * 1.2;
|
||||
const width = g.left - 12;
|
||||
const group = add(svg, "g", {
|
||||
class: "match-box",
|
||||
role: "img",
|
||||
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
|
||||
});
|
||||
add(group, "rect", {
|
||||
class: "match-box-frame",
|
||||
x: x - width / 2,
|
||||
y: top,
|
||||
width,
|
||||
height: 3 * line + 8,
|
||||
rx: 6,
|
||||
});
|
||||
const texts: [string, string][] = [
|
||||
[String(match.computer), "match-box-score"],
|
||||
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
|
||||
[String(match.player), "match-box-score"],
|
||||
];
|
||||
for (const [i, [text, cssClass]] of texts.entries()) {
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: cssClass,
|
||||
x,
|
||||
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
"text-anchor": "middle",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
controls: BoardControls,
|
||||
): void {
|
||||
const x = g.frame + (g.left - 4) / 2;
|
||||
const r = (g.left - 4) / 2 - 6;
|
||||
const top = g.edge + g.target / 2 + 2;
|
||||
const bottom = g.height - g.edge - g.target / 2 - 2;
|
||||
const control = (name: IconName, action: string, label: string, y: number, on: boolean) => {
|
||||
const group = add(svg, "g", { class: on ? "control" : "control disabled" });
|
||||
add(group, "circle", { class: "control-circle", cx: x, cy: y, r });
|
||||
const size = r * 1.1;
|
||||
const scale = size / 24;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
|
||||
});
|
||||
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
|
||||
if (!on) {
|
||||
return;
|
||||
}
|
||||
if (action === "menu") {
|
||||
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
|
||||
// la barre haute du portrait.
|
||||
const link = add(hits, "a", {
|
||||
href: "#/menu",
|
||||
"data-action": action,
|
||||
role: "link",
|
||||
"aria-label": label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x: Math.max(0, x - g.target / 2),
|
||||
y: Math.max(0, y - g.target / 2),
|
||||
width: g.target,
|
||||
height: g.target,
|
||||
});
|
||||
} else {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
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 {
|
||||
control("undo", "undo", TEXTS.undoTitle, bottom, controls.backEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
|
||||
* Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : un pion fantôme (contour seul) là où chaque pion
|
||||
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
|
||||
* (sur la barre), et une flèche par demi-coup.
|
||||
*/
|
||||
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
|
||||
function drawTraceRings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
const R = g.radius;
|
||||
for (const [place, n] of trace.left) {
|
||||
// Au-dessus des pions restés sur la case.
|
||||
const remaining = countAt(board, side, place);
|
||||
for (let k = 0; k < n; k++) {
|
||||
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
|
||||
addRing(svg, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
const top = topIndex(g, board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
addRing(svg, g, "trace-landed", place, slotCenter(g, place, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
const top = topIndex(g, board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
|
||||
addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les flèches d'un coup, une par demi-coup : sous les pions. */
|
||||
function drawTraceArrows(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
const defs = add(svg, "defs", {});
|
||||
const marker = add(defs, "marker", {
|
||||
id: "trace-head",
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 8,
|
||||
refY: 5,
|
||||
markerWidth: 3.5,
|
||||
markerHeight: 3.5,
|
||||
markerWidth: 3,
|
||||
markerHeight: 3,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
@@ -378,20 +687,35 @@ function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): voi
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
const start = slotCenter(from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
|
||||
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
|
||||
const start = slotCenter(g, from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0));
|
||||
drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se
|
||||
* posera (sur la pile du joueur, ou au pied de la flèche). */
|
||||
function drawLandings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
targets: ReadonlySet<Place>,
|
||||
): void {
|
||||
for (const place of targets) {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1);
|
||||
addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
|
||||
function topIndex(board: BoardModel, side: Side, place: Place): number {
|
||||
function topIndex(g: Geometry, board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, stackLimit(g, place)) - 1;
|
||||
}
|
||||
|
||||
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
|
||||
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
|
||||
function drawArrow(svg: SVGSVGElement, g: Geometry, start: Spot, end: Spot, gap: number): void {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
@@ -402,6 +726,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"stroke-width": g.arrowWidth,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
@@ -420,6 +745,9 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
@@ -444,7 +772,7 @@ export async function slideChecker(
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
{ duration: ms, easing: EASING_STANDARD },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
@@ -485,97 +813,119 @@ const PIPS: Record<number, [number, number][]> = {
|
||||
};
|
||||
|
||||
/** Les dés, au milieu de la moitié droite. */
|
||||
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
const center = RIGHT_HALF + HALF_WIDTH / 2;
|
||||
function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): void {
|
||||
const center = g.rightHalf + 3 * g.pointWidth;
|
||||
const size = g.die;
|
||||
const gap = 8;
|
||||
const total = dice.length * DIE + (dice.length - 1) * gap;
|
||||
const total = dice.length * size + (dice.length - 1) * gap;
|
||||
dice.forEach((die, i) => {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const x = center - total / 2 + i * (size + gap);
|
||||
const y = g.height / 2 - size / 2;
|
||||
const group = add(svg, "g", {
|
||||
class: die.blocked ? "die die-blocked" : "die",
|
||||
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: size, height: size, rx: 6 });
|
||||
for (const [px, py] of PIPS[die.value] ?? []) {
|
||||
add(group, "circle", {
|
||||
class: `pip-${die.side}`,
|
||||
cx: x + px * DIE,
|
||||
cy: y + py * DIE,
|
||||
r: 3.5,
|
||||
cx: x + px * size,
|
||||
cy: y + py * size,
|
||||
r: size / 10,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + size, x2: x + size, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
|
||||
* sinon du côté de son propriétaire. Au centre, il montre 64. */
|
||||
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(cube);
|
||||
const x = center.x - CUBE / 2;
|
||||
const y = center.y - CUBE / 2;
|
||||
/** Le videau : dans la colonne des sorties en portrait, dans la colonne de
|
||||
* commandes en paysage ; au milieu s'il est au centre (il montre alors 64),
|
||||
* sinon du côté de son propriétaire. */
|
||||
function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(g, cube);
|
||||
const size = g.cube;
|
||||
const x = center.x - size / 2;
|
||||
const y = center.y - size / 2;
|
||||
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{ class: "cube-value", x: center.x, y: center.y + 6 },
|
||||
{
|
||||
class: "cube-value",
|
||||
x: center.x,
|
||||
y: center.y + g.cubeFont * 0.35,
|
||||
"font-size": g.cubeFont,
|
||||
},
|
||||
String(cube.owner === null ? 64 : cube.value),
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Le centre du videau dans le plateau de sortie. */
|
||||
function cubeCenter(cube: Cube): Spot {
|
||||
/** Le centre du videau. */
|
||||
function cubeCenter(g: Geometry, cube: Cube): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (g.layout === "landscape") {
|
||||
const shift = g.target + 6;
|
||||
const y =
|
||||
cube.owner === "computer"
|
||||
? middle - shift
|
||||
: cube.owner === "player"
|
||||
? middle + shift
|
||||
: middle;
|
||||
return { x: g.frame + (g.left - 4) / 2, y };
|
||||
}
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
|
||||
return { x: g.trayLeft + g.tray / 2, y: middle + shift };
|
||||
}
|
||||
|
||||
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). Renvoie
|
||||
* leur centre. */
|
||||
function waitingDice(svg: SVGSVGElement): Spot {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
/** Portrait : les dés du joueur en attente d'être lancés, faces vides, au
|
||||
* milieu de la moitié gauche (la droite garde le dernier jet de
|
||||
* l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement, g: Geometry): void {
|
||||
const center = g.x0 + 3 * g.pointWidth;
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
|
||||
for (const x of [center - gap / 2 - g.die, center + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: MIDDLE - DIE / 2,
|
||||
width: DIE,
|
||||
height: DIE,
|
||||
y: g.height / 2 - g.die / 2,
|
||||
width: g.die,
|
||||
height: g.die,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
return center;
|
||||
}
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* doubler) : au moins MIN_TARGET de côté, centrée sur ce qu'on touche, avec
|
||||
* un rôle et un nom accessibles. Les boutons restent pour le clavier.
|
||||
* jouer le coup, doubler, menu…) : au moins g.target de côté (44 px à
|
||||
* l'écran), centrée sur ce qu'on touche, avec un rôle et un nom accessibles.
|
||||
*/
|
||||
function addAction(
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
action: string,
|
||||
label: string,
|
||||
center: Spot,
|
||||
width: number,
|
||||
): void {
|
||||
const w = Math.max(width, g.target);
|
||||
add(hits, "rect", {
|
||||
class: "hit action",
|
||||
"data-action": action,
|
||||
role: "button",
|
||||
"aria-label": label,
|
||||
// Sans déborder du dessin à droite (le videau est contre le bord).
|
||||
x: Math.min(center.x - width / 2, WIDTH - width),
|
||||
y: center.y - MIN_TARGET / 2,
|
||||
width,
|
||||
height: MIN_TARGET,
|
||||
// Sans déborder du dessin (le videau est contre le bord).
|
||||
x: Math.max(0, Math.min(center.x - w / 2, g.width - w)),
|
||||
y: center.y - g.target / 2,
|
||||
width: w,
|
||||
height: g.target,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
|
||||
// temps que l'écran rejoigne le menu de l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
|
||||
const RESULT_KIND: Record<ResultKind, string> = {
|
||||
single: "partie simple",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
};
|
||||
|
||||
function moves(n: number): string {
|
||||
return n > 1 ? `${n} coups` : `${n} coup`;
|
||||
}
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const GAMES_TEXTS = {
|
||||
button: "Mes parties",
|
||||
title: "Mes parties",
|
||||
empty: "Aucune partie enregistrée.",
|
||||
close: "Fermer",
|
||||
resume: "Reprendre",
|
||||
showBranches: "Branches",
|
||||
resumeBranch: "Reprendre cette branche",
|
||||
exportSgf: "Exporter (SGF)",
|
||||
remove: "Supprimer",
|
||||
confirmRemove: "Supprimer pour de bon (branches comprises)",
|
||||
shown: "partie affichée",
|
||||
busy: "Attendez la fin du coup en cours.",
|
||||
failed: (message: string) => `Erreur : ${message}`,
|
||||
|
||||
/** « 3 nov. 2026, 14:05 ». */
|
||||
date: (ms: number) =>
|
||||
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
||||
|
||||
status: (
|
||||
result: { winner: Side; kind: ResultKind; points: number } | null,
|
||||
moveCount: number,
|
||||
) => {
|
||||
if (result === null) {
|
||||
return `En cours, ${moves(moveCount)}`;
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
|
||||
},
|
||||
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
|
||||
|
||||
/** Une branche : d'où elle part et où elle en est. */
|
||||
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
|
||||
const start =
|
||||
forkMoveCount === null
|
||||
? "Partie d'origine"
|
||||
: forkMoveCount === 0
|
||||
? "Rejouée depuis le début"
|
||||
: `Rejouée après le coup ${forkMoveCount}`;
|
||||
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
|
||||
},
|
||||
currentBranch: " (branche courante)",
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
|
||||
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne
|
||||
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
|
||||
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
|
||||
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
|
||||
//
|
||||
// La feuille a son adresse (#/parties), et chaque partie la sienne
|
||||
// (#/parties/<id>, ses branches dépliées) : l'écran de jeu l'ouvre et la
|
||||
// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien.
|
||||
|
||||
import { branches } from "../domain/game/branches.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
export interface GamesViewOptions {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
readonly list: () => Promise<SavedGameSummary[]>;
|
||||
/** Le journal d'une partie (null si elle a disparu). */
|
||||
readonly journal: (id: string) => Promise<GameJournal | null>;
|
||||
/** L'identifiant de la partie affichée à l'écran. */
|
||||
readonly shownId: () => string | null;
|
||||
/** Reprend une partie, au bout de la branche branchHead si elle est
|
||||
* donnée (sinon là où elle en était), et quitte la feuille. */
|
||||
readonly resume: (id: string, branchHead?: string) => Promise<void>;
|
||||
readonly remove: (id: string) => Promise<void>;
|
||||
/** Le fichier SGF d'une partie : son nom et son contenu. */
|
||||
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
|
||||
/** La croix ou le voile touché : quitter la feuille (l'écran de jeu
|
||||
* change d'adresse, puis appelle hide). */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export interface GamesView {
|
||||
/** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les
|
||||
* branches de la partie expanded dépliées. */
|
||||
show(expanded: string | null): Promise<void>;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
}
|
||||
|
||||
/** Crée la feuille « Mes parties » (fermée) dans la page. */
|
||||
export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
const veil = element("div", "veil sheet-veil");
|
||||
veil.id = "games-veil";
|
||||
veil.hidden = true;
|
||||
const sheet = element("div", "sheet games");
|
||||
sheet.id = "games";
|
||||
sheet.setAttribute("role", "dialog");
|
||||
sheet.setAttribute("aria-modal", "true");
|
||||
sheet.setAttribute("aria-labelledby", "games-title");
|
||||
const head = element("div", "sheet-head");
|
||||
const title = element("h2", "sheet-title");
|
||||
title.id = "games-title";
|
||||
title.textContent = GAMES_TEXTS.title;
|
||||
const close = button("", () => options.onClose(), "icon-button");
|
||||
close.id = "games-close";
|
||||
close.setAttribute("aria-label", GAMES_TEXTS.close);
|
||||
close.append(icon("close"));
|
||||
head.append(title, close);
|
||||
const list = element("ul", "games-list");
|
||||
const message = element("p", "games-message");
|
||||
message.setAttribute("aria-live", "polite");
|
||||
sheet.append(head, message, list);
|
||||
veil.append(sheet);
|
||||
document.body.append(veil);
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
// La partie dont les branches sont dépliées (adresse #/parties/<id>).
|
||||
let expanded: string | null = null;
|
||||
|
||||
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
|
||||
const run = (action: () => Promise<void>) => async () => {
|
||||
message.textContent = "";
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
message.textContent = GAMES_TEXTS.failed(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function render(): Promise<void> {
|
||||
const games = await options.list();
|
||||
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
|
||||
if (games.length === 0) {
|
||||
message.textContent = GAMES_TEXTS.empty;
|
||||
}
|
||||
}
|
||||
|
||||
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
|
||||
const item = element("li", "games-item");
|
||||
item.dataset.id = game.id;
|
||||
const heading = element("p");
|
||||
heading.textContent = [
|
||||
GAMES_TEXTS.date(game.startedAt),
|
||||
GAMES_TEXTS.status(game.result, game.moveCount),
|
||||
GAMES_TEXTS.branchCount(game.branchCount),
|
||||
shown ? GAMES_TEXTS.shown : "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" · ");
|
||||
const branchList = element("ul", "games-branches");
|
||||
branchList.hidden = true;
|
||||
const actions = element("div", "games-actions");
|
||||
actions.append(
|
||||
button(
|
||||
GAMES_TEXTS.resume,
|
||||
run(() => options.resume(game.id)),
|
||||
"",
|
||||
"resume",
|
||||
),
|
||||
branchesLink(game.id),
|
||||
button(
|
||||
GAMES_TEXTS.exportSgf,
|
||||
run(async () => download(await options.exportSgf(game.id))),
|
||||
"secondary",
|
||||
"export",
|
||||
),
|
||||
// Supprimer se fait en deux touchers, sans boîte du navigateur :
|
||||
// le premier change le bouton en demande de confirmation.
|
||||
button(
|
||||
GAMES_TEXTS.remove,
|
||||
run(async () => {
|
||||
const remove = item.querySelector<HTMLElement>('[data-action="remove"]');
|
||||
if (remove?.dataset.armed !== "true") {
|
||||
if (remove !== null) {
|
||||
remove.dataset.armed = "true";
|
||||
remove.textContent = GAMES_TEXTS.confirmRemove;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await options.remove(game.id);
|
||||
await render();
|
||||
await expand();
|
||||
}),
|
||||
"secondary",
|
||||
"remove",
|
||||
),
|
||||
);
|
||||
// La partie à l'écran ne se supprime pas : elle continue d'être
|
||||
// enregistrée.
|
||||
const remove = actions.querySelector<HTMLElement>('[data-action="remove"]');
|
||||
if (remove !== null) {
|
||||
remove.hidden = shown;
|
||||
}
|
||||
item.append(heading, actions, branchList);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** « Branches » : le lien qui déplie les branches d'une partie (ou les
|
||||
* replie, quand elles le sont déjà). */
|
||||
function branchesLink(id: string): HTMLAnchorElement {
|
||||
const link = element("a", "button secondary");
|
||||
link.textContent = GAMES_TEXTS.showBranches;
|
||||
link.dataset.action = "branches";
|
||||
link.href = routeHash({ screen: "games", game: id });
|
||||
link.setAttribute("aria-expanded", "false");
|
||||
return link;
|
||||
}
|
||||
|
||||
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
|
||||
const journal = await options.journal(id);
|
||||
const all = journal === null ? [] : branches(journal);
|
||||
target.replaceChildren(
|
||||
...all.map((branch) => {
|
||||
const item = element("li", "games-branch");
|
||||
item.dataset.head = branch.head;
|
||||
const text = element("span");
|
||||
text.textContent =
|
||||
GAMES_TEXTS.branch(
|
||||
branch.forkedFrom === null ? null : branch.forkMoveCount,
|
||||
branch.moveCount,
|
||||
branch.over,
|
||||
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
|
||||
item.append(
|
||||
text,
|
||||
button(
|
||||
GAMES_TEXTS.resumeBranch,
|
||||
run(() => options.resume(id, branch.head)),
|
||||
"secondary",
|
||||
"resume-branch",
|
||||
),
|
||||
);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Déplie les branches de la partie expanded, replie les autres. */
|
||||
async function expand(): Promise<void> {
|
||||
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
|
||||
const id = item.dataset.id ?? "";
|
||||
const open = id === expanded;
|
||||
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
|
||||
link?.setAttribute("aria-expanded", String(open));
|
||||
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
|
||||
const branchList = item.querySelector<HTMLElement>(".games-branches");
|
||||
if (branchList === null) {
|
||||
continue;
|
||||
}
|
||||
if (open && branchList.hidden) {
|
||||
await run(() => renderBranches(id, branchList))();
|
||||
}
|
||||
branchList.hidden = !open;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async show(next) {
|
||||
expanded = next;
|
||||
if (veil.hidden) {
|
||||
message.textContent = "";
|
||||
veil.hidden = false;
|
||||
await run(render)();
|
||||
close.focus();
|
||||
}
|
||||
await expand();
|
||||
},
|
||||
hide() {
|
||||
veil.hidden = true;
|
||||
},
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className = "",
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const created = document.createElement(tag);
|
||||
if (className !== "") {
|
||||
created.className = className;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
function button(
|
||||
text: string,
|
||||
onClick: () => unknown,
|
||||
className = "",
|
||||
action = "",
|
||||
): HTMLButtonElement {
|
||||
const created = element("button", className);
|
||||
created.type = "button";
|
||||
created.textContent = text;
|
||||
if (action !== "") {
|
||||
created.dataset.action = action;
|
||||
}
|
||||
created.onclick = () => void onClick();
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Fait télécharger un fichier texte au navigateur. */
|
||||
function download(file: { fileName: string; text: string }): void {
|
||||
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
|
||||
const link = element("a");
|
||||
link.href = url;
|
||||
link.download = file.fileName;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// La géométrie du plateau, calculée d'après la forme de l'écran (fonction
|
||||
// pure, testée sous Node).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le dessin est en unités du viewBox, de hauteur fixe (520). En portrait, le
|
||||
// plateau garde ses proportions habituelles (640 × 520) et suit la largeur de
|
||||
// l'écran. En paysage, « le plateau est l'écran » (ADR 0012, amendements du
|
||||
// 2026-11-03) : sa largeur suit le rapport largeur/hauteur de l'écran, et les
|
||||
// flèches s'élargissent d'autant. Formules du composant Board des maquettes
|
||||
// « Révision UX » : largeur d'une flèche = ce qui reste une fois ôtés le
|
||||
// cadre, les colonnes et la barre, divisé par 12 ; rayon d'un pion = le plus
|
||||
// petit de la demi-largeur d'une flèche (moins 2) et du dixième de la
|
||||
// hauteur d'une demi-table (hors bande des numéros et bande centrale).
|
||||
|
||||
/** Portrait : barre haute, bandeaux et barre d'actions autour du plateau ;
|
||||
* paysage : le plateau seul, commandes comprises. */
|
||||
export type Layout = "portrait" | "landscape";
|
||||
|
||||
export interface Geometry {
|
||||
readonly layout: Layout;
|
||||
/** Taille du dessin, en unités du viewBox. */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
/** Épaisseur du cadre sur les côtés. */
|
||||
readonly frame: number;
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
|
||||
readonly edge: number;
|
||||
/** Colonne de commandes à gauche (0 en portrait). */
|
||||
readonly left: number;
|
||||
/** Colonne des sorties, à droite. */
|
||||
readonly tray: number;
|
||||
readonly bar: number;
|
||||
/** Bande centrale, entre les pointes des flèches (dés, consigne). */
|
||||
readonly band: number;
|
||||
readonly pointWidth: number;
|
||||
readonly radius: number;
|
||||
/** D'un pion au suivant dans une pile. */
|
||||
readonly step: number;
|
||||
/** Bord gauche de la première colonne de flèches. */
|
||||
readonly x0: number;
|
||||
readonly barLeft: number;
|
||||
/** Bord gauche de la moitié droite. */
|
||||
readonly rightHalf: number;
|
||||
readonly trayLeft: number;
|
||||
/** Côté minimal d'une zone à toucher : 44 px à l'écran. */
|
||||
readonly target: number;
|
||||
/** Tailles de texte, en unités : numéros des flèches (12 px au moins à
|
||||
* l'écran), consigne de la bande et pastilles des comptes (13 px). */
|
||||
readonly numberSize: number;
|
||||
readonly bandFont: number;
|
||||
readonly chipFont: number;
|
||||
/** Videau : côté et taille du chiffre ; nombre posé sur une pile trop
|
||||
* haute (13 px au moins à l'écran). */
|
||||
readonly cube: number;
|
||||
readonly cubeFont: number;
|
||||
readonly countFont: number;
|
||||
/** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */
|
||||
readonly arrowWidth: number;
|
||||
readonly die: number;
|
||||
/** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un
|
||||
* nombre sur le dernier) : en paysage, ils s'arrêtent avant les
|
||||
* pastilles des comptes. */
|
||||
readonly barStack: number;
|
||||
/** Hauteur d'une pastille de compte, au bout de la barre (paysage). */
|
||||
readonly chipHeight: number;
|
||||
}
|
||||
|
||||
export const BOARD_HEIGHT = 520;
|
||||
/** Rapport largeur / hauteur du plateau en portrait. */
|
||||
export const PORTRAIT_ASPECT = 640 / 520;
|
||||
/** Pions dessinés au plus sur une flèche ; au-delà, un nombre sur le dernier. */
|
||||
export const MAX_STACK = 5;
|
||||
|
||||
/** Une taille en unités qui fait au moins px pixels à l'écran. */
|
||||
function atLeast(px: number, pxPerUnit: number, min: number): number {
|
||||
return Math.max(min, Math.ceil(px / pxPerUnit));
|
||||
}
|
||||
|
||||
/**
|
||||
* La géométrie du plateau pour une mise en page. aspect : rapport largeur /
|
||||
* hauteur de la place offerte (ignoré en portrait) ; pxPerUnit : pixels à
|
||||
* l'écran par unité du dessin, pour les tailles minimales (textes, cibles).
|
||||
*/
|
||||
export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number): Geometry {
|
||||
const height = BOARD_HEIGHT;
|
||||
const target = atLeast(46, pxPerUnit, 40);
|
||||
const numberSize = atLeast(12.5, pxPerUnit, 13);
|
||||
const text = atLeast(13.5, pxPerUnit, 17);
|
||||
const sizes = {
|
||||
cubeFont: text,
|
||||
cube: Math.max(36, text + 20),
|
||||
countFont: Math.max(18, text),
|
||||
arrowWidth: 2.5 / pxPerUnit,
|
||||
};
|
||||
if (layout === "portrait") {
|
||||
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
|
||||
const frame = 8;
|
||||
const tray = 48;
|
||||
const bar = 40;
|
||||
const width = 640;
|
||||
const pointWidth = (width - 3 * frame - tray - bar) / 12;
|
||||
const band = 84;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
|
||||
const x0 = frame;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge: frame,
|
||||
left: 0,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step: 2 * radius + 2,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth + frame,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 17),
|
||||
chipFont: atLeast(13.5, pxPerUnit, 17),
|
||||
...sizes,
|
||||
die: 36,
|
||||
barStack: MAX_STACK,
|
||||
chipHeight: 0,
|
||||
};
|
||||
}
|
||||
const width = Math.round(height * aspect);
|
||||
const frame = 6;
|
||||
const edge = Math.max(24, numberSize + 8);
|
||||
const left = target + 4;
|
||||
const tray = 56;
|
||||
const bar = 64;
|
||||
const band = 80;
|
||||
const pointWidth = (width - 2 * frame - left - tray - bar) / 12;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2 - 4) / 10);
|
||||
const step = 2 * radius + 1;
|
||||
const chipFont = atLeast(13.5, pxPerUnit, 18);
|
||||
const chipHeight = chipFont + 12;
|
||||
// Pions de la barre, depuis le milieu (voir board-view) : le dernier
|
||||
// s'arrête à 2 unités de la pastille.
|
||||
const room = height / 2 - edge - 4 - chipHeight - 2 - (radius + 4) - radius;
|
||||
const barStack = Math.max(1, Math.min(MAX_STACK, Math.floor(room / step) + 1));
|
||||
const x0 = frame + left;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge,
|
||||
left,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 22),
|
||||
chipFont,
|
||||
...sizes,
|
||||
die: Math.min(40, band - 12),
|
||||
barStack,
|
||||
chipHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne de flèches (0 à 11). */
|
||||
export function columnLeft(g: Geometry, column: number): number {
|
||||
return column < 6 ? g.x0 + column * g.pointWidth : g.rightHalf + (column - 6) * g.pointWidth;
|
||||
}
|
||||
|
||||
/** Ordonnée du centre du pion numéro index (0 : contre le bord) d'une pile
|
||||
* de la rangée du haut ou du bas. */
|
||||
export function stackY(g: Geometry, top: boolean, index: number): number {
|
||||
const offset = g.radius + 1 + Math.min(index, MAX_STACK) * g.step;
|
||||
return top ? g.edge + offset : g.height - g.edge - offset;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Les vibrations (docs/identite/charte/interaction.md) : elles doublent un
|
||||
// événement visible, jamais seules, et se coupent dans le menu. Rien ne vibre
|
||||
// pour un toucher de bouton ni pour le lancer des dés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export type HapticEvent =
|
||||
| "checkerPlaced"
|
||||
| "checkerHit"
|
||||
| "computerDoubles"
|
||||
| "gameWon"
|
||||
| "gameLost";
|
||||
|
||||
/** Motifs en millisecondes (vibration, pause, vibration…). */
|
||||
export const HAPTIC_PATTERNS: Readonly<Record<HapticEvent, readonly number[]>> = {
|
||||
checkerPlaced: [12],
|
||||
checkerHit: [25, 40, 25],
|
||||
computerDoubles: [40, 60, 40],
|
||||
gameWon: [30, 50, 30, 50, 60],
|
||||
gameLost: [60],
|
||||
};
|
||||
|
||||
/** navigator.vibrate, s'il existe. */
|
||||
export type Vibrate = (pattern: number[]) => boolean;
|
||||
|
||||
/**
|
||||
* La fonction qui fait vibrer pour un événement : rien si le navigateur ne
|
||||
* sait pas vibrer ou si le joueur a coupé les vibrations. Une seule vibration
|
||||
* à la fois : chaque appel remplace le précédent (comportement de
|
||||
* navigator.vibrate).
|
||||
*/
|
||||
export function createHaptics(
|
||||
vibrate: Vibrate | undefined,
|
||||
enabled: () => boolean,
|
||||
): (event: HapticEvent) => void {
|
||||
return (event) => {
|
||||
if (vibrate !== undefined && enabled()) {
|
||||
try {
|
||||
vibrate([...HAPTIC_PATTERNS[event]]);
|
||||
} catch {
|
||||
// Navigateur qui refuse (pas encore de geste du joueur) : la
|
||||
// marque visible suffit.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// La question de l'aide qui correspond au moment de la partie : le bouton
|
||||
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
|
||||
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
|
||||
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
|
||||
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
|
||||
// titre dans un .feature demande de le changer ici.
|
||||
|
||||
import { routeHash, slug } from "./router.js";
|
||||
|
||||
/** Le moment de la partie, vu de l'aide. */
|
||||
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
|
||||
|
||||
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
|
||||
moving: "Comment jouer un coup ?",
|
||||
stuck: "Je ne peux rien jouer avec mes dés",
|
||||
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
|
||||
over: "Comment se termine une partie ?",
|
||||
};
|
||||
|
||||
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
|
||||
* question particulière : null). */
|
||||
export function helpHash(moment: HelpMoment | null): string {
|
||||
return routeHash({
|
||||
screen: "help",
|
||||
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
|
||||
});
|
||||
}
|
||||
+54
-6
@@ -14,10 +14,16 @@ export interface HelpFeature {
|
||||
}
|
||||
|
||||
export interface HelpScenario {
|
||||
/** Le titre, formulé comme la question du joueur. */
|
||||
readonly title: string;
|
||||
/** La réponse : le paragraphe sous le titre. */
|
||||
readonly description: readonly string[];
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
|
||||
* s'affichent pas dans l'aide : elles prouvent la réponse. */
|
||||
readonly steps: readonly string[];
|
||||
/** La position de l'exemple, donnée par l'étiquette
|
||||
* @position:<Position ID> du scénario (null sans étiquette). */
|
||||
readonly position: string | null;
|
||||
}
|
||||
|
||||
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
|
||||
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
|
||||
/**
|
||||
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
|
||||
* libre consécutives forment un paragraphe ; les commentaires (#) sont
|
||||
* ignorés.
|
||||
* ignorés ; une étiquette @position:<Position ID> donne la position de
|
||||
* l'exemple du scénario qui la suit.
|
||||
*/
|
||||
export function parseFeature(text: string): HelpFeature {
|
||||
let title = "";
|
||||
const description: string[] = [];
|
||||
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
|
||||
const scenarios: {
|
||||
title: string;
|
||||
description: string[];
|
||||
steps: string[];
|
||||
position: string | null;
|
||||
}[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let tags: string[] = [];
|
||||
|
||||
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
|
||||
// du dernier scénario.
|
||||
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
|
||||
const scenario = SCENARIO.exec(line);
|
||||
if (line === "" || line.startsWith("#")) {
|
||||
endParagraph();
|
||||
} else if (line.startsWith("@")) {
|
||||
endParagraph();
|
||||
tags.push(...line.split(/\s+/));
|
||||
} else if (feature) {
|
||||
endParagraph();
|
||||
title = feature[1] ?? "";
|
||||
tags = [];
|
||||
} else if (scenario) {
|
||||
endParagraph();
|
||||
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
|
||||
const position = tags.find((tag) => tag.startsWith("@position:"));
|
||||
scenarios.push({
|
||||
title: scenario[2] ?? "",
|
||||
description: [],
|
||||
steps: [],
|
||||
position: position === undefined ? null : position.slice("@position:".length),
|
||||
});
|
||||
tags = [];
|
||||
} else if (STEP.test(line) && scenarios.length > 0) {
|
||||
endParagraph();
|
||||
scenarios.at(-1)?.steps.push(line);
|
||||
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
|
||||
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
|
||||
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
|
||||
* phrase « En bref », et ses renvois « Voir aussi ». */
|
||||
export interface GlossaryEntry {
|
||||
readonly term: string;
|
||||
readonly english: string;
|
||||
readonly brief: string;
|
||||
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
|
||||
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
|
||||
}
|
||||
|
||||
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
|
||||
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
|
||||
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
|
||||
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
||||
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
|
||||
if (term === undefined || brief === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { term, brief: brief.replaceAll("**", "") };
|
||||
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
|
||||
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
|
||||
term: m[1] ?? "",
|
||||
name: m[2] ?? "",
|
||||
}));
|
||||
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
|
||||
}
|
||||
|
||||
/** Un texte sans accents ni majuscules, pour la recherche dans le
|
||||
* glossaire. */
|
||||
export function searchable(text: string): string {
|
||||
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
|
||||
* phrase « En bref » contiennent chacun des mots cherchés) ? */
|
||||
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
|
||||
const words = searchable(query).split(/\s+/).filter(Boolean);
|
||||
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
|
||||
return words.every((word) => haystack.includes(word));
|
||||
}
|
||||
|
||||
+289
-53
@@ -1,21 +1,42 @@
|
||||
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
|
||||
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
|
||||
// (ADR 0008). Rien n'est recopié dans le code.
|
||||
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
|
||||
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
|
||||
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
|
||||
// dans le code.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
|
||||
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
|
||||
// est ouvert (show). Le titre d'un scénario est la question, sa
|
||||
// description la réponse ; ses étapes, la preuve que l'application fait ce
|
||||
// que dit la réponse, ne s'affichent pas
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
|
||||
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
type HelpScenario,
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "./help-text.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { type HelpRoute, routeHash, slug } from "./router.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = ["docs/scenarios/jouer-une-partie.feature"];
|
||||
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",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
"docs/scenarios/installer-l-application.feature",
|
||||
"docs/scenarios/decouvrir-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -26,10 +47,37 @@ async function fetchText(url: string): Promise<string> {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
/** Une fiche du glossaire et son nom de fichier, sans .md. */
|
||||
interface GlossaryCard extends GlossaryEntry {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryCard[]> {
|
||||
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
|
||||
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
return texts.flatMap((text, i) => {
|
||||
const entry = parseGlossaryEntry(text);
|
||||
const name = (files[i] ?? "").replace(/\.md$/, "");
|
||||
return entry === null ? [] : [{ ...entry, name }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
|
||||
* copie du site : la bulle du compte de points en affiche le texte. */
|
||||
export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | null> {
|
||||
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
|
||||
}
|
||||
|
||||
/** Le commit publié (version.json, écrit par make site), en abrégé. */
|
||||
async function publishedVersion(): Promise<string | null> {
|
||||
try {
|
||||
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
|
||||
return typeof version.commit === "string" && version.commit !== ""
|
||||
? version.commit.slice(0, 12)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
@@ -45,58 +93,246 @@ function element<K extends keyof HTMLElementTagNameMap>(
|
||||
return child;
|
||||
}
|
||||
|
||||
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
|
||||
element(parent, "h2", feature.title);
|
||||
for (const paragraph of feature.description) {
|
||||
element(parent, "p", paragraph);
|
||||
}
|
||||
for (const scenario of feature.scenarios) {
|
||||
element(parent, "h3", scenario.title);
|
||||
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
|
||||
interface Question {
|
||||
readonly id: string;
|
||||
readonly section: HTMLElement;
|
||||
readonly link: HTMLAnchorElement;
|
||||
readonly answer: HTMLElement;
|
||||
}
|
||||
|
||||
export interface HelpView {
|
||||
/** Montre l'onglet, la question ou le terme que désigne l'adresse
|
||||
* (l'aide est lue au premier appel). */
|
||||
show(route: HelpRoute): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
|
||||
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
|
||||
*/
|
||||
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
|
||||
const play = document.createElement("div");
|
||||
play.id = "help-play";
|
||||
const glossary = document.createElement("div");
|
||||
glossary.id = "help-glossary";
|
||||
const about = document.createElement("div");
|
||||
about.id = "help-about";
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "help-notice";
|
||||
notice.setAttribute("role", "status");
|
||||
const questions: Question[] = [];
|
||||
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
|
||||
let loading: Promise<boolean> | null = null;
|
||||
// La question ouverte au dernier affichage : refermée, elle reste en vue.
|
||||
let lastOpen: Question | null = null;
|
||||
|
||||
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
|
||||
let id = slug(scenario.title);
|
||||
for (let n = 2; questions.some((q) => q.id === id); n++) {
|
||||
id = `${slug(scenario.title)}-${n}`;
|
||||
}
|
||||
const section = element(parent, "section");
|
||||
section.className = "question";
|
||||
section.id = `aide-${id}`;
|
||||
const heading = element(section, "h3");
|
||||
const link = element(heading, "a", scenario.title);
|
||||
link.href = routeHash({ screen: "help", question: id });
|
||||
link.setAttribute("aria-controls", `aide-${id}-reponse`);
|
||||
link.setAttribute("aria-expanded", "false");
|
||||
const answer = element(section, "div");
|
||||
answer.id = `aide-${id}-reponse`;
|
||||
answer.className = "answer";
|
||||
answer.hidden = true;
|
||||
for (const paragraph of scenario.description) {
|
||||
element(parent, "p", paragraph);
|
||||
element(answer, "p", paragraph);
|
||||
}
|
||||
const steps = element(parent, "ul");
|
||||
steps.className = "steps";
|
||||
for (const step of scenario.steps) {
|
||||
element(steps, "li", step);
|
||||
if (scenario.position !== null) {
|
||||
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
|
||||
board.className = "see-board";
|
||||
board.href = routeHash({
|
||||
screen: "position",
|
||||
position: scenario.position,
|
||||
match: null,
|
||||
});
|
||||
}
|
||||
questions.push({ id, section, link, answer });
|
||||
}
|
||||
}
|
||||
|
||||
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
|
||||
element(parent, "h2", TEXTS.glossaryTitle);
|
||||
const list = element(parent, "dl");
|
||||
list.className = "glossary";
|
||||
for (const entry of entries) {
|
||||
element(list, "dt", entry.term);
|
||||
element(list, "dd", entry.brief);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
|
||||
* font rien). */
|
||||
export async function showHelp(container: HTMLElement): Promise<void> {
|
||||
if (container.dataset.loaded === "true") {
|
||||
return;
|
||||
}
|
||||
container.replaceChildren();
|
||||
const status = element(container, "p", TEXTS.helpLoading);
|
||||
try {
|
||||
const [features, glossary] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
]);
|
||||
status.remove();
|
||||
for (const feature of features) {
|
||||
renderFeature(container, feature);
|
||||
function renderFeature(feature: HelpFeature): void {
|
||||
element(play, "h2", feature.title);
|
||||
for (const paragraph of feature.description) {
|
||||
element(play, "p", paragraph).className = "feature-intro";
|
||||
}
|
||||
for (const scenario of feature.scenarios) {
|
||||
renderScenario(play, scenario);
|
||||
}
|
||||
renderGlossary(container, glossary);
|
||||
const about = element(container, "p", `${TEXTS.about} `);
|
||||
about.className = "about";
|
||||
const link = element(about, "a", TEXTS.engineTestLink);
|
||||
link.href = "moteur/";
|
||||
container.dataset.loaded = "true";
|
||||
} catch {
|
||||
status.textContent = TEXTS.helpFailed;
|
||||
}
|
||||
|
||||
function renderGlossary(entries: readonly GlossaryCard[]): void {
|
||||
element(glossary, "h2", TEXTS.glossaryTitle);
|
||||
const label = element(glossary, "label");
|
||||
label.className = "glossary-search";
|
||||
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
|
||||
const search = element(label, "input");
|
||||
search.id = "glossary-search";
|
||||
search.type = "search";
|
||||
search.autocomplete = "off";
|
||||
search.spellcheck = false;
|
||||
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
|
||||
none.className = "glossary-none muted";
|
||||
none.hidden = true;
|
||||
const list = element(glossary, "dl");
|
||||
list.className = "glossary";
|
||||
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
|
||||
for (const entry of sorted) {
|
||||
const card = element(list, "div");
|
||||
card.className = "term-card";
|
||||
const dt = element(card, "dt");
|
||||
dt.id = `glossaire-${entry.name}`;
|
||||
const link = element(dt, "a", entry.term);
|
||||
link.href = routeHash({ screen: "glossary", term: entry.name });
|
||||
if (entry.english !== "") {
|
||||
const english = element(dt, "span", entry.english);
|
||||
english.className = "english";
|
||||
english.lang = "en";
|
||||
}
|
||||
const dd = element(card, "dd");
|
||||
element(dd, "p", entry.brief);
|
||||
if (entry.seeAlso.length > 0) {
|
||||
const see = element(dd, "p");
|
||||
see.className = "see-also";
|
||||
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
|
||||
entry.seeAlso.forEach((other, i) => {
|
||||
if (i > 0) {
|
||||
see.append(", ");
|
||||
}
|
||||
const a = element(see, "a", other.term);
|
||||
a.href = routeHash({ screen: "glossary", term: other.name });
|
||||
});
|
||||
}
|
||||
terms.set(entry.name, { dt, card, entry });
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
let shown = 0;
|
||||
for (const { card, entry } of terms.values()) {
|
||||
const match = matchesSearch(entry, search.value);
|
||||
card.hidden = !match;
|
||||
shown += match ? 1 : 0;
|
||||
}
|
||||
none.hidden = shown > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAbout(version: string | null): void {
|
||||
element(about, "h2", TEXTS.aboutTitle);
|
||||
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
|
||||
element(about, "p", paragraph).className = "about";
|
||||
}
|
||||
const line = element(
|
||||
about,
|
||||
"p",
|
||||
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
|
||||
);
|
||||
line.id = "about-version";
|
||||
line.className = "about";
|
||||
}
|
||||
|
||||
function renderTutorialCard(): void {
|
||||
const card = element(container, "a");
|
||||
card.id = "help-tutorial";
|
||||
card.className = "card help-tutorial";
|
||||
card.href = "#/tutoriel/1";
|
||||
const text = element(card, "span");
|
||||
text.className = "help-tutorial-text";
|
||||
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
|
||||
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
|
||||
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
|
||||
}
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
container.replaceChildren(notice);
|
||||
notice.textContent = TEXTS.helpLoading;
|
||||
try {
|
||||
const [features, entries, version] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
publishedVersion(),
|
||||
]);
|
||||
const intro = element(play, "p", HOME_TEXTS.helpIntro);
|
||||
intro.className = "help-intro muted";
|
||||
for (const feature of features) {
|
||||
renderFeature(feature);
|
||||
}
|
||||
renderGlossary(entries);
|
||||
renderAbout(version);
|
||||
notice.textContent = "";
|
||||
renderTutorialCard();
|
||||
container.append(play, glossary, about);
|
||||
container.dataset.loaded = "true";
|
||||
return true;
|
||||
} catch {
|
||||
notice.textContent = TEXTS.helpFailed;
|
||||
loading = null; // on réessaiera à la prochaine ouverture
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async show(route) {
|
||||
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
|
||||
if (tab.dataset.screen === route.screen) {
|
||||
tab.setAttribute("aria-current", "page");
|
||||
} else {
|
||||
tab.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
loading ??= load();
|
||||
if (!(await loading)) {
|
||||
return;
|
||||
}
|
||||
play.hidden = route.screen !== "help";
|
||||
glossary.hidden = route.screen !== "glossary";
|
||||
about.hidden = route.screen !== "about";
|
||||
|
||||
const question = route.screen === "help" ? route.question : null;
|
||||
for (const q of questions) {
|
||||
const open = q.id === question;
|
||||
q.answer.hidden = !open;
|
||||
q.link.setAttribute("aria-expanded", String(open));
|
||||
// Le lien d'une question ouverte la referme.
|
||||
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
|
||||
q.section.classList.toggle("open", open);
|
||||
}
|
||||
const term = route.screen === "glossary" ? route.term : null;
|
||||
for (const [name, { dt, card }] of terms) {
|
||||
dt.classList.toggle("current", name === term);
|
||||
card.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
dt.setAttribute("aria-current", "true");
|
||||
card.hidden = false;
|
||||
} else {
|
||||
dt.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
const missing =
|
||||
(question !== null && !questions.some((q) => q.id === question)) ||
|
||||
(term !== null && !terms.has(term));
|
||||
notice.textContent = missing ? TEXTS.helpNotFound : "";
|
||||
|
||||
const opened = questions.find((q) => q.id === question) ?? null;
|
||||
const closed = route.screen === "help" && opened === null ? lastOpen : null;
|
||||
lastOpen = opened;
|
||||
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
|
||||
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
} else if (closed !== null) {
|
||||
closed.section.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
|
||||
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
|
||||
|
||||
/** Les deux choix du premier lancement : « Je découvre » mène au
|
||||
* tutoriel, « Je connais les règles » à la partie. */
|
||||
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
|
||||
|
||||
/** Les réglages après l'un des deux choix du premier lancement. */
|
||||
export function welcomed(): Partial<Settings> {
|
||||
return { welcomed: true };
|
||||
}
|
||||
|
||||
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
|
||||
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
|
||||
* gardée (null). */
|
||||
export function launchHash(hash: string): string | null {
|
||||
return hash === "" || hash === "#" ? "#/accueil" : null;
|
||||
}
|
||||
|
||||
/** L'accueil, au premier lancement ou ensuite. */
|
||||
export interface HomeModel {
|
||||
/** Premier lancement : le choix « Je découvre » / « Je connais les
|
||||
* règles », et rien d'autre. */
|
||||
readonly welcome: boolean;
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
|
||||
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
|
||||
* encore connu, le moteur se charge). */
|
||||
export function homeModel(
|
||||
settings: Settings,
|
||||
playing: boolean | null,
|
||||
installed: boolean,
|
||||
): HomeModel {
|
||||
const welcome = isFirstLaunch(settings);
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
/** La ligne « Installer l'application » du menu : tant que l'application
|
||||
* n'est pas installée. */
|
||||
export function installMenuShown(settings: Settings, installed: boolean): boolean {
|
||||
return !installed && !settings.installed;
|
||||
}
|
||||
|
||||
/** Les réglages après « Plus tard » sur l'invitation à installer. */
|
||||
export function dismissInstall(settings: Settings): Partial<Settings> {
|
||||
return {
|
||||
installDismissals: settings.installDismissals + 1,
|
||||
installDismissedAt: settings.gamesFinished,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
|
||||
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
|
||||
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { InstallPlatform } from "./install.js";
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n > 1 ? many : one}`;
|
||||
}
|
||||
|
||||
export const HOME_TEXTS = {
|
||||
// Premier lancement
|
||||
welcomeTitle: "Bienvenue à la table",
|
||||
welcomeText:
|
||||
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
|
||||
discover: "Je découvre",
|
||||
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
|
||||
knowRules: "Je connais les règles",
|
||||
knowRulesDetail: "Une partie tout de suite",
|
||||
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
|
||||
|
||||
// Accueil
|
||||
homeLink: "Accueil",
|
||||
homeTitle: "À vous de jouer",
|
||||
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
|
||||
gameOverline: "Partie en cours",
|
||||
noGameOverline: "Pas de partie en cours",
|
||||
againstComputer: "Contre l'ordinateur",
|
||||
homePips: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
homeLoading: "Chargement…",
|
||||
resumeGame: "Reprendre la partie",
|
||||
rulesTitle: "Les règles du jeu",
|
||||
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
|
||||
rulesStart: "Commencer",
|
||||
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
installTitle: "Installer Ustagammon",
|
||||
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
|
||||
install: "Installer",
|
||||
installLater: "Plus tard",
|
||||
installMenu: "Installer l'application",
|
||||
installLead:
|
||||
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
|
||||
installSteps: {
|
||||
firefox: [
|
||||
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
|
||||
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
|
||||
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
ios: [
|
||||
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
|
||||
"Choisissez « Sur l'écran d'accueil ».",
|
||||
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
} satisfies Record<InstallPlatform, readonly string[]>,
|
||||
installUnderstood: "J'ai compris",
|
||||
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
|
||||
|
||||
// Mise à jour
|
||||
updateAvailable: "Nouvelle version prête.",
|
||||
updateApply: "Mettre à jour",
|
||||
updateCheck: "Rechercher une mise à jour",
|
||||
updateChecking: "Recherche d'une mise à jour…",
|
||||
updateLatest: "Vous avez la dernière version.",
|
||||
updateUnavailable:
|
||||
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
|
||||
|
||||
// Tutoriel
|
||||
tutorialMenu: "Les règles (tutoriel)",
|
||||
tutorialProgress: "Étapes du tutoriel",
|
||||
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
tutorialSkip: "Quitter",
|
||||
tutorialSkipLabel: "Quitter le tutoriel",
|
||||
tutorialOverline: (n: number, total: number, practice: boolean) =>
|
||||
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
tutorialEndOverline: "Les règles · fin",
|
||||
tutorialEndTitle: "Vous connaissez les règles",
|
||||
back: "Retour",
|
||||
next: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayLabel: "Revoir le coup",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
|
||||
illustratedEnd: (kind: ResultKind) =>
|
||||
kind === "single"
|
||||
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
|
||||
: kind === "gammon"
|
||||
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
|
||||
computerDoubles: "L'ordinateur double.",
|
||||
|
||||
// Aide
|
||||
helpTutorialTitle: "Nouveau au backgammon ?",
|
||||
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
|
||||
helpTutorialLink: "Apprendre les règles",
|
||||
helpIntro:
|
||||
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
|
||||
seeOnBoard: "Voir sur le plateau",
|
||||
glossarySearch: "Chercher un mot",
|
||||
glossaryNone: "Aucun mot ne correspond.",
|
||||
glossarySeeAlso: "Voir aussi :",
|
||||
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
|
||||
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
|
||||
aboutParagraphs: [
|
||||
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,242 @@
|
||||
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
|
||||
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
|
||||
// installer, les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
import { NO_MARKS, renderBoard } from "./board-view.js";
|
||||
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
export interface HomeViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
/** « Nouvelle partie » (bouton : une action, pas un écran). */
|
||||
readonly newGame: () => void;
|
||||
readonly install: () => void;
|
||||
readonly dismissInstall: () => void;
|
||||
/** Un des deux choix du premier lancement a été suivi. */
|
||||
readonly welcomed: () => void;
|
||||
}
|
||||
|
||||
export interface HomeState {
|
||||
readonly model: HomeModel;
|
||||
/** Le plateau de la partie en cours ou de la dernière partie (null :
|
||||
* jamais joué). */
|
||||
readonly sides: SideBoards | null;
|
||||
/** La dernière étape du tutoriel vue. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
|
||||
export interface HomeView {
|
||||
render(state: HomeState): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = id;
|
||||
svg.setAttribute("class", `board ${className}`);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
parent.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function logo(parent: HTMLElement): void {
|
||||
for (const [variant, file] of [
|
||||
["logo-light", "logo-horizontal-couleur.svg"],
|
||||
["logo-dark", "logo-horizontal-sombre.svg"],
|
||||
] as const) {
|
||||
const img = element(parent, "img", { className: variant });
|
||||
img.src = `icons/${file}`;
|
||||
img.alt = "Ustagammon";
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
|
||||
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une grande carte-lien du premier lancement : le choix et son détail. */
|
||||
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
|
||||
const link = element(parent, "a", { id, className: "choice" });
|
||||
link.href = href;
|
||||
element(link, "strong", { text: label });
|
||||
element(link, "span", { text: detail });
|
||||
return link;
|
||||
}
|
||||
|
||||
export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
|
||||
// Premier lancement.
|
||||
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
|
||||
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
|
||||
logo(welcomeLogo);
|
||||
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
|
||||
const welcomeText = element(welcome, "div", { className: "welcome-text" });
|
||||
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
|
||||
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
|
||||
const choices = element(welcome, "div", { className: "welcome-choices" });
|
||||
const discover = choice(
|
||||
choices,
|
||||
"welcome-discover",
|
||||
WELCOME_LINKS.discover,
|
||||
HOME_TEXTS.discover,
|
||||
HOME_TEXTS.discoverDetail,
|
||||
);
|
||||
discover.classList.add("primary");
|
||||
const play = choice(
|
||||
choices,
|
||||
"welcome-play",
|
||||
WELCOME_LINKS.play,
|
||||
HOME_TEXTS.knowRules,
|
||||
HOME_TEXTS.knowRulesDetail,
|
||||
);
|
||||
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
|
||||
for (const link of [discover, play]) {
|
||||
link.addEventListener("click", () => options.welcomed());
|
||||
}
|
||||
|
||||
// Ensuite.
|
||||
const main = element(section, "div", { id: "home-main", className: "home-main" });
|
||||
const intro = element(main, "div", { className: "home-intro" });
|
||||
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
|
||||
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
|
||||
|
||||
const card = element(main, "article", { id: "home-game", className: "card home-game" });
|
||||
const cardTop = element(card, "div", { className: "home-game-top" });
|
||||
const miniBox = element(cardTop, "div", { className: "home-mini" });
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
id: "home-resume",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.resumeGame,
|
||||
});
|
||||
resume.href = "#/";
|
||||
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
|
||||
fresh.type = "button";
|
||||
fresh.addEventListener("click", () => options.newGame());
|
||||
|
||||
const install = element(main, "aside", { id: "install-card", className: "install-card" });
|
||||
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
|
||||
const symbol = element(install, "img", { className: "install-symbol" });
|
||||
symbol.src = "icons/symbole-couleur.svg";
|
||||
symbol.alt = "";
|
||||
const installText = element(install, "span", { className: "install-text" });
|
||||
element(installText, "strong", { text: HOME_TEXTS.installTitle });
|
||||
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
|
||||
const installButton = element(install, "button", {
|
||||
id: "install-card-button",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.install,
|
||||
});
|
||||
installButton.type = "button";
|
||||
installButton.addEventListener("click", () => options.install());
|
||||
const later = element(install, "button", { id: "install-later", className: "icon-button" });
|
||||
later.type = "button";
|
||||
later.append(icon("close"));
|
||||
later.setAttribute("aria-label", HOME_TEXTS.installLater);
|
||||
later.title = HOME_TEXTS.installLater;
|
||||
later.addEventListener("click", () => options.dismissInstall());
|
||||
|
||||
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
|
||||
const rulesText = element(rules, "span", { className: "home-rules-text" });
|
||||
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
footerSymbol.alt = "";
|
||||
element(footer, "span", { text: HOME_TEXTS.footer });
|
||||
|
||||
return {
|
||||
render(state) {
|
||||
const { model } = state;
|
||||
welcome.hidden = !model.welcome;
|
||||
main.hidden = model.welcome;
|
||||
if (model.welcome) {
|
||||
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
|
||||
// Une seule action principale : « Reprendre la partie », ou
|
||||
// « Nouvelle partie » quand il n'y a rien à reprendre.
|
||||
resume.hidden = !model.resume;
|
||||
fresh.textContent = TEXTS.newGame;
|
||||
fresh.classList.toggle("secondary", model.resume);
|
||||
fresh.disabled = state.busy;
|
||||
install.hidden = !model.installCard;
|
||||
|
||||
const seen = state.tutorialSeen;
|
||||
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
|
||||
rulesAction.textContent =
|
||||
seen === 0
|
||||
? HOME_TEXTS.rulesStart
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Les pictogrammes de l'interface (composant Icons de la charte,
|
||||
// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en
|
||||
// currentColor, dessinés en SVG. Les tracés viennent des maquettes
|
||||
// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la
|
||||
// classe « icon » (app.css) : aucun style dans le SVG (CSP).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ICON_PATHS = {
|
||||
menu: "M4 7h16M4 12h16M4 17h16",
|
||||
help: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5",
|
||||
exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5",
|
||||
undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
|
||||
replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5",
|
||||
close: "M6 6l12 12M18 6L6 18",
|
||||
chevron: "M9 6l6 6-6 6",
|
||||
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||
newGame: "M12 5v14M5 12h14",
|
||||
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
|
||||
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
|
||||
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
verdictBad: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_PATHS;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Un pictogramme, décoratif (le nom est porté par son bouton). */
|
||||
export function icon(name: IconName): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", `icon icon-${name}`);
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", ICON_PATHS[name]);
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
|
||||
// aide.md, « Installation ») : l'invite du système quand le navigateur en
|
||||
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
|
||||
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
|
||||
// l'application installée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
|
||||
export type InstallPlatform = "ios" | "firefox";
|
||||
|
||||
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
|
||||
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
|
||||
export function installPlatform(userAgent: string): InstallPlatform {
|
||||
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
|
||||
}
|
||||
|
||||
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
|
||||
interface InstallPromptEvent extends Event {
|
||||
readonly prompt: () => Promise<void>;
|
||||
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export interface Installer {
|
||||
/** L'application tourne installée, ou vient de l'être. */
|
||||
readonly installed: boolean;
|
||||
/** Le navigateur a donné son invite d'installation. */
|
||||
readonly canPrompt: boolean;
|
||||
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
|
||||
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
|
||||
* empêche le navigateur de l'afficher de lui-même), et note l'installation
|
||||
* (display-mode: standalone, événement appinstalled). onChange est appelé à
|
||||
* chaque changement.
|
||||
*/
|
||||
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
|
||||
const standalone = window.matchMedia("(display-mode: standalone)");
|
||||
let installedNow = false;
|
||||
let deferred: InstallPromptEvent | null = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
event.preventDefault();
|
||||
deferred = event as InstallPromptEvent;
|
||||
onChange(false);
|
||||
});
|
||||
window.addEventListener("appinstalled", () => {
|
||||
installedNow = true;
|
||||
deferred = null;
|
||||
onChange(true);
|
||||
});
|
||||
standalone.addEventListener("change", () => onChange(false));
|
||||
|
||||
return {
|
||||
get installed() {
|
||||
return installedNow || standalone.matches;
|
||||
},
|
||||
get canPrompt() {
|
||||
return deferred !== null;
|
||||
},
|
||||
async offer() {
|
||||
const event = deferred;
|
||||
if (event === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
// Une invite ne sert qu'une fois.
|
||||
deferred = null;
|
||||
try {
|
||||
await event.prompt();
|
||||
const choice = await event.userChoice;
|
||||
return choice.outcome;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
} finally {
|
||||
onChange(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
|
||||
// descriptions des niveaux sont ceux de la charte ; le reste est une
|
||||
// première version, à valider par Pierre.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ComputerLevel } from "../domain/game/level.js";
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const MATCH_TEXTS = {
|
||||
// Écran « Nouvelle partie »
|
||||
title: "Nouvelle partie",
|
||||
close: "Fermer sans rien changer",
|
||||
kindLabel: "Type de partie",
|
||||
free: "Partie libre",
|
||||
match: "Match",
|
||||
/** « Le premier à [3] [5] [7] [11] points ». */
|
||||
lengthBefore: "Le premier à",
|
||||
lengthAfter: "points",
|
||||
lengthLabel: (n: number) => `Match en ${n} points`,
|
||||
crawfordRule:
|
||||
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
|
||||
crawfordLink: "règle de Crawford",
|
||||
jacobyRule:
|
||||
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
|
||||
jacobyLink: "règle de Jacoby",
|
||||
levelTitle: "Niveau de l'ordinateur",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Débutant",
|
||||
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
|
||||
},
|
||||
amateur: {
|
||||
name: "Amateur",
|
||||
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
|
||||
},
|
||||
intermediate: {
|
||||
name: "Intermédiaire",
|
||||
text: "Un habitué de la table : solide, sans être implacable.",
|
||||
},
|
||||
advanced: {
|
||||
name: "Avancé",
|
||||
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
|
||||
},
|
||||
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
|
||||
champion: {
|
||||
name: "Champion",
|
||||
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
|
||||
},
|
||||
} satisfies Record<ComputerLevel, { name: string; text: string }>,
|
||||
duringGame: "Pendant la partie",
|
||||
advice: "Conseil du maître",
|
||||
adviceDetail: "Une ligne après chacun de vos coups",
|
||||
startGame: "Commencer la partie",
|
||||
startMatch: (n: number) => `Commencer le match en ${n} points`,
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
/** « Match en 5 points », et « · partie Crawford ». */
|
||||
matchLine: (length: number, crawford: boolean) =>
|
||||
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
|
||||
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
|
||||
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
/** Paysage : « sur 5 ». */
|
||||
outOf: (length: number) => `sur ${length}`,
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie
|
||||
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
|
||||
* après un match). */
|
||||
replay: "Rejouer",
|
||||
changeSettings: "Changer les réglages",
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
|
||||
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
|
||||
matchDetail: (
|
||||
winner: "player" | "computer",
|
||||
n: number,
|
||||
length: number,
|
||||
player: number,
|
||||
computer: number,
|
||||
over: boolean,
|
||||
) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
|
||||
over ? "Score final" : `Match en ${length} points`
|
||||
} : vous ${player}, l'ordinateur ${computer}.`,
|
||||
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
// La navigation entre les écrans : l'adresse de la page est la source de
|
||||
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
|
||||
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
|
||||
// dessine l'écran que désigne l'adresse.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
|
||||
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
|
||||
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
|
||||
// l'onglet garde, dans history.state, la suite des adresses visitées
|
||||
// jusqu'à elle. Elle survit au rechargement de la page.
|
||||
|
||||
import { parseRoute, type Route, stepsBack } from "./router.js";
|
||||
|
||||
/** Au plus autant d'adresses gardées par entrée de l'historique. */
|
||||
const TRAIL_MAX = 50;
|
||||
|
||||
export interface Navigation {
|
||||
/** L'écran que désigne l'adresse actuelle. */
|
||||
readonly route: Route;
|
||||
/** Va à une adresse, comme un lien (empile une entrée). */
|
||||
go(hash: string): void;
|
||||
/** Remplace l'adresse actuelle, sans empiler. */
|
||||
replace(hash: string): void;
|
||||
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
|
||||
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
|
||||
* nouvel onglet), remplace l'adresse par celle de la partie. */
|
||||
leave(stay: (route: Route) => boolean): void;
|
||||
}
|
||||
|
||||
interface TrailState {
|
||||
readonly ustagammonTrail: readonly string[];
|
||||
}
|
||||
|
||||
function trailOf(state: unknown): readonly string[] | null {
|
||||
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
|
||||
return Array.isArray(trail) ? trail : null;
|
||||
}
|
||||
|
||||
/** Le fragment actuel, « #/ » s'il est vide. */
|
||||
function currentHash(): string {
|
||||
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche la navigation sur la page : onChange est appelé à chaque
|
||||
* changement d'adresse (pas au démarrage : l'appelant lit route).
|
||||
*/
|
||||
export function startNavigation(onChange: (route: Route) => void): Navigation {
|
||||
let trail: readonly string[] = [];
|
||||
// Un retour dans l'historique est en cours (history.go est asynchrone) :
|
||||
// une adresse demandée entre-temps attend son arrivée.
|
||||
let leaving: ReturnType<typeof setTimeout> | null = null;
|
||||
let queued: string | null = null;
|
||||
|
||||
const remember = (next: readonly string[]) => {
|
||||
trail = next.slice(-TRAIL_MAX);
|
||||
try {
|
||||
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
|
||||
} catch {
|
||||
// Historique indisponible (page dans un cadre) : la navigation
|
||||
// marche quand même, « Retour » ramène alors à la partie.
|
||||
}
|
||||
};
|
||||
|
||||
// L'entrée actuelle : déjà connue (rechargement), ou la première.
|
||||
const known = trailOf(history.state);
|
||||
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const hash = currentHash();
|
||||
const saved = trailOf(history.state);
|
||||
if (saved !== null && saved.at(-1) === hash) {
|
||||
// Retour ou avance dans l'historique : une entrée déjà connue.
|
||||
trail = saved;
|
||||
} else {
|
||||
// Un lien suivi : une nouvelle entrée.
|
||||
remember([...trail, hash]);
|
||||
}
|
||||
onChange(parseRoute(hash));
|
||||
if (leaving !== null) {
|
||||
clearTimeout(leaving);
|
||||
leaving = null;
|
||||
const next = queued;
|
||||
queued = null;
|
||||
if (next !== null) {
|
||||
go(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function go(hash: string): void {
|
||||
if (leaving !== null) {
|
||||
queued = hash;
|
||||
} else if (hash !== currentHash()) {
|
||||
location.hash = hash;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(hash: string): void {
|
||||
if (hash === currentHash()) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(location.href);
|
||||
url.hash = hash;
|
||||
const next = [...trail.slice(0, -1), hash];
|
||||
try {
|
||||
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
|
||||
} catch {
|
||||
location.replace(url);
|
||||
return;
|
||||
}
|
||||
trail = next;
|
||||
// replaceState ne déclenche pas hashchange.
|
||||
onChange(parseRoute(hash));
|
||||
}
|
||||
|
||||
return {
|
||||
get route() {
|
||||
return parseRoute(currentHash());
|
||||
},
|
||||
go,
|
||||
replace,
|
||||
leave(stay) {
|
||||
if (leaving !== null) {
|
||||
return; // déjà en route
|
||||
}
|
||||
const steps = stepsBack(trail, stay);
|
||||
if (steps === null) {
|
||||
replace("#/");
|
||||
return;
|
||||
}
|
||||
// Au cas où le navigateur ne reviendrait pas (historique
|
||||
// indisponible), on n'attend pas plus d'une seconde.
|
||||
leaving = setTimeout(() => {
|
||||
leaving = null;
|
||||
queued = null;
|
||||
}, 1000);
|
||||
history.go(steps);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
|
||||
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
|
||||
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
|
||||
* du milieu ou une touche, il reste un lien ordinaire.
|
||||
*/
|
||||
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigation.replace(new URL(link.href).hash);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
|
||||
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
|
||||
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
|
||||
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
|
||||
// en cours ; seul « Commencer » en lance une.
|
||||
|
||||
import { COMPUTER_LEVELS } from "../domain/game/level.js";
|
||||
import { MATCH_LENGTHS } from "../domain/game/match.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
import type { Settings } from "./settings.js";
|
||||
|
||||
export interface NewGameViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly settings: () => Settings;
|
||||
/** Un choix a changé : le garder. */
|
||||
readonly change: (changes: Partial<Settings>) => void;
|
||||
/** « Commencer la partie » ou « Commencer le match ». */
|
||||
readonly start: () => void;
|
||||
}
|
||||
|
||||
export interface NewGameView {
|
||||
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
|
||||
readonly closeLink: HTMLAnchorElement;
|
||||
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
|
||||
render(busy: boolean): void;
|
||||
/** Le premier élément à recevoir le focus à l'ouverture. */
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
|
||||
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
|
||||
const button = element(parent, "button", {
|
||||
id,
|
||||
className,
|
||||
...(text === undefined ? {} : { text }),
|
||||
});
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "radio");
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
|
||||
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
|
||||
const p = element(parent, "p", { id, className: "new-game-rule" });
|
||||
p.append(`${text} `);
|
||||
const a = element(p, "a", { text: link });
|
||||
a.href = routeHash({ screen: "glossary", term });
|
||||
p.append(".");
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
section.setAttribute("aria-labelledby", "new-game-title");
|
||||
|
||||
const head = element(section, "div", { className: "new-game-head" });
|
||||
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
|
||||
closeLink.href = "#/";
|
||||
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
|
||||
closeLink.append(icon("close"));
|
||||
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
|
||||
|
||||
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
|
||||
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
const kindColumn = element(body, "div", { className: "new-game-column" });
|
||||
const levelColumn = element(body, "div", { className: "new-game-column" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(kindColumn, "div", { className: "segmented" });
|
||||
kinds.setAttribute("role", "radiogroup");
|
||||
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
|
||||
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
|
||||
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(kindColumn, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
const lengths = element(matchOptions, "div", { className: "lengths" });
|
||||
lengths.setAttribute("role", "radiogroup");
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
|
||||
const lengthButtons = MATCH_LENGTHS.map((n) => {
|
||||
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
|
||||
button.addEventListener("click", () => options.change({ matchLength: n }));
|
||||
return button;
|
||||
});
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
|
||||
rule(
|
||||
matchOptions,
|
||||
"new-game-crawford",
|
||||
MATCH_TEXTS.crawfordRule,
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(kindColumn, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
rule(
|
||||
freeOptions,
|
||||
"new-game-jacoby",
|
||||
MATCH_TEXTS.jacobyRule,
|
||||
MATCH_TEXTS.jacobyLink,
|
||||
"regle-de-jacoby",
|
||||
);
|
||||
|
||||
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
|
||||
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(levelColumn, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
|
||||
button.dataset.level = level;
|
||||
element(button, "span", { className: "level-bar" });
|
||||
button.addEventListener("click", () => options.change({ level }));
|
||||
return button;
|
||||
});
|
||||
const levelName = element(levelColumn, "p", {
|
||||
id: "new-game-level-name",
|
||||
className: "level-name",
|
||||
});
|
||||
const levelText = element(levelColumn, "p", {
|
||||
id: "new-game-level-text",
|
||||
className: "level-text",
|
||||
});
|
||||
|
||||
// 3. Pendant la partie.
|
||||
element(levelColumn, "h3", {
|
||||
className: "overline during-game",
|
||||
text: MATCH_TEXTS.duringGame,
|
||||
});
|
||||
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
|
||||
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
|
||||
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
|
||||
advice.addEventListener("click", () =>
|
||||
options.change({ masterAdvice: !options.settings().masterAdvice }),
|
||||
);
|
||||
|
||||
// 4. Barre d'action collée en bas.
|
||||
const actions = element(section, "div", { className: "new-game-actions" });
|
||||
const start = element(actions, "button", { id: "new-game-start" });
|
||||
start.type = "button";
|
||||
start.addEventListener("click", () => options.start());
|
||||
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
|
||||
|
||||
return {
|
||||
closeLink,
|
||||
render(busy) {
|
||||
const s = options.settings();
|
||||
const isMatch = s.gameKind === "match";
|
||||
free.setAttribute("aria-checked", String(!isMatch));
|
||||
match.setAttribute("aria-checked", String(isMatch));
|
||||
matchOptions.hidden = !isMatch;
|
||||
freeOptions.hidden = isMatch;
|
||||
for (const [i, button] of lengthButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
|
||||
}
|
||||
const chosen = COMPUTER_LEVELS.indexOf(s.level);
|
||||
for (const [i, button] of levelButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(i === chosen));
|
||||
button.classList.toggle("reached", i <= chosen);
|
||||
}
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
|
||||
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
|
||||
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
|
||||
// testées sous Node (app/tests/router.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** Un écran de l'application, tel que le désigne son adresse. */
|
||||
export type Route =
|
||||
/** #/ : la partie en cours. */
|
||||
| { readonly screen: "game" }
|
||||
/** #/accueil. */
|
||||
| { readonly screen: "home" }
|
||||
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
|
||||
| { readonly screen: "help"; readonly question: string | null }
|
||||
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
|
||||
| { readonly screen: "glossary"; readonly term: string | null }
|
||||
/** #/a-propos. */
|
||||
| { readonly screen: "about" }
|
||||
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
|
||||
* #/tutoriel/<n>/a-vous. */
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { 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. */
|
||||
| { readonly screen: "unknown"; readonly hash: string };
|
||||
|
||||
export type Screen = Route["screen"];
|
||||
|
||||
export const GAME_ROUTE: Route = { screen: "game" };
|
||||
|
||||
/** Longueur d'un Position ID de gnubg. */
|
||||
const POSITION_ID_LENGTH = 14;
|
||||
|
||||
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
|
||||
* chiffres et tirets. */
|
||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** L'écran que désigne un fragment (avec ou sans # au début). */
|
||||
export function parseRoute(hash: string): Route {
|
||||
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
if (fragment === "" || fragment === "/") {
|
||||
return GAME_ROUTE;
|
||||
}
|
||||
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
|
||||
if (!fragment.startsWith("/")) {
|
||||
return unknown;
|
||||
}
|
||||
const [head = "", ...rest] = fragment.slice(1).split("/");
|
||||
// Une barre finale (#/aide/) ne change rien.
|
||||
if (rest.at(-1) === "") {
|
||||
rest.pop();
|
||||
}
|
||||
if (head === "position") {
|
||||
return positionRoute(rest.join("/")) ?? unknown;
|
||||
}
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (rest.length > 1) {
|
||||
return unknown;
|
||||
}
|
||||
const name = rest[0] ?? null;
|
||||
if (name !== null && !NAME.test(name)) {
|
||||
return unknown;
|
||||
}
|
||||
switch (head) {
|
||||
case "":
|
||||
return name === null ? GAME_ROUTE : unknown;
|
||||
case "accueil":
|
||||
return name === null ? { screen: "home" } : unknown;
|
||||
case "aide":
|
||||
return { screen: "help", question: name };
|
||||
case "glossaire":
|
||||
return { screen: "glossary", term: name };
|
||||
case "a-propos":
|
||||
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":
|
||||
return name === null ? { screen: "install" } : unknown;
|
||||
default:
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
|
||||
function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
const [name, part, ...more] = rest;
|
||||
if (name === undefined) {
|
||||
return { screen: "tutorial", step: null, practice: false };
|
||||
}
|
||||
if (more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "tutorialEnd" } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
|
||||
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
|
||||
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
|
||||
* comprise aussi : le texte est décodé en entier, puis découpé à la
|
||||
* longueur du Position ID. Leur validité est l'affaire du moteur.
|
||||
*/
|
||||
function positionRoute(text: string): Route | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === "") {
|
||||
return null;
|
||||
}
|
||||
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
|
||||
const match = decoded.slice(POSITION_ID_LENGTH + 1);
|
||||
return {
|
||||
screen: "position",
|
||||
position: decoded.slice(0, POSITION_ID_LENGTH),
|
||||
match: match === "" ? null : match,
|
||||
};
|
||||
}
|
||||
return { screen: "position", position: decoded, match: null };
|
||||
}
|
||||
|
||||
/** Le fragment d'un écran, # compris. */
|
||||
export function routeHash(route: Route): string {
|
||||
switch (route.screen) {
|
||||
case "game":
|
||||
return "#/";
|
||||
case "home":
|
||||
return "#/accueil";
|
||||
case "help":
|
||||
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
|
||||
case "glossary":
|
||||
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
|
||||
case "about":
|
||||
return "#/a-propos";
|
||||
case "tutorial":
|
||||
return route.step === null
|
||||
? "#/tutoriel"
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/fin";
|
||||
case "install":
|
||||
return "#/installer";
|
||||
case "games":
|
||||
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
|
||||
? position
|
||||
: `${position}/${encodeURIComponent(route.match)}`;
|
||||
}
|
||||
case "unknown":
|
||||
return route.hash;
|
||||
}
|
||||
}
|
||||
|
||||
/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */
|
||||
export type HelpRoute = Extract<Route, { screen: "help" | "glossary" | "about" }>;
|
||||
|
||||
/** L'adresse est-elle celle d'un écran de l'aide ? */
|
||||
export function isHelpScreen(route: Route): route is HelpRoute {
|
||||
return route.screen === "help" || route.screen === "glossary" || route.screen === "about";
|
||||
}
|
||||
|
||||
/** L'identifiant d'une question de l'aide : son titre, en minuscules sans
|
||||
* accents, les mots séparés par des tirets. */
|
||||
export function slug(title: string): string {
|
||||
return title
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Où revenir en quittant un écran (« Retour », Échap, la croix d'une
|
||||
* feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas
|
||||
* de cet écran. trail est la suite des adresses de l'onglet jusqu'à
|
||||
* l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou
|
||||
* null si aucune adresse ne convient : il faut alors remplacer l'adresse
|
||||
* par celle de la partie, sans rien empiler.
|
||||
*/
|
||||
export function stepsBack(
|
||||
trail: readonly string[],
|
||||
stay: (route: Route) => boolean,
|
||||
): number | null {
|
||||
for (let i = trail.length - 2; i >= 0; i--) {
|
||||
const hash = trail[i];
|
||||
if (hash !== undefined && !stay(parseRoute(hash))) {
|
||||
return i - (trail.length - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Les réglages de l'application (menu) et ce qui en dépend, sans navigateur
|
||||
// (testé sous Node). Ils sont enregistrés à part de la partie, en JSON.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
|
||||
// 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 {
|
||||
/** Vibrations aux moments forts (activées par défaut). */
|
||||
readonly vibrations: boolean;
|
||||
/** Écran maintenu allumé pendant une partie (activé par défaut). */
|
||||
readonly keepScreenOn: boolean;
|
||||
readonly theme: Theme;
|
||||
/** Parties commencées : la consigne est longue pendant les trois
|
||||
* premières, courte ensuite. */
|
||||
readonly gamesStarted: number;
|
||||
/** Parties terminées (l'invitation à installer vient après la
|
||||
* première). */
|
||||
readonly gamesFinished: number;
|
||||
/** Le choix du premier lancement (« Je découvre », « Je connais les
|
||||
* règles ») a été fait : il ne revient plus. */
|
||||
readonly welcomed: boolean;
|
||||
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
|
||||
* la fin). La barre de progression ne mène qu'aux étapes vues. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
|
||||
* le nombre de parties terminées à la dernière fois. */
|
||||
readonly installDismissals: number;
|
||||
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,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
gamesFinished: 0,
|
||||
welcomed: false,
|
||||
tutorialSeen: 0,
|
||||
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. */
|
||||
export const LONG_PROMPT_GAMES = 3;
|
||||
|
||||
/** tutorialSeen quand la fin du tutoriel a été vue. */
|
||||
export const TUTORIAL_DONE = 99;
|
||||
|
||||
/** Parties terminées de plus avant que l'invitation à installer, masquée
|
||||
* par « Plus tard », revienne (une seule fois). */
|
||||
export const INSTALL_AGAIN_AFTER = 3;
|
||||
|
||||
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
|
||||
* valeur illisible garde sa valeur par défaut. */
|
||||
export function parseSettings(text: string | null): Settings {
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text ?? "{}");
|
||||
if (parsed !== null && typeof parsed === "object") {
|
||||
raw = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (
|
||||
key: "vibrations" | "keepScreenOn" | "welcomed" | "installed" | "masterAdvice",
|
||||
) => (typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]);
|
||||
const count = (
|
||||
key:
|
||||
| "gamesStarted"
|
||||
| "gamesFinished"
|
||||
| "tutorialSeen"
|
||||
| "installDismissals"
|
||||
| "installDismissedAt",
|
||||
) => {
|
||||
const value = raw[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: DEFAULT_SETTINGS[key];
|
||||
};
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted: count("gamesStarted"),
|
||||
gamesFinished: count("gamesFinished"),
|
||||
welcomed: bool("welcomed"),
|
||||
tutorialSeen: count("tutorialSeen"),
|
||||
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";
|
||||
}
|
||||
|
||||
/** La consigne doit-elle encore être longue ? (La partie en cours compte.) */
|
||||
export function longPrompts(settings: Settings): boolean {
|
||||
return settings.gamesStarted <= LONG_PROMPT_GAMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier lancement : rien n'a encore été fait dans l'application (le choix
|
||||
* d'accueil, une partie terminée, une étape du tutoriel). La partie
|
||||
* commencée d'office au chargement compte pour une : jusqu'à une partie
|
||||
* commencée, c'est encore le premier lancement.
|
||||
*/
|
||||
export function isFirstLaunch(settings: Settings): boolean {
|
||||
return (
|
||||
!settings.welcomed &&
|
||||
settings.gamesStarted <= 1 &&
|
||||
settings.gamesFinished === 0 &&
|
||||
settings.tutorialSeen === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
|
||||
* l'application installée ; à partir de la première partie terminée ;
|
||||
* « Plus tard » la masque, et elle ne revient qu'une fois, après
|
||||
* INSTALL_AGAIN_AFTER parties terminées de plus.
|
||||
*/
|
||||
export function installCardShown(settings: Settings, installed: boolean): boolean {
|
||||
if (installed || settings.installed || settings.gamesFinished < 1) {
|
||||
return false;
|
||||
}
|
||||
if (settings.installDismissals === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
settings.installDismissals === 1 &&
|
||||
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
|
||||
);
|
||||
}
|
||||
+141
-27
@@ -22,9 +22,11 @@ function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
|
||||
function seconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
|
||||
/** Une consigne : sa forme longue (les trois premières parties) et sa forme
|
||||
* courte (ensuite, et toujours en paysage, dans la bande du plateau). */
|
||||
export interface PromptText {
|
||||
readonly long: string;
|
||||
readonly short: string;
|
||||
}
|
||||
|
||||
export const TEXTS = {
|
||||
@@ -36,59 +38,171 @@ export const TEXTS = {
|
||||
double: "Doubler",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
confirm: "Jouer ce coup",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
undoTitle: "Annuler le demi-coup",
|
||||
newGame: "Nouvelle partie",
|
||||
reviewGame: "Revoir la partie",
|
||||
help: "Aide",
|
||||
menu: "Menu",
|
||||
fullscreen: "Plein écran",
|
||||
exitFullscreen: "Quitter le plein écran",
|
||||
back: "Retour à la partie",
|
||||
close: "Fermer",
|
||||
/** Ce que fait un toucher sur les dés (nom accessible de la zone). */
|
||||
diceActions: {
|
||||
roll: "Lancer les dés",
|
||||
confirm: "Jouer ce coup",
|
||||
skip: "Passer votre tour",
|
||||
},
|
||||
moveHint: "Le coup fini, touchez les dés pour le jouer.",
|
||||
|
||||
// Menu
|
||||
helpAndGlossary: "Aide et glossaire",
|
||||
theme: "Thème",
|
||||
themes: { auto: "Automatique", light: "Clair", dark: "Sombre" },
|
||||
vibrations: "Vibrations",
|
||||
keepScreenOn: "Écran allumé pendant la partie",
|
||||
newGameStarted: "Nouvelle partie commencée.",
|
||||
resumeOld: "Reprendre l'ancienne",
|
||||
|
||||
// Bandeaux des joueurs et compte de points
|
||||
you: "Vous",
|
||||
computer: "Ordinateur",
|
||||
yourTurn: "à vous de jouer",
|
||||
computerTurn: "joue",
|
||||
lead: (n: number) => `mène de ${n}`,
|
||||
pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`,
|
||||
pipCountFile: "compte-de-points.md",
|
||||
seeGlossary: "Voir le glossaire",
|
||||
|
||||
// Chargement
|
||||
loading: "Chargement du moteur…",
|
||||
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
|
||||
actionFailed: (message: string) => `Erreur : ${message}`,
|
||||
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
|
||||
|
||||
// Déroulement
|
||||
// Déroulement (ligne d'information)
|
||||
opening: (player: number, computer: number) =>
|
||||
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
|
||||
player > computer ? "Vous commencez." : "L'ordinateur commence."
|
||||
}`,
|
||||
yourRoll: "À vous : doublez ou lancez les dés.",
|
||||
yourRollNoDouble: "À vous : lancez les dés.",
|
||||
yourMove: (dice: readonly number[]) =>
|
||||
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
computerMoved: (dice: readonly number[], move: string) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move}.`,
|
||||
computerCannotMove: (dice: readonly number[]) =>
|
||||
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
|
||||
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
computerTakes: (value: number) =>
|
||||
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
|
||||
youTook: (value: number) =>
|
||||
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
|
||||
// Consignes
|
||||
prompts: {
|
||||
loading: { long: "", short: "Chargement…" },
|
||||
yourRoll: {
|
||||
long: "À vous : touchez les dés pour les lancer, ou le videau pour doubler.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourRollNoDouble: {
|
||||
long: "À vous : touchez les dés pour les lancer.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourMove: (dice: readonly number[]): PromptText => ({
|
||||
long: `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
short: `Jouez ${roll(dice)}.`,
|
||||
}),
|
||||
pickTarget: {
|
||||
long: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
short: "Choisissez la case.",
|
||||
},
|
||||
confirmOrUndo: {
|
||||
long: "Touchez vos dés pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
short: "Touchez les dés pour jouer.",
|
||||
},
|
||||
stuck: {
|
||||
long: "Aucun coup possible : touchez vos dés pour passer votre tour.",
|
||||
short: "Aucun coup : touchez les dés.",
|
||||
},
|
||||
thinking: { long: "L'ordinateur réfléchit", short: "L'ordinateur réfléchit" },
|
||||
computerDoubles: {
|
||||
long: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
short: "L'ordinateur double.",
|
||||
},
|
||||
} satisfies Record<string, PromptText | ((dice: readonly number[]) => PromptText)>,
|
||||
|
||||
// Fin de partie
|
||||
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
|
||||
youLose: (kind: ResultKind, n: number) =>
|
||||
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
|
||||
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
|
||||
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
|
||||
|
||||
// Sous le plateau
|
||||
pipCounts: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
gameOver: "Partie finie",
|
||||
endOverline: "Fin de partie",
|
||||
/** Titre du panneau de fin : qui gagne, et comment. */
|
||||
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
|
||||
if (dropped) {
|
||||
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
|
||||
}
|
||||
return winner === "player"
|
||||
? `Vous gagnez ${RESULT_KIND[kind]}`
|
||||
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
|
||||
},
|
||||
/** « 8 points pour lui, videau à 4. » */
|
||||
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
|
||||
cube > 1 ? `, videau à ${cube}` : ""
|
||||
}.`,
|
||||
|
||||
// Aide
|
||||
helpTitle: "Aide",
|
||||
glossaryTitle: "Glossaire",
|
||||
helpTabs: "Rubriques de l'aide",
|
||||
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
|
||||
glossaryTitle: "Les mots du jeu",
|
||||
aboutTitle: "À propos",
|
||||
helpLoading: "Chargement de l'aide…",
|
||||
helpFailed: "L'aide n'a pas pu se charger.",
|
||||
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
|
||||
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
|
||||
engineTestLink: "Test du moteur",
|
||||
|
||||
// Adresses (ADR 0013)
|
||||
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
|
||||
|
||||
// Lien vers une position (ADR 0013)
|
||||
copyPositionLink: "Copier le lien de la position",
|
||||
linkCopied: "Lien de la position copié.",
|
||||
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
|
||||
positionTitle: "Position partagée",
|
||||
unreadablePositionTitle: "Lien illisible",
|
||||
unreadablePosition:
|
||||
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
|
||||
backToGame: "Retour à ma partie",
|
||||
playFromHere: "Jouer à partir d'ici",
|
||||
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
|
||||
/** Ce que montre la vue d'une position, sous le plateau. */
|
||||
positionDetail: (
|
||||
pips: Readonly<Record<"player" | "computer", number>>,
|
||||
cube: { value: number; owner: "player" | "computer" | null },
|
||||
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
|
||||
winner: "player" | "computer" | null,
|
||||
) =>
|
||||
[
|
||||
winner === null
|
||||
? "Vous avez la main : vos pions ivoire, en bas."
|
||||
: `La partie est finie dans cette position : ${
|
||||
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
|
||||
}.`,
|
||||
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
|
||||
cube.value > 1 || cube.owner !== null
|
||||
? `Videau à ${cube.value}, ${
|
||||
cube.owner === null
|
||||
? "au centre"
|
||||
: cube.owner === "player"
|
||||
? "chez vous"
|
||||
: "chez l'ordinateur"
|
||||
}.`
|
||||
: "",
|
||||
match === null
|
||||
? ""
|
||||
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
|
||||
// de progression (charte : « les segments sont des liens vers les étapes
|
||||
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Route } from "./router.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import type { Tutorial } from "./tutorial-text.js";
|
||||
|
||||
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
|
||||
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
|
||||
|
||||
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
|
||||
* étape hors du tutoriel mène à la première, un « à vous » absent à
|
||||
* l'explication de son étape. */
|
||||
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
if (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
export function placeRoute(place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "tutorialEnd" }
|
||||
: { screen: "tutorial", step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran suivant (la fin après la dernière étape). */
|
||||
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
|
||||
if (place === "end") {
|
||||
return "end";
|
||||
}
|
||||
const step = tutorial.steps[place.step - 1];
|
||||
if (!place.practice && step?.practice != null) {
|
||||
return { step: place.step, practice: true };
|
||||
}
|
||||
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'écran précédent (null avant la première étape). */
|
||||
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
|
||||
if (place === "end") {
|
||||
const last = tutorial.steps.at(-1);
|
||||
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
|
||||
}
|
||||
if (place.practice) {
|
||||
return { step: place.step, practice: false };
|
||||
}
|
||||
const before = tutorial.steps[place.step - 2];
|
||||
return before === undefined
|
||||
? null
|
||||
: { step: before.number, practice: before.practice !== null };
|
||||
}
|
||||
|
||||
/** Ce que retient la progression quand on arrive sur un écran : la
|
||||
* dernière étape vue (TUTORIAL_DONE à la fin). */
|
||||
export function seenAfter(seen: number, place: TutorialPlace): number {
|
||||
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
|
||||
}
|
||||
|
||||
/** Un segment de la barre de progression. */
|
||||
export interface ProgressSegment {
|
||||
readonly step: number;
|
||||
/** Étape faite ou en cours : le segment est en brique. */
|
||||
readonly done: boolean;
|
||||
/** Étape déjà vue : le segment est un lien vers elle. */
|
||||
readonly link: boolean;
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
|
||||
* seen ont déjà été vues. */
|
||||
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
|
||||
const current = place === "end" ? total + 1 : place.step;
|
||||
const reached = Math.max(seen, current);
|
||||
return Array.from({ length: total }, (_, i) => {
|
||||
const step = i + 1;
|
||||
return {
|
||||
step,
|
||||
done: step <= current,
|
||||
link: step <= reached && step !== current,
|
||||
current: step === current,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
|
||||
// docs/tutoriel/README.md), copié tel quel dans le site par make site
|
||||
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
|
||||
* glossaire (nom de fichier sans .md). */
|
||||
export interface Inline {
|
||||
readonly text: string;
|
||||
readonly strong?: boolean;
|
||||
readonly term?: string;
|
||||
}
|
||||
|
||||
export type Paragraph = readonly Inline[];
|
||||
|
||||
/** Un écran du tutoriel : explication, ou « à vous ». */
|
||||
export interface TutorialScreen {
|
||||
/** Identifiant de position de gnubg, vu du camp au trait. */
|
||||
readonly position: string;
|
||||
readonly onRoll: "player" | "computer";
|
||||
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
|
||||
readonly roll: readonly [number, number] | null;
|
||||
/** Explication : le coup illustré, en notation de gnubg. */
|
||||
readonly move: string | null;
|
||||
/** Explication : le sens du jeu dessiné en pointillés. */
|
||||
readonly path: boolean;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
/** « À vous » de videau : la bonne réponse au double. */
|
||||
readonly answer: "take" | "pass" | null;
|
||||
readonly right: Paragraph;
|
||||
readonly wrong: Paragraph;
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
export interface TutorialStep {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly explain: TutorialScreen;
|
||||
readonly practice: TutorialScreen | null;
|
||||
}
|
||||
|
||||
export interface Tutorial {
|
||||
readonly steps: readonly TutorialStep[];
|
||||
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
|
||||
}
|
||||
|
||||
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
|
||||
export function parseInline(text: string): Paragraph {
|
||||
const parts: Inline[] = [];
|
||||
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) {
|
||||
parts.push({ text: text.slice(last, index) });
|
||||
}
|
||||
if (match[1] !== undefined && match[2] !== undefined) {
|
||||
parts.push({ text: match[1], term: match[2] });
|
||||
} else {
|
||||
parts.push({ text: match[3] ?? "", strong: true });
|
||||
}
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push({ text: text.slice(last) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface RawScreen {
|
||||
keys: Map<string, string[]>;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function emptyScreen(): RawScreen {
|
||||
return { keys: new Map(), paragraphs: [] };
|
||||
}
|
||||
|
||||
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
|
||||
if (text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const match = /^([1-6])-([1-6])$/.exec(text);
|
||||
if (match === null) {
|
||||
throw new Error(`${where} : jet illisible « ${text} »`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
const one = (key: string) => raw.keys.get(key)?.[0];
|
||||
const position = one("position");
|
||||
if (position === undefined) {
|
||||
throw new Error(`${where} : position absente`);
|
||||
}
|
||||
const trait = one("trait");
|
||||
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
|
||||
throw new Error(`${where} : trait inconnu « ${trait} »`);
|
||||
}
|
||||
const answer = one("réponse");
|
||||
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
|
||||
throw new Error(`${where} : réponse inconnue « ${answer} »`);
|
||||
}
|
||||
return {
|
||||
position,
|
||||
onRoll: trait === "ordinateur" ? "computer" : "player",
|
||||
roll: rollOf(one("jet"), where),
|
||||
move: one("coup") ?? null,
|
||||
path: one("chemin") === "oui",
|
||||
expected: raw.keys.get("attendu") ?? [],
|
||||
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
|
||||
right: parseInline(one("juste") ?? ""),
|
||||
wrong: parseInline(one("refus") ?? ""),
|
||||
paragraphs: raw.paragraphs.map(parseInline),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
|
||||
* étapes ne se suivent pas. */
|
||||
export function parseTutorial(text: string): Tutorial {
|
||||
const steps: {
|
||||
number: number;
|
||||
title: string;
|
||||
explain: RawScreen;
|
||||
practice: RawScreen | null;
|
||||
}[] = [];
|
||||
let end: RawScreen | null = null;
|
||||
let current: RawScreen | null = null;
|
||||
let paragraph: string[] = [];
|
||||
|
||||
const endParagraph = () => {
|
||||
if (paragraph.length > 0 && current !== null) {
|
||||
current.paragraphs.push(paragraph.join(" "));
|
||||
}
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const step = /^## (\d+)\. (.+)$/.exec(line);
|
||||
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
|
||||
if (line === "") {
|
||||
endParagraph();
|
||||
} else if (step !== null) {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
steps.push({
|
||||
number: Number(step[1]),
|
||||
title: step[2] ?? "",
|
||||
explain: current,
|
||||
practice: null,
|
||||
});
|
||||
} else if (line === "## Fin") {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
end = current;
|
||||
} else if (line === "### À vous") {
|
||||
endParagraph();
|
||||
const last = steps.at(-1);
|
||||
if (last === undefined) {
|
||||
throw new Error("« À vous » hors d'une étape");
|
||||
}
|
||||
current = emptyScreen();
|
||||
last.practice = current;
|
||||
} else if (current === null) {
|
||||
// Notice avant la première étape : non affichée.
|
||||
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
|
||||
const values = current.keys.get(key[1] ?? "") ?? [];
|
||||
values.push((key[2] ?? "").trim());
|
||||
current.keys.set(key[1] ?? "", values);
|
||||
} else {
|
||||
paragraph.push(line);
|
||||
}
|
||||
}
|
||||
endParagraph();
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
if (step.number !== i + 1) {
|
||||
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
|
||||
}
|
||||
});
|
||||
if (end === null) {
|
||||
throw new Error("écran de fin absent");
|
||||
}
|
||||
const finish: RawScreen = end;
|
||||
return {
|
||||
steps: steps.map((step) => ({
|
||||
number: step.number,
|
||||
title: step.title,
|
||||
explain: screenOf(step.explain, `étape ${step.number}`),
|
||||
practice:
|
||||
step.practice === null
|
||||
? null
|
||||
: screenOf(step.practice, `étape ${step.number}, à vous`),
|
||||
})),
|
||||
end: {
|
||||
action: finish.keys.get("action")?.[0] ?? "",
|
||||
paragraphs: finish.paragraphs.map(parseInline),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
|
||||
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
|
||||
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
|
||||
import type { MoveSteps } from "../domain/game/game.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
type Place,
|
||||
placeOf as placeOfChecker,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
board(): Promise<{
|
||||
player: TutorialBoard["sides"]["player"];
|
||||
computer: TutorialBoard["sides"]["computer"];
|
||||
}>;
|
||||
nextHalfMoves(): Promise<HalfMove[]>;
|
||||
play(halfMove: HalfMove): Promise<PracticeVerdict>;
|
||||
reset(): void;
|
||||
readonly diceToPlay: readonly { value: number; used: boolean }[];
|
||||
}
|
||||
|
||||
/** Ce que le tutoriel demande au domaine et au moteur. */
|
||||
export interface TutorialAccess {
|
||||
board(position: string, onRoll: Side): Promise<TutorialBoard>;
|
||||
illustrate(
|
||||
position: string,
|
||||
roll: Roll,
|
||||
move: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
|
||||
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface TutorialViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly access: TutorialAccess;
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
|
||||
readonly landscape: () => boolean;
|
||||
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
|
||||
readonly replace: (hash: string) => void;
|
||||
}
|
||||
|
||||
export interface TutorialView {
|
||||
show(route: Route): Promise<void>;
|
||||
/** La taille de l'écran a changé : redessiner le plateau. */
|
||||
relayout(): void;
|
||||
/** L'écran est quitté : arrêter le coup illustré. */
|
||||
hide(): 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 paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
|
||||
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
target.replaceChildren();
|
||||
for (const part of paragraph) {
|
||||
if (part.term !== undefined) {
|
||||
const link = document.createElement("a");
|
||||
link.href = routeHash({ screen: "glossary", term: part.term });
|
||||
link.textContent = part.text;
|
||||
target.append(link);
|
||||
} else if (part.strong === true) {
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = part.text;
|
||||
target.append(strong);
|
||||
} else {
|
||||
target.append(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
const right = center(11);
|
||||
const left = center(0) - g.pointWidth * 0.25;
|
||||
const turn = g.pointWidth * 0.6;
|
||||
const end = g.trayLeft + g.tray * 0.25;
|
||||
const d =
|
||||
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
|
||||
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", className);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
}
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-board";
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
replay.title = HOME_TEXTS.replayLabel;
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-take",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: "tutorial-back",
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
let autoplay: ReturnType<typeof setTimeout> | null = null;
|
||||
// Un coup illustré se joue, ou un geste est jugé : les touchers
|
||||
// attendent (data-busy, pour les tests dans un navigateur).
|
||||
let busy = false;
|
||||
const setBusy = (value: boolean) => {
|
||||
busy = value;
|
||||
section.toggleAttribute("data-busy", value);
|
||||
};
|
||||
|
||||
// L'écran affiché.
|
||||
let screen: TutorialScreen | null = null;
|
||||
let place: TutorialPlace | null = null;
|
||||
let boards: TutorialBoard | null = null;
|
||||
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
|
||||
let illustrated = false;
|
||||
let practice: PracticeSession | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
let solved = false;
|
||||
|
||||
const load = () => {
|
||||
tutorial ??= fetch(SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${SOURCE} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parseTutorial)
|
||||
.catch((error: unknown) => {
|
||||
tutorial = null;
|
||||
throw error;
|
||||
});
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
const box = svg.getBoundingClientRect();
|
||||
if (options.landscape()) {
|
||||
const width = box.width || window.innerWidth;
|
||||
const height = box.height || window.innerHeight;
|
||||
return boardGeometry("landscape", width / height, height / 520);
|
||||
}
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
|
||||
}
|
||||
|
||||
function shownDice(): ShownDie[] {
|
||||
if (practice !== null) {
|
||||
return practice.diceToPlay.map((die) => ({
|
||||
value: die.value,
|
||||
side: "player",
|
||||
blocked: false,
|
||||
used: die.used,
|
||||
}));
|
||||
}
|
||||
const roll = screen?.move != null ? screen.roll : null;
|
||||
if (roll === null || roll === undefined) {
|
||||
return [];
|
||||
}
|
||||
const [a, b] = roll;
|
||||
return [a, b].map((value) => ({
|
||||
value,
|
||||
side: "player" as const,
|
||||
blocked: false,
|
||||
used: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Redessine le plateau dans son état courant. */
|
||||
function draw(sides: TutorialBoard["sides"] | null = null): void {
|
||||
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
|
||||
if (current === undefined || current === null) {
|
||||
return;
|
||||
}
|
||||
const g = geometry();
|
||||
const decor: BoardDecor = {
|
||||
...NO_MARKS,
|
||||
dice: shownDice(),
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
illustrated && illustration !== null
|
||||
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
|
||||
: null,
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer(): void {
|
||||
if (autoplay !== null) {
|
||||
clearTimeout(autoplay);
|
||||
autoplay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
|
||||
* puis ses traces ; sans mouvement si le système le demande. */
|
||||
async function playIllustration(id: number): Promise<void> {
|
||||
const shownMove = illustration;
|
||||
if (shownMove === null || id !== shown) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
replay.disabled = true;
|
||||
illustrated = false;
|
||||
try {
|
||||
if (!options.reducedMotion.matches) {
|
||||
const { steps } = shownMove;
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (i > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
}
|
||||
const from = placeOfChecker("player", halfMove.from);
|
||||
const to = placeOfChecker("player", halfMove.to);
|
||||
draw(steps.boards[i] ?? null);
|
||||
const start = checkerSpot(svg, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
draw(steps.boards[i + 1] ?? null);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
illustrated = true;
|
||||
replay.disabled = false;
|
||||
draw();
|
||||
const result = shownMove.after.result;
|
||||
feedback.textContent =
|
||||
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
|
||||
feedback.dataset.verdict = result === null ? "" : "info";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTargets(): Promise<void> {
|
||||
targets =
|
||||
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
|
||||
feedback.dataset.verdict = verdict;
|
||||
fillParagraph(feedback, words);
|
||||
if (verdict === "right") {
|
||||
feedback.prepend(icon("verdictGood"));
|
||||
}
|
||||
}
|
||||
|
||||
function markSolved(): void {
|
||||
solved = true;
|
||||
section.toggleAttribute("data-solved", true);
|
||||
next.classList.remove("secondary");
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
const session = practice;
|
||||
const current = screen;
|
||||
if (session === null || current === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
selected = null;
|
||||
try {
|
||||
const verdict = await session.play(halfMove);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(await session.board());
|
||||
if (verdict === "right") {
|
||||
markSolved();
|
||||
setVerdict("right", current.right);
|
||||
} else if (verdict === "wrong") {
|
||||
// Le pion revient, et une phrase dit pourquoi.
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
session.reset();
|
||||
setVerdict("wrong", current.wrong);
|
||||
} else {
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
}
|
||||
await refreshTargets();
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
draw(practice === null ? null : await practice.board());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (practice === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
const placeName = target
|
||||
?.closest("[data-place]")
|
||||
?.getAttribute("data-place") as Place | null;
|
||||
if (placeName == null) {
|
||||
return;
|
||||
}
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
|
||||
if (halfMove !== undefined) {
|
||||
void playHalfMove(halfMove);
|
||||
return;
|
||||
}
|
||||
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
|
||||
if (only !== null) {
|
||||
void playHalfMove(only);
|
||||
return;
|
||||
}
|
||||
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
});
|
||||
|
||||
replay.addEventListener("click", () => {
|
||||
clearTimer();
|
||||
void playIllustration(shown);
|
||||
});
|
||||
|
||||
async function answer(takes: boolean): Promise<void> {
|
||||
const current = screen;
|
||||
if (current === null || solved || busy) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
try {
|
||||
const right = (await access.shouldTake(current.position)) === takes;
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (right) {
|
||||
markSolved();
|
||||
}
|
||||
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (segment.current) {
|
||||
item.setAttribute("aria-current", "step");
|
||||
}
|
||||
bar.append(item);
|
||||
}
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async show(route) {
|
||||
const id = ++shown;
|
||||
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
|
||||
delete section.dataset.ready;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
practice = null;
|
||||
illustration = null;
|
||||
illustrated = false;
|
||||
screen = null;
|
||||
boards = null;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
solved = false;
|
||||
section.toggleAttribute("data-solved", false);
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
task.hidden = true;
|
||||
cube.hidden = true;
|
||||
replay.hidden = true;
|
||||
take.disabled = false;
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
const step = loaded.steps[at.step - 1];
|
||||
const current = at.practice ? step?.practice : step?.explain;
|
||||
if (step === undefined || current == null) {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
for (const paragraph of paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
if (at.practice) {
|
||||
task.hidden = false;
|
||||
task.replaceChildren();
|
||||
for (const [i, paragraph] of current.paragraphs.entries()) {
|
||||
if (i > 0) {
|
||||
task.append(" ");
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
fillParagraph(span, paragraph);
|
||||
task.append(span);
|
||||
}
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
if (currentScreen === null) {
|
||||
return;
|
||||
}
|
||||
// Le plateau : la position, puis le coup illustré ou le geste à
|
||||
// faire, tous lus par le moteur.
|
||||
try {
|
||||
await options.engineReady;
|
||||
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
boards = shownBoard;
|
||||
if (
|
||||
at !== "end" &&
|
||||
at.practice &&
|
||||
currentScreen.roll !== null &&
|
||||
currentScreen.expected.length > 0
|
||||
) {
|
||||
practice = await access.practice(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.expected,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
await refreshTargets();
|
||||
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
|
||||
cube.hidden = false;
|
||||
feedback.textContent = HOME_TEXTS.computerDoubles;
|
||||
feedback.dataset.verdict = "info";
|
||||
} else if (
|
||||
at !== "end" &&
|
||||
currentScreen.move !== null &&
|
||||
currentScreen.roll !== null
|
||||
) {
|
||||
illustration = await access.illustrate(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.move,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
replay.hidden = false;
|
||||
replay.disabled = true;
|
||||
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(practice === null ? null : await practice.board());
|
||||
section.dataset.ready = String(id);
|
||||
} catch (error) {
|
||||
if (id === shown) {
|
||||
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
relayout() {
|
||||
if (place === null || busy) {
|
||||
return;
|
||||
}
|
||||
if (practice !== null) {
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
} else {
|
||||
draw();
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
shown++;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// La mise à jour de l'application : le service worker (web/sw.js) installe
|
||||
// chaque nouvelle version publiée dans son propre cache, puis attend
|
||||
// l'accord du joueur. L'application affiche alors, entre deux tours, le
|
||||
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
|
||||
// active la nouvelle version et recharge la page, qui reprend la partie
|
||||
// enregistrée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export interface Updates {
|
||||
/** Une nouvelle version est installée et attend l'accord du joueur. */
|
||||
readonly available: boolean;
|
||||
/** « Rechercher une mise à jour » : demande au navigateur de relire le
|
||||
* service worker publié. found : une nouvelle version attend (elle a pu
|
||||
* demander de télécharger tout le site) ; latest : rien de nouveau ;
|
||||
* unavailable : pas de service worker (http hors localhost, navigateur
|
||||
* qui le refuse) ou réseau absent. */
|
||||
check(): Promise<"found" | "latest" | "unavailable">;
|
||||
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
|
||||
* la main. */
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
/** Message au service worker en attente : prendre la main (sw.js). */
|
||||
export const SKIP_WAITING = "skip-waiting";
|
||||
|
||||
/**
|
||||
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
|
||||
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
|
||||
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
|
||||
* pure, testée sous Node.
|
||||
*/
|
||||
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
|
||||
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
|
||||
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
|
||||
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
|
||||
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
betweenTwoTurns: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && betweenTwoTurns && !otherBanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le service worker une fois ready résolue (le moteur chargé :
|
||||
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
|
||||
* nouvelle version attend.
|
||||
*/
|
||||
export function startUpdates(
|
||||
script: URL,
|
||||
ready: Promise<unknown>,
|
||||
onAvailable: () => void,
|
||||
): Updates {
|
||||
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let waiting: ServiceWorker | null = null;
|
||||
|
||||
/** Une version installée attend, et une version plus ancienne a la
|
||||
* main : c'est une mise à jour (à la toute première installation,
|
||||
* aucune page n'est encore contrôlée, et rien n'attend). */
|
||||
const notice = (worker: ServiceWorker | null) => {
|
||||
if (worker !== null && worker.state === "installed" && container?.controller) {
|
||||
waiting = worker;
|
||||
onAvailable();
|
||||
}
|
||||
};
|
||||
|
||||
/** Suit une version en cours d'installation ; résolue quand elle est
|
||||
* installée (vrai) ou abandonnée (faux). */
|
||||
const follow = (worker: ServiceWorker): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const settle = () => {
|
||||
if (worker.state === "installed" || worker.state === "activated") {
|
||||
notice(worker);
|
||||
resolve(true);
|
||||
} else if (worker.state === "redundant") {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
worker.addEventListener("statechange", settle);
|
||||
settle();
|
||||
});
|
||||
|
||||
if (container !== null) {
|
||||
ready
|
||||
.then(() => container.register(script))
|
||||
.then((reg) => {
|
||||
registration = reg;
|
||||
notice(reg.waiting);
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
reg.addEventListener("updatefound", () => {
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Refusé (http hors localhost) : l'application marche sans.
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get available() {
|
||||
return waiting !== null;
|
||||
},
|
||||
async check() {
|
||||
const reg = registration;
|
||||
if (reg === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
try {
|
||||
await reg.update();
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
if (reg.waiting !== null) {
|
||||
notice(reg.waiting);
|
||||
return "found";
|
||||
}
|
||||
if (reg.installing !== null) {
|
||||
return (await follow(reg.installing)) ? "found" : "latest";
|
||||
}
|
||||
return "latest";
|
||||
},
|
||||
apply() {
|
||||
const worker = waiting ?? registration?.waiting ?? null;
|
||||
if (container === null || worker === null) {
|
||||
return;
|
||||
}
|
||||
let reloading = false;
|
||||
container.addEventListener("controllerchange", () => {
|
||||
if (!reloading) {
|
||||
reloading = true;
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
worker.postMessage({ type: SKIP_WAITING });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Tests du domaine : les branches d'une partie et son export SGF, sur des
|
||||
// journaux écrits à la main (sans moteur, sauf un faux pour une position de
|
||||
// départ particulière). La relecture des SGF par le vrai GNU Backgammon est
|
||||
// faite par tests/test_web.sh (tests/sgf/check.py).
|
||||
//
|
||||
// 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 { branches, switchToBranch } from "../src/domain/game/branches.js";
|
||||
import type { Board, Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
type NewGameEvent,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { exportSgf } from "../src/domain/game/sgf.js";
|
||||
|
||||
function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal {
|
||||
return events.reduce(appendEvent, journal);
|
||||
}
|
||||
|
||||
const start: NewGameEvent = {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
};
|
||||
const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({
|
||||
type: "MovePlayed",
|
||||
side,
|
||||
halfMoves: parseHalfMoves(text),
|
||||
position,
|
||||
});
|
||||
const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({
|
||||
type: "DiceRolled",
|
||||
side,
|
||||
roll: [a, b],
|
||||
});
|
||||
|
||||
/**
|
||||
* Une partie et ses branches :
|
||||
* 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4,
|
||||
* 5 coup 24/18 13/9, 6 dés joueur 2-1 ;
|
||||
* rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même
|
||||
* ouverture, autre coup) : 8 coup 24/23 13/10.
|
||||
*/
|
||||
function branchedGame(): GameJournal {
|
||||
let j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 3, 1),
|
||||
move("player", "8/5 6/5"),
|
||||
roll("computer", 6, 4),
|
||||
move("computer", "24/18 13/9"),
|
||||
roll("player", 2, 1),
|
||||
);
|
||||
j = add(goBackTo(j, "3"), roll("computer", 5, 5));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
return j;
|
||||
}
|
||||
|
||||
test("les branches : feuilles de l'arbre, avec leur point de départ", () => {
|
||||
const j = branchedGame();
|
||||
const all = branches(j);
|
||||
assert.deepEqual(
|
||||
all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]),
|
||||
[
|
||||
["6", null, 0, 2, false],
|
||||
["7", "3", 1, 1, false],
|
||||
["8", "2", 0, 1, true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(branches(EMPTY_JOURNAL), []);
|
||||
|
||||
// Passer d'une branche à l'autre ne perd rien.
|
||||
const back = switchToBranch(j, "6");
|
||||
assert.equal(back.events, j.events);
|
||||
assert.deepEqual(
|
||||
currentLine(back).map((e) => e.id),
|
||||
["1", "2", "3", "4", "5", "6"],
|
||||
);
|
||||
assert.throws(() => switchToBranch(j, "4"), /branche introuvable/);
|
||||
});
|
||||
|
||||
test("une branche rejouée puis continuée garde son ordre de création", () => {
|
||||
// La ligne d'origine continue après la création d'une branche : elle
|
||||
// reste la première.
|
||||
let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
j = add(goBackTo(j, "3"), roll("computer", 6, 4));
|
||||
assert.deepEqual(
|
||||
branches(j).map((b) => [b.head, b.forkedFrom]),
|
||||
[
|
||||
["5", null],
|
||||
["4", "2"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/** Un faux moteur qui ne sait que décoder un plateau. */
|
||||
function boardEngine(board: Board): Engine {
|
||||
return { board: async () => board } as unknown as Engine;
|
||||
}
|
||||
const noEngine = boardEngine({} as Board);
|
||||
const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" };
|
||||
|
||||
test("SGF : les coups, les variantes, la branche courante d'abord", async () => {
|
||||
const sgf = await exportSgf(noEngine, branchedGame(), names);
|
||||
assert.equal(
|
||||
sgf,
|
||||
"(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" +
|
||||
"PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" +
|
||||
// Branche courante : même ouverture, autre coup.
|
||||
"\n;B[31ablo])(" +
|
||||
// Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)).
|
||||
"\n;B[31qtst](" +
|
||||
// Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et
|
||||
// des dés lancés, pas joués.
|
||||
"\n;W[64xrmi]\n;PL[B]DI[21])(" +
|
||||
"\n;PL[W]DI[55])))\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : barre, sortie, coup impossible, videau et résultat", async () => {
|
||||
const j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 1, 6),
|
||||
move("computer", "bar/19 6/off"),
|
||||
{ type: "CubeOffered", side: "player" },
|
||||
{ type: "CubeTaken", side: "computer" },
|
||||
roll("player", 6, 6),
|
||||
move("player", ""),
|
||||
{ type: "CubeOffered", side: "computer" },
|
||||
{ type: "CubeDropped", side: "player" },
|
||||
{ type: "GameEnded", winner: "computer", kind: "single", points: 2 },
|
||||
);
|
||||
const sgf = await exportSgf(noEngine, j, names);
|
||||
assert.ok(sgf.includes("RE[W+2]"));
|
||||
assert.ok(
|
||||
sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"),
|
||||
sgf,
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : une position de départ particulière est posée", async () => {
|
||||
// Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ;
|
||||
// l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur.
|
||||
const points = (entries: Record<number, number>) =>
|
||||
Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0);
|
||||
const engine = boardEngine({
|
||||
onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 },
|
||||
opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 },
|
||||
swappedPosition: "X",
|
||||
result: null,
|
||||
});
|
||||
const j = add(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: "ABC",
|
||||
cube: { value: 4, owner: "computer" },
|
||||
firstTurn: "player",
|
||||
});
|
||||
const sgf = await exportSgf(engine, j, names);
|
||||
assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf);
|
||||
await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// L'adaptateur du moteur WebAssembly pour les niveaux, l'analyse et la
|
||||
// chance (étape 1a), sur le vrai moteur. La concordance des nombres avec
|
||||
// gnubg de bureau est vérifiée par tests/test_wasm_reference.sh ; ici, la
|
||||
// traduction des réponses en types du domaine.
|
||||
//
|
||||
// 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 { fileURLToPath } from "node:url";
|
||||
import { type EngineCube, LUCK_THRESHOLDS, SKILL_THRESHOLDS } from "../src/domain/game/engine.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const centered: EngineCube = { value: 1, owner: "centered" };
|
||||
const middlegame = "mM/gATCw5+ABMA";
|
||||
|
||||
test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meilleur coup", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const analysis = await engine.analyseMove(
|
||||
START_POSITION,
|
||||
centered,
|
||||
[3, 1],
|
||||
"4HPwATDgc+IBKA",
|
||||
2,
|
||||
);
|
||||
assert.ok(analysis !== null);
|
||||
assert.equal(analysis.moveCount, 16);
|
||||
assert.equal(analysis.rank, 2);
|
||||
assert.equal(analysis.played.notation, "24/23 13/10");
|
||||
assert.equal(analysis.best.notation, "8/5 6/5");
|
||||
assert.equal(analysis.skill, "veryBad");
|
||||
assert.ok(
|
||||
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
|
||||
);
|
||||
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
|
||||
});
|
||||
|
||||
test("sans coup possible, il n'y a rien à analyser", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const position = "27YBBwAcz8EDQA";
|
||||
assert.equal(await engine.analyseMove(position, centered, [5, 5], position, 0), null);
|
||||
});
|
||||
|
||||
test("double/prend : passer est une erreur de l'adversaire, prendre n'en est pas une", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const position = "6O5uAAC4uzsAAA";
|
||||
const pass = await engine.analyseCube(position, centered, "pass", 2);
|
||||
assert.equal(pass.analysed, true);
|
||||
assert.equal(pass.skill, "bad");
|
||||
assert.ok(pass.equities !== null && pass.equities.doubleTake < pass.equities.doublePass);
|
||||
const take = await engine.analyseCube(position, centered, "take", 2);
|
||||
assert.deepEqual([take.skill, take.equityLoss], ["none", 0]);
|
||||
const opponentOwns = await engine.analyseCube(
|
||||
position,
|
||||
{ value: 2, owner: "opponent" },
|
||||
"noDouble",
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(opponentOwns, {
|
||||
analysed: false,
|
||||
equities: null,
|
||||
equityLoss: 0,
|
||||
skill: "none",
|
||||
});
|
||||
});
|
||||
|
||||
test("la chance d'un jet et sa catégorie", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const opening = await engine.luck(START_POSITION, centered, [3, 1]);
|
||||
assert.ok(Math.abs(opening.value - 0.2126) < 2e-3);
|
||||
assert.equal(opening.rating, "none");
|
||||
const doubleSix = await engine.luck(START_POSITION, centered, [6, 6]);
|
||||
assert.ok(doubleSix.value > LUCK_THRESHOLDS.lucky);
|
||||
assert.equal(doubleSix.rating, "lucky");
|
||||
});
|
||||
|
||||
// Régression : sans cache vidé à chaque appel (engine.c, start_evaluation),
|
||||
// une analyse à 2 plis changeait la chance calculée ensuite (évaluations à
|
||||
// 0 pli des réseaux d'élagage restées dans le cache de gnubg).
|
||||
test("un résultat ne dépend pas des calculs faits avant", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const before = await engine.luck(middlegame, centered, [6, 4]);
|
||||
await engine.analyseCube(middlegame, centered, "noDouble", 2);
|
||||
await engine.analyseMove(middlegame, centered, [6, 4], "mM/gATDGzuABMA", 2);
|
||||
assert.deepEqual(await engine.luck(middlegame, centered, [6, 4]), before);
|
||||
});
|
||||
|
||||
test("le niveau débutant joue toujours le même coup, différent du meilleur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const move = await engine.levelMove(middlegame, centered, [6, 4], "beginner");
|
||||
assert.equal(move?.notation, "24/20 24/18");
|
||||
}
|
||||
assert.equal((await engine.levelMove(middlegame, centered, [6, 4], "2ply"))?.notation, "24/14");
|
||||
const cube = await engine.levelCubeDecision("6O5uAAC4uzsAAA", centered, "beginner");
|
||||
assert.equal(cube.double, false);
|
||||
});
|
||||
|
||||
test("les seuils du domaine sont ceux du moteur", async () => {
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const module = await import(new URL("build/wasm/engine.mjs", root).href);
|
||||
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
|
||||
const raw = await wrapper.loadEngine(
|
||||
module.default,
|
||||
fileURLToPath(new URL("build/wasm/", root)),
|
||||
);
|
||||
const { result } = raw.run(["levels"]) as {
|
||||
result: {
|
||||
levels: { name: string }[];
|
||||
skill_thresholds: { doubtful: number; bad: number; very_bad: number };
|
||||
luck_thresholds: { lucky: number; very_lucky: number };
|
||||
};
|
||||
};
|
||||
const close = (a: number, b: number) => Math.abs(a - b) < 1e-6;
|
||||
assert.ok(close(result.skill_thresholds.doubtful, SKILL_THRESHOLDS.doubtful));
|
||||
assert.ok(close(result.skill_thresholds.bad, SKILL_THRESHOLDS.bad));
|
||||
assert.ok(close(result.skill_thresholds.very_bad, SKILL_THRESHOLDS.veryBad));
|
||||
assert.ok(close(result.luck_thresholds.lucky, LUCK_THRESHOLDS.lucky));
|
||||
assert.ok(close(result.luck_thresholds.very_lucky, LUCK_THRESHOLDS.veryLucky));
|
||||
assert.deepEqual(
|
||||
result.levels.map((l) => l.name),
|
||||
["beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"],
|
||||
);
|
||||
});
|
||||
@@ -45,18 +45,20 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
for (const halfMove of first.halfMoves) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
await game.confirmMove();
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
+329
-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. */
|
||||
@@ -49,6 +59,37 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
return { valid: false, complete: false, position: null };
|
||||
},
|
||||
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,
|
||||
equities: null,
|
||||
equityLoss: 0,
|
||||
skill: "none",
|
||||
}),
|
||||
luck: async () => ({ value: 0, rating: "none" }),
|
||||
encodeMatch: async () => "MATCH",
|
||||
decodeMatch: async () => ({
|
||||
matchLength: 0,
|
||||
score: [0, 0],
|
||||
cubeValue: 1,
|
||||
cubeOwner: -1,
|
||||
playerOnRoll: 0,
|
||||
crawford: false,
|
||||
jacoby: true,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -78,14 +119,111 @@ test("un coup se saisit demi-coup par demi-coup ; un demi-coup refusé ne change
|
||||
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
|
||||
|
||||
// Coup complet : il attend la validation, hors du journal.
|
||||
assert.equal(game.moveComplete, true);
|
||||
assert.equal(game.displayedPosition, "AFTER");
|
||||
assert.equal(game.events.length, 2);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 7 }), false);
|
||||
|
||||
await game.confirmMove();
|
||||
const last = game.events.at(-1);
|
||||
assert.equal(last?.type, "MovePlayed");
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
assert.equal(game.state.position, "SWAPPED");
|
||||
});
|
||||
|
||||
test("un coup incomplet ne se valide pas", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await assert.rejects(game.confirmMove(), /pas complet/);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await assert.rejects(game.confirmMove(), /pas complet/);
|
||||
assert.equal(game.events.length, 2);
|
||||
});
|
||||
|
||||
test("Annuler défait les demi-coups un par un, sans toucher au journal", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
const journal = game.journal;
|
||||
|
||||
game.undoLastHalfMove();
|
||||
assert.deepEqual(game.pendingHalfMoves, [{ from: 8, to: 5 }]);
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(game.displayedPosition, "HALF");
|
||||
|
||||
game.undoLastHalfMove();
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.displayedPosition, START_POSITION);
|
||||
game.undoLastHalfMove(); // rien à défaire : sans effet
|
||||
assert.equal(game.journal, journal);
|
||||
|
||||
// Le coup se ressaisit ensuite normalement.
|
||||
assert.equal(await game.playHalfMove({ from: 8, to: 5 }), true);
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 5 }), true);
|
||||
assert.equal(game.moveComplete, true);
|
||||
});
|
||||
|
||||
test("les dés utilisés par les demi-coups saisis sont marqués", async () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
assert.deepEqual(game.diceToPlay, [
|
||||
{ value: 3, used: false },
|
||||
{ value: 1, used: false },
|
||||
]);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
assert.deepEqual(game.diceToPlay, [
|
||||
{ value: 3, used: true },
|
||||
{ value: 1, used: false },
|
||||
]);
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
assert.deepEqual(
|
||||
game.diceToPlay.map((d) => d.used),
|
||||
[true, true],
|
||||
);
|
||||
await game.confirmMove();
|
||||
assert.deepEqual(game.diceToPlay, []);
|
||||
});
|
||||
|
||||
test("un double donne quatre dés, marqués un par un ; une sortie prend le plus petit dé suffisant", async () => {
|
||||
const engine = fakeEngine({
|
||||
checkHalfMoves: async () => ({ valid: true, complete: false, position: "P" }),
|
||||
});
|
||||
const double = Game.start(engine, {
|
||||
dice: fixedDice([2, 2]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await double.roll();
|
||||
assert.equal(double.diceToPlay.length, 4);
|
||||
await double.playHalfMove({ from: 13, to: 11 });
|
||||
await double.playHalfMove({ from: 11, to: 9 });
|
||||
assert.deepEqual(
|
||||
double.diceToPlay.map((d) => d.used),
|
||||
[true, true, false, false],
|
||||
);
|
||||
|
||||
const bearOff = Game.start(engine, {
|
||||
dice: fixedDice([6, 4]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await bearOff.roll();
|
||||
// 3/off : le 4 suffit, le 6 reste.
|
||||
await bearOff.playHalfMove({ from: 3, to: 0 });
|
||||
assert.deepEqual(bearOff.diceToPlay, [
|
||||
{ value: 6, used: false },
|
||||
{ value: 4, used: true },
|
||||
]);
|
||||
// 6/2 demande le 4 : la sortie a donc pris le 6.
|
||||
await bearOff.playHalfMove({ from: 6, to: 2 });
|
||||
assert.deepEqual(
|
||||
bearOff.diceToPlay.map((d) => d.used),
|
||||
[true, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("sans coup possible, le tour passe quand le joueur le laisse passer", async () => {
|
||||
const engine = fakeEngine({ legalMoves: async () => [] });
|
||||
const game = Game.start(engine, {
|
||||
@@ -120,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");
|
||||
@@ -134,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(
|
||||
@@ -166,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);
|
||||
});
|
||||
|
||||
@@ -192,6 +324,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1, 6, 5, 4, 2]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const played = game.events.at(-1);
|
||||
assert.equal(played?.type, "MovePlayed");
|
||||
|
||||
@@ -200,6 +333,7 @@ test("annuler repart du parent ; l'action suivante ouvre une branche", async ()
|
||||
assert.equal(game.state.turn, "player");
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
|
||||
// Deux coups accrochés au même jet : l'ancien reste dans le journal.
|
||||
const moves = game.journal.events.filter((e) => e.type === "MovePlayed");
|
||||
@@ -220,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(() => {
|
||||
@@ -244,6 +378,7 @@ test("une partie se reprend depuis son journal ; un journal d'une autre version
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const saved = JSON.parse(JSON.stringify(game.journal));
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), saved);
|
||||
@@ -293,3 +428,181 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () =
|
||||
assert.equal(swapped.player.pipCount, 2);
|
||||
assert.equal(swapped.computer.pipCount, 1);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Match (1c)
|
||||
|
||||
test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => {
|
||||
const cubes: EngineCube[] = [];
|
||||
const levels: Level[] = [];
|
||||
const engine = fakeEngine({
|
||||
levelDouble: async (_position, cube, level) => {
|
||||
cubes.push(cube);
|
||||
levels.push(level);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false };
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1, 4, 2]),
|
||||
level: "intermediate",
|
||||
match,
|
||||
});
|
||||
const start = game.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "intermediate");
|
||||
assert.deepEqual(start.match, match);
|
||||
assert.equal(game.state.level, "intermediate");
|
||||
// L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite
|
||||
// l'ordinateur pense à doubler, et le match lui est donné vu de lui.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.deepEqual(levels, ["intermediate"]);
|
||||
assert.deepEqual(cubes[0]?.match, {
|
||||
length: 5,
|
||||
score: { onRoll: 3, opponent: 1 },
|
||||
crawford: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => {
|
||||
const winning: Board = {
|
||||
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
|
||||
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
|
||||
swappedPosition: "SWAPPED",
|
||||
result: { winner: "onRoll", kind: "gammon", points: 2 },
|
||||
};
|
||||
const engine = fakeEngine({ board: async () => winning });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([5, 2]),
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
assert.equal(game.nextGame, null);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const end = game.events.at(-1);
|
||||
assert.ok(end?.type === "GameEnded");
|
||||
assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true });
|
||||
const next = game.nextGame;
|
||||
assert.deepEqual(next, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 2, computer: 0 }, crawford: true },
|
||||
});
|
||||
|
||||
// La partie Crawford, gagnée : le match est fini, plus de partie suivante.
|
||||
const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) });
|
||||
await crawford.playHalfMove({ from: 8, to: 5 });
|
||||
await crawford.playHalfMove({ from: 6, to: 5 });
|
||||
await crawford.confirmMove();
|
||||
assert.deepEqual(crawford.state.matchAfter, {
|
||||
score: { player: 4, computer: 0 },
|
||||
over: true,
|
||||
crawford: false,
|
||||
});
|
||||
assert.equal(crawford.nextGame, null);
|
||||
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
|
||||
// 0, au même niveau.
|
||||
assert.deepEqual(crawford.rematch, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("passer un double dans un match : le moteur compte le match", async () => {
|
||||
const engine = fakeEngine({ levelDouble: async () => true });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1]),
|
||||
match: { length: 3, score: { player: 0, computer: 1 }, crawford: false },
|
||||
});
|
||||
// L'ordinateur joue son jet d'ouverture, le joueur son coup, puis
|
||||
// l'ordinateur double.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled");
|
||||
await game.drop();
|
||||
assert.deepEqual(game.state.matchAfter, {
|
||||
score: { player: 0, computer: 2 },
|
||||
over: false,
|
||||
crawford: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie libre n'a ni match ni partie suivante", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, DEFAULT_LEVEL);
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
|
||||
assert.deepEqual(game.rematch, { level: "0ply" });
|
||||
});
|
||||
|
||||
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
|
||||
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
|
||||
// sans level ni match, un coup joué, une branche.
|
||||
const v1 = {
|
||||
events: [
|
||||
{
|
||||
id: "1",
|
||||
parent: null,
|
||||
version: 1,
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
},
|
||||
{ id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] },
|
||||
{
|
||||
id: "3",
|
||||
parent: "2",
|
||||
version: 1,
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 6, to: 5 },
|
||||
],
|
||||
position: "SWAPPED",
|
||||
},
|
||||
{ id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] },
|
||||
],
|
||||
head: "3",
|
||||
} as unknown as GameJournal;
|
||||
const before = replay(currentLine(v1));
|
||||
const migrated = migrateJournal(v1);
|
||||
assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION));
|
||||
assert.equal(migrated.head, "3");
|
||||
const start = migrated.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "2ply");
|
||||
assert.equal(start.match, undefined);
|
||||
// Rien d'autre ne change : mêmes événements, même arbre, même état.
|
||||
const strip = (journal: GameJournal) =>
|
||||
journal.events.map(({ version: _, ...event }) => {
|
||||
const { level: _level, ...rest } = event as typeof event & { level?: unknown };
|
||||
return rest;
|
||||
});
|
||||
assert.deepEqual(strip(migrated), strip(v1));
|
||||
assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" });
|
||||
// Une version courante passe telle quelle.
|
||||
assert.equal(migrateJournal(migrated), migrated);
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1)));
|
||||
assert.equal(resumed.state.level, "2ply");
|
||||
assert.equal(resumed.state.match, null);
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
// La partie continue en version 2.
|
||||
await resumed.playComputerTurn();
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
|
||||
// premier lancement, règles d'apparition de la carte d'installation,
|
||||
// réglages enregistrés par une version précédente.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
INSTALL_AGAIN_AFTER,
|
||||
installCardShown,
|
||||
isFirstLaunch,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
TUTORIAL_DONE,
|
||||
} from "../src/ui/settings.js";
|
||||
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../src/ui/tutorial-text.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
|
||||
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
|
||||
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
|
||||
assert.equal(old.welcomed, false);
|
||||
assert.equal(old.gamesFinished, 0);
|
||||
assert.equal(old.tutorialSeen, 0);
|
||||
assert.equal(old.installed, false);
|
||||
const saved = parseSettings(
|
||||
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
|
||||
'"installDismissedAt": 2, "installed": true}',
|
||||
);
|
||||
assert.deepEqual(saved, {
|
||||
...DEFAULT_SETTINGS,
|
||||
welcomed: true,
|
||||
gamesFinished: 2,
|
||||
tutorialSeen: 5,
|
||||
installDismissals: 1,
|
||||
installDismissedAt: 2,
|
||||
installed: true,
|
||||
});
|
||||
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
|
||||
});
|
||||
|
||||
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
|
||||
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
|
||||
// La partie commencée d'office au chargement ne compte pas.
|
||||
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
|
||||
});
|
||||
|
||||
test("invitation à installer : après la première partie terminée, une seule relance", () => {
|
||||
const at = (changes: Partial<Settings>, installed = false) =>
|
||||
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
|
||||
assert.ok(!at({}), "jamais avant la première partie terminée");
|
||||
assert.ok(at({ gamesFinished: 1 }));
|
||||
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
|
||||
assert.ok(!at({ gamesFinished: 1, installed: true }));
|
||||
// « Plus tard » : masquée, puis de retour après trois parties de plus.
|
||||
const later = { installDismissals: 1, installDismissedAt: 1 };
|
||||
assert.equal(INSTALL_AGAIN_AFTER, 3);
|
||||
assert.ok(!at({ ...later, gamesFinished: 1 }));
|
||||
assert.ok(!at({ ...later, gamesFinished: 3 }));
|
||||
assert.ok(at({ ...later, gamesFinished: 4 }));
|
||||
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
|
||||
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
|
||||
});
|
||||
|
||||
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
const explain = (step: number) => ({ step, practice: false });
|
||||
const practice = (step: number) => ({ step, practice: true });
|
||||
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
|
||||
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
|
||||
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
|
||||
assert.equal(nextPlace(tutorial, practice(8)), "end");
|
||||
assert.equal(previousPlace(tutorial, explain(1)), null);
|
||||
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
|
||||
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
|
||||
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
|
||||
explain(2),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
|
||||
assert.equal(seenAfter(5, explain(3)), 5);
|
||||
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
|
||||
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
|
||||
const bar = progress(8, explain(3), 5);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.link).map((s) => s.step),
|
||||
[1, 2, 4, 5],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.done).map((s) => s.step),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.current).map((s) => s.step),
|
||||
[3],
|
||||
);
|
||||
});
|
||||
|
||||
test("la marche à suivre pour installer dépend du navigateur", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
const chrome =
|
||||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/130.0 Mobile Safari/537.36";
|
||||
assert.equal(installPlatform(iphone), "ios");
|
||||
assert.equal(installPlatform(ipad), "ios");
|
||||
assert.equal(installPlatform(firefox), "firefox");
|
||||
// Sans invite du système, Chrome suit la marche à suivre de Firefox
|
||||
// (menu ⋮, « Installer »), la plus proche de la sienne.
|
||||
assert.equal(installPlatform(chrome), "firefox");
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
// Tests de la révision UX qui se vérifient sans navigateur : géométrie du
|
||||
// plateau selon l'écran, vibrations, réglages, compte de points, plateau
|
||||
// gardé pour le chargement, et règles de la charte vérifiables dans le code.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { createSettingsStore } from "../src/storage/settings-store.js";
|
||||
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
|
||||
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
|
||||
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
|
||||
import { ICON_PATHS } from "../src/ui/icons.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LONG_PROMPT_GAMES,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
} from "../src/ui/settings.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
|
||||
|
||||
// --------------------------------------------------------------- Géométrie
|
||||
|
||||
/** Les écrans en paysage de la recette : largeur et hauteur en pixels,
|
||||
* dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large
|
||||
* encore. */
|
||||
const LANDSCAPES: [number, number][] = [
|
||||
[915, 412],
|
||||
[844, 390],
|
||||
[844, 320],
|
||||
[740, 360],
|
||||
[800, 308],
|
||||
];
|
||||
|
||||
test("géométrie en portrait : les mesures du plateau d'origine", () => {
|
||||
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
|
||||
assert.equal(g.width, 640);
|
||||
assert.equal(g.pointWidth, 44);
|
||||
assert.equal(g.radius, 20);
|
||||
assert.equal(g.step, 42);
|
||||
assert.equal(g.trayLeft + g.tray + g.frame, 640);
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
|
||||
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
|
||||
});
|
||||
|
||||
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const px = height / BOARD_HEIGHT;
|
||||
const g = boardGeometry("landscape", width / height, px);
|
||||
const where = `${width}x${height}`;
|
||||
assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`);
|
||||
// Les colonnes, la barre et les flèches occupent toute la largeur.
|
||||
assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`);
|
||||
assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`);
|
||||
assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`);
|
||||
assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`);
|
||||
assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`);
|
||||
assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`);
|
||||
assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`);
|
||||
assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`);
|
||||
assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`);
|
||||
assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`);
|
||||
assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`);
|
||||
// Les numéros tiennent dans le cadre.
|
||||
assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`);
|
||||
}
|
||||
});
|
||||
|
||||
test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => {
|
||||
const cases = [
|
||||
boardGeometry("portrait", 640 / 520, 0.62),
|
||||
...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)),
|
||||
];
|
||||
for (const g of cases) {
|
||||
const band = g.height / 2 - g.die / 2;
|
||||
const last = stackY(g, true, MAX_STACK - 1) + g.radius;
|
||||
assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT);
|
||||
const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius;
|
||||
const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight);
|
||||
assert.ok(g.barStack >= 1);
|
||||
assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Vibrations
|
||||
|
||||
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
|
||||
const calls: number[][] = [];
|
||||
let on = true;
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
calls.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => on,
|
||||
);
|
||||
haptic("checkerPlaced");
|
||||
haptic("checkerHit");
|
||||
haptic("computerDoubles");
|
||||
haptic("gameWon");
|
||||
haptic("gameLost");
|
||||
assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]);
|
||||
on = false;
|
||||
haptic("checkerHit");
|
||||
assert.equal(calls.length, 5);
|
||||
// Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur.
|
||||
assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon"));
|
||||
const refusing = createHaptics(
|
||||
() => {
|
||||
throw new Error("refusé");
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
assert.doesNotThrow(() => refusing("gameWon"));
|
||||
// Aucun motif ne dépasse 60 ms par impulsion (interaction.md).
|
||||
for (const pattern of Object.values(HAPTIC_PATTERNS)) {
|
||||
assert.ok(pattern.every((ms) => ms > 0 && ms <= 60));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Réglages
|
||||
|
||||
test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => {
|
||||
assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS);
|
||||
assert.equal(DEFAULT_SETTINGS.vibrations, true);
|
||||
assert.equal(DEFAULT_SETTINGS.keepScreenOn, true);
|
||||
assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS);
|
||||
assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
vibrations: false,
|
||||
theme: "dark",
|
||||
gamesStarted: 4,
|
||||
});
|
||||
assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
assert.equal(nextTheme("auto"), "light");
|
||||
assert.equal(nextTheme("light"), "dark");
|
||||
assert.equal(nextTheme("dark"), "auto");
|
||||
});
|
||||
|
||||
test("consigne longue pendant les trois premières parties, courte ensuite", () => {
|
||||
assert.equal(LONG_PROMPT_GAMES, 3);
|
||||
for (const games of [0, 1, 2, 3]) {
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true);
|
||||
}
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false);
|
||||
});
|
||||
|
||||
test("stockage des réglages et du dernier plateau affiché", () => {
|
||||
const data = new Map<string, string>();
|
||||
const store = createSettingsStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
assert.equal(store.loadSettings(), null);
|
||||
store.saveSettings('{"vibrations":false}');
|
||||
assert.equal(store.loadSettings(), '{"vibrations":false}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } };
|
||||
store.saveBoard(board);
|
||||
assert.deepEqual(store.loadBoard(), board);
|
||||
// Un plateau abîmé est ignoré : la position de départ sera dessinée.
|
||||
data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const full = createSettingsStore({
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.saveBoard(board));
|
||||
assert.equal(full.loadBoard(), null);
|
||||
assert.equal(full.loadSettings(), null);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
test("compte de points : le plus petit mène, de l'écart", () => {
|
||||
assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 });
|
||||
assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 });
|
||||
assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 });
|
||||
// La position de départ : 15 pions, 167 points chacun.
|
||||
for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) {
|
||||
assert.equal(
|
||||
side.points.reduce((a, b) => a + b, 0),
|
||||
15,
|
||||
);
|
||||
assert.equal(
|
||||
side.points.reduce((sum, n, i) => sum + n * (i + 1), 0),
|
||||
side.pipCount,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Charte
|
||||
|
||||
/** Les fichiers .ts d'un dossier, récursivement. */
|
||||
function sources(dir: string): string[] {
|
||||
const url = new URL(dir, root);
|
||||
return readdirSync(url).flatMap((name) => {
|
||||
const path = `${dir}${name}`;
|
||||
return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
|
||||
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
|
||||
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
});
|
||||
|
||||
test("les deux blocs du thème sombre de app.css sont identiques", () => {
|
||||
const css = read("web/app.css");
|
||||
const block = (start: string) => {
|
||||
const from = css.indexOf(start);
|
||||
assert.ok(from >= 0, `bloc ${start} absent`);
|
||||
const body = css.slice(css.indexOf("{", from + start.length - 1) + 1);
|
||||
return body
|
||||
.slice(0, body.indexOf("}"))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
assert.deepEqual(
|
||||
block(':root:not([data-theme="light"]) {'),
|
||||
block(':root[data-theme="dark"] {'),
|
||||
);
|
||||
});
|
||||
|
||||
test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => {
|
||||
assert.doesNotMatch(read("web/index.html"), /\sstyle=|<style/);
|
||||
for (const path of sources("app/src/")) {
|
||||
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
|
||||
for (const name of [
|
||||
"menu",
|
||||
"help",
|
||||
"fullscreen",
|
||||
"exitFullscreen",
|
||||
"undo",
|
||||
"replay",
|
||||
"close",
|
||||
]) {
|
||||
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
|
||||
}
|
||||
for (const d of Object.values(ICON_PATHS)) {
|
||||
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -42,6 +42,7 @@ async function playerMove(position: PositionId, dice: [number, number], move: st
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
|
||||
}
|
||||
await game.confirmMove();
|
||||
return game;
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ test("un coup simple : un plateau par demi-coup, sans pion frappé", async () =>
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.confirmMove();
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.equal(steps.side, "player");
|
||||
@@ -121,6 +123,7 @@ test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Tests des liens de position (app/src/domain/game/position-link.ts, ADR
|
||||
// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau
|
||||
// vu du joueur au trait, identifiants illisibles.
|
||||
//
|
||||
// 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 type { EngineCube } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import {
|
||||
linkOf,
|
||||
readPosition,
|
||||
setupOf,
|
||||
UnreadableLinkError,
|
||||
} from "../src/domain/game/position-link.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { parseRoute, routeHash } from "../src/ui/router.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const cases: [EngineCube, -1 | 0 | 1][] = [
|
||||
[{ value: 1, owner: "centered" }, -1],
|
||||
[{ value: 2, owner: "onRoll" }, 0],
|
||||
[{ value: 4, owner: "opponent" }, 1],
|
||||
];
|
||||
for (const [cube, owner] of cases) {
|
||||
const id = await engine.encodeMatch(cube);
|
||||
assert.equal(id.length, 12);
|
||||
const match = await engine.decodeMatch(id);
|
||||
assert.equal(match.matchLength, 0);
|
||||
assert.equal(match.playerOnRoll, 0);
|
||||
assert.equal(match.cubeValue, cube.value);
|
||||
assert.equal(match.cubeOwner, owner);
|
||||
}
|
||||
});
|
||||
|
||||
test("sans Match ID : partie libre, videau au centre", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const shared = await readPosition(engine, { position: START_POSITION, matchId: null });
|
||||
assert.deepEqual(shared.cube, { value: 1, owner: null });
|
||||
assert.equal(shared.match, null);
|
||||
assert.equal(shared.result, null);
|
||||
assert.equal(shared.sides.player.pipCount, 167);
|
||||
assert.equal(shared.sides.computer.pipCount, 167);
|
||||
assert.deepEqual(setupOf(shared), {
|
||||
position: START_POSITION,
|
||||
firstTurn: "player",
|
||||
cube: { value: 1, owner: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("des identifiants illisibles donnent une erreur claire", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const link of [
|
||||
{ position: "abc", matchId: null },
|
||||
{ position: "4HPwATDgc/ABM*", matchId: null },
|
||||
{ position: START_POSITION, matchId: "pas un id" },
|
||||
{ position: START_POSITION, matchId: "" },
|
||||
]) {
|
||||
await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link));
|
||||
}
|
||||
});
|
||||
|
||||
test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Videau chez l'ordinateur, à 2 : une partie commencée ainsi.
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: {
|
||||
position: "jGfkASjg8wcBMA",
|
||||
firstTurn: "player",
|
||||
cube: { value: 2, owner: "computer" },
|
||||
},
|
||||
});
|
||||
const link = await linkOf(engine, game.state);
|
||||
const route = parseRoute(
|
||||
routeHash({ screen: "position", position: link.position, match: link.matchId }),
|
||||
);
|
||||
assert.ok(route.screen === "position");
|
||||
const shared = await readPosition(engine, { position: route.position, matchId: route.match });
|
||||
assert.deepEqual(shared.sides, await game.displayedBoard());
|
||||
assert.deepEqual(shared.cube, { value: 2, owner: "computer" });
|
||||
});
|
||||
|
||||
test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Jet d'ouverture 1-6 : l'ordinateur commence.
|
||||
const game = Game.start(engine, { dice: fixedDice([1, 6]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
const link = await linkOf(engine, game.state);
|
||||
assert.equal(link.position, game.state.position);
|
||||
const shared = await readPosition(engine, link);
|
||||
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
|
||||
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de
|
||||
// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran
|
||||
// redonne son adresse.
|
||||
//
|
||||
// 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 {
|
||||
isHelpScreen,
|
||||
parseRoute,
|
||||
type Route,
|
||||
routeHash,
|
||||
slug,
|
||||
stepsBack,
|
||||
} from "../src/ui/router.js";
|
||||
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
const MATCH = "cAkAAAAAAAAA";
|
||||
|
||||
/** Les adresses du tableau de l'ADR, et l'écran de chacune. */
|
||||
const TABLE: [string, Route][] = [
|
||||
["#/", { screen: "game" }],
|
||||
["#/accueil", { screen: "home" }],
|
||||
["#/aide", { screen: "help", question: null }],
|
||||
["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }],
|
||||
["#/glossaire", { screen: "glossary", term: null }],
|
||||
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
|
||||
["#/a-propos", { screen: "about" }],
|
||||
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
|
||||
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
|
||||
["#/tutoriel/fin", { screen: "tutorialEnd" }],
|
||||
["#/installer", { screen: "install" }],
|
||||
["#/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 },
|
||||
],
|
||||
["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }],
|
||||
];
|
||||
|
||||
test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => {
|
||||
for (const [hash, route] of TABLE) {
|
||||
assert.deepEqual(parseRoute(hash), route, hash);
|
||||
assert.equal(routeHash(route), hash, hash);
|
||||
}
|
||||
});
|
||||
|
||||
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
|
||||
assert.deepEqual(parseRoute(""), { screen: "game" });
|
||||
assert.deepEqual(parseRoute("#"), { screen: "game" });
|
||||
assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null });
|
||||
assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" });
|
||||
});
|
||||
|
||||
test("une adresse inconnue est reconnue comme telle", () => {
|
||||
for (const hash of [
|
||||
"#/inconnu",
|
||||
"#aide",
|
||||
"#/aide/a/b",
|
||||
"#/aide/Majuscules",
|
||||
"#/menu/plus",
|
||||
"#/tutoriel/0",
|
||||
"#/tutoriel/x",
|
||||
"#/tutoriel/3/b",
|
||||
"#/tutoriel/3/a-vous/x",
|
||||
"#/tutoriel/fin/a-vous",
|
||||
"#/installer/x",
|
||||
"#/a-propos/x",
|
||||
"#/position",
|
||||
"#/position/%E0%A4%A",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
assert.equal(routeHash(parseRoute("#/inconnu")), "#/inconnu");
|
||||
});
|
||||
|
||||
test("un lien de position collé sans encodage (+ et / du base64) est compris", () => {
|
||||
assert.deepEqual(parseRoute(`#/position/${START}/${MATCH}`), {
|
||||
screen: "position",
|
||||
position: START,
|
||||
match: MATCH,
|
||||
});
|
||||
assert.deepEqual(parseRoute("#/position/jGfkASjg8wcBMA/cAkAAAAAAAAA"), {
|
||||
screen: "position",
|
||||
position: "jGfkASjg8wcBMA",
|
||||
match: MATCH,
|
||||
});
|
||||
// + reste un + (pas une espace), encodé ou non.
|
||||
const plus = "AAAA+AAAAAAAAA";
|
||||
assert.equal(
|
||||
routeHash({ screen: "position", position: plus, match: null }),
|
||||
"#/position/AAAA%2BAAAAAAAAA",
|
||||
);
|
||||
assert.deepEqual(parseRoute("#/position/AAAA%2BAAAAAAAAA"), {
|
||||
screen: "position",
|
||||
position: plus,
|
||||
match: null,
|
||||
});
|
||||
assert.deepEqual(parseRoute(`#/position/${plus}`), {
|
||||
screen: "position",
|
||||
position: plus,
|
||||
match: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("un identifiant de position de mauvaise longueur arrive tel quel au moteur, qui jugera", () => {
|
||||
assert.deepEqual(parseRoute("#/position/abc"), {
|
||||
screen: "position",
|
||||
position: "abc",
|
||||
match: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("l'identifiant d'une question : le titre sans accents ni ponctuation", () => {
|
||||
assert.equal(slug("Jouer un coup, puis l'annuler"), "jouer-un-coup-puis-l-annuler");
|
||||
assert.equal(slug(" Été : déjà vu ? "), "ete-deja-vu");
|
||||
assert.equal(
|
||||
slug("Rejouer d'ici sans perdre l'original"),
|
||||
"rejouer-d-ici-sans-perdre-l-original",
|
||||
);
|
||||
});
|
||||
|
||||
test("les écrans de l'aide : jouer, les mots du jeu, à propos", () => {
|
||||
assert.ok(isHelpScreen(parseRoute("#/aide/x")));
|
||||
assert.ok(isHelpScreen(parseRoute("#/glossaire")));
|
||||
assert.ok(isHelpScreen(parseRoute("#/a-propos")));
|
||||
assert.ok(!isHelpScreen(parseRoute("#/menu")));
|
||||
});
|
||||
|
||||
test("quitter un écran revient à la dernière adresse qui n'en est pas", () => {
|
||||
const help = (route: Route) => isHelpScreen(route);
|
||||
// Partie, aide, une question : « Retour » remonte de deux pas.
|
||||
assert.equal(stepsBack(["#/", "#/aide", "#/aide/q"], help), -2);
|
||||
// Le menu ouvert depuis l'aide : on revient à l'aide.
|
||||
const menu = (route: Route) => route.screen === "menu";
|
||||
assert.equal(stepsBack(["#/", "#/aide", "#/menu"], menu), -1);
|
||||
// Ouvert directement dans un nouvel onglet : rien avant, on remplace.
|
||||
assert.equal(stepsBack(["#/aide/q"], help), null);
|
||||
assert.equal(stepsBack(["#/aide", "#/glossaire/blot"], help), null);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
|
||||
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
|
||||
// installer passent par les modules de l'interface qui les décident
|
||||
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
|
||||
// vrai moteur, le tutoriel par son texte source.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
dismissInstall,
|
||||
homeModel,
|
||||
installMenuShown,
|
||||
launchHash,
|
||||
WELCOME_LINKS,
|
||||
welcomed,
|
||||
} from "../../src/ui/home-model.js";
|
||||
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
|
||||
import { installPlatform } from "../../src/ui/install.js";
|
||||
import { parseRoute } from "../../src/ui/router.js";
|
||||
import { parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { placeOf } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { seededDice, step } from "../support/play.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let game: Game | null = null;
|
||||
let installed = false;
|
||||
let route = "";
|
||||
let installSteps: readonly string[] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Ce que montre l'accueil, la partie étant celle du scénario. */
|
||||
function home() {
|
||||
const playing = game === null ? false : game.state.phase !== "over";
|
||||
return homeModel(settings, playing, installed);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Premier lancement
|
||||
|
||||
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
game = null;
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
|
||||
assert.equal(launchHash(""), "#/accueil");
|
||||
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
|
||||
assert.ok(home().welcome);
|
||||
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
|
||||
});
|
||||
|
||||
When("vous choisissez de découvrir les règles", () => {
|
||||
settings = { ...settings, ...welcomed() };
|
||||
route = WELCOME_LINKS.discover;
|
||||
});
|
||||
|
||||
Then("le tutoriel s'ouvre à sa première étape", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
|
||||
});
|
||||
|
||||
Then("ce choix ne vous est plus proposé", () => {
|
||||
assert.ok(!home().welcome);
|
||||
});
|
||||
|
||||
Given("vous avez choisi de jouer tout de suite", () => {
|
||||
settings = { ...parseSettings(null), ...welcomed() };
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Given("une partie est en cours", () => {
|
||||
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
|
||||
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
|
||||
});
|
||||
|
||||
When("vous rouvrez Ustagammon", () => {
|
||||
route = launchHash("") ?? "";
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de reprendre votre partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(home().resume);
|
||||
});
|
||||
|
||||
When("la partie se termine", async () => {
|
||||
assert.ok(game !== null);
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
assert.ok(i < 2000, "la partie ne finit pas");
|
||||
await step(game, engine);
|
||||
}
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose une nouvelle partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(!home().resume);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Installation
|
||||
|
||||
Given("votre navigateur ne propose pas lui-même l'installation", () => {
|
||||
installSteps = [];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application avec Firefox", () => {
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application sur iPhone", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /menu du navigateur/);
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /bouton de partage/);
|
||||
});
|
||||
|
||||
Then("l'accueil ne vous propose pas d'installer l'application", () => {
|
||||
assert.ok(!home().installCard);
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose d'installer l'application", () => {
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous terminez une partie", () => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
|
||||
});
|
||||
|
||||
When("vous terminez {int} parties de plus", (n: number) => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
|
||||
});
|
||||
|
||||
When("vous choisissez « Plus tard »", () => {
|
||||
settings = { ...settings, ...dismissInstall(settings) };
|
||||
});
|
||||
|
||||
Then("le menu vous le propose toujours", () => {
|
||||
assert.ok(installMenuShown(settings, installed));
|
||||
});
|
||||
|
||||
Given("vous avez terminé une partie", () => {
|
||||
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
|
||||
installed = false;
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous installez l'application", () => {
|
||||
// L'événement appinstalled du navigateur, retenu dans les réglages.
|
||||
installed = true;
|
||||
settings = { ...settings, installed: true };
|
||||
});
|
||||
|
||||
Then("le menu non plus", () => {
|
||||
assert.ok(!installMenuShown(settings, installed));
|
||||
// Même dans un onglet ouvert plus tard, hors de l'application installée.
|
||||
assert.ok(!installMenuShown(settings, false));
|
||||
assert.ok(!homeModel(settings, true, false).installCard);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
|
||||
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
|
||||
// chaque geste est jugé par le domaine branché sur le vrai moteur, comme
|
||||
// dans l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import {
|
||||
MovePractice,
|
||||
type PracticeVerdict,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../../src/domain/learning/tutorial.js";
|
||||
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let tutorial: Tutorial;
|
||||
let seen = 0;
|
||||
let place: TutorialPlace = { step: 1, practice: false };
|
||||
let screen: TutorialScreen;
|
||||
let practice: MovePractice | null = null;
|
||||
let verdict: PracticeVerdict = "continue";
|
||||
let answeredRight = false;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
});
|
||||
|
||||
Given("vous commencez le tutoriel", () => {
|
||||
seen = 0;
|
||||
place = { step: 1, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
});
|
||||
|
||||
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
|
||||
});
|
||||
|
||||
Then("chaque position du tutoriel se lit sur le plateau", async () => {
|
||||
for (const step of tutorial.steps) {
|
||||
for (const current of [step.explain, step.practice]) {
|
||||
if (current !== null) {
|
||||
const board = await tutorialBoard(engine, current.position, current.onRoll);
|
||||
assert.equal(board.sides.player.points.length, 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
When("vous arrivez à l'étape {int}", (n: number) => {
|
||||
for (let step = 1; step <= n; step++) {
|
||||
place = { step, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
|
||||
const links = progress(tutorial.steps.length, place, seen)
|
||||
.filter((segment) => segment.link)
|
||||
.map((segment) => segment.step);
|
||||
assert.deepEqual(links, [1, 2]);
|
||||
});
|
||||
|
||||
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
|
||||
const found = tutorial.steps[n - 1]?.practice;
|
||||
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
|
||||
screen = found;
|
||||
practice =
|
||||
screen.roll === null || screen.expected.length === 0
|
||||
? null
|
||||
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
|
||||
verdict = "continue";
|
||||
answeredRight = false;
|
||||
});
|
||||
|
||||
When(
|
||||
"vous déplacez un pion de la flèche {int} à la flèche {int}",
|
||||
async (from: number, to: number) => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from, to });
|
||||
if (verdict === "wrong") {
|
||||
practice.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Then("le pion revient, et une phrase vous dit pourquoi", () => {
|
||||
assert.equal(verdict, "wrong");
|
||||
assert.deepEqual(practice?.pendingHalfMoves, []);
|
||||
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
|
||||
});
|
||||
|
||||
When("vous réunissez deux pions sur la flèche 5", async () => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from: 8, to: 5 });
|
||||
assert.equal(verdict, "continue");
|
||||
verdict = await practice.play({ from: 6, to: 5 });
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre coup", () => {
|
||||
assert.equal(verdict, "right");
|
||||
assert.ok(screen.right.length > 0);
|
||||
});
|
||||
|
||||
When("vous répondez « Passer »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Prendre »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === true;
|
||||
});
|
||||
|
||||
Then("le maître dit qu'il aurait pris", () => {
|
||||
assert.ok(!answeredRight);
|
||||
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre réponse", () => {
|
||||
assert.ok(answeredRight);
|
||||
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
|
||||
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
|
||||
// (settings.ts) et la session d'enregistrement ; les parties et le match,
|
||||
// par le domaine branché sur le vrai moteur, avec des dés imposés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
|
||||
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
|
||||
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
|
||||
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
|
||||
// 6-5, le joueur au trait gagne un gammon.
|
||||
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
|
||||
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
|
||||
// gagne une partie simple avec 6-5.
|
||||
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let session: GameSession;
|
||||
let game: Game;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
|
||||
async function playFirstMove(g: Game): Promise<void> {
|
||||
await g.roll();
|
||||
const state = g.state;
|
||||
assert.ok(state.roll !== null);
|
||||
const [move] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(move !== undefined);
|
||||
for (const halfMove of move.halfMoves) {
|
||||
assert.ok(await g.playHalfMove(halfMove));
|
||||
}
|
||||
await g.confirmMove();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Le niveau
|
||||
|
||||
Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), {
|
||||
start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
});
|
||||
|
||||
Then(
|
||||
"une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée",
|
||||
() => {
|
||||
assert.equal(settings.gameKind, "free");
|
||||
assert.equal(settings.level, "intermediate");
|
||||
assert.equal(settings.masterAdvice, true);
|
||||
assert.deepEqual(chosenGame(settings), { level: "intermediate" });
|
||||
},
|
||||
);
|
||||
|
||||
When("vous choisissez le niveau Champion et commencez la partie", () => {
|
||||
settings = { ...settings, level: "champion" };
|
||||
game = session.newGame(chosenGame(settings));
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => {
|
||||
assert.equal(game.state.level, `${plies}ply`);
|
||||
// Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue
|
||||
// le coup que le moteur donne pour ce niveau.
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const before = game.state;
|
||||
await game.playComputerTurn();
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
assert.ok(played?.type === "MovePlayed" && played.side === "computer");
|
||||
const roll = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
assert.ok(roll?.type === "DiceRolled");
|
||||
const expected = await engine.levelMove(
|
||||
before.position,
|
||||
engineCube(before, "computer"),
|
||||
roll.roll,
|
||||
"2ply",
|
||||
);
|
||||
assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []);
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("la partie reprise garde ce niveau", async () => {
|
||||
const resumed = await session.restore();
|
||||
assert.ok(resumed !== null);
|
||||
assert.equal(resumed.state.level, "2ply");
|
||||
});
|
||||
|
||||
Then("vos choix sont proposés à la prochaine nouvelle partie", () => {
|
||||
const again = parseSettings(JSON.stringify(settings));
|
||||
assert.equal(again.level, "champion");
|
||||
assert.equal(chosenGame(again).level, "2ply");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Un match entier
|
||||
|
||||
// Les parties du match, dans l'ordre.
|
||||
let games: Game[] = [];
|
||||
|
||||
Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => {
|
||||
settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" };
|
||||
games = [];
|
||||
});
|
||||
|
||||
When("le match se joue jusqu'au bout, partie après partie", async () => {
|
||||
// Des dés reproductibles, partagés par toutes les parties du match.
|
||||
const dice = seededDice(3);
|
||||
let next: NewGame | null = chosenGame(settings);
|
||||
while (next !== null) {
|
||||
assert.ok(games.length < 20, "le match ne finit pas");
|
||||
const g = Game.start(engine, { dice, ...next });
|
||||
await playToTheEnd(g, engine);
|
||||
games.push(g);
|
||||
next = g.nextGame;
|
||||
}
|
||||
});
|
||||
|
||||
Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => {
|
||||
const last = games.at(-1)?.state;
|
||||
assert.ok(last?.matchAfter);
|
||||
assert.equal(last.matchAfter.over, true);
|
||||
const { player, computer } = last.matchAfter.score;
|
||||
assert.ok(Math.max(player, computer) >= n);
|
||||
// Avant la dernière partie, personne n'avait encore les points.
|
||||
for (const g of games.slice(0, -1)) {
|
||||
assert.equal(g.state.matchAfter?.over, false);
|
||||
const score = g.state.matchAfter?.score;
|
||||
assert.ok(score !== undefined && Math.max(score.player, score.computer) < n);
|
||||
}
|
||||
});
|
||||
|
||||
Then("chaque partie commence avec le score laissé par la précédente", () => {
|
||||
assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 });
|
||||
for (let i = 1; i < games.length; i++) {
|
||||
assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la partie Crawford s'est jouée sans videau", () => {
|
||||
const crawford = games.filter((g) => g.state.match?.crawford === true);
|
||||
assert.equal(crawford.length, 1, "une partie Crawford, et une seule");
|
||||
for (const g of crawford) {
|
||||
assert.ok(!g.events.some((e) => e.type === "CubeOffered"));
|
||||
assert.equal(g.state.cube.value, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Rejouer
|
||||
|
||||
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
|
||||
function engineLevelNamed(name: string) {
|
||||
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
|
||||
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
|
||||
return ENGINE_LEVELS[level];
|
||||
}
|
||||
|
||||
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.phase, "over");
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
Given(
|
||||
"un match en {int} points contre un ordinateur {word} vient de se terminer",
|
||||
async (n: number, name: string) => {
|
||||
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
|
||||
// fini.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.matchAfter?.over, true);
|
||||
assert.equal(game.nextGame, null);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous rejouez", () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
|
||||
});
|
||||
|
||||
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
});
|
||||
|
||||
Then(
|
||||
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
|
||||
(n: number, name: string) => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.deepEqual(game.state.match, firstGameOf(n));
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- La partie Crawford
|
||||
|
||||
let crawfordGame: Game;
|
||||
|
||||
Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" },
|
||||
level: "beginner",
|
||||
match: firstGameOf(3),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur gagne la partie", async () => {
|
||||
await game.playComputerTurn();
|
||||
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
|
||||
});
|
||||
|
||||
Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => {
|
||||
assert.deepEqual(game.state.matchAfter?.score, { player, computer });
|
||||
});
|
||||
|
||||
Then("la partie suivante est la partie Crawford", () => {
|
||||
assert.equal(game.nextGame?.match?.crawford, true);
|
||||
});
|
||||
|
||||
When("la partie Crawford commence", () => {
|
||||
const next = game.nextGame;
|
||||
assert.ok(next?.match !== undefined);
|
||||
crawfordGame = Game.start(engine, {
|
||||
...next,
|
||||
setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
Then("personne ne peut doubler pendant cette partie", async () => {
|
||||
assert.equal(await crawfordGame.canDouble(), false);
|
||||
// L'ordinateur non plus, dans la même position vue de lui.
|
||||
const state = crawfordGame.state;
|
||||
const board = await engine.board(state.position, engineCube(state, "player"));
|
||||
assert.equal(
|
||||
await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
When("vous gagnez la partie Crawford", async () => {
|
||||
await playFirstMove(crawfordGame);
|
||||
assert.deepEqual(crawfordGame.state.matchAfter, {
|
||||
score: { player: 1, computer: 2 },
|
||||
over: false,
|
||||
crawford: false,
|
||||
});
|
||||
});
|
||||
|
||||
Then("vous pouvez de nouveau doubler dans la partie suivante", async () => {
|
||||
const next = crawfordGame.nextGame;
|
||||
assert.ok(next?.match !== undefined);
|
||||
const match: Match = next.match;
|
||||
assert.equal(match.crawford, false);
|
||||
const after = Game.start(engine, {
|
||||
...next,
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
});
|
||||
assert.equal(await after.canDouble(), true);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- La règle Jacoby
|
||||
|
||||
Given("vous allez gagner un gammon sans que personne ait doublé", () => {
|
||||
// La position est choisie à chaque partie, ci-dessous.
|
||||
});
|
||||
|
||||
When("vous gagnez une partie libre", async () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
});
|
||||
|
||||
When("vous gagnez la même partie dans un match", async () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
match: firstGameOf(5),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
});
|
||||
|
||||
Then("vous marquez {int} point(s)", (points: number) => {
|
||||
assert.equal(game.state.result?.winner, "player");
|
||||
assert.equal(game.state.result?.kind, "gammon");
|
||||
assert.equal(game.state.result?.points, points);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { boardModel, pipLead } from "../../src/ui/board-model.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
|
||||
@@ -80,6 +81,15 @@ async function play(text: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Joue les demi-coups puis valide le coup, complet. */
|
||||
async function playAndConfirm(text: string): Promise<boolean> {
|
||||
if (!(await play(text))) {
|
||||
return false;
|
||||
}
|
||||
await game.confirmMove();
|
||||
return true;
|
||||
}
|
||||
|
||||
function lastEvent(): GameEvent {
|
||||
const event = game.events.at(-1);
|
||||
assert.ok(event !== undefined, "aucun événement");
|
||||
@@ -160,12 +170,62 @@ Then("la position n'a pas changé", () => {
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
// ------------------------------------------ Valider ou reprendre son coup
|
||||
|
||||
Then("votre coup attend votre validation", async () => {
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(game.moveComplete, true);
|
||||
// Rien n'est encore dans le journal, et plus aucun pion ne bouge.
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("vos dés sont tous marqués comme joués", () => {
|
||||
assert.ok(game.diceToPlay.length > 0);
|
||||
assert.ok(game.diceToPlay.every((die) => die.used));
|
||||
});
|
||||
|
||||
When("vous validez votre coup", async () => {
|
||||
await game.confirmMove();
|
||||
});
|
||||
|
||||
Then("seul votre {int} est marqué comme joué", (value: number) => {
|
||||
const used = game.diceToPlay.filter((die) => die.used);
|
||||
assert.deepEqual(
|
||||
used.map((die) => die.value),
|
||||
[value],
|
||||
);
|
||||
});
|
||||
|
||||
Then("chacun de vos pions jouables n'a plus qu'une case d'arrivée", async () => {
|
||||
const halfMoves = await game.nextHalfMoves();
|
||||
assert.ok(halfMoves.length > 0);
|
||||
const starts = halfMoves.map((h) => h.from);
|
||||
assert.equal(new Set(starts).size, starts.length);
|
||||
});
|
||||
|
||||
When("vous annulez le dernier demi-coup", () => {
|
||||
game.undoLastHalfMove();
|
||||
});
|
||||
|
||||
Then("il ne reste de joué que {word}", (text: string) => {
|
||||
assert.deepEqual(game.pendingHalfMoves, parseHalfMoves(text));
|
||||
assert.equal(game.moveComplete, false);
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
Then("aucun de vos dés n'est marqué comme joué", () => {
|
||||
assert.ok(game.diceToPlay.length > 0);
|
||||
assert.ok(game.diceToPlay.every((die) => !die.used));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- L'ordinateur joue
|
||||
|
||||
Given("c'est au tour de l'ordinateur", async () => {
|
||||
// Vous commencez avec 3-1, puis l'ordinateur tirera 6-4.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
|
||||
assert.equal(await play("8/5 6/5"), true);
|
||||
assert.equal(await playAndConfirm("8/5 6/5"), true);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
@@ -224,7 +284,7 @@ Then("c'est toujours à vous", () => {
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
When("vous laissez passer votre tour", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
@@ -285,8 +345,75 @@ Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
// Les bandeaux lisent le compte de points du plateau donné par le moteur ;
|
||||
// qui mène se calcule dans l'interface (board-model.ts).
|
||||
|
||||
async function pips() {
|
||||
return boardModel(await game.displayedBoard()).pipCount;
|
||||
}
|
||||
|
||||
Then("chaque camp a un compte de points de {int}", async (n: number) => {
|
||||
assert.deepEqual(await pips(), { player: n, computer: n });
|
||||
});
|
||||
|
||||
Then("personne ne mène la course", async () => {
|
||||
assert.equal(pipLead(await pips()).leader, null);
|
||||
});
|
||||
|
||||
Then("vous menez la course de {int}", async (n: number) => {
|
||||
assert.deepEqual(pipLead(await pips()), { leader: "player", by: n });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
// La confirmation elle-même est une question de l'interface (une fenêtre
|
||||
// « Doubler à 2 ? ») : ces étapes vérifient que rien n'est proposé tant que
|
||||
// vous n'avez pas confirmé, et ce qui suit votre confirmation.
|
||||
|
||||
Given("vous pouvez doubler", async () => {
|
||||
game = Game.start(engine, { setup: { position: START_POSITION, firstTurn: "player" } });
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
When("vous demandez à doubler", async () => {
|
||||
assert.equal(await game.canDouble(), true);
|
||||
});
|
||||
|
||||
When("vous changez d'avis", () => {
|
||||
// Vous refermez la question : la partie n'est pas touchée.
|
||||
});
|
||||
|
||||
Then("rien n'est proposé à l'ordinateur", () => {
|
||||
assert.ok(!game.events.some((e) => e.type === "CubeOffered"));
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(game.state.cube, { value: 1, owner: null });
|
||||
});
|
||||
|
||||
When("vous confirmez", async () => {
|
||||
await game.double();
|
||||
});
|
||||
|
||||
Then("l'ordinateur prend ou passe, comme le recommande le moteur", async () => {
|
||||
const decision = await engine.cubeDecision(
|
||||
START_POSITION,
|
||||
{ value: 1, owner: "centered" },
|
||||
STANDARD_PLY,
|
||||
);
|
||||
const offered = game.events.findIndex((e) => e.type === "CubeOffered");
|
||||
const answer = game.events[offered + 1];
|
||||
assert.ok(offered >= 0 && answer !== undefined);
|
||||
if (decision.take) {
|
||||
assert.equal(answer.type, "CubeTaken");
|
||||
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
|
||||
} else {
|
||||
assert.equal(answer.type, "CubeDropped");
|
||||
assert.deepEqual(game.state.result, { winner: "player", kind: "single", points: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
Given("l'ordinateur vous propose de doubler", async () => {
|
||||
game = Game.start(engine, { setup: { position: COMPUTER_DOUBLES, firstTurn: "computer" } });
|
||||
await game.playComputerTurn();
|
||||
@@ -299,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", () => {
|
||||
@@ -346,7 +473,7 @@ Given("un joueur a sorti tous ses pions", async () => {
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await play("6/off 5/off"), true);
|
||||
assert.equal(await playAndConfirm("6/off 5/off"), true);
|
||||
});
|
||||
|
||||
Then("le résultat indique le gagnant et les points gagnés", () => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles
|
||||
// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le
|
||||
// stockage des parties sur un localStorage simulé en mémoire. Fermer puis
|
||||
// rouvrir l'application revient à rouvrir ce stockage et à reprendre la
|
||||
// partie en cours, comme main.ts au démarrage.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import type { GameLibrary } from "../../src/storage/game-library.js";
|
||||
import type { KeyValueStorage } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, seededDice } from "../support/play.js";
|
||||
|
||||
let engine: Engine;
|
||||
let storage: KeyValueStorage;
|
||||
let library: GameLibrary;
|
||||
let game: Game;
|
||||
let shownId: string;
|
||||
/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */
|
||||
let played: { id: string; journal: GameJournal }[];
|
||||
/** La partie d'origine, avant « Rejouer d'ici ». */
|
||||
let original: { head: string; ids: string[] };
|
||||
/** Le coup du joueur d'où l'on rejoue. */
|
||||
let replayed: MovePlayed;
|
||||
let sgf: string;
|
||||
let nextSeed = 1;
|
||||
|
||||
Before(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
storage = memoryStorage();
|
||||
library = (await openGameLibrary({ localStorage: storage })).library;
|
||||
played = [];
|
||||
});
|
||||
|
||||
/** Commence une partie, comme l'application : un nouvel identifiant. */
|
||||
function startGame(): void {
|
||||
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
|
||||
shownId = `partie-${nextSeed++}`;
|
||||
}
|
||||
|
||||
/** Joue des coups, en enregistrant après chacun comme l'application. */
|
||||
async function play(moves: number): Promise<void> {
|
||||
for (let i = 0; i < moves; i++) {
|
||||
await playMoves(game, engine, 1);
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
}
|
||||
}
|
||||
|
||||
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, { level: "0ply" });
|
||||
shownId = saved.summary.id;
|
||||
}
|
||||
|
||||
/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses
|
||||
* dés déjà lancés). Renvoie ce coup. */
|
||||
function goBackToSecondMove(): MovePlayed {
|
||||
original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) };
|
||||
const second = game.events.filter(
|
||||
(e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player",
|
||||
)[1];
|
||||
assert.ok(second?.parent != null, "moins de deux coups du joueur");
|
||||
game.goBackTo(second.parent);
|
||||
return second;
|
||||
}
|
||||
|
||||
/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */
|
||||
async function playAnotherMove(first: MovePlayed): Promise<void> {
|
||||
const state = game.state;
|
||||
assert.ok(state.roll !== null);
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
// Move.position est vue de celui qui joue, MovePlayed.position de son
|
||||
// adversaire : on compare les demi-coups.
|
||||
const same = (m: (typeof moves)[number]) =>
|
||||
m.halfMoves.length === first.halfMoves.length &&
|
||||
m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from);
|
||||
const move = moves.find((m) => !same(m));
|
||||
assert.ok(move !== undefined, "un seul coup possible");
|
||||
for (const halfMove of move.halfMoves) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed" && last.position !== first.position);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Reprendre une partie parmi plusieurs
|
||||
|
||||
Given("vous avez joué deux parties, l'une après l'autre", async () => {
|
||||
for (const moves of [5, 3]) {
|
||||
startGame();
|
||||
await play(moves);
|
||||
played.push({ id: shownId, journal: game.journal });
|
||||
}
|
||||
});
|
||||
|
||||
When("vous fermez l'application puis la rouvrez", reopen);
|
||||
|
||||
Then("la dernière partie jouée est à l'écran", () => {
|
||||
const last = played.at(-1);
|
||||
assert.equal(shownId, last?.id);
|
||||
assert.deepEqual(game.journal, last?.journal);
|
||||
});
|
||||
|
||||
Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => {
|
||||
const ids = (await library.list()).map((s) => s.id);
|
||||
assert.deepEqual(ids, played.map((p) => p.id).reverse());
|
||||
});
|
||||
|
||||
When("vous reprenez la première partie", async () => {
|
||||
await library.setCurrent(played[0]?.id as string);
|
||||
await reopen();
|
||||
});
|
||||
|
||||
Then("elle est à l'écran, telle que vous l'aviez laissée", () => {
|
||||
assert.equal(shownId, played[0]?.id);
|
||||
assert.deepEqual(game.journal, played[0]?.journal);
|
||||
});
|
||||
|
||||
// ------------------------------------------- Rejouer d'ici sans perdre l'original
|
||||
|
||||
Given("vous avez joué plusieurs coups", async () => {
|
||||
startGame();
|
||||
await play(8);
|
||||
});
|
||||
|
||||
When("vous rejouez d'ici à partir de votre deuxième coup", () => {
|
||||
// Le retour seul : le coup suivant ouvrira la branche.
|
||||
replayed = goBackToSecondMove();
|
||||
});
|
||||
|
||||
When("vous choisissez un autre coup", async () => {
|
||||
await playAnotherMove(replayed);
|
||||
});
|
||||
|
||||
Then("la partie a deux branches", () => {
|
||||
assert.equal(game.branches.length, 2);
|
||||
});
|
||||
|
||||
Then("la partie d'origine est intacte", () => {
|
||||
const origin = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.equal(origin?.head, original.head);
|
||||
const copy = Game.resume(engine, game.journal);
|
||||
copy.switchBranch(original.head);
|
||||
assert.deepEqual(
|
||||
copy.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
Then("vous êtes sur la nouvelle branche", () => {
|
||||
const current = game.branches.find((b) => b.containsHead);
|
||||
assert.ok(current !== undefined && current.forkedFrom !== null);
|
||||
assert.equal(current.head, game.journal.head);
|
||||
});
|
||||
|
||||
Then("vous pouvez revenir à la partie d'origine, au coup près", () => {
|
||||
const branch = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.ok(branch !== undefined);
|
||||
game.switchBranch(branch.head);
|
||||
assert.deepEqual(
|
||||
game.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Exporter une partie
|
||||
|
||||
Given("vous avez rejoué d'ici une autre suite", async () => {
|
||||
await playAnotherMove(goBackToSecondMove());
|
||||
await play(2);
|
||||
});
|
||||
|
||||
When("vous exportez la partie", async () => {
|
||||
sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
});
|
||||
});
|
||||
|
||||
Then("vous obtenez un fichier SGF de GNU Backgammon", () => {
|
||||
assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40));
|
||||
assert.ok(sgf.trimEnd().endsWith(")"));
|
||||
});
|
||||
|
||||
Then("il contient vos coups, et l'autre branche en variante", () => {
|
||||
// Un nœud par coup joué, toutes branches comprises (B ou W, puis les
|
||||
// dés), et deux variantes à l'embranchement.
|
||||
const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length;
|
||||
assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves);
|
||||
assert.equal(sgf.match(/\(\n;/g)?.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008,
|
||||
// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite
|
||||
// puis relue), la position par le domaine et le vrai moteur WebAssembly, et
|
||||
// les parties par le même enregistrement que l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
linkOf,
|
||||
readPosition,
|
||||
type SharedPosition,
|
||||
setupOf,
|
||||
UnreadableLinkError,
|
||||
} from "../../src/domain/game/position-link.js";
|
||||
import { createGameLibrary, type GameLibrary } 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 { parseRoute, routeHash } from "../../src/ui/router.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, seededDice, step } from "../support/play.js";
|
||||
|
||||
let engine: Engine;
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
let game: Game;
|
||||
let gameId: string | null;
|
||||
let journalBefore: GameJournal;
|
||||
let address: string;
|
||||
let shared: SharedPosition | null;
|
||||
let failure: unknown;
|
||||
|
||||
/** Une position de milieu de partie (gnubg), vue du joueur au trait. */
|
||||
const MIDDLE_GAME = "jGfkASjg8wcBMA";
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Before(() => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
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;
|
||||
});
|
||||
|
||||
/** Ouvre une adresse comme l'application : le routeur la lit, le moteur
|
||||
* lit la position. */
|
||||
async function open(hash: string): Promise<void> {
|
||||
const route = parseRoute(hash);
|
||||
assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`);
|
||||
if (route.screen !== "position") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
shared = await readPosition(engine, { position: route.position, matchId: route.match });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
|
||||
Given("votre partie en cours compte quelques coups", async () => {
|
||||
game = session.newGame();
|
||||
// Des coups jusqu'à ce que ce soit à vous de lancer les dés.
|
||||
await playMoves(game, engine, 4);
|
||||
const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling";
|
||||
while (!yourRoll() && game.state.phase !== "over") {
|
||||
await step(game, engine);
|
||||
}
|
||||
assert.ok(yourRoll(), "partie finie trop tôt");
|
||||
session.save(game.journal);
|
||||
gameId = session.shownId;
|
||||
journalBefore = game.journal;
|
||||
});
|
||||
|
||||
When("vous copiez le lien de la position", async () => {
|
||||
const link = await linkOf(engine, game.state);
|
||||
address = routeHash({ screen: "position", position: link.position, match: link.matchId });
|
||||
});
|
||||
|
||||
Then("le lien contient l'identifiant de la position et celui du match", () => {
|
||||
const route = parseRoute(address);
|
||||
assert.ok(route.screen === "position");
|
||||
assert.equal(route.position, game.state.position);
|
||||
assert.equal(route.match?.length, 12);
|
||||
assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/);
|
||||
});
|
||||
|
||||
When("vous ouvrez ce lien", async () => {
|
||||
await open(address);
|
||||
});
|
||||
|
||||
Then("vous voyez exactement la même position, avec le même videau", async () => {
|
||||
assert.ok(shared !== null, `lien illisible : ${failure}`);
|
||||
assert.deepEqual(shared.sides, await game.displayedBoard());
|
||||
assert.deepEqual(shared.cube, game.state.cube);
|
||||
assert.equal(shared.match, null);
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => {
|
||||
const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" });
|
||||
address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId });
|
||||
await open(address);
|
||||
});
|
||||
|
||||
Then("vous voyez cette position, avec la main", async () => {
|
||||
assert.ok(shared !== null, `lien illisible : ${failure}`);
|
||||
const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" });
|
||||
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
|
||||
assert.equal(shared.result, null);
|
||||
});
|
||||
|
||||
Then("votre partie n'a pas changé", () => {
|
||||
assert.equal(session.shownId, gameId);
|
||||
assert.deepEqual(game.journal, journalBefore);
|
||||
});
|
||||
|
||||
When("vous choisissez « Jouer à partir d'ici »", () => {
|
||||
assert.ok(shared !== null);
|
||||
game = session.newGame({ setup: setupOf(shared) });
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => {
|
||||
assert.notEqual(session.shownId, gameId);
|
||||
assert.equal(game.state.position, MIDDLE_GAME);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
Then("le videau est à 2, chez l'ordinateur", () => {
|
||||
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
|
||||
});
|
||||
|
||||
Then("votre partie précédente est toujours enregistrée", async () => {
|
||||
assert.ok(gameId !== null);
|
||||
const saved = await library.load(gameId);
|
||||
assert.deepEqual(saved?.journal, journalBefore);
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => {
|
||||
// Coupé d'un caractère, puis un caractère hors du base64.
|
||||
shared = null;
|
||||
failure = null;
|
||||
await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA");
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => {
|
||||
shared = null;
|
||||
failure = null;
|
||||
await open("#/position/4HPwATDgc%2FABMA/cAk*");
|
||||
});
|
||||
|
||||
Then("le lien est déclaré illisible", () => {
|
||||
assert.equal(shared, null);
|
||||
assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
|
||||
// la partie reprise passe par le domaine et le vrai moteur, et par le même
|
||||
// enregistrement que l'application ; les réglages et les vibrations par les
|
||||
// modules de l'interface qui les portent (settings.ts, haptics.ts).
|
||||
//
|
||||
// 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 type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameLibrary, type GameLibrary } 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 { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let oldJournal: GameJournal;
|
||||
let oldBoard: unknown;
|
||||
let settings: Settings;
|
||||
let vibrations: number[][] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
// -------------------------------------------------- Nouvelle partie annulable
|
||||
|
||||
// Comme dans l'application : chaque partie est enregistrée par la session
|
||||
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
|
||||
// stockage par son identifiant.
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
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 }),
|
||||
});
|
||||
game = session.newGame();
|
||||
dice = () => 1 + Math.floor(Math.random() * 6);
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
session.save(game.journal);
|
||||
oldId = session.shownId;
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = session.newGame();
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence aussitôt", () => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
|
||||
});
|
||||
|
||||
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
|
||||
assert.equal(BANNER_MS, seconds * 1000);
|
||||
});
|
||||
|
||||
When("vous reprenez l'ancienne partie", async () => {
|
||||
const old = await session.resumePrevious();
|
||||
assert.ok(old !== null);
|
||||
game = old;
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.equal(session.shownId, oldId);
|
||||
assert.deepEqual((await library.current())?.journal, oldJournal);
|
||||
// La nouvelle partie, abandonnée, est supprimée.
|
||||
assert.deepEqual(
|
||||
(await library.list()).map((s) => s.id),
|
||||
[oldId],
|
||||
);
|
||||
assert.deepEqual(game.journal, oldJournal);
|
||||
assert.deepEqual(await game.displayedBoard(), oldBoard);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- Réglages
|
||||
|
||||
Given("vous ouvrez l'application pour la première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
vibrations = [];
|
||||
});
|
||||
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
vibrations.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => settings.vibrations,
|
||||
);
|
||||
|
||||
When("un pion est frappé", () => {
|
||||
vibrations = [];
|
||||
haptic("checkerHit");
|
||||
});
|
||||
|
||||
Then("le téléphone vibre deux fois, brièvement", () => {
|
||||
assert.deepEqual(vibrations, [[25, 40, 25]]);
|
||||
});
|
||||
|
||||
When("vous coupez les vibrations dans le menu", () => {
|
||||
settings = { ...settings, vibrations: false };
|
||||
});
|
||||
|
||||
Then("le téléphone ne vibre pas", () => {
|
||||
assert.deepEqual(vibrations, []);
|
||||
});
|
||||
|
||||
Then("l'écran reste allumé pendant une partie", () => {
|
||||
assert.equal(settings.keepScreenOn, true);
|
||||
});
|
||||
|
||||
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
|
||||
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
|
||||
});
|
||||
|
||||
Then("l'écran peut se mettre en veille comme d'habitude", () => {
|
||||
assert.equal(settings.keepScreenOn, false);
|
||||
});
|
||||
|
||||
Then("le thème suit celui du téléphone", () => {
|
||||
assert.equal(settings.theme, "auto");
|
||||
});
|
||||
|
||||
When("vous changez de thème dans le menu", () => {
|
||||
// Rien : l'étape suivante fait le tour des thèmes.
|
||||
});
|
||||
|
||||
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
const seen = [settings.theme];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
seen.push(nextTheme(seen.at(-1) ?? "auto"));
|
||||
}
|
||||
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Mise à jour
|
||||
|
||||
// Le bandeau dépend de deux choses : une version attend (le service worker
|
||||
// l'a installée), et la partie est entre deux tours. La partie est celle du
|
||||
// domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
const shownNow = (otherBanner = false) =>
|
||||
updateBannerShown(
|
||||
updateAvailable,
|
||||
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
|
||||
otherBanner,
|
||||
);
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
When("vous déplacez un pion de votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!shownNow());
|
||||
});
|
||||
|
||||
When("vous avez joué votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
|
||||
await game.confirmMove();
|
||||
});
|
||||
|
||||
Then("l'application vous propose de mettre à jour", () => {
|
||||
assert.ok(shownNow());
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!shownNow(true));
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
// Tests du stockage des parties (src/storage/), sans navigateur : la
|
||||
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
|
||||
// de localStorage), la migration de la partie des versions précédentes et
|
||||
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
|
||||
// navigateur (tests/browser/play.mjs).
|
||||
//
|
||||
// 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 { branches } from "../src/domain/game/branches.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
|
||||
import { createGameSession } from "../src/storage/game-session.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
|
||||
|
||||
/** Une partie commencée, et éventuellement quelques événements de plus. */
|
||||
function journal(extraRolls = 0): GameJournal {
|
||||
let j = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
for (let i = 0; i < extraRolls; i++) {
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Une horloge qui avance d'une seconde à chaque lecture. */
|
||||
function clock(start = 1_000_000): () => number {
|
||||
let t = start;
|
||||
return () => {
|
||||
t += 1000;
|
||||
return t;
|
||||
};
|
||||
}
|
||||
|
||||
function library(storage: KeyValueStorage = memoryStorage()) {
|
||||
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
|
||||
}
|
||||
|
||||
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
|
||||
const games = library();
|
||||
assert.deepEqual(await games.list(), []);
|
||||
assert.equal(await games.current(), null);
|
||||
|
||||
const a = await games.save("a", journal(), { level: "2ply" });
|
||||
await games.save("b", journal(1), { level: "beginner" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b", "a"],
|
||||
);
|
||||
assert.equal(a.status, "playing");
|
||||
assert.equal(a.result, null);
|
||||
assert.equal(a.moveCount, 0);
|
||||
assert.equal(a.branchCount, 1);
|
||||
|
||||
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
|
||||
const again = await games.save("a", journal(2), { level: "2ply" });
|
||||
assert.equal(again.startedAt, a.startedAt);
|
||||
assert.ok(again.updatedAt > a.updatedAt);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.deepEqual((await games.load("a"))?.journal, journal(2));
|
||||
assert.equal((await games.load("b"))?.summary.level, "beginner");
|
||||
assert.equal(await games.load("inconnue"), null);
|
||||
});
|
||||
|
||||
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
|
||||
const games = library();
|
||||
await games.save("ancienne", journal(), { level: "2ply" });
|
||||
await games.save("nouvelle", journal(1), { level: "2ply" });
|
||||
assert.equal((await games.current())?.summary.id, "nouvelle");
|
||||
|
||||
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
|
||||
// être modifiée ; la nouvelle peut être supprimée.
|
||||
const before = await games.load("ancienne");
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("nouvelle");
|
||||
assert.deepEqual(await games.current(), before);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["ancienne"],
|
||||
);
|
||||
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
|
||||
|
||||
// La partie en cours supprimée : la dernière jouée la remplace.
|
||||
await games.save("autre", journal(), { level: "2ply" });
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("ancienne");
|
||||
assert.equal((await games.current())?.summary.id, "autre");
|
||||
});
|
||||
|
||||
test("les opérations sont faites dans l'ordre des appels", async () => {
|
||||
const games = library();
|
||||
// Lancées sans attendre, comme l'enregistrement après chaque action.
|
||||
void games.save("a", journal(), { level: "2ply" });
|
||||
void games.save("a", journal(3), { level: "2ply" });
|
||||
void games.remove("a");
|
||||
void games.save("b", journal(), { level: "2ply" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("les branches et le résultat sont résumés", async () => {
|
||||
const games = library();
|
||||
let j = journal(2);
|
||||
j = goBackTo(j, "2");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
|
||||
j = appendEvent(j, {
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [],
|
||||
position: START_POSITION,
|
||||
});
|
||||
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
|
||||
const summary = await games.save("x", j, { level: "2ply" });
|
||||
assert.equal(summary.branchCount, 2);
|
||||
assert.equal(summary.moveCount, 1);
|
||||
assert.equal(summary.status, "over");
|
||||
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
|
||||
});
|
||||
|
||||
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
|
||||
const storage = memoryStorage();
|
||||
const games = library(storage);
|
||||
await games.save("a", journal(), { level: "2ply" });
|
||||
storage.setItem("ustagammon.game.a", "{pas du JSON");
|
||||
assert.equal(await games.load("a"), null);
|
||||
assert.equal(isJournal({ events: [], head: 3 }), false);
|
||||
assert.equal(isJournal(null), false);
|
||||
|
||||
const full = library({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
|
||||
// La bibliothèque reste utilisable après un échec.
|
||||
assert.deepEqual(await full.list(), []);
|
||||
});
|
||||
|
||||
test("la partie des versions précédentes est migrée, une seule fois", async () => {
|
||||
const storage = memoryStorage();
|
||||
const old = journal(1);
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(old));
|
||||
const games = library(storage);
|
||||
const id = await migrateLegacyGame(storage, games, 42);
|
||||
assert.ok(id !== null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
const current = await games.current();
|
||||
assert.equal(current?.summary.id, id);
|
||||
assert.deepEqual(current?.journal, old);
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
|
||||
// Un ancien enregistrement illisible est seulement effacé.
|
||||
storage.setItem("ustagammon.current-game", "{pas du JSON");
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
});
|
||||
|
||||
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
|
||||
const storage = memoryStorage();
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
|
||||
const opened = await openGameLibrary({ localStorage: storage });
|
||||
assert.equal(opened.kind, "localStorage");
|
||||
assert.equal((await opened.library.list()).length, 1);
|
||||
|
||||
const refused: KeyValueStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
};
|
||||
const memory = await openGameLibrary({ localStorage: refused });
|
||||
assert.equal(memory.kind, "memory");
|
||||
await memory.library.save("a", journal(), { level: "2ply" });
|
||||
assert.equal((await memory.library.current())?.summary.id, "a");
|
||||
|
||||
// IndexedDB qui échoue à l'ouverture : repli.
|
||||
const broken = {
|
||||
open: () => {
|
||||
throw new Error("indisponible");
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
|
||||
});
|
||||
|
||||
test("les identifiants de partie sont distincts", () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
|
||||
assert.ok(ids.size > 95);
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
|
||||
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),
|
||||
});
|
||||
assert.equal(await session.restore(), null);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
const first = session.newGame();
|
||||
const firstId = session.shownId as string;
|
||||
session.save(first.journal);
|
||||
const second = session.newGame();
|
||||
session.save(second.journal);
|
||||
assert.notEqual(session.shownId, firstId);
|
||||
|
||||
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
|
||||
const back = await session.resumePrevious();
|
||||
assert.deepEqual(back?.journal, first.journal);
|
||||
assert.equal(session.shownId, firstId);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
[firstId],
|
||||
);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
// Une branche reprise devient la ligne courante enregistrée.
|
||||
let j = goBackTo(first.journal, "1");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
|
||||
await games.save(firstId, j, { level: "2ply" });
|
||||
const original = branches(j).find((b) => b.forkedFrom === null);
|
||||
assert.ok(original !== undefined);
|
||||
const opened = await session.open(firstId, original.head);
|
||||
assert.equal(opened.journal.head, original.head);
|
||||
assert.equal((await games.current())?.journal.head, original.head);
|
||||
await assert.rejects(session.open("inconnue"), /introuvable/);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Jouer une partie du domaine sans interface, pour les tests, les
|
||||
// scénarios et les parties d'exemple : les deux camps, avec des dés
|
||||
// reproductibles.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { Game } from "../../src/domain/game/game.js";
|
||||
import type { DiceSource } from "../../src/domain/game/roll.js";
|
||||
|
||||
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
|
||||
export function seededDice(seed: number): DiceSource {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return 1 + (Math.floor(state / 65536) % 6);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
|
||||
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
|
||||
* doubles si take, et double quand il le peut si double.
|
||||
*/
|
||||
export async function step(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const state = game.state;
|
||||
if (state.turn === "computer" && state.phase === "doubled") {
|
||||
if (strategy.take ?? true) {
|
||||
game.take();
|
||||
} else {
|
||||
await game.drop();
|
||||
}
|
||||
} else if (state.turn === "computer") {
|
||||
await game.playComputerTurn();
|
||||
} else if (state.phase === "rolling") {
|
||||
if (strategy.double === true && (await game.canDouble())) {
|
||||
await game.double();
|
||||
} else {
|
||||
await game.roll();
|
||||
}
|
||||
} else if (state.phase === "moving" && state.roll !== null) {
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
|
||||
if (move === undefined) {
|
||||
await game.skipTurn();
|
||||
return;
|
||||
}
|
||||
for (const halfMove of move.halfMoves) {
|
||||
if (!(await game.playHalfMove(halfMove))) {
|
||||
throw new Error("coup légal refusé");
|
||||
}
|
||||
}
|
||||
await game.confirmMove();
|
||||
} else {
|
||||
throw new Error(`phase inattendue : ${state.phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */
|
||||
export async function playMoves(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
count: number,
|
||||
choice = 0,
|
||||
): Promise<void> {
|
||||
let played = 0;
|
||||
while (played < count && game.state.phase !== "over") {
|
||||
const before = game.events.length;
|
||||
await step(game, engine, { choice });
|
||||
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
if (i > 2000) {
|
||||
throw new Error("la partie ne finit pas");
|
||||
}
|
||||
await step(game, engine, { choice });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
|
||||
// Backgammon de référence (tests/test_sgf_reference.sh).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
|
||||
//
|
||||
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
|
||||
// parties variées : complètes, avec branches, avec videau, depuis une
|
||||
// position particulière. Pour chaque branche de chaque partie, écrit
|
||||
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
|
||||
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
|
||||
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js";
|
||||
|
||||
/** Le dernier événement « coup joué » de la ligne courante. */
|
||||
function lastMoveId(game: Game, skip = 0): string {
|
||||
const moves = game.events.filter((event) => event.type === "MovePlayed");
|
||||
const move = moves.at(-1 - skip);
|
||||
if (move === undefined) {
|
||||
throw new Error("aucun coup joué");
|
||||
}
|
||||
return move.id;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const out = process.argv[2];
|
||||
if (out === undefined) {
|
||||
throw new Error("usage : sgf-samples.js <dossier>");
|
||||
}
|
||||
mkdirSync(out, { recursive: true });
|
||||
const engine = await loadNodeEngine();
|
||||
const games: [string, Game][] = [];
|
||||
|
||||
// Une partie complète, puis une autre où le joueur joue autrement.
|
||||
for (const [seed, choice] of [
|
||||
[3, 0],
|
||||
[11, 2],
|
||||
] as const) {
|
||||
const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" });
|
||||
await playToTheEnd(game, engine, choice);
|
||||
games.push([`complete-${seed}`, game]);
|
||||
}
|
||||
|
||||
// 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), level: "0ply" });
|
||||
await playMoves(game, engine, 14);
|
||||
const original = game.events.at(-1)?.id as string;
|
||||
game.goBackTo(lastMoveId(game, 6));
|
||||
await playMoves(game, engine, 8);
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
|
||||
if (rolled === undefined) {
|
||||
throw new Error("aucun jet du joueur");
|
||||
}
|
||||
game.goBackTo(rolled.id);
|
||||
await playMoves(game, engine, 5, 1);
|
||||
game.switchBranch(original);
|
||||
await playToTheEnd(game, engine);
|
||||
games.push(["branches", game]);
|
||||
}
|
||||
|
||||
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
|
||||
{
|
||||
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 });
|
||||
}
|
||||
games.push(["player-doubles", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière : l'ordinateur, au trait, double
|
||||
// (position de référence « cube-double-take ») ; le joueur prend dans
|
||||
// une branche, passe dans une autre.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(13),
|
||||
level: "0ply",
|
||||
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
|
||||
});
|
||||
await step(game, engine);
|
||||
const offered = game.events.at(-1)?.id as string;
|
||||
await playMoves(game, engine, 6);
|
||||
game.goBackTo(offered);
|
||||
await step(game, engine, { take: false });
|
||||
games.push(["setup-cube", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière, videau déjà doublé chez le joueur,
|
||||
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(17),
|
||||
level: "0ply",
|
||||
setup: {
|
||||
position: "t+0OAADg/wcAQA",
|
||||
firstTurn: "player",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
});
|
||||
await playMoves(game, engine, 4);
|
||||
await game.roll();
|
||||
games.push(["setup-unfinished", game]);
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
for (const [name, game] of games) {
|
||||
const original = game.journal;
|
||||
for (const [i, branch] of game.branches.entries()) {
|
||||
game.switchBranch(branch.head);
|
||||
const sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ordinateur",
|
||||
date: "2026-11-03",
|
||||
});
|
||||
// Après chaque coup, la position vue de celui qui a alors la main,
|
||||
// et le résultat.
|
||||
const expected = {
|
||||
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
|
||||
result: game.state.result,
|
||||
};
|
||||
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
|
||||
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
|
||||
files++;
|
||||
}
|
||||
game.goBackTo(original.head as string);
|
||||
}
|
||||
console.log(`${files} parties exportées dans ${out}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,212 @@
|
||||
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
|
||||
// chaque position est valide pour le vrai moteur, chaque coup illustré et
|
||||
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
|
||||
// justes et refuse les autres, et la réponse au double est celle du moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { Move } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
canStillLeadTo,
|
||||
illustratedMove,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../src/domain/learning/tutorial.js";
|
||||
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
|
||||
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.deepEqual(
|
||||
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
|
||||
[3, 4, 6, 8],
|
||||
);
|
||||
assert.ok(tutorial.end.action !== "");
|
||||
assert.ok(tutorial.end.paragraphs.length > 0);
|
||||
for (const step of tutorial.steps) {
|
||||
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
|
||||
const practice = step.practice;
|
||||
if (practice !== null) {
|
||||
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
|
||||
assert.ok(
|
||||
practice.right.length > 0 && practice.wrong.length > 0,
|
||||
`étape ${step.number} : phrases juste et refus absentes`,
|
||||
);
|
||||
assert.ok(
|
||||
practice.expected.length > 0 || practice.answer !== null,
|
||||
`étape ${step.number} : ni coup attendu, ni réponse au double`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
|
||||
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
|
||||
const inlines = [
|
||||
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
|
||||
...tutorial.end.paragraphs.flat(),
|
||||
];
|
||||
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
|
||||
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
|
||||
for (const term of terms) {
|
||||
assert.ok(
|
||||
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
|
||||
`fiche ${term}.md absente`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les liens et le gras du texte sont lus", () => {
|
||||
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
|
||||
{ text: "Un " },
|
||||
{ text: "blot", term: "blot" },
|
||||
{ text: " " },
|
||||
{ text: "seul", strong: true },
|
||||
{ text: ", voilà." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("un texte mal formé est refusé", () => {
|
||||
assert.throws(() =>
|
||||
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
|
||||
);
|
||||
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
|
||||
});
|
||||
|
||||
/** Tous les écrans, avec leur nom pour les messages. */
|
||||
function screens(): [string, TutorialScreen][] {
|
||||
return tutorial.steps.flatMap((s) => [
|
||||
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
|
||||
...(s.practice === null
|
||||
? []
|
||||
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
|
||||
]);
|
||||
}
|
||||
|
||||
test("chaque position du tutoriel est valide pour le moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
|
||||
const count = (side: "player" | "computer") => {
|
||||
const s = board.sides[side];
|
||||
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
|
||||
};
|
||||
assert.equal(count("player"), 15, `${name} : pions ivoire`);
|
||||
assert.equal(count("computer"), 15, `${name} : pions noyer`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.move === null) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
|
||||
const { steps, after } = await illustratedMove(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.move,
|
||||
);
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
|
||||
if (name === "étape 7") {
|
||||
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
|
||||
} else {
|
||||
assert.equal(after.result, null, name);
|
||||
}
|
||||
if (name === "étape 4") {
|
||||
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.expected.length === 0) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : pas de jet`);
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
for (const move of legal) {
|
||||
const practice = await MovePractice.start(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.expected,
|
||||
);
|
||||
let verdict = "continue";
|
||||
for (const halfMove of move.halfMoves) {
|
||||
verdict = await practice.play(halfMove);
|
||||
if (verdict !== "continue") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const right = screen.expected.includes(move.notation);
|
||||
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
|
||||
}
|
||||
// Un coup attendu au moins, et un coup refusé au moins.
|
||||
assert.ok(
|
||||
legal.some((m) => !screen.expected.includes(m.notation)),
|
||||
`${name} : rien à refuser`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("le joueur peut revenir en arrière après un coup refusé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
|
||||
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
|
||||
practice.reset();
|
||||
assert.deepEqual(practice.pendingHalfMoves, []);
|
||||
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
|
||||
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
|
||||
});
|
||||
|
||||
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.answer === null) {
|
||||
continue;
|
||||
}
|
||||
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
|
||||
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
|
||||
}
|
||||
});
|
||||
|
||||
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
|
||||
const move = (notation: string, halfMoves: [number, number][]): Move => ({
|
||||
notation,
|
||||
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
|
||||
position: notation,
|
||||
});
|
||||
const expected = [
|
||||
move("8/5 6/5", [
|
||||
[8, 5],
|
||||
[6, 5],
|
||||
]),
|
||||
];
|
||||
assert.ok(canStillLeadTo([], expected));
|
||||
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
|
||||
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
|
||||
assert.ok(
|
||||
!canStillLeadTo(
|
||||
[
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 8, to: 5 },
|
||||
],
|
||||
expected,
|
||||
),
|
||||
);
|
||||
});
|
||||
+110
-50
@@ -1,6 +1,6 @@
|
||||
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
|
||||
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
|
||||
// l'aide, l'enregistrement de la partie.
|
||||
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -9,11 +9,25 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import { boardModel, moveTargets, moveTrace, placeOf, pointSlot } from "../src/ui/board-model.js";
|
||||
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
|
||||
import { parseHalfMoves } from "../src/domain/game/position.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
placeOf,
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "../src/ui/board-model.js";
|
||||
import { FEATURES } from "../src/ui/help.js";
|
||||
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
|
||||
import {
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "../src/ui/help-text.js";
|
||||
import { slug } from "../src/ui/router.js";
|
||||
import { TEXTS } from "../src/ui/texts.js";
|
||||
|
||||
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
|
||||
@@ -47,6 +61,18 @@ test("cases, barre et sortie de chaque camp", () => {
|
||||
assert.equal(placeOf("computer", 0), "off-computer");
|
||||
});
|
||||
|
||||
test("couleur des flèches : impaires en point-a, paires en point-b, quelle que soit la colonne", () => {
|
||||
for (const number of [1, 13, 23]) {
|
||||
assert.equal(pointColor(number), "point-a", `flèche ${number}`);
|
||||
}
|
||||
for (const number of [2, 12, 24]) {
|
||||
assert.equal(pointColor(number), "point-b", `flèche ${number}`);
|
||||
}
|
||||
// 12 et 13 partagent la colonne 0, mais pas la couleur.
|
||||
assert.equal(pointSlot(12).column, pointSlot(13).column);
|
||||
assert.notEqual(pointColor(12), pointColor(13));
|
||||
});
|
||||
|
||||
test("disposition : 1 à 12 en bas de droite à gauche, 13 à 24 en haut de gauche à droite", () => {
|
||||
assert.deepEqual(pointSlot(1), { top: false, column: 11 });
|
||||
assert.deepEqual(pointSlot(7), { top: false, column: 5 });
|
||||
@@ -65,6 +91,11 @@ test("les demi-coups possibles donnent les pions jouables et leurs cases d'arriv
|
||||
assert.deepEqual([...targets.keys()], ["p8", "bar-player", "p3"]);
|
||||
assert.deepEqual([...(targets.get("p8")?.keys() ?? [])], ["p5", "p7"]);
|
||||
assert.deepEqual(targets.get("p3")?.get("off-player"), { from: 3, to: 0 });
|
||||
// Un seul toucher suffit pour un pion à destination unique.
|
||||
assert.deepEqual(onlyHalfMove(targets, "p3"), { from: 3, to: 0 });
|
||||
assert.deepEqual(onlyHalfMove(targets, "bar-player"), { from: 25, to: 22 });
|
||||
assert.equal(onlyHalfMove(targets, "p8"), null);
|
||||
assert.equal(onlyHalfMove(targets, "p13"), null);
|
||||
});
|
||||
|
||||
test("traces d'un coup simple : une flèche par demi-coup, départs et arrivées", () => {
|
||||
@@ -120,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
|
||||
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
|
||||
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
|
||||
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
|
||||
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
|
||||
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
|
||||
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
|
||||
assert.deepEqual(feature.scenarios[0]?.steps, [
|
||||
"Quand vous commencez une nouvelle partie",
|
||||
"Alors les pions sont en position de départ",
|
||||
"Et le premier jet de dés désigne qui commence",
|
||||
]);
|
||||
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
|
||||
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
|
||||
const refused = feature.scenarios.find(
|
||||
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
|
||||
);
|
||||
assert.match(
|
||||
refused?.description[0] ?? "",
|
||||
/^Une flèche où l'ordinateur a au moins deux pions/,
|
||||
);
|
||||
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
|
||||
assert.equal(feature.scenarios[2]?.position, null);
|
||||
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
|
||||
for (const scenario of feature.scenarios) {
|
||||
assert.ok(scenario.steps.length >= 2, scenario.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
|
||||
const listed = read("docs/scenarios/README.md");
|
||||
const ids = new Set<string>();
|
||||
for (const url of FEATURES) {
|
||||
assert.ok(
|
||||
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
|
||||
`${url} hors de l'index`,
|
||||
);
|
||||
const feature = parseFeature(read(url));
|
||||
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
|
||||
for (const scenario of feature.scenarios) {
|
||||
// La réponse se lit seule : pas de vocabulaire technique.
|
||||
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
|
||||
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
|
||||
assert.ok(
|
||||
!scenario.description.join(" ").includes(word),
|
||||
`${scenario.title} : « ${word} » dans la réponse`,
|
||||
);
|
||||
}
|
||||
const id = slug(scenario.title);
|
||||
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
|
||||
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
|
||||
for (const title of Object.values(HELP_QUESTIONS)) {
|
||||
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
|
||||
}
|
||||
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
|
||||
assert.equal(helpHash(null), "#/aide");
|
||||
});
|
||||
|
||||
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
|
||||
assert.ok(entry !== null);
|
||||
assert.equal(entry.english, "Home board");
|
||||
assert.deepEqual(
|
||||
entry.seeAlso.map((s) => s.name),
|
||||
["sortie", "barre", "point"],
|
||||
);
|
||||
assert.ok(matchesSearch(entry, "camp"));
|
||||
assert.ok(matchesSearch(entry, "HOME"));
|
||||
assert.ok(matchesSearch(entry, "fleches sortir"));
|
||||
assert.ok(!matchesSearch(entry, "videau"));
|
||||
});
|
||||
|
||||
test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
|
||||
assert.ok(files.includes("videau.md"));
|
||||
@@ -144,51 +231,24 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
assert.ok(entry !== null, file);
|
||||
assert.ok(entry.brief.length > 10, file);
|
||||
}
|
||||
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
|
||||
term: "Videau",
|
||||
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
});
|
||||
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
|
||||
assert.equal(videau?.term, "Videau");
|
||||
assert.equal(videau?.english, "Cube");
|
||||
assert.equal(
|
||||
videau?.brief,
|
||||
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
);
|
||||
assert.equal(parseGlossaryEntry("pas une fiche"), null);
|
||||
});
|
||||
|
||||
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
};
|
||||
const store = createGameStore(storage);
|
||||
assert.equal(store.load(), null);
|
||||
const journal = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
store.save(journal);
|
||||
assert.deepEqual(store.load(), journal);
|
||||
for (const key of data.keys()) {
|
||||
data.set(key, "{pas du JSON");
|
||||
}
|
||||
assert.equal(store.load(), null);
|
||||
// Un stockage plein ou interdit ne casse pas la partie.
|
||||
const full = createGameStore({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.save(journal));
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
|
||||
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
|
||||
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
|
||||
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
|
||||
assert.equal(
|
||||
TEXTS.youLose("single", 1),
|
||||
"L'ordinateur gagne la partie simple : 1 point pour lui.",
|
||||
);
|
||||
assert.equal(
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9"),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,3 +94,37 @@ les justifie pas.
|
||||
dépendances seront fixés avec le socle, à la fin de l'étape 0.
|
||||
- Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y
|
||||
renvoie.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Le stockage des parties est un format durable
|
||||
|
||||
Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base
|
||||
« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les
|
||||
clés `localStorage` de l'application (`ustagammon.settings`,
|
||||
`ustagammon.last-board`, et le repli des parties) rejoignent le journal
|
||||
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.
|
||||
|
||||
@@ -68,3 +68,24 @@ vérification des dépendances.
|
||||
standard ») est tenu même au niveau fort (2 plis) : c'est le niveau standard
|
||||
de l'ordinateur à l'étape 0d.
|
||||
- Firefox sur le téléphone reste à vérifier, sans bloquer.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Pages de test du moteur retirées du site
|
||||
|
||||
Décidé par Pierre après son premier test sur le téléphone : « supprimer la
|
||||
partie test du moteur totalement ».
|
||||
|
||||
- Les pages de test et de mesure de l'étape 0b (`web/moteur/` : `index.html`,
|
||||
`bench.html` et leurs scripts) sont supprimées. Le site publié n'a plus rien
|
||||
sous `/moteur/`, ni dans le cache du service worker ; `make deploy`
|
||||
n'annonce plus que l'application, et l'aide ne renvoie plus vers le test du
|
||||
moteur.
|
||||
- Les mesures du Fairphone 4 ci-dessus, faites avec `bench.html`, restent la
|
||||
référence. Pour les refaire, la mesure se lance sous Node sur le PC
|
||||
(`make bench`) : son code partagé est déplacé de `web/moteur/benchmark.mjs`
|
||||
vers `engine-wasm/benchmark.mjs`. Une mesure sur le téléphone demanderait de
|
||||
remettre une page de mesure, à décider le moment venu.
|
||||
- Le moteur reste vérifié par la suite de contrat sur WebAssembly
|
||||
(`make test`) et, dans l'application, par la partie jouée dans un navigateur
|
||||
(`make test-browser`).
|
||||
|
||||
@@ -77,3 +77,23 @@ paru « vraiment pas optimale ».
|
||||
La bascule automatique écartée plus haut était celle qui forçait
|
||||
l'orientation ; ici, seule la mise en page suit l'orientation choisie par le
|
||||
joueur.
|
||||
|
||||
### 2026-11-03 — Placement des contrôles en paysage, après la revue UX
|
||||
|
||||
La revue UX de l'étape 0e.2 a montré deux conflits dans le placement décrit
|
||||
par l'amendement précédent : la moitié basse de la colonne des sorties est une
|
||||
cible de jeu (toucher fait sortir un pion), et les pions frappés s'empilent
|
||||
depuis le milieu de la barre. Arbitrage de la session design, sur délégation
|
||||
de Pierre :
|
||||
|
||||
- une colonne de commandes à gauche, dans le cadre : menu en haut, videau au
|
||||
milieu (il monte ou descend selon son propriétaire), « Annuler le
|
||||
demi-coup » en bas, remplacé par « Revoir » quand le coup de l'ordinateur
|
||||
est montré ; « Quitter le plein écran » passe dans le menu ;
|
||||
- la colonne des sorties reste réservée aux pions sortis ;
|
||||
- les comptes de points sont des pastilles aux deux bouts de la barre, aux
|
||||
couleurs du camp, en 13 px.
|
||||
|
||||
Maquettes : rangée « Révision UX (étape 0e) : paysage » du canevas
|
||||
(variantes thème sombre, barre d'adresse visible et fin de partie). Règles de
|
||||
comportement : `docs/identite/charte/interaction.md`.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 0013. Une position du plateau dans l'adresse de la page
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre (besoin), Claude Code (format)
|
||||
|
||||
## Contexte
|
||||
|
||||
Pierre a demandé, au premier test sur le téléphone (2026-11-03), qu'avec la
|
||||
seule adresse on arrive à une position du plateau : chaque position devient un
|
||||
lien direct, à partager, à mettre dans une leçon ou dans un prompt pour une IA.
|
||||
GNU Backgammon décrit déjà toute position par deux identifiants publics et
|
||||
documentés : le Position ID (14 caractères, les pions) et le Match ID
|
||||
(12 caractères : videau, score, longueur du match, joueur au trait, dés). Le
|
||||
moteur sait les lire et les écrire (`engine.h`).
|
||||
|
||||
## Décision
|
||||
|
||||
- L'adresse porte la position dans le fragment (après `#`) :
|
||||
`https://ustagammon.sans.pub/#position=<Position ID>&match=<Match ID>`,
|
||||
chaque identifiant encodé avec `encodeURIComponent` (le base64 de gnubg
|
||||
contient `+` et `/`). `match` est facultatif : sans lui, partie libre, videau
|
||||
au centre, joueur du bas au trait, sans dés.
|
||||
- Ouvrir un tel lien affiche la position, non jouable, avec deux actions :
|
||||
« Jouer à partir d'ici » (nouvelle partie contre l'ordinateur depuis cette
|
||||
position, sans toucher à la partie en cours) et « Retour à ma partie ».
|
||||
- Toute position affichée (partie, coup de l'ordinateur, analyse, leçon) peut
|
||||
être copiée en lien (« Copier le lien de la position »).
|
||||
- Un identifiant illisible pour le moteur donne un message clair et le retour
|
||||
à la partie, jamais une page cassée.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Paramètres de requête (`?position=`)** : ils changent l'adresse demandée
|
||||
au serveur et au service worker ; hors ligne, la page en cache ne
|
||||
correspondrait plus. Le fragment ne quitte jamais le navigateur.
|
||||
- **XGID (format d'eXtreme Gammon)** : répandu, mais un seul identifiant de
|
||||
plus à décoder ; gnubg l'importe, son import pourra s'ajouter plus tard
|
||||
(outil « import d'un identifiant GNU ou XGID » du cadrage).
|
||||
- **Format maison plus lisible** : il faudrait le décoder sans le moteur, donc
|
||||
réimplémenter une règle de représentation (ADR 0007).
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le fragment est lu au chargement et à chaque changement (`hashchange`) ; le
|
||||
domaine reçoit une position et un Match ID, le moteur les valide.
|
||||
- Les liens restent valables tant que GNU Backgammon garde ces formats, stables
|
||||
depuis des années.
|
||||
- Les liens contiennent la position, rien d'autre : pas de donnée personnelle.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Une adresse pour chaque écran, des liens plutôt que des états
|
||||
|
||||
Demandé par Pierre : « il faudra vraiment que chaque page (l'aide, etc.) ait
|
||||
une URL. Utilise des liens accessibles, pas des états. » Aucun lien de
|
||||
position n'a encore circulé : le format ci-dessus est remplacé par un schéma
|
||||
unique pour tous les écrans, avant sa première mise en œuvre.
|
||||
|
||||
- Chaque écran a son adresse, dans le fragment (pour les mêmes raisons
|
||||
qu'au-dessus : hors ligne, service worker, aucune configuration serveur) :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Partie en cours | `#/` |
|
||||
| Accueil | `#/accueil` |
|
||||
| Aide (onglet Jouer) | `#/aide`, une question : `#/aide/<identifiant>` |
|
||||
| Les mots du jeu | `#/glossaire`, un terme : `#/glossaire/<fichier sans .md>` |
|
||||
| À propos | `#/a-propos` |
|
||||
| Tutoriel | `#/tutoriel`, une étape : `#/tutoriel/<n>` |
|
||||
| Mes parties | `#/parties`, une partie : `#/parties/<id>` |
|
||||
| Réglages / menu | `#/menu` |
|
||||
| Une position | `#/position/<Position ID>/<Match ID>` (Match ID facultatif) |
|
||||
|
||||
Les identifiants de gnubg sont encodés avec `encodeURIComponent`. Les
|
||||
écrans à venir (analyse, leçons, progression) suivront le même schéma.
|
||||
- On change d'écran par de vrais liens (`<a href="#/aide">`), pas par des
|
||||
boutons qui modifient un état caché : le lien se partage, s'ouvre dans un
|
||||
nouvel onglet, se lit aux lecteurs d'écran comme un lien, et le bouton
|
||||
« retour » du téléphone ramène à l'écran précédent. Les actions de jeu
|
||||
(lancer, jouer, doubler) restent des boutons : elles ne sont pas des écrans.
|
||||
- Les feuilles (menu, explication) et panneaux qui sont des écrans à part
|
||||
entière ont aussi leur adresse ; une confirmation ou un bandeau éphémère
|
||||
n'en a pas.
|
||||
- Une adresse inconnue mène à la partie en cours avec un message clair.
|
||||
- L'adresse de l'écran courant est la source de vérité de la navigation :
|
||||
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
|
||||
|
||||
### 2026-11-09 — Adresses du tutoriel et de l'installation
|
||||
|
||||
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
|
||||
| Fin du tutoriel | `#/tutoriel/fin` |
|
||||
| Marche à suivre pour installer l'application | `#/installer` |
|
||||
|
||||
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.
|
||||
@@ -19,3 +19,4 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
|
||||
| [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté |
|
||||
| [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté |
|
||||
| [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté |
|
||||
| [0013](0013-position-dans-l-adresse.md) | Une position du plateau dans l'adresse de la page | accepté |
|
||||
|
||||
+3
-4
@@ -2,8 +2,8 @@
|
||||
|
||||
Ustagammon est un site statique (PWA, ADR 0009) : des fichiers HTML,
|
||||
JavaScript, le moteur en WebAssembly et ses données. L'application est à la
|
||||
racine ; les pages de test du moteur de l'étape 0b restent publiées sous
|
||||
`/moteur/` (test du moteur) et `/moteur/bench.html` (mesure). Il est publié sur
|
||||
racine ; les pages de test du moteur de l'étape 0b ont été retirées (ADR
|
||||
0009, amendement du 2026-11-03). Il est publié sur
|
||||
CapRover, machine `sans-pub` (https://captain.cloud.sans.pub), app
|
||||
`ustagammon`, sur https://ustagammon.sans.pub.
|
||||
|
||||
@@ -112,8 +112,7 @@ ssh sans.pub 'sudo docker service ps srv-captain--ustagammon --format "{{.Image}
|
||||
```
|
||||
|
||||
Puis ouvrir le site sur le téléphone : le plateau s'affiche et une consigne
|
||||
remplace « Chargement du moteur… ». La page de test du moteur
|
||||
(https://ustagammon.sans.pub/moteur/) doit afficher « Moteur prêt ».
|
||||
remplace « Chargement du moteur… ».
|
||||
|
||||
## Retour arrière
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Étape 1 — V1 « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il décrit l'étape en cours et sera supprimé à sa clôture,
|
||||
après report de ce qui est durable dans les ADR et le journal.
|
||||
|
||||
**Critère de fin** (cadrage) : un débutant apprend et joue seul ; un joueur de
|
||||
club comprend ses erreurs. Livré avec un plan de test (`docs/guide-test-etape-1.md`)
|
||||
que Pierre déroule sur le Fairphone 4.
|
||||
|
||||
**Préalable** : les sous-étapes 0e (retours du premier test) de la feuille de
|
||||
route, dont la revue UX validée par Pierre.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 0010,
|
||||
0011 et 0012. Ce qui n'y figure pas est hors de l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| 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 (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
- **Un coach bienveillant, mis en avant mais discret** (2026-11-03) : le
|
||||
conseil du maître et les conseils sont une signature de l'app, présents à
|
||||
chaque partie, sans jamais interrompre le jeu ni culpabiliser le joueur. Le
|
||||
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
|
||||
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
|
||||
|
||||
## Décisions de Pierre (2026-11-09)
|
||||
|
||||
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
|
||||
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
|
||||
l'étape 1.
|
||||
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
|
||||
(fait en 0e.5).
|
||||
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
|
||||
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
|
||||
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
|
||||
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
|
||||
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
|
||||
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
|
||||
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
|
||||
par un test, et le texte n'affirme rien que le moteur ne confirme.
|
||||
- **Plein écran par défaut dans l'app installée** : non (pas de
|
||||
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
|
||||
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
|
||||
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
|
||||
des phrases du maître (section « Les phrases du maître » de
|
||||
`docs/identite/charte/ecrans-etape-1.md`).
|
||||
|
||||
## Ordre de réalisation
|
||||
|
||||
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étape a son plan dans `docs/plans/` et sa merge request.
|
||||
@@ -23,18 +23,13 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0e.1 | Saisie du coup : pion à destination unique joué en un toucher, coup à valider et annulable demi-coup par demi-coup, dés joués marqués, dés des deux côtés touchables, videau confirmé ; pages de test du moteur retirées | 🔄 | `plans/2026-11-03-saisie-du-coup.md` |
|
||||
| 0e.2 | Revue UX par un designer UI : compte de points, fluidité (bouton « Nouvelle partie », interruptions), paysage ; propositions à valider par Pierre | 🔄 | `plans/2026-11-03-revue-ux.md` |
|
||||
| 0e.3 | Paysage sur téléphone : plateau sur presque tout l'écran, contrôles en pictogrammes (ADR 0012 à amender), selon la revue validée | ⏳ | |
|
||||
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | |
|
||||
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
| Étape | Contenu | État |
|
||||
| --- | --- | --- |
|
||||
| 1. V1 Jouer et comprendre | Charte graphique appliquée à la PWA (ADR 0010, `docs/identite/charte/`), plateau soigné, niveaux, conseil du maître après le coup, bilan de fin de partie, premières leçons numérotées (ADR 0011), mode immersif en paysage (ADR 0012), analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
|
||||
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
|
||||
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
|
||||
|
||||
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
|
||||
|
||||
@@ -35,10 +35,13 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
|
||||
| Terme | Anglais (code) |
|
||||
| --- | --- |
|
||||
| [Appréciation](appreciation.md) | Skill |
|
||||
| [Backgammon](backgammon.md) | Backgammon |
|
||||
| [Barre](barre.md) | Bar |
|
||||
| [Blot](blot.md) | Blot |
|
||||
| [Branche](branche.md) | Branch |
|
||||
| [Camp](camp.md) | Home board |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Coup](coup.md) | Move |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
@@ -50,9 +53,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Identifiant de match](identifiant-de-match.md) | Match ID |
|
||||
| [Identifiant de position](identifiant-de-position.md) | Position ID |
|
||||
| [Jet](jet.md) | Roll |
|
||||
| [Lien de position](lien-de-position.md) | Position link |
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Partie enregistrée](partie-enregistree.md) | Saved game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
| [Pion](pion.md) | Checker |
|
||||
| [Plateau](plateau.md) | Board |
|
||||
@@ -61,5 +67,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Prendre](prendre.md) | Take |
|
||||
| [Règle de Crawford](regle-de-crawford.md) | Crawford rule |
|
||||
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
|
||||
| [SGF](sgf.md) | SGF |
|
||||
| [Sortie](sortie.md) | Bear-off |
|
||||
| [Videau](videau.md) | Cube |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Appréciation
|
||||
english: Skill
|
||||
code: Skill
|
||||
---
|
||||
# Appréciation
|
||||
|
||||
**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix.
|
||||
|
||||
GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus
|
||||
de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous,
|
||||
il n'y a rien à signaler.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md)
|
||||
@@ -1,9 +1,14 @@
|
||||
---
|
||||
term: Branche
|
||||
english: Branch
|
||||
code: Branch
|
||||
---
|
||||
# Branche
|
||||
|
||||
**En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md)
|
||||
Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à
|
||||
l'autre depuis « Mes parties », et l'export SGF les contient toutes, en
|
||||
variantes.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
term: Camp
|
||||
english: Home board
|
||||
---
|
||||
# Camp
|
||||
|
||||
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
|
||||
|
||||
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
|
||||
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
|
||||
|
||||
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Chance
|
||||
english: Luck
|
||||
code: Luck
|
||||
---
|
||||
# Chance
|
||||
|
||||
**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles.
|
||||
|
||||
Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la
|
||||
moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans
|
||||
l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si
|
||||
on a perdu à cause des dés ou de son jeu.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Jet](jet.md)
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
term: Identifiant de match
|
||||
english: Match ID
|
||||
code: MatchId
|
||||
---
|
||||
# Identifiant de match
|
||||
|
||||
**En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon.
|
||||
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md)
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md)
|
||||
|
||||
@@ -7,4 +7,4 @@ code: PositionId
|
||||
|
||||
**En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon.
|
||||
|
||||
Voir aussi : [Identifiant de match](identifiant-de-match.md)
|
||||
Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Lien de position
|
||||
english: Position link
|
||||
code: PositionLink
|
||||
---
|
||||
# Lien de position
|
||||
|
||||
**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match.
|
||||
|
||||
Le lien montre la position du point de vue du joueur qui a la main : c'est
|
||||
vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis
|
||||
cette position, sans toucher à votre partie en cours. Le lien ne contient que
|
||||
la position : aucune donnée personnelle.
|
||||
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md)
|
||||
@@ -1,9 +1,16 @@
|
||||
---
|
||||
term: Match
|
||||
english: Match
|
||||
code: Match
|
||||
---
|
||||
# Match
|
||||
|
||||
**En bref** : suite de parties jouée jusqu'à ce qu'un joueur atteigne un nombre de points fixé à l'avance, par exemple 5 points.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md)
|
||||
Chaque partie rapporte ses points à son gagnant : 1 pour une partie simple,
|
||||
2 pour un gammon, 3 pour un backgammon, multipliés par la valeur du videau.
|
||||
Le premier qui atteint la longueur du match le gagne. Dans Ustagammon, un
|
||||
match se joue en 3, 5, 7 ou 11 points ; le score, la fin du match et la
|
||||
partie Crawford sont calculés par GNU Backgammon.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md), [Identifiant de match](identifiant-de-match.md)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
term: Niveau de jeu
|
||||
english: Level
|
||||
code: Level
|
||||
---
|
||||
# Niveau de jeu
|
||||
|
||||
**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance.
|
||||
|
||||
Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant,
|
||||
amateur, intermédiaire, avancé) jugent la position sans regarder les jets
|
||||
suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible :
|
||||
c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de
|
||||
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)
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Partie enregistrée
|
||||
english: Saved game
|
||||
code: SavedGameSummary
|
||||
---
|
||||
# Partie enregistrée
|
||||
|
||||
**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ».
|
||||
|
||||
Chaque partie est enregistrée après chacune de vos actions : fermer
|
||||
l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous
|
||||
exportez une partie.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.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)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
term: SGF
|
||||
english: SGF
|
||||
---
|
||||
# SGF
|
||||
|
||||
**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser.
|
||||
|
||||
SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches
|
||||
d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche
|
||||
courante.
|
||||
|
||||
Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md)
|
||||
@@ -14,6 +14,10 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
|
||||
| `ecrans-etape-1.md` | Écrans de l'étape 1, lot par lot : nouvelle partie et match (1c), conseil du maître (1d), bilan et analyse (1e), IA (1f), leçons (1g), progression (1h), gestes (1i) |
|
||||
| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions |
|
||||
| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone |
|
||||
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
|
||||
| `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur |
|
||||
| `bundle.css` | Styles des composants ; mêmes noms de classes que `web/app.css` |
|
||||
| `board.js` | Dessin de référence du plateau (repère 640 × 520), pour comparaison |
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Accueil, installation, tutoriel et aide
|
||||
|
||||
Règles de l'étape 0e.5 (et 1g pour le tutoriel). Maquettes : rangée « Étape
|
||||
0e.5 » du canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe et
|
||||
l'artboard « Accueil ». Les textes sont une **première version, à valider par
|
||||
Pierre**. Comportement général : `interaction.md`.
|
||||
|
||||
## Accueil
|
||||
|
||||
**Premier lancement** (aucune partie ni leçon enregistrée) : le logo, le
|
||||
plateau en position de départ, « Bienvenue à la table », une phrase, puis deux
|
||||
grands choix :
|
||||
|
||||
- **« Je découvre »** (action principale) → le tutoriel, étape 1 ;
|
||||
- **« Je connais les règles »** → une partie tout de suite.
|
||||
|
||||
Le choix ne revient plus. Le tutoriel reste dans le menu.
|
||||
|
||||
**Ensuite**, de haut en bas :
|
||||
|
||||
1. **La partie en cours** : miniature du plateau, comptes de points,
|
||||
« Reprendre la partie » (seule action principale de l'écran) et
|
||||
« Nouvelle partie ». Sans partie en cours : « Nouvelle partie » devient
|
||||
l'action principale.
|
||||
2. **L'invitation à installer**, si elle doit apparaître (voir plus bas).
|
||||
3. **La prochaine leçon** : une seule carte (titre, état, progression,
|
||||
« Continuer » en secondaire), avec le lien « Toutes les leçons ».
|
||||
4. **La progression résumée**, en une ligne `caption` : « 1 leçon terminée
|
||||
sur 8 · 12 parties jouées · 4 gagnées ».
|
||||
5. En pied de page, la phrase de l'Atelier du Huit.
|
||||
|
||||
Pas de chiffres d'erreur ou d'équité sur l'accueil : ils viennent avec
|
||||
l'analyse.
|
||||
|
||||
## Installation
|
||||
|
||||
Le but : proposer l'installation au bon moment, une fois, sans jamais
|
||||
interrompre.
|
||||
|
||||
- **Jamais** au premier lancement, jamais pendant une partie, jamais en
|
||||
fenêtre qui s'impose.
|
||||
- **Quand** : à partir de la fin de la **première partie terminée**, quand le
|
||||
joueur a vu ce que vaut l'app.
|
||||
- **Où** : une carte compacte sur l'accueil (symbole, « Installer
|
||||
Ustagammon », « Sur l'écran d'accueil, en plein écran, sans réseau. »,
|
||||
bouton « Installer » et croix « Plus tard »), et une ligne « Installer
|
||||
l'application » dans le menu, toujours présente tant que l'app n'est pas
|
||||
installée.
|
||||
- **« Plus tard »** masque la carte ; elle revient une seule fois, après
|
||||
trois parties de plus. Ensuite, seul le menu la propose.
|
||||
- **Chrome Android** : le bouton déclenche l'invite du système, gardée depuis
|
||||
l'événement `beforeinstallprompt`. Sans cet événement (navigateur qui ne
|
||||
propose pas l'installation), le même bouton ouvre la marche à suivre.
|
||||
- **Firefox Android** : une feuille en trois étapes numérotées (menu ⋮,
|
||||
« Installer » ou « Ajouter à l'écran d'accueil », confirmer), qui se ferme
|
||||
par « J'ai compris ».
|
||||
- **iPhone (Safari)** : même feuille, avec ces étapes : bouton de partage,
|
||||
« Sur l'écran d'accueil », « Ajouter ».
|
||||
- **Une fois installée** (`display-mode: standalone`, ou événement
|
||||
`appinstalled`) : plus aucune proposition, la ligne du menu disparaît.
|
||||
|
||||
## Tutoriel des règles
|
||||
|
||||
### Forme
|
||||
|
||||
- Huit étapes, chacune en **écrans courts** : un titre, un plateau illustré,
|
||||
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
|
||||
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
|
||||
- En haut : une barre de progression en huit segments (`accent` pour les
|
||||
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
|
||||
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
|
||||
« Continuer ».
|
||||
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
|
||||
plateau est le composant de l'app, non jouable sauf sur les écrans
|
||||
« à vous ». Aucune règle n'est codée hors du moteur : un écran « à vous »
|
||||
demande au moteur si le coup du joueur fait partie des coups permis attendus.
|
||||
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
|
||||
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
|
||||
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
|
||||
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
|
||||
veut dire « à faire »).
|
||||
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
|
||||
courte, « Continuer » devient l'action principale. Un geste faux : le pion
|
||||
revient, et une phrase dit pourquoi, sans le mot « erreur ».
|
||||
- Chaque terme nouveau est un lien vers sa fiche du glossaire, à sa première
|
||||
apparition.
|
||||
|
||||
### Texte, première version
|
||||
|
||||
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
|
||||
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
|
||||
sort. Le premier qui a sorti tous les siens gagne la partie. »
|
||||
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
|
||||
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
|
||||
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
|
||||
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
|
||||
l'ordinateur font le même chemin, en sens inverse. »
|
||||
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
|
||||
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
|
||||
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
|
||||
quatre fois de 4. » *À vous* : jouer 3-1 en faisant le point 5 (8/5 6/5).
|
||||
4. **Frapper un pion seul.** « Un pion seul sur une flèche s'appelle un blot.
|
||||
Si vous vous posez dessus, vous le frappez : il part sur la barre et devra
|
||||
refaire tout le chemin. Une flèche où l'adversaire a deux pions ou plus est
|
||||
fermée : vous ne pouvez pas vous y poser. » *À vous* : frapper le blot du
|
||||
point 7 avec 6-1.
|
||||
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
|
||||
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
|
||||
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
|
||||
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
|
||||
passe. »
|
||||
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
|
||||
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
|
||||
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
|
||||
droite. » *À vous* : sortir deux pions avec 6-3.
|
||||
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
|
||||
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
|
||||
reste en plus un pion sur la barre ou dans votre camp, c'est un
|
||||
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
|
||||
que si le videau a été doublé : c'est la règle de Jacoby. »
|
||||
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
|
||||
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
|
||||
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
|
||||
passe, et vous gagnez tout de suite. Celui qui a pris garde le cube : lui
|
||||
seul pourra redoubler. » *À vous* : décider de prendre ou passer, avec une
|
||||
phrase du maître ensuite.
|
||||
|
||||
Fin du tutoriel : « Vous connaissez les règles. Le reste s'apprend en jouant,
|
||||
et le maître vous accompagne. » → « Jouer ma première partie ».
|
||||
|
||||
## Aide
|
||||
|
||||
L'aide n'est plus une copie brute des fichiers : c'est une page organisée en
|
||||
questions, avec un ton. **La source reste unique** (ADR 0008) : les mêmes
|
||||
fichiers servent d'aide et de tests.
|
||||
|
||||
- **Trois onglets** : « Jouer » (les scénarios), « Les mots du jeu » (le
|
||||
glossaire), « À propos ». Le tutoriel est proposé en tête de page.
|
||||
- **« Jouer »** : un scénario = une question repliable (`details`), fermée par
|
||||
défaut sauf celle qui correspond à l'écran d'où l'on vient.
|
||||
- Le **titre du scénario** est la question, formulée comme le joueur se la
|
||||
pose : « Comment jouer un coup ? », « Je me suis trompé de pion », « Je ne
|
||||
peux rien jouer avec mes dés ». C'est un changement de titre dans les
|
||||
`.feature` : les tests n'en dépendent pas.
|
||||
- La **description du scénario** (le paragraphe sous le titre) est la
|
||||
réponse affichée. Elle se lit seule, au présent, au « vous ».
|
||||
- Les **étapes Gherkin** ne s'affichent pas dans l'aide : elles restent la
|
||||
preuve que l'app fait ce que dit la réponse. « Voir sur le plateau » ouvre
|
||||
la position de l'exemple quand le scénario en donne une (lien ADR 0013).
|
||||
- La **description de la fonctionnalité** sert d'introduction à l'onglet.
|
||||
- **« Les mots du jeu »** : une fiche du glossaire = une carte (le mot, son nom
|
||||
anglais en `caption` italique, « En bref », les renvois), triées par ordre
|
||||
alphabétique, avec un champ de recherche en tête.
|
||||
- **Ton** : celui du maître de café, patient et concret. Pas de « Étant
|
||||
donné que », pas de vocabulaire technique (moteur, scénario, événement).
|
||||
|
||||
## Liens (ADR 0013, amendement : une adresse par écran)
|
||||
|
||||
Chaque élément qui mène ailleurs est un vrai lien (`<a href>`), qu'on peut
|
||||
ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
|
||||
| Élément | Adresse |
|
||||
| --- | --- |
|
||||
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
|
||||
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
|
||||
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
|
||||
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
|
||||
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
|
||||
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
|
||||
| Menu | `#/menu` |
|
||||
|
||||
- Les segments de la barre de progression du tutoriel sont des liens vers les
|
||||
étapes déjà vues, pas vers les suivantes.
|
||||
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
|
||||
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
|
||||
sans retour possible.
|
||||
@@ -34,7 +34,7 @@ window.UstaBoard = (function () {
|
||||
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + TB + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
|
||||
o += '<rect class="board-tray" x="' + TRAY_X + '" y="' + (H / 2 + 4) + '" width="' + TRAY_W + '" height="' + (H / 2 - TB - 4) + '" rx="4"/>';
|
||||
for (var point = 1; point <= 24; point++) {
|
||||
var pl = place(point), x = colX(pl.col), cls = (pl.col % 2 === 0) === pl.top ? "point-a" : "point-b";
|
||||
var pl = place(point), x = colX(pl.col), cls = point % 2 === 1 ? "point-a" : "point-b"; // impairs en noyer, pairs en chêne
|
||||
var base = pl.top ? TB : H - TB, apex = pl.top ? TB + PH : H - TB - PH;
|
||||
o += '<polygon class="' + cls + '" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
|
||||
if ((state.targets || []).indexOf(point) >= 0)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Écrans de l'étape 1
|
||||
|
||||
Règles de design des écrans de l'étape 1 (`docs/etape-1.md`), lot par lot.
|
||||
Maquettes : canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
|
||||
« Étape 1 ». Comportement général : `interaction.md` ; liens : ADR 0013.
|
||||
|
||||
## 1c. Nouvelle partie et match
|
||||
|
||||
### Écran « Nouvelle partie » (`#/nouvelle-partie`)
|
||||
|
||||
Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
|
||||
étapes, de haut en bas :
|
||||
|
||||
1. **Type de partie** : un sélecteur à deux segments, « Partie libre » et
|
||||
« Match » (fond `surface-sunken`, segment choisi en `surface` avec
|
||||
`shadow-raised`).
|
||||
- **Match** : « Le premier à [3] [5] [7] [11] points », quatre boutons
|
||||
carrés de 44 px, le choisi en brique. Dessous, une phrase sur la règle de
|
||||
Crawford, avec le lien vers sa fiche du glossaire.
|
||||
- **Partie libre** : une phrase sur la règle de Jacoby, avec le lien vers
|
||||
sa fiche. Pas d'interrupteur pour Crawford ni Jacoby : ce sont les règles
|
||||
usuelles, et le joueur débutant n'a pas à les choisir.
|
||||
2. **Niveau de l'ordinateur** : six barres de hauteur croissante, à toucher
|
||||
(Débutant, Amateur, Intermédiaire, Avancé, Expert, Champion) ; les barres
|
||||
jusqu'au niveau choisi sont en brique, le niveau choisi porte l'anneau de
|
||||
focus. Dessous, le nom en `heading` et une phrase qui le décrit en mots de
|
||||
joueur, jamais en plis ni en bruit :
|
||||
- Débutant : « Joue simplement et laisse passer des occasions. Idéal pour
|
||||
vos premières parties. »
|
||||
- Amateur : « Un joueur qui connaît les règles et commet encore des
|
||||
erreurs. »
|
||||
- Intermédiaire : « Un habitué de la table : solide, sans être
|
||||
implacable. »
|
||||
- Avancé : « Un bon joueur de club. Peu d'erreurs de pions, un videau
|
||||
sérieux. »
|
||||
- Expert : « Très peu d'erreurs. Chaque point se gagne. »
|
||||
- Champion : « GNU Backgammon à son meilleur niveau sur ce téléphone. »
|
||||
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
|
||||
« Une ligne après chacun de vos coups ».
|
||||
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
|
||||
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
|
||||
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
|
||||
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
|
||||
Commencer une nouvelle partie alors qu'une partie est en cours suit la règle
|
||||
« annuler plutôt que confirmer » (bandeau « Reprendre l'ancienne »).
|
||||
|
||||
### Score de match sur l'écran de jeu
|
||||
|
||||
- **Portrait** : une ligne `overline` centrée sous la barre haute, « Match en
|
||||
5 points », complétée par « · partie Crawford » pendant la partie Crawford ;
|
||||
dans chaque bandeau, une pastille carrée (contour `border-strong`, chiffre
|
||||
gras) avec le score de ce joueur, juste avant son compte de points.
|
||||
- **Paysage** : dans la colonne de commandes, sous le menu, une case qui
|
||||
empile le score de l'ordinateur, « sur 5 », puis le vôtre (même ordre que
|
||||
les bandeaux : l'ordinateur en haut, vous en bas).
|
||||
- Le score se lit aussi au lecteur d'écran : « Match en 5 points :
|
||||
l'ordinateur 4, vous 2 ».
|
||||
- Pendant la partie Crawford, le videau n'est pas affiché : il n'existe pas
|
||||
pour cette partie.
|
||||
|
||||
## 1d. Conseil du maître
|
||||
|
||||
### La ligne du maître
|
||||
|
||||
Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne de
|
||||
44 px, réservée sous le bandeau du joueur (elle ne pousse rien) :
|
||||
|
||||
- icône d'appréciation, puis le **mot en couleur** suivi d'un point
|
||||
(« Bon coup. »), puis la **phrase du maître en italique**, sur une ligne,
|
||||
tronquée par des points de suspension si elle est trop longue, et un
|
||||
chevron ;
|
||||
- elle apparaît **après** la validation du coup du joueur (ou sa décision de
|
||||
videau), dès que l'analyse est prête, en fondu de `duration-fast` ; elle
|
||||
s'efface à l'action suivante du joueur (lancer les dés, toucher un pion) ;
|
||||
- **pendant que le joueur réfléchit, rien n'apparaît** ; si l'analyse arrive
|
||||
trop tard (le joueur a déjà relancé), la ligne n'apparaît pas, et le coup
|
||||
reste commenté dans le bilan ;
|
||||
- conseil désactivé : la ligne reste réservée et vide (la mise en page ne
|
||||
bouge pas).
|
||||
|
||||
### Les mots
|
||||
|
||||
| Seuil gnubg (écart d'équité) | Mot | Couleur | Icône |
|
||||
| --- | --- | --- | --- |
|
||||
| meilleur coup, ou moins de 0,03 | Bon coup | `verdict-good` | coche |
|
||||
| 0,03 à 0,06 (doubtful) | Imprécision | `verdict-doubtful` | point d'interrogation |
|
||||
| 0,06 à 0,12 (bad) | Erreur | `verdict-bad` | croix |
|
||||
| plus de 0,12 (very bad) | Grosse erreur | `verdict-bad` | croix |
|
||||
|
||||
Pour le videau : « Bon choix » et « Bonne décision » remplacent « Bon coup ».
|
||||
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
|
||||
« gaffe », et le choix final revient à Pierre (ton).
|
||||
|
||||
### La feuille d'explication
|
||||
|
||||
Maquette « Feuille d'explication du maître ». Toucher la ligne ouvre une
|
||||
feuille qui monte du bas (`duration-moderate`), au-dessus du plateau voilé :
|
||||
|
||||
1. le mot d'appréciation et le contexte (« Erreur · votre 3-1 d'ouverture ») ;
|
||||
2. le titre : le coup du maître, à la première personne (« J'aurais joué
|
||||
8/5 6/5 ») ; pour un bon coup : « C'est aussi le coup que j'aurais joué » ;
|
||||
3. la phrase du maître, en italique ;
|
||||
4. un sélecteur à deux segments, « Votre coup » et « Le coup du maître », au-
|
||||
dessus d'un plateau qui montre la position d'avant le coup ; le coup choisi
|
||||
se joue pion par pion (`duration-fast`), avec un tracé du départ à
|
||||
l'arrivée : `text` pour votre coup, `verdict-good` pour celui du maître ;
|
||||
5. « Voir les chiffres du moteur » (repliée par défaut) : le classement des
|
||||
coups avec leur écart d'équité et le niveau d'évaluation ; votre coup est en
|
||||
gras ;
|
||||
6. « Demander à une IA » (1f) et « Fermer ».
|
||||
|
||||
Le lien vers la fiche d'un concept du glossaire n'apparaît que si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
|
||||
### Les phrases du maître
|
||||
|
||||
- **Le maître parle à la première personne et vouvoie** : « J'aurais joué… »,
|
||||
jamais « Vous auriez dû… », jamais « Il fallait… ».
|
||||
- **Il ne cite que des faits** : le coup recommandé, le rang de votre coup,
|
||||
les pions frappés, les points faits ou laissés (lisibles dans le coup
|
||||
lui-même), l'écart d'équité sur demande. Aucune explication stratégique
|
||||
inventée (« la case la plus précieuse », « trop risqué ») : celles-là
|
||||
viendront des leçons, relues par un joueur fort.
|
||||
- **Trois parties au plus** : ce que vous avez fait, ce que le maître préfère,
|
||||
un fait qui les distingue. Moins de 140 caractères pour la ligne ; la
|
||||
feuille peut aller jusqu'à deux phrases.
|
||||
- **Ton** : encourageant et sobre. Pas de point d'exclamation, pas d'émoji, pas
|
||||
de superlatif. Les bons coups ont plusieurs formules qui tournent
|
||||
(« C'est le coup du maître. », « Exactement ce que j'aurais joué. »,
|
||||
« Bien vu. »), pour ne pas lasser.
|
||||
- **Exemples** (gabarits à remplir avec les données du moteur) :
|
||||
- Bon coup : « Bien vu. 8/5 6/5 fait votre point 5. »
|
||||
- Imprécision : « J'aurais joué {meilleur}. Votre coup était le
|
||||
{rang}e choix, tout près. »
|
||||
- Erreur : « J'aurais joué {meilleur}. Votre {joué} était le {rang}e
|
||||
choix. »
|
||||
- Videau, prise juste : « Bonne décision : prendre était le bon choix. »
|
||||
- Videau, double manqué : « Ici, j'aurais doublé. »
|
||||
|
||||
## 1e. Bilan et analyse
|
||||
|
||||
### Bilan (`#/parties/<id>/bilan`)
|
||||
|
||||
Maquette « Bilan de fin de partie ». Il remplace le panneau de fin de partie
|
||||
quand l'analyse est prête ; avant, le panneau actuel s'affiche et le bilan le
|
||||
remplace en place, sans saut.
|
||||
|
||||
1. `overline` « Fin de partie », titre `display` (« Vous gagnez », « L'ordinateur
|
||||
gagne un gammon »), puis une phrase : le résultat en points et une
|
||||
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
|
||||
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
|
||||
la chance donnés par le moteur, jamais inventée.
|
||||
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
|
||||
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
|
||||
couleur d'appréciation.
|
||||
3. Deux jauges sur une ligne chacune :
|
||||
- « Votre jeu » : le taux d'erreur traduit en niveau (correspondance avec
|
||||
les classements de gnubg documentée, mêmes noms que les niveaux de
|
||||
l'ordinateur), jauge en `verdict-good` ;
|
||||
- « Les dés » : la chance, jauge centrée (à droite si elle vous a aidé, à
|
||||
gauche sinon), en `border-strong`, avec trois mots possibles : « pour
|
||||
vous », « équilibrés », « contre vous » (« un peu » ou « nettement »
|
||||
selon l'écart).
|
||||
Lien « Comment lire ces mesures » vers les fiches du glossaire.
|
||||
4. « Le coup à revoir » : la plus grosse erreur, mini-plateau, mot
|
||||
d'appréciation, coup joué et coup du maître ; « Revoir ce coup » (ouvre
|
||||
l'analyse sur ce coup) et « Demander à une IA ». Sans erreur ni
|
||||
imprécision : la carte dit « Aucun coup à revoir : vous avez joué comme le
|
||||
maître. »
|
||||
5. Barre d'actions : « Voir l'analyse » et « Nouvelle partie » (principale).
|
||||
|
||||
### Analyse (`#/parties/<id>/analyse/<coup>`)
|
||||
|
||||
Maquette interactive « Analyse ». Une page qui défile, de haut en bas :
|
||||
|
||||
1. **Courbe de vos chances de gagner**, coup après coup, en pourcentage (pas
|
||||
en équité : un débutant lit « 41 % », pas « −0,18 »). Tracé `text` de 2 px,
|
||||
aire `accent-soft`, ligne pointillée à 50 %. Les coups commentés sont des
|
||||
points de couleur d'appréciation, les autres de petits points `text`.
|
||||
**On parcourt la courbe du doigt** : chaque coup possède une bande verticale
|
||||
sur toute la hauteur du graphique, qui sert de cible tactile ; le coup
|
||||
choisi porte un trait vertical brique et un point cerclé.
|
||||
2. **Visualiseur** : « ‹ Coup 7 sur 24 · 6-1 › », le plateau de la position
|
||||
d'avant le coup, la phrase du maître, le sélecteur « Votre coup » / « Le
|
||||
coup du maître » de la feuille 1d, puis « Rejouer d'ici » (crée une
|
||||
branche, ADR 0011 et 1b) et « Demander à une IA ».
|
||||
3. **Les coups à revoir** : filtres en pastilles de 44 px (« Tout »,
|
||||
« Erreurs », « Imprécisions », avec leurs nombres), puis une ligne par
|
||||
coup : numéro, dés, coup joué, mot d'appréciation. Toucher une ligne place
|
||||
le visualiseur et la courbe sur ce coup.
|
||||
|
||||
Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
|
||||
|
||||
## 1f. Demander à une IA
|
||||
|
||||
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
|
||||
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
|
||||
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
|
||||
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
|
||||
puis le texte du prompt reste lisible dans une zone `surface-sunken`
|
||||
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
|
||||
|
||||
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
|
||||
|
||||
Maquette « Leçons ». En tête, un rappel discret « Les règles du jeu ·
|
||||
revoir » (le tutoriel de 0e.5). Puis les cinq leçons numérotées, dans
|
||||
l'ordre, chacune avec une pastille ronde à son numéro :
|
||||
|
||||
- terminée : pastille `verdict-good`, « Terminée » ;
|
||||
- en cours : carte bordée de brique, pastille brique, « 2 exercices sur 4 »,
|
||||
barre de progression, bouton « Continuer » (seule action principale de
|
||||
l'écran) ;
|
||||
- à commencer : pastille au contour `border-strong`.
|
||||
|
||||
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
|
||||
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
|
||||
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur).
|
||||
|
||||
## 1h. Progression (`#/progression`)
|
||||
|
||||
Maquette « Progression ».
|
||||
|
||||
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
|
||||
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
|
||||
tendance monte, la phrase reste encourageante et sans reproche (« Votre
|
||||
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
|
||||
pour reprendre un point. »).
|
||||
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
|
||||
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
|
||||
vers la fiche « taux d'erreur ».
|
||||
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
|
||||
en chiffre gras aligné à droite), chacune ouvrant son bilan.
|
||||
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
|
||||
courbe apparaîtra après trois parties. ».
|
||||
|
||||
## 1i. Gestes sur le plateau
|
||||
|
||||
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
|
||||
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
|
||||
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
|
||||
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
|
||||
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
|
||||
sa place en `duration-fast`, sans vibration ni message.
|
||||
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
|
||||
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
|
||||
vaut un simple toucher, sans message d'erreur.
|
||||
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
|
||||
qu'une case d'arrivée).
|
||||
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
|
||||
tutoriel et l'aide en parlent.
|
||||
@@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
|
||||
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
|
||||
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
|
||||
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches alternées `point-a` (noyer clair) et `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
|
||||
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
|
||||
|
||||
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
|
||||
@@ -56,6 +56,10 @@ Sobre et utile : un pion glisse vers sa flèche (150 à 250 ms, sans rebond), le
|
||||
- Icônes d'interface : traits simples de 2px à bouts ronds, dessinés en SVG dans le code (pas de police d'icônes, pas d'émoji). Les seules icônes du système pour l'instant : les trois appréciations de coups (coche, point d'interrogation, croix) et la copie (deux feuillets).
|
||||
- Dés, pions et videau sont dessinés en SVG avec les couleurs du plateau, jamais en images.
|
||||
|
||||
## Interaction
|
||||
|
||||
Règles de comportement (réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l’écran de jeu) : section « Interaction ».
|
||||
|
||||
## Composants
|
||||
|
||||
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d'un coup (`MoveVerdict`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
|
||||
Plateau (`Board`) et ses pièces (`Checker`, `Die`, `DoublingCube`), boutons (`Button`), barre haute (`TopBar`), zone de consigne (`GamePrompt`), appréciation d’un coup (`MoveVerdict`), pictogrammes (`Icons`), fiche de glossaire (`GlossaryCard`), carte de leçon (`LessonCard`) et « Demander à une IA » (`AskAI`). Chacun a sa fiche avec ses règles d'usage et un aperçu dans les deux thèmes.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Interaction
|
||||
|
||||
Ustagammon doit se sentir **net et rapide**, comme un outil bien réglé : chaque toucher répond tout de suite, rien ne saute, rien n'interrompt la partie sans raison. La chaleur vient de la matière et du ton ; la précision vient du comportement.
|
||||
|
||||
## Principes
|
||||
|
||||
1. **Réponse immédiate.** Un toucher produit un effet visible en moins de 100 ms : le pion part, le bouton s'enfonce. Le moteur calcule ensuite, en arrière-plan ; l'interface ne l'attend jamais pour réagir.
|
||||
2. **Rien ne bouge sous le doigt.** Les zones de l'écran de jeu ont une hauteur fixe : seuls leurs contenus changent. Une ligne vide garde sa place.
|
||||
3. **Annuler plutôt que confirmer.** Une action se fait tout de suite et se rattrape pendant 10 s par un bandeau « Reprendre » (nouvelle partie, abandon). Une boîte de confirmation n'est permise que pour une décision de jeu irréversible qui engage la partie : prendre ou passer un double, doubler.
|
||||
4. **Une seule action principale par moment**, en brique, à droite, sous le pouce.
|
||||
5. **Le joueur garde la main.** L'app ne joue jamais à sa place : quand aucun coup n'est possible, les dés se barrent et le joueur passe en les touchant (demande explicite de Pierre).
|
||||
6. **Le maître parle après le coup, discrètement** (ADR 0011) : une ligne réservée sous le plateau, qui s'ouvre sur l'explication si on la touche. Jamais de fenêtre qui s'impose.
|
||||
7. **Le sens passe toujours par deux canaux** : couleur et mot, vibration et marque visible, raccourci et bouton.
|
||||
|
||||
## Mouvement
|
||||
|
||||
| Jeton | Valeur | Usage |
|
||||
| --- | --- | --- |
|
||||
| `duration-instant` | 90ms | Retour d'appui d'un bouton, apparition du contour d'un pion jouable. |
|
||||
| `duration-fast` | 160ms | Pion qui glisse d'une flèche à l'autre, dés qui se posent, bandeaux. |
|
||||
| `duration-moderate` | 240ms | Feuille du menu qui monte, panneau de fin de partie, bascule de mise en page. |
|
||||
| `easing-standard` | cubic-bezier(0.2, 0, 0, 1) | Tout ce qui se déplace : départ franc, arrivée douce. |
|
||||
| `easing-exit` | cubic-bezier(0.4, 0, 1, 1) | Ce qui disparaît. |
|
||||
|
||||
- Pas de rebond, pas d'élastique, pas de fondu de plus de 240 ms.
|
||||
- Les coups de l'ordinateur se jouent demi-coup par demi-coup, 160 ms par pion, 120 ms de pause entre deux : on suit le coup sans l'attendre.
|
||||
- Le bouton enfoncé descend de 1 px et s'assombrit de 6 %, sans ombre.
|
||||
- `prefers-reduced-motion` : les déplacements deviennent des apparitions instantanées ; les dés ne roulent pas.
|
||||
|
||||
## Vibrations
|
||||
|
||||
Activées par défaut, désactivables dans le menu (« Vibrations »). Elles doublent un événement visible, jamais seules. Rien ne vibre pour un toucher de bouton ni pour le lancer des dés.
|
||||
|
||||
| Moment | Motif (ms) |
|
||||
| --- | --- |
|
||||
| Votre pion se pose | `12` |
|
||||
| Un pion est frappé | `25, 40, 25` |
|
||||
| L'ordinateur propose de doubler | `40, 60, 40` |
|
||||
| Fin de partie gagnée | `30, 50, 30, 50, 60` |
|
||||
| Fin de partie perdue | `60` |
|
||||
|
||||
Les durées se règlent en main sur le Fairphone 4. Une seule vibration à la fois : la dernière annule la précédente.
|
||||
|
||||
## Clavier (ordinateur)
|
||||
|
||||
Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) quand un clavier est détecté. `?` ouvre la liste.
|
||||
|
||||
| Touche | Action |
|
||||
| --- | --- |
|
||||
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
|
||||
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
|
||||
| `↑` `↓` | Choisir sa case d'arrivée |
|
||||
| `Entrée` | Poser le pion |
|
||||
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
|
||||
| `D` | Doubler |
|
||||
| `P` / `N` | Prendre / passer, quand l'ordinateur double |
|
||||
| `F` | Plein écran |
|
||||
| `M` | Menu |
|
||||
| `?` | Aide et raccourcis |
|
||||
| `Échap` | Fermer le menu, l'aide ou la bulle ouverte ; ne touche jamais à la partie |
|
||||
|
||||
Le plateau entier se joue au clavier : c'est aussi ce qui le rend accessible aux lecteurs d'écran.
|
||||
|
||||
## Pictogrammes
|
||||
|
||||
Trait de 2 px à bouts ronds, sur une grille de 20 × 20, `currentColor`, dessinés en SVG dans le code. Le jeu complet : menu, aide, plein écran, quitter le plein écran, annuler (flèche de retour), revoir (flèche circulaire), fermer, chevron, copier, et les trois appréciations (coche, point d'interrogation, croix dans un cercle). En portrait, la barre haute affiche un libellé de 13 px sous chaque pictogramme ; en paysage et sur ordinateur, le pictogramme seul avec son `aria-label`.
|
||||
|
||||
## Mises en page de l'écran de jeu
|
||||
|
||||
- **Portrait** : barre haute, bandeau de l'ordinateur, plateau, bandeau du joueur (teinté `accent-soft` quand c'est à lui), ligne du maître ou du dernier coup, consigne, et barre d'actions collée en bas. Hauteurs fixes.
|
||||
- **Paysage** (dès que le téléphone est tourné ; ADR 0012) : le plateau est l'écran. Colonne de commandes à gauche dans le cadre (menu, videau, annuler ou revoir), sorties à droite, comptes de points en pastilles aux deux bouts de la barre, consigne et dés dans la bande centrale. Numéros des flèches à 12 px au moins.
|
||||
- **Ordinateur** : plateau à gauche, colonne à droite avec les bandeaux des joueurs, la liste des coups, la consigne, les actions et leurs raccourcis.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Recette design de l'écran de jeu
|
||||
|
||||
Critères vérifiables sur les captures de `make test-browser` (412×915,
|
||||
915×412, 844×320, thèmes clair et sombre) et à la main sur le Fairphone 4.
|
||||
Chaque ligne se coche ou se refuse ; une ligne refusée dit pourquoi.
|
||||
Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
|
||||
## Toutes tailles
|
||||
|
||||
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
|
||||
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
|
||||
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
|
||||
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
|
||||
- [ ] `overscroll-behavior: none` : tirer vers le bas ne recharge pas la page.
|
||||
|
||||
## Portrait (412 × 915)
|
||||
|
||||
- [ ] Ordre de haut en bas : barre haute, bandeau ordinateur, plateau, bandeau joueur, ligne du maître ou du dernier coup, consigne, barre d'actions collée en bas.
|
||||
- [ ] Stabilité : sur une partie complète, la position verticale du plateau et de la barre d'actions ne varie pas d'un pixel (comparer les boîtes entre deux captures successives).
|
||||
- [ ] Barre haute : pictogrammes avec libellé de 13 px (Aide, Plein écran, Menu) ; pas de bouton « Nouvelle partie ».
|
||||
- [ ] Comptes de points en 20 px gras, chiffres alignés ; le bandeau du joueur qui a la main est teinté `accent-soft` ; l'écart s'écrit « mène de N ».
|
||||
- [ ] Plus de durée de calcul « (0,5 s) » dans les messages.
|
||||
- [ ] Aucun coup possible : dés barrés et message ; le tour ne passe qu'au toucher des dés.
|
||||
- [ ] Conseil du maître : une ligne, après le coup du joueur seulement ; toucher l'ouvre.
|
||||
|
||||
## Paysage (915 × 412 et 844 × 320)
|
||||
|
||||
- [ ] Le cadre noyer touche les quatre bords ; aucune marge de page, aucune colonne de texte.
|
||||
- [ ] Flèches d'au moins 44 px de large ; pions d'au moins 22 px de diamètre à 844 × 320.
|
||||
- [ ] Colonne de commandes à gauche : menu en haut, videau au milieu, annuler (ou revoir) en bas ; colonne des sorties libre.
|
||||
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
|
||||
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
|
||||
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
|
||||
|
||||
## Comportement
|
||||
|
||||
- [ ] Un toucher sur un pion produit un effet visible en moins de 100 ms (trace de performance de Playwright).
|
||||
- [ ] Déplacements à 160 ms en `easing-standard`, sans rebond ; coups de l'ordinateur pion par pion ; tout est instantané avec `prefers-reduced-motion`.
|
||||
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
|
||||
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
|
||||
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
|
||||
@@ -195,7 +195,7 @@
|
||||
"light": "#6B4430",
|
||||
"dark": "#3A2519"
|
||||
},
|
||||
"usage": "Flèches sombres (noyer clair), une sur deux."
|
||||
"usage": "Flèches impaires (1, 3, 5…), noyer clair."
|
||||
},
|
||||
{
|
||||
"name": "point-b",
|
||||
@@ -203,7 +203,7 @@
|
||||
"light": "#C9AE88",
|
||||
"dark": "#7E6142"
|
||||
},
|
||||
"usage": "Flèches claires (chêne), une sur deux."
|
||||
"usage": "Flèches paires (2, 4, 6…), chêne."
|
||||
},
|
||||
{
|
||||
"name": "point-number",
|
||||
@@ -526,5 +526,38 @@
|
||||
},
|
||||
"meta": {
|
||||
"source": "Brief créatif + ADR 0010 + web/app.css (noms des variables du plateau)"
|
||||
},
|
||||
"duration": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "duration-instant",
|
||||
"value": "90ms",
|
||||
"usage": "Retour d'appui d'un bouton, contour d'un pion jouable."
|
||||
},
|
||||
{
|
||||
"name": "duration-fast",
|
||||
"value": "160ms",
|
||||
"usage": "Pion qui glisse, dés qui se posent, bandeaux."
|
||||
},
|
||||
{
|
||||
"name": "duration-moderate",
|
||||
"value": "240ms",
|
||||
"usage": "Feuille du menu, panneau de fin de partie, bascule de mise en page. Jamais plus long."
|
||||
}
|
||||
]
|
||||
},
|
||||
"easing": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "easing-standard",
|
||||
"value": "cubic-bezier(0.2, 0, 0, 1)",
|
||||
"usage": "Tout ce qui se déplace : départ franc, arrivée douce."
|
||||
},
|
||||
{
|
||||
"name": "easing-exit",
|
||||
"value": "cubic-bezier(0.4, 0, 1, 1)",
|
||||
"usage": "Ce qui disparaît."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+65
-2
@@ -128,10 +128,73 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
la session technique implémente. Numéros des flèches à 12 px minimum.
|
||||
- Décision de Pierre : la vue paysage « plateau = écran » s'applique dès la
|
||||
rotation du téléphone, sans plein écran (amendement de l'ADR 0012).
|
||||
- Mode avion vérifié par Pierre sur le téléphone : l'application fonctionne
|
||||
hors ligne.
|
||||
- Étape 1 découpée dans `docs/etape-1.md` (sous-étapes 1a à 1j).
|
||||
- Nouvel objectif fixé par Pierre : traiter les retours 0e (les siens et ceux
|
||||
du designer), puis réaliser l'étape 1, livrée avec un plan de test.
|
||||
|
||||
**Reste à faire**
|
||||
- Le test en mode avion sur le téléphone n'a pas été rapporté ; le hors ligne
|
||||
est vérifié dans Chromium par `make test-browser`.
|
||||
- Firefox sur le téléphone reste à mesurer.
|
||||
|
||||
## 2026-11-03 (jour) : coordination design et développement
|
||||
|
||||
**Fait**
|
||||
- Leçons numérotées, conseil du maître après le coup et bilan de fin de partie
|
||||
retenus par Pierre (ADR 0011) ; mode immersif en paysage (ADR 0012), puis
|
||||
vue paysage dès la rotation, avec le plateau qui occupe tout l'écran.
|
||||
- Revue UX du designer de la session de développement (étape 0e.2), arbitrée
|
||||
par la session design sur délégation de Pierre : portrait à zones fixes,
|
||||
compte de points en bandeaux (pastilles en paysage), menu en feuille,
|
||||
« annuler plutôt que confirmer », pas de passage automatique du tour,
|
||||
vibrations, pictogrammes, clavier sur ordinateur. Règles dans
|
||||
`docs/identite/charte/interaction.md` (MR !23), second amendement de
|
||||
l'ADR 0012 (placement des contrôles en paysage).
|
||||
- Maquettes des variantes sur le canevas (paysage clair, sombre, avec barre
|
||||
d'adresse, fin de partie ; portrait révisé, conseil, nouvelle partie
|
||||
annulable ; menu ; ordinateur avec raccourcis).
|
||||
- Couleur des flèches corrigée dans la charte (impaires en noyer clair, paires
|
||||
en chêne ; MR !25). La correction dans l'app est faite par la session de
|
||||
développement, dans la MR de saisie du coup.
|
||||
|
||||
**Reste à faire**
|
||||
- Implémentation par la session de développement (plan
|
||||
`docs/plans/2026-11-03-revision-ux.md`), puis revue design sur ses captures
|
||||
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
|
||||
Pierre sur le téléphone.
|
||||
- Réglage en main des vibrations sur le Fairphone 4.
|
||||
|
||||
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
|
||||
|
||||
**Fait**
|
||||
- Retours du premier test sur téléphone, traités et publiés :
|
||||
- saisie du coup (MR !28) ;
|
||||
- révision UX arbitrée par la session « identité » (MR !35) ;
|
||||
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
|
||||
- Fusionnés :
|
||||
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
|
||||
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
|
||||
- Décisions techniques :
|
||||
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
|
||||
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
|
||||
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
|
||||
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
|
||||
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
|
||||
- « Grosse erreur » plutôt que « gaffe » ;
|
||||
- pas d'indice en V1 ;
|
||||
- pas de relecture préalable des leçons ;
|
||||
- pas de plein écran par défaut ;
|
||||
- seuils de gnubg ;
|
||||
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
|
||||
- Nouvelles demandes de Pierre, intégrées aux plans :
|
||||
- une adresse pour chaque écran ;
|
||||
- la mise à jour forcée de l'app (0e.5) ;
|
||||
- un coach bienveillant, mis en avant mais discret.
|
||||
|
||||
**Reste à faire**
|
||||
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
|
||||
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
|
||||
- Étape 1 :
|
||||
- 1c en cours ;
|
||||
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
|
||||
- enfin le plan de test 1j.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Fin de partie et analyse (1e)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1e (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
|
||||
|
||||
## Consignes
|
||||
|
||||
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
|
||||
|
||||
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
|
||||
- le taux d'erreur à la manière de gnubg ;
|
||||
- la chance aux dés (`luck`) pour chaque camp ;
|
||||
- le décompte des coups par appréciation.
|
||||
2. **Bilan**, dans le panneau de fin de partie existant :
|
||||
- le résultat ;
|
||||
- les coups par appréciation ;
|
||||
- le taux d'erreur comparé à la chance, en phrases simples ;
|
||||
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
|
||||
- un emplacement pour le bouton « Demander à une IA » (1f) ;
|
||||
- « Voir l'analyse ».
|
||||
3. **Écran d'analyse** :
|
||||
- un graphe d'équité où toucher un point ouvre la position ;
|
||||
- la liste des erreurs, filtrable par appréciation ;
|
||||
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
|
||||
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
|
||||
4. **Tests** :
|
||||
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
|
||||
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
|
||||
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
|
||||
- L'analyse s'ouvre par son adresse.
|
||||
- « Rejouer d'ici » crée une branche.
|
||||
- Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user