Compare commits
85
Commits
@@ -1,7 +1,7 @@
|
||||
# CLAUDE.md — Règles du projet
|
||||
|
||||
Ce fichier est lu par Claude Code au début de chaque session. Il fixe le cadre du projet.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, l'étape en cours dans `docs/etape-0.md`.
|
||||
Le cadrage complet est dans `docs/cadrage.md`, le travail en cours dans `docs/feuille-de-route.md`.
|
||||
|
||||
**En début de session**, lire `docs/organisation.md`, `docs/feuille-de-route.md`, la fin
|
||||
de `docs/journal.md`, l'index des ADR (`docs/adr/README.md`) et les plans en cours dans `docs/plans/`, puis reprendre là où en
|
||||
|
||||
+8
-1
@@ -27,8 +27,15 @@ Les contenus pédagogiques (leçons, textes) sont sous licence CC BY-SA 4.0.
|
||||
(Juan Pablo del Peral, Huerta Tipográfica), licence
|
||||
SIL Open Font License 1.1 — https://github.com/google/fonts/tree/main/ofl/alegreyasans
|
||||
Ses tracés (graisse Bold) composent le logotype, dans `docs/identite/logo/`.
|
||||
Cinq graisses (Regular, Italic, Medium, Bold, ExtraBold) sont embarquées
|
||||
dans l'application (`web/fonts/`, avec la licence `OFL.txt`), réduites aux
|
||||
caractères latins et converties en woff2 par fontTools (143 Ko en tout).
|
||||
- **Logo et icônes d'Ustagammon** (`docs/identite/logo/`), licence
|
||||
CC BY-SA 4.0 ; publiés dans `icons/` du site (favicon, icônes de la PWA,
|
||||
logo de la barre haute).
|
||||
- **fontTools** (MIT), utilisé par `docs/identite/generer-logo.py` pour
|
||||
convertir le texte du logotype en tracés.
|
||||
convertir le texte du logotype en tracés, et pour produire les fichiers
|
||||
woff2 de la police ; avec **Brotli** (MIT), qui les compresse.
|
||||
|
||||
## Outils de développement
|
||||
|
||||
|
||||
@@ -107,17 +107,23 @@ 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
|
||||
|
||||
deploy-web: deploy ## Comme make deploy (ancien nom, étape 0b)
|
||||
|
||||
# Fichiers de docs/identite/logo/ publiés dans icons/ (manifeste, favicon,
|
||||
# barre haute).
|
||||
SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone-maskable-512.png \
|
||||
apple-touch-icon-180.png favicon.svg favicon-32.png \
|
||||
logo-horizontal-couleur.svg logo-horizontal-sombre.svg symbole-couleur.svg symbole-sombre.svg
|
||||
|
||||
# Le site tel qu'il sera publié, reconstruit entièrement à chaque appel :
|
||||
# - web/ à la racine : la page de l'application, son style, le manifeste, les
|
||||
# icônes, le Web Worker du moteur, et les pages de test sous moteur/ ;
|
||||
# - web/ à la racine : la page de l'application, son style, sa police, le
|
||||
# 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
|
||||
@@ -128,6 +134,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
rm -rf $(BUILD_DIR)/site
|
||||
mkdir -p $(BUILD_DIR)/site/wasm $(BUILD_DIR)/site/docs
|
||||
cp -r web/. $(BUILD_DIR)/site/
|
||||
mkdir -p $(BUILD_DIR)/site/icons
|
||||
cp $(addprefix docs/identite/logo/,$(SITE_ICONS)) $(BUILD_DIR)/site/icons/
|
||||
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/
|
||||
@@ -155,8 +163,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
|
||||
|
||||
@@ -38,6 +38,141 @@ 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'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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+197
-38
@@ -47,6 +47,20 @@ export interface SideBoards {
|
||||
readonly computer: BoardSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup joué, demi-coup par demi-coup, pour le montrer : les plateaux
|
||||
* successifs, tels que le moteur les donne.
|
||||
*/
|
||||
export interface MoveSteps {
|
||||
readonly side: Side;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** boards[0] : avant le coup ; boards[i + 1] : après le demi-coup i. */
|
||||
readonly boards: readonly SideBoards[];
|
||||
/** hits[i] : le demi-coup i a frappé un pion adverse, envoyé sur la barre
|
||||
* (lu sur les plateaux du moteur, pas déduit des règles). */
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
@@ -65,9 +79,10 @@ export class Game {
|
||||
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;
|
||||
@@ -127,12 +142,37 @@ export class Game {
|
||||
|
||||
/** 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
|
||||
@@ -141,14 +181,45 @@ 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).
|
||||
const onRoll = state.turn ?? "player";
|
||||
const board = await this.#engine.board(
|
||||
this.displayedPosition,
|
||||
engineCube(state.cube, onRoll),
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le dernier coup joué de la partie, demi-coup par demi-coup (null s'il
|
||||
* n'y en a pas). Les plateaux intermédiaires viennent du moteur
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
|
||||
);
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +231,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);
|
||||
@@ -175,7 +246,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);
|
||||
@@ -199,47 +270,81 @@ export class Game {
|
||||
|
||||
// ------------------------------------------------------------- Joueur
|
||||
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer, son tour passe. */
|
||||
/** Le joueur lance les dés. S'il ne peut pas jouer (cannotMove), son
|
||||
* tour ne passe qu'une fois qu'il l'a constaté (skipTurn). */
|
||||
async roll(): Promise<void> {
|
||||
const state = this.#expect("player", "rolling");
|
||||
const roll = rollDice(this.#dice);
|
||||
this.#append({ type: "DiceRolled", side: "player", roll });
|
||||
const moves = await this.#engine.legalMoves(state.position, roll);
|
||||
if (moves.length === 0) {
|
||||
await this.#playMove("player", [], null);
|
||||
this.#expect("player", "rolling");
|
||||
this.#append({ type: "DiceRolled", side: "player", roll: rollDice(this.#dice) });
|
||||
}
|
||||
|
||||
/** Le joueur a lancé les dés, mais le moteur ne lui donne aucun coup
|
||||
* légal : il ne peut que laisser passer son tour. */
|
||||
async cannotMove(): Promise<boolean> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return false;
|
||||
}
|
||||
const moves = await this.#engine.legalMoves(state.position, rollToPlay(state));
|
||||
return moves.length === 0;
|
||||
}
|
||||
|
||||
/** Le joueur, qui ne peut pas jouer son jet, laisse passer son tour.
|
||||
* Erreur s'il a un coup possible. */
|
||||
async skipTurn(): Promise<void> {
|
||||
this.#expect("player", "moving");
|
||||
if (!(await this.cannotMove())) {
|
||||
throw new Error("vous avez un coup possible : jouez-le");
|
||||
}
|
||||
await this.#playMove("player", [], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -325,6 +430,14 @@ export class Game {
|
||||
this.#journal = appendEvent(this.#journal, event);
|
||||
}
|
||||
|
||||
/** 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));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
}
|
||||
|
||||
/** Vérifie que c'est bien à side d'agir, dans l'une des phases. */
|
||||
#expect(side: Side, ...phases: GameState["phase"][]): GameState {
|
||||
const state = this.state;
|
||||
@@ -384,6 +497,52 @@ export class Game {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -12,12 +12,20 @@ import type {
|
||||
Board,
|
||||
BoardResult,
|
||||
BoardSide,
|
||||
CubeAction,
|
||||
CubeAnalysis,
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
EvaluatedMove,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Luck,
|
||||
LuckRating,
|
||||
Move,
|
||||
MoveAnalysis,
|
||||
Ply,
|
||||
Skill,
|
||||
} from "../domain/game/engine.js";
|
||||
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
@@ -32,6 +40,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;
|
||||
@@ -86,8 +104,108 @@ 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 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 };
|
||||
},
|
||||
|
||||
async checkHalfMoves(
|
||||
@@ -139,6 +257,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 };
|
||||
}
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import { Game } from "./domain/game/game.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
@@ -16,6 +17,7 @@ import { startApp } from "./ui/app.js";
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
const settings = createSettingsStore(window.localStorage);
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
@@ -31,8 +33,12 @@ startApp({
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
resumeGame: (journal) => Game.resume(engine, journal),
|
||||
saveGame: (journal) => store.save(journal),
|
||||
confirm: (question) => window.confirm(question),
|
||||
cachedBoard: () => settings.loadBoard(),
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 "./game-store.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: KeyValueStorage): 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
+1127
-145
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,71 @@ 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é. */
|
||||
readonly side: Side;
|
||||
/** Une flèche par demi-coup, de sa case de départ à sa case d'arrivée. */
|
||||
readonly arrows: readonly { readonly from: Place; readonly to: Place }[];
|
||||
/** Par endroit, le nombre de pions arrivés et restés là (entourés). */
|
||||
readonly landed: ReadonlyMap<Place, number>;
|
||||
/** Par endroit, le nombre de pions partis (un pion fantôme chacun). */
|
||||
readonly left: ReadonlyMap<Place, number>;
|
||||
/** Le nombre de pions adverses frappés, envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les traces d'un coup de side : ses demi-coups, et pour chacun s'il a frappé.
|
||||
* Un pion qui fait étape sur une case (13/7 7/4) n'y laisse ni fantôme ni
|
||||
* cercle : seuls comptent le départ et l'arrivée de chaque pion.
|
||||
*/
|
||||
export function moveTrace(
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
hits: readonly boolean[],
|
||||
): MoveTrace {
|
||||
const balance = new Map<Place, number>();
|
||||
const arrows = halfMoves.map((h) => {
|
||||
const from = placeOf(side, h.from);
|
||||
const to = placeOf(side, h.to);
|
||||
balance.set(from, (balance.get(from) ?? 0) - 1);
|
||||
balance.set(to, (balance.get(to) ?? 0) + 1);
|
||||
return { from, to };
|
||||
});
|
||||
const landed = new Map<Place, number>();
|
||||
const left = new Map<Place, number>();
|
||||
for (const [place, n] of balance) {
|
||||
if (n > 0) {
|
||||
landed.set(place, n);
|
||||
} else if (n < 0) {
|
||||
left.set(place, -n);
|
||||
}
|
||||
}
|
||||
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 } {
|
||||
@@ -90,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 };
|
||||
}
|
||||
|
||||
+651
-143
@@ -7,16 +7,48 @@
|
||||
// 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 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 { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
export interface ShownDie {
|
||||
readonly value: number;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
@@ -28,29 +60,31 @@ export interface BoardDecor {
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
readonly selected: Place | null;
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les cases de départ et d'arrivée du dernier coup de l'ordinateur. */
|
||||
readonly lastMove: ReadonlySet<Place>;
|
||||
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
export const NO_MARKS = {
|
||||
movable: new Set<Place>(),
|
||||
selected: null,
|
||||
targets: new Set<Place>(),
|
||||
trace: null,
|
||||
diceAction: null,
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
const OFF_SLAB = 12; // épaisseur d'un pion sorti
|
||||
const DIE = 36;
|
||||
const CUBE = 34;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -68,56 +102,154 @@ 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;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/** Centre du i-ième pion d'une pile, depuis le bord du plateau. */
|
||||
function stackY(top: boolean, index: number): number {
|
||||
const offset = RADIUS + 1 + index * STEP;
|
||||
return top ? FRAME + offset : HEIGHT - FRAME - offset;
|
||||
/**
|
||||
* Centre du pion numéro index (0 : le premier posé) d'un endroit : sur une
|
||||
* case, depuis le bord du plateau ; sur la barre, depuis le milieu (le
|
||||
* 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(g: Geometry, place: Place, index: number): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step;
|
||||
return {
|
||||
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"
|
||||
? 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)));
|
||||
return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) };
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
if (place === `bar-${side}`) {
|
||||
return board.bar[side];
|
||||
}
|
||||
if (place === `off-${side}`) {
|
||||
return board.off[side];
|
||||
}
|
||||
const point = board.points.find((p) => p.place === place);
|
||||
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");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, hits, board, point.number, decor);
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
drawBar(svg, pieces, g, hits, board, decor);
|
||||
drawTray(svg, pieces, g, hits, board, decor);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceArrows(svg, g, board, decor.trace);
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = 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.classList.add("can-double");
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
drawBar(svg, hits, board, decor);
|
||||
drawTray(svg, hits, board, decor);
|
||||
drawDice(svg, decor.dice);
|
||||
drawCube(svg, decor.cube);
|
||||
// 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,
|
||||
@@ -128,62 +260,70 @@ 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) {
|
||||
const y = (i: number) => stackY(top, i);
|
||||
drawStack(svg, point.owner, point.count, center, y, point.place, decor);
|
||||
}
|
||||
if (decor.lastMove.has(point.place)) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: center,
|
||||
cy: top ? MIDDLE - 12 : MIDDLE + 12,
|
||||
r: 5,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une pile de pions, le i-ième centré en (cx, y(i)) ; le dernier porte le
|
||||
* nombre total s'il y en a trop pour les montrer tous. */
|
||||
/** Une pile de pions ; le dernier porte le nombre total s'il y en a trop
|
||||
* 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,
|
||||
cx: number,
|
||||
y: (index: number) => 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}`];
|
||||
@@ -192,81 +332,369 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const cy = y(i);
|
||||
add(svg, "circle", { class: classes.join(" "), cx, cy, r: RADIUS });
|
||||
if (last && count > MAX_STACK) {
|
||||
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 > limit) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: `checker-count count-${side}`, x: cx, y: cy + 6 },
|
||||
{
|
||||
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,
|
||||
});
|
||||
const cx = BAR_LEFT + BAR_WIDTH / 2;
|
||||
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) {
|
||||
const up = side === "player";
|
||||
const y = (i: number) =>
|
||||
up ? MIDDLE - RADIUS - 4 - i * STEP : MIDDLE + RADIUS + 4 + i * STEP;
|
||||
drawStack(svg, side, board.bar[side], cx, y, `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) {
|
||||
for (let i = 0; i < board.off[side]; i++) {
|
||||
const y =
|
||||
side === "computer" ? FRAME + i * OFF_SLAB : HEIGHT - FRAME - (i + 1) * OFF_SLAB;
|
||||
add(svg, "rect", {
|
||||
class: `checker checker-${side}`,
|
||||
x: TRAY_LEFT + 4,
|
||||
y: y + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; 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,
|
||||
});
|
||||
}
|
||||
if (decor.lastMove.has("off-computer")) {
|
||||
add(svg, "circle", {
|
||||
class: "last-move",
|
||||
cx: TRAY_LEFT + TRAY_WIDTH / 2,
|
||||
cy: MIDDLE - 70,
|
||||
r: 5,
|
||||
});
|
||||
}
|
||||
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, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: g.trayLeft + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
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,
|
||||
g: Geometry,
|
||||
cssClass: string,
|
||||
place: Place,
|
||||
spot: Spot,
|
||||
r: number,
|
||||
): void {
|
||||
if (place.startsWith("off-")) {
|
||||
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;
|
||||
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 + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
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) {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
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 (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 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, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(g, board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
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(g, board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
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,
|
||||
markerHeight: 3,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
const departures = new Map<Place, number>();
|
||||
for (const { from, to } of trace.arrows) {
|
||||
// De la place libre laissée par le pion (son fantôme) à son pion
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
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(g: Geometry, board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
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, 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);
|
||||
const keep = length > 2 * gap ? (length - gap) / length : 0.5;
|
||||
add(svg, "line", {
|
||||
class: "trace-arrow",
|
||||
x1: start.x,
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"stroke-width": g.arrowWidth,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre du pion du dessus d'un endroit, sur le plateau tel qu'il est
|
||||
* dessiné (null s'il n'y a pas de pion). */
|
||||
export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
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).
|
||||
* Résolue à la fin du glissement.
|
||||
*/
|
||||
export async function slideChecker(
|
||||
svg: SVGSVGElement,
|
||||
place: Place,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const checker = svg.querySelector(`[data-checker="${place}"]`);
|
||||
const to = checkerSpot(svg, place);
|
||||
if (checker === null || to === null || typeof checker.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
// Devant les autres pions pendant le trajet, mais derrière les zones à
|
||||
// toucher, toujours dessinées en dernier.
|
||||
svg.insertBefore(checker, svg.lastChild);
|
||||
const animation = checker.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: EASING_STANDARD },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
// Position des points d'un dé, en fractions de son côté.
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[0.5, 0.5]],
|
||||
@@ -303,40 +731,120 @@ 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 group = add(svg, "g", { class: "die", "data-value": die.value });
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
const x = center - total / 2 + i * (size + gap);
|
||||
const y = g.height / 2 - size / 2;
|
||||
const group = add(svg, "g", {
|
||||
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: 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 + 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): void {
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
const x = TRAY_LEFT + (TRAY_WIDTH - CUBE) / 2;
|
||||
const y = MIDDLE + shift - 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: x + CUBE / 2, y: y + CUBE / 2 + 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. */
|
||||
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: g.trayLeft + g.tray / 2, y: middle + shift };
|
||||
}
|
||||
|
||||
/** 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 - gap / 2 - g.die, center + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: g.height / 2 - g.die / 2,
|
||||
width: g.die,
|
||||
height: g.die,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* 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 (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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une zone à toucher. Elle porte son état (can-move, can-land) pour les
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+11
-4
@@ -15,7 +15,10 @@ import {
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = ["docs/scenarios/jouer-une-partie.feature"];
|
||||
const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -32,6 +35,12 @@ async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
@@ -91,10 +100,8 @@ export async function showHelp(container: HTMLElement): Promise<void> {
|
||||
renderFeature(container, feature);
|
||||
}
|
||||
renderGlossary(container, glossary);
|
||||
const about = element(container, "p", `${TEXTS.about} `);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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",
|
||||
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",
|
||||
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,63 @@
|
||||
// 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).
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
vibrations: true,
|
||||
keepScreenOn: true,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
};
|
||||
|
||||
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
|
||||
export const LONG_PROMPT_GAMES = 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") =>
|
||||
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
const games = raw.gamesStarted;
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted:
|
||||
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
+95
-26
@@ -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,50 +38,118 @@ export const TEXTS = {
|
||||
double: "Doubler",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
confirm: "Jouer ce coup",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
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[]) =>
|
||||
`Vous ne pouvez pas jouer ${roll(dice)} : votre tour passe.`,
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
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",
|
||||
@@ -87,5 +157,4 @@ export const TEXTS = {
|
||||
helpLoading: "Chargement de l'aide…",
|
||||
helpFailed: "L'aide n'a pas pu se charger.",
|
||||
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
|
||||
engineTestLink: "Test du moteur",
|
||||
} as const;
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -36,10 +36,17 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
} else {
|
||||
assert.ok(state.roll !== null);
|
||||
const [first] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(first !== undefined, "le joueur devrait avoir un coup");
|
||||
if (first === undefined) {
|
||||
// Aucun coup possible : le joueur laisse passer son tour.
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
continue;
|
||||
}
|
||||
for (const halfMove of first.halfMoves) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
await game.confirmMove();
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
|
||||
+132
-1
@@ -49,6 +49,16 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
return { valid: false, complete: false, position: null };
|
||||
},
|
||||
board: async () => notOver,
|
||||
levelMove: async () => move,
|
||||
levelCubeDecision: async () => noDouble,
|
||||
analyseMove: async () => null,
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
equityLoss: 0,
|
||||
skill: "none",
|
||||
}),
|
||||
luck: async () => ({ value: 0, rating: "none" }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -78,27 +88,145 @@ 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("sans coup possible, le tour passe", async () => {
|
||||
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, {
|
||||
dice: fixedDice([6, 6]),
|
||||
setup: { position: "BLOCKED", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
// Le tour ne passe pas tout seul : le jet reste à « jouer ».
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
await game.skipTurn();
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed");
|
||||
assert.deepEqual(last.halfMoves, []);
|
||||
assert.equal(game.state.turn, "computer");
|
||||
});
|
||||
|
||||
test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () => {
|
||||
const engine = fakeEngine({
|
||||
legalMoves: async () => [
|
||||
{ notation: "8/5", halfMoves: [{ from: 8, to: 5 }], position: "P" },
|
||||
],
|
||||
});
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: { position: "X", firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
assert.equal(await game.cannotMove(), false);
|
||||
await assert.rejects(game.skipTurn(), /coup possible/);
|
||||
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 }),
|
||||
@@ -171,6 +299,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");
|
||||
|
||||
@@ -179,6 +308,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");
|
||||
@@ -223,6 +353,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);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// 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", () => {
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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,146 @@
|
||||
// Un coup demi-coup par demi-coup (Game.lastMoveSteps), sur le vrai moteur
|
||||
// WebAssembly : les plateaux intermédiaires que l'interface anime, et les
|
||||
// pions frappés qu'elle signale.
|
||||
//
|
||||
// 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 { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
formatHalfMoves,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
} from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 13, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 10 du joueur (sa case 15), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_CAN_HIT = "4P8HEADg/wcEAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur : un blot sur la case 21 du joueur (sa case 4), quatorze sur
|
||||
// sa case 6.
|
||||
const PLAYER_ON_BAR = "yP8PAADg/wcAQA";
|
||||
|
||||
// Le joueur, au trait, n'a plus que deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
/** Une partie où le joueur, au trait, joue le jet dice à la position. */
|
||||
async function playerMove(position: PositionId, dice: [number, number], move: string) {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice(dice),
|
||||
setup: { position, firstTurn: "player" },
|
||||
});
|
||||
await game.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true, formatHalfMoves([halfMove]));
|
||||
}
|
||||
await game.confirmMove();
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Le nombre de pions d'un camp sur une case (1 à 24), sur la barre ou sortis. */
|
||||
function at(side: BoardSide, point: number | "bar" | "off"): number {
|
||||
if (point === "bar") {
|
||||
return side.bar;
|
||||
}
|
||||
return point === "off" ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
test("un coup simple : un plateau par demi-coup, sans pion frappé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
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");
|
||||
assert.equal(formatHalfMoves(steps.halfMoves), "8/5 6/5");
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
const counts = steps.boards.map((b) => [at(b.player, 8), at(b.player, 6), at(b.player, 5)]);
|
||||
assert.deepEqual(counts, [
|
||||
[3, 5, 0],
|
||||
[2, 5, 1],
|
||||
[2, 4, 2],
|
||||
]);
|
||||
// Le dernier plateau est celui de la partie après le coup.
|
||||
assert.deepEqual(steps.boards[2], await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("un pion frappé part sur la barre au demi-coup qui le frappe", async () => {
|
||||
const game = await playerMove(PLAYER_CAN_HIT, [3, 1], "13/10 10/9");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
const bar = steps.boards.map((b) => at(b.computer, "bar"));
|
||||
assert.deepEqual(bar, [0, 1, 1]);
|
||||
// Le blot était sur la case 10 du joueur, la case 15 de l'ordinateur.
|
||||
assert.equal(at(steps.boards[0]?.computer as BoardSide, 15), 1);
|
||||
assert.equal(at(steps.boards[1]?.computer as BoardSide, 15), 0);
|
||||
assert.equal(at(steps.boards[2]?.player as BoardSide, 9), 1);
|
||||
});
|
||||
|
||||
test("un coup depuis la barre, qui frappe en rentrant", async () => {
|
||||
const game = await playerMove(PLAYER_ON_BAR, [4, 2], "bar/21 6/4");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [true, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => [at(b.player, "bar"), at(b.player, 21), at(b.computer, "bar")]),
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, 1, 1],
|
||||
[0, 1, 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("une sortie de pions, jusqu'à la fin de la partie", async () => {
|
||||
const game = await playerMove(PLAYER_ALMOST_DONE, [6, 5], "6/off 5/off");
|
||||
assert.equal(game.state.phase, "over");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
assert.deepEqual(steps.hits, [false, false]);
|
||||
assert.deepEqual(
|
||||
steps.boards.map((b) => at(b.player, "off")),
|
||||
[13, 14, 15],
|
||||
);
|
||||
});
|
||||
|
||||
test("le coup de l'ordinateur, vu dans sa propre numérotation", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Vous commencez avec 3-1, puis l'ordinateur tire 6-4.
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4]) });
|
||||
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);
|
||||
assert.equal(steps.side, "computer");
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1);
|
||||
// Chaque demi-coup déplace un pion de l'ordinateur de sa case de départ.
|
||||
for (const [i, h] of steps.halfMoves.entries()) {
|
||||
const from = h.from === BAR ? "bar" : h.from;
|
||||
const before = steps.boards[i]?.computer as BoardSide;
|
||||
const after = steps.boards[i + 1]?.computer as BoardSide;
|
||||
assert.equal(at(after, from), at(before, from) - 1);
|
||||
}
|
||||
assert.deepEqual(steps.boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
test("avant le premier coup, il n'y a rien à montrer", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: fixedDice([3, 1]) });
|
||||
assert.equal(await game.lastMoveSteps(), null);
|
||||
});
|
||||
@@ -7,12 +7,19 @@
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { BoardSide, Engine, Move } from "../../src/domain/game/engine.js";
|
||||
import type { GameEvent } from "../../src/domain/game/events.js";
|
||||
import { Game, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import { type PositionId, parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { Game, type MoveSteps, STANDARD_PLY } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
BAR,
|
||||
OFF,
|
||||
type PositionId,
|
||||
parseHalfMoves,
|
||||
START_POSITION,
|
||||
} 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.
|
||||
@@ -27,11 +34,32 @@ const COMPUTER_DOUBLES = "6O5uAAC4uzsAAA";
|
||||
// l'ordinateur n'en a sorti aucun.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
// Le joueur, au trait : un pion sur la barre, quatorze sur sa case 6.
|
||||
// L'ordinateur tient ses six premières cases (vos cases 19 à 24), où votre
|
||||
// pion devrait rentrer.
|
||||
const PLAYER_SHUT_OUT = "t+0OAADg/wcAQA";
|
||||
|
||||
// L'ordinateur, au trait : un pion sur sa case 13, quatorze sur sa case 1.
|
||||
// Vous : un blot sur votre case 15 (sa case 10), deux pions sur votre case
|
||||
// 13 (sa case 12), douze sur votre case 6. Avec 3-1, son seul coup permis
|
||||
// est 13/10* 10/9 : il frappe votre blot.
|
||||
const COMPUTER_MUST_HIT = "4P8BEwD/PwAEAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let accepted: boolean;
|
||||
let positionBefore: PositionId;
|
||||
let computerTurn: { before: GameState; ms: number };
|
||||
let computerMove: MoveSteps;
|
||||
|
||||
/** Le nombre de pions d'un camp à une case d'un demi-coup (barre, sortie ou
|
||||
* 1 à 24, dans sa numérotation). */
|
||||
function checkersAt(side: BoardSide, point: number): number {
|
||||
if (point === BAR) {
|
||||
return side.bar;
|
||||
}
|
||||
return point === OFF ? side.off : (side.points[point - 1] ?? 0);
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
@@ -53,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");
|
||||
@@ -133,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");
|
||||
});
|
||||
|
||||
@@ -171,8 +258,162 @@ Then(
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------- Aucun coup possible
|
||||
|
||||
Given("votre pion sur la barre ne peut pas rentrer", () => {
|
||||
// Les six cases où il pourrait rentrer sont tenues par l'ordinateur.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: PLAYER_SHUT_OUT, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("vous lancez les dés", async () => {
|
||||
await game.roll();
|
||||
});
|
||||
|
||||
Then("aucun coup n'est possible avec votre jet", async () => {
|
||||
assert.deepEqual(game.state.roll, [6, 5]);
|
||||
assert.equal(await game.cannotMove(), true);
|
||||
assert.deepEqual(await game.nextHalfMoves(), []);
|
||||
});
|
||||
|
||||
Then("c'est toujours à vous", () => {
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous laissez passer votre tour", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
assert.deepEqual(event.halfMoves, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------- Voir le coup de l'ordinateur
|
||||
|
||||
Given("un de vos pions est seul à portée de l'ordinateur", () => {
|
||||
// L'ordinateur tirera 3-1. Le videau est chez vous : il ne peut pas
|
||||
// doubler, il joue.
|
||||
game = Game.start(engine, {
|
||||
setup: {
|
||||
position: COMPUTER_MUST_HIT,
|
||||
firstTurn: "computer",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
dice: fixedDice([3, 1]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur a joué", async () => {
|
||||
await game.playComputerTurn();
|
||||
const played = lastEvent();
|
||||
assert.ok(played.type === "MovePlayed" && played.side === "computer");
|
||||
const steps = await game.lastMoveSteps();
|
||||
assert.ok(steps !== null);
|
||||
computerMove = steps;
|
||||
});
|
||||
|
||||
Then("son coup vous est montré case par case", async () => {
|
||||
const { side, halfMoves, boards } = computerMove;
|
||||
assert.equal(side, "computer");
|
||||
assert.ok(halfMoves.length > 0);
|
||||
assert.equal(boards.length, halfMoves.length + 1);
|
||||
// Chaque plateau ne diffère du précédent que d'un pion de l'ordinateur,
|
||||
// parti de la case de départ du demi-coup pour sa case d'arrivée.
|
||||
for (const [i, h] of halfMoves.entries()) {
|
||||
const before = boards[i]?.computer;
|
||||
const after = boards[i + 1]?.computer;
|
||||
assert.ok(before !== undefined && after !== undefined);
|
||||
assert.equal(checkersAt(after, h.from), checkersAt(before, h.from) - 1);
|
||||
assert.equal(checkersAt(after, h.to), checkersAt(before, h.to) + 1);
|
||||
}
|
||||
// Le dernier est le plateau de la partie.
|
||||
assert.deepEqual(boards.at(-1), await game.displayedBoard());
|
||||
});
|
||||
|
||||
Then("votre pion frappé part sur la barre", () => {
|
||||
const { boards, hits } = computerMove;
|
||||
const hit = hits.indexOf(true);
|
||||
assert.ok(hit >= 0, "aucun pion frappé");
|
||||
assert.equal(boards[hit]?.player.bar, 0);
|
||||
assert.equal(boards[hit + 1]?.player.bar, 1);
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -232,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,143 @@
|
||||
// É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 { createGameStore } from "../../src/storage/game-store.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 { 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 (localStorage,
|
||||
// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal.
|
||||
const data = new Map<string, string>();
|
||||
const store = createGameStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
store.save(game.journal);
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = Game.start(engine);
|
||||
store.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", () => {
|
||||
game = Game.resume(engine, oldJournal);
|
||||
store.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.deepEqual(store.load(), oldJournal);
|
||||
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"]);
|
||||
});
|
||||
+83
-9
@@ -10,9 +10,17 @@ 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 { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import { boardModel, moveTargets, placeOf, pointSlot } from "../src/ui/board-model.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
placeOf,
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "../src/ui/board-model.js";
|
||||
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
|
||||
import { TEXTS } from "../src/ui/texts.js";
|
||||
|
||||
@@ -47,6 +55,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 +85,60 @@ 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", () => {
|
||||
// L'ordinateur joue 13/9 13/7 dans sa numérotation : ses cases 13, 9 et
|
||||
// 7 sont les cases 12, 16 et 18 du joueur.
|
||||
const trace = moveTrace("computer", parseHalfMoves("13/9 13/7"), [false, false]);
|
||||
assert.deepEqual(trace.arrows, [
|
||||
{ from: "p12", to: "p16" },
|
||||
{ from: "p12", to: "p18" },
|
||||
]);
|
||||
assert.deepEqual([...trace.left], [["p12", 2]]);
|
||||
assert.deepEqual(
|
||||
[...trace.landed],
|
||||
[
|
||||
["p16", 1],
|
||||
["p18", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(trace.hits, 0);
|
||||
});
|
||||
|
||||
test("traces : un pion qui fait étape ne laisse ni fantôme ni cercle à l'étape", () => {
|
||||
const trace = moveTrace("computer", parseHalfMoves("24/18 18/14"), [false, true]);
|
||||
assert.equal(trace.arrows.length, 2);
|
||||
assert.deepEqual([...trace.left], [["p1", 1]]);
|
||||
assert.deepEqual([...trace.landed], [["p11", 1]]);
|
||||
assert.equal(trace.hits, 1);
|
||||
});
|
||||
|
||||
test("traces : rentrée depuis la barre avec un pion frappé, sortie de pions", () => {
|
||||
const entering = moveTrace("computer", parseHalfMoves("bar/22 6/4"), [true, false]);
|
||||
assert.deepEqual(entering.arrows[0], { from: "bar-computer", to: "p3" });
|
||||
assert.deepEqual(
|
||||
[...entering.left],
|
||||
[
|
||||
["bar-computer", 1],
|
||||
["p19", 1],
|
||||
],
|
||||
);
|
||||
assert.equal(entering.hits, 1);
|
||||
const bearingOff = moveTrace("computer", parseHalfMoves("6/off 5/off"), [false, false]);
|
||||
assert.deepEqual([...bearingOff.landed], [["off-computer", 2]]);
|
||||
assert.deepEqual(
|
||||
[...bearingOff.left],
|
||||
[
|
||||
["p19", 1],
|
||||
["p20", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () => {
|
||||
@@ -133,13 +207,13 @@ test("la partie enregistrée se relit ; un enregistrement illisible est ignoré"
|
||||
});
|
||||
|
||||
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.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -52,3 +52,9 @@ construire ou tester ; rien n'est embarqué dans l'application.
|
||||
Validés par Pierre avec l'ADR 0009, dans Docker, jamais embarqués :
|
||||
TypeScript (`tsc`, Apache-2.0), Biome (MIT ou Apache-2.0),
|
||||
`@cucumber/cucumber` (MIT), `dependency-cruiser` (MIT).
|
||||
|
||||
### 2026-11-03 — Playwright pour les tests dans le navigateur
|
||||
|
||||
Validé par Pierre : Playwright 1.48.2 (Apache-2.0), image
|
||||
`mcr.microsoft.com/playwright:v1.48.2-noble`, utilisé seulement par
|
||||
`make test-browser` (Chromium et Firefox sans écran). Rien n'est embarqué.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -29,7 +29,7 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
|
||||
noyer et brique, comme les flèches d'un plateau. En monochrome, les quatre
|
||||
flèches prennent la couleur du texte.
|
||||
- **Icône d'application** : symbole sur fond ivoire `#F3EAD8`.
|
||||
- **Police** : Alegreya Sans (SIL Open Font License 1.1), pour le logotype et,
|
||||
- **Police** : Alegreya Sans (SIL Open Font License 1.1), pour le logotype et
|
||||
pour l'interface. Pierre a validé cette dépendance, ainsi que fontTools
|
||||
(MIT), outil de développement qui sert à régénérer le logotype, le
|
||||
2026-11-03.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 0011. Leçons numérotées, conseil du maître après le coup, bilan de fin de partie
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Le cadrage (`docs/cadrage.md`) prévoit pour la V1 un coach en direct, un écran
|
||||
de fin de partie et des leçons par concept, sans en fixer la forme. Les
|
||||
maquettes des écrans (https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe),
|
||||
faites avec la charte graphique (ADR 0010), en ont proposé une. Pierre en a
|
||||
retenu trois éléments le 2026-11-03.
|
||||
|
||||
Un point des maquettes contredisait le cadrage : le conseil apparaissait avant
|
||||
que le joueur ne joue, en donnant la réponse. Pierre a tranché pour un
|
||||
commentaire après le coup.
|
||||
|
||||
## Décision
|
||||
|
||||
- **Leçons numérotées** : un parcours de leçons dans un ordre fixe
|
||||
(« Leçon 1 », « Leçon 2 »…). Chaque leçon affiche son état (à commencer,
|
||||
« 3 exercices sur 5 », terminée) et sa progression. L'accueil propose de
|
||||
continuer la leçon en cours.
|
||||
- **Conseil du maître** : c'est la forme du coach en direct du cadrage. Il
|
||||
commente le coup **après** que le joueur l'a joué, jamais avant. Il donne
|
||||
l'appréciation du moteur avec un mot et une icône (bon coup, imprécision,
|
||||
erreur), puis une phrase du maître qui dit quel coup il aurait joué et
|
||||
pourquoi, avec un lien pour en savoir plus. L'écart d'équité n'apparaît que
|
||||
sur demande. Il reste désactivable, comme le prévoit le cadrage.
|
||||
- **Bilan de fin de partie** : le résultat, le décompte des coups par
|
||||
appréciation (bons coups, imprécisions, erreurs), « le coup à revoir »
|
||||
(la plus grosse erreur, avec sa position, le coup joué et le meilleur coup)
|
||||
et le prompt prêt à copier pour une IA.
|
||||
|
||||
Les maquettes de référence sont les écrans « Plateau de jeu », « Leçon et
|
||||
aide » et « Fin de partie » du canevas ci-dessus.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Conseil avant le coup, avec la réponse** : le joueur n'essaie plus par
|
||||
lui-même, et on n'apprend pas en recopiant.
|
||||
- **Indice avant le coup, sans la réponse, en plus du commentaire après** :
|
||||
écarté pour l'instant au profit d'un seul mécanisme simple. L'« indice » de
|
||||
la liste des fonctions du cadrage reste à concevoir séparément.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le commentaire après le coup demande une analyse du coup joué par le moteur
|
||||
(classement des coups et écart avec le meilleur). Elle doit tenir dans le
|
||||
budget de temps du téléphone, en arrière-plan, sans retarder le tour
|
||||
suivant.
|
||||
- Les seuils entre « bon coup », « imprécision » et « erreur » sont à fixer,
|
||||
en s'appuyant sur ceux de GNU Backgammon (doubtful, bad, very bad).
|
||||
- Les phrases du maître doivent rester exactes : elles s'appuient sur les
|
||||
coups et les chiffres du moteur, jamais sur une explication inventée.
|
||||
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
|
||||
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
|
||||
progression est stockée localement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 0012. Mode immersif : le plateau en paysage, sur demande
|
||||
|
||||
- **Statut** : accepté
|
||||
- **Date** : 2026-11-03
|
||||
- **Décideurs** : Pierre
|
||||
|
||||
## Contexte
|
||||
|
||||
Sur l'écran de jeu en portrait, le plateau n'occupe qu'environ 35 % de l'écran
|
||||
d'un téléphone (pions de 24 px sur un écran de 390 px de large). Pierre le
|
||||
trouve peu immersif et a demandé un mode où le plateau prend le maximum de
|
||||
l'écran.
|
||||
|
||||
Deux pistes ont été maquettées
|
||||
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Mode immersif ») :
|
||||
le téléphone en paysage, plateau sur toute la hauteur ; ou le portrait avec le
|
||||
plateau tourné d'un quart de tour. Toutes deux donnent environ 57 % de l'écran
|
||||
au plateau et des pions de 30 px : le petit côté de l'écran limite la taille
|
||||
des pions dans les deux cas.
|
||||
|
||||
## Décision
|
||||
|
||||
- Un **mode immersif en paysage** : le plateau occupe toute la hauteur, dans
|
||||
son orientation habituelle. À gauche : le menu, les comptes de points et la
|
||||
consigne. À droite : l'aide et les actions. Le fond est en noyer jusqu'aux
|
||||
bords de l'écran.
|
||||
- On y entre **par un geste explicite** : un bouton « Plein écran » sur l'écran
|
||||
de jeu en portrait. On en sort par le menu ou par le retour du téléphone.
|
||||
- Le bouton force la vue paysage même si le téléphone est tenu droit ou si sa
|
||||
rotation automatique est verrouillée : passage en plein écran
|
||||
(`requestFullscreen`), puis verrouillage de l'orientation
|
||||
(`screen.orientation.lock("landscape")`).
|
||||
- Si le verrouillage est refusé (navigateur ou système qui ne le permet pas,
|
||||
iPhone), l'app affiche la vue paysage tournée d'un quart de tour dans l'écran
|
||||
portrait. Le résultat est le même en main.
|
||||
- L'écran de jeu en portrait reste la vue par défaut, pour jouer d'une main.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Plateau tourné en portrait** : il change la lecture du plateau (numéros de
|
||||
côté, camps à gauche et à droite) et risque de dérouter les débutants habitués
|
||||
à une vraie table.
|
||||
- **Bascule automatique quand on tourne le téléphone** : elle ne marche pas si
|
||||
la rotation est verrouillée, et elle surprend en cours de partie. Elle reste
|
||||
possible en plus du bouton, plus tard.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le rendu du plateau doit s'adapter à deux mises en page (portrait et paysage)
|
||||
sans changer le domaine ni le moteur : seule la vue change (ADR 0007).
|
||||
- `screen.orientation.lock` n'est accepté qu'en plein écran, et pas partout :
|
||||
la solution de repli par rotation CSS est obligatoire, et elle doit être
|
||||
testée (`make test-browser`).
|
||||
- Le plein écran masque la barre d'état du téléphone, ce qui libère encore un
|
||||
peu de place.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Vue paysage dès la rotation, plateau = écran
|
||||
|
||||
Décidé par Pierre après son premier essai sur le téléphone, où la vue paysage
|
||||
à deux colonnes (plateau limité à 70 % de la largeur, texte à droite) lui a
|
||||
paru « vraiment pas optimale ».
|
||||
|
||||
- Dès que le téléphone est en paysage, même sans plein écran, l'app applique
|
||||
la mise en page « plateau = écran » sur la hauteur réelle (`100dvh`, barre
|
||||
d'adresse comprise). La mise en page à deux colonnes disparaît.
|
||||
- Dans cette vue, les contrôles sont dans le plateau : comptes de points sur la
|
||||
barre, videau au milieu de la barre, dés et consigne dans la bande centrale,
|
||||
pictogrammes (menu, annuler, plein écran) dans la colonne des sorties
|
||||
(maquette « Immersif : plein écran, contrôles dans le plateau » du canevas
|
||||
ci-dessus). La géométrie du plateau suit les proportions de l'écran ; les
|
||||
numéros des flèches font au moins 12 px.
|
||||
- Le bouton « Plein écran » garde son rôle : plein écran, verrouillage en
|
||||
paysage, repli par rotation CSS.
|
||||
|
||||
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,87 @@
|
||||
# 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.
|
||||
@@ -17,3 +17,6 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
|
||||
| [0008](0008-glossaire-et-scenarios-source-unique.md) | Glossaire et scénarios : une source unique pour le code, l'aide et les tests | accepté |
|
||||
| [0009](0009-socle-pwa-typescript.md) | Socle : PWA en TypeScript, moteur en WebAssembly | accepté |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Étape 0 — Prototype technique
|
||||
|
||||
**But** : prouver que la chaîne technique fonctionne avant d'investir dans le design, et trancher entre deux socles : PWA (moteur en WebAssembly) ou Android natif.
|
||||
|
||||
**Règle** : chaque sous-étape se termine par une démonstration que le développeur peut lancer lui-même avec une commande `make`.
|
||||
|
||||
---
|
||||
|
||||
## 0a. Le moteur en ligne de commande
|
||||
|
||||
**Objectif** : interroger le moteur GNU Backgammon sur une position, depuis le PC.
|
||||
|
||||
À faire :
|
||||
1. Récupérer les sources du moteur (choisir entre gnubg, gnubg-nn ou le moteur extrait par CBG ; justifier le choix dans un ADR, `docs/adr/`).
|
||||
2. Définir une **interface C minimale** du moteur (`engine/include/engine.h`, identifiants en anglais : ADR 0007), indépendante de toute interface graphique :
|
||||
- initialiser le moteur (charger réseaux, base de fin de partie, table d'équité de match) ;
|
||||
- lister les coups légaux pour une position et des dés ;
|
||||
- donner le meilleur coup et le classement des coups avec leur équité, pour un niveau d'évaluation donné ;
|
||||
- donner la décision de videau (pas de double, double/prend, double/passe, avec équités) ;
|
||||
- évaluer une position (probabilités de gain, gammon, backgammon).
|
||||
Les positions entrent et sortent au format **GNU Position ID + Match ID**.
|
||||
3. Écrire `engine-cli`, un petit programme qui expose ces fonctions en ligne de commande, avec une sortie JSON.
|
||||
4. Générer un jeu de **positions de référence** (au moins 30 : ouvertures, contact, course, sortie, décisions de videau) avec le GNU Backgammon de bureau dans Docker, et stocker les résultats attendus dans `tests/reference/`.
|
||||
5. `make test` compare `engine-cli` aux références (même meilleur coup, équités à une tolérance près, à fixer).
|
||||
|
||||
**Démo** : `make engine && ./build/engine-cli bestmove <position_id> <match_id> 31` affiche le meilleur coup en JSON ; `make test` passe.
|
||||
|
||||
---
|
||||
|
||||
## 0b. Le test WebAssembly (décisif)
|
||||
|
||||
**Objectif** : savoir si le moteur tourne assez vite et léger dans le navigateur du téléphone.
|
||||
|
||||
À faire :
|
||||
1. Compiler le même moteur et la même interface C en WebAssembly avec Emscripten (dans Docker).
|
||||
2. Charger le moteur dans un **Web Worker** (jamais sur le fil d'affichage).
|
||||
3. Page HTML minimale : un champ Position ID, des dés, un bouton « meilleur coup », l'affichage du résultat et du temps de calcul.
|
||||
4. Rejouer les positions de référence dans le navigateur : les résultats doivent concorder avec 0a.
|
||||
5. `make deploy-web` sert la page sur le réseau local pour l'ouvrir depuis le Fairphone 4.
|
||||
6. `make bench` (ou une page de mesure) produit un petit rapport : temps par coup selon le niveau d'évaluation, taille téléchargée, mémoire utilisée.
|
||||
|
||||
**Critères de réussite (à ajuster après mesure)** :
|
||||
|
||||
| Mesure sur le Fairphone 4 | Seuil |
|
||||
| --- | --- |
|
||||
| Meilleur coup, niveau rapide (0-ply GNU) | moins de 0,3 s |
|
||||
| Meilleur coup, niveau fort (2-ply GNU) | moins de 3 s |
|
||||
| Décision de videau, niveau fort | moins de 3 s |
|
||||
| Taille totale à télécharger (wasm + données, compressé) | moins de 40 Mo |
|
||||
| Résultats identiques aux références de 0a | 100 % |
|
||||
|
||||
**Démo** : la page ouverte sur le Fairphone donne le meilleur coup d'une position de référence, avec le temps de calcul affiché.
|
||||
|
||||
---
|
||||
|
||||
## 0c. Android natif (seulement si 0b échoue)
|
||||
|
||||
**Objectif** : la même démonstration en application Android.
|
||||
|
||||
À faire :
|
||||
1. Compiler le moteur pour arm64 avec le NDK (dans Docker), en s'appuyant sur la configuration de CBG.
|
||||
2. Application Android minimale, construite en ligne de commande (Gradle sans Android Studio, `-j2`, démon Gradle limité en mémoire).
|
||||
3. Même écran de test et mêmes mesures qu'en 0b.
|
||||
4. `make deploy` installe l'APK sur le Fairphone via adb.
|
||||
|
||||
---
|
||||
|
||||
## 0d. Plateau minimal et décision
|
||||
|
||||
1. Sur le socle retenu, un **plateau minimal** : une partie complète jouable contre le moteur, coups et videau décidés par le moteur, dés tirés par l'app.
|
||||
2. Rédiger un ADR (`docs/adr/`) : socle retenu (PWA ou Android natif), mesures obtenues, raisons, risques restants.
|
||||
|
||||
**Critère de fin de l'étape 0** : une partie complète jouée sur le Fairphone 4, moins de 2 s par coup de l'IA en niveau standard, tous les tests au vert.
|
||||
@@ -0,0 +1,56 @@
|
||||
# É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 | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 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.
|
||||
|
||||
## Questions pour Pierre (ne bloquent pas le démarrage)
|
||||
|
||||
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
|
||||
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
|
||||
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
|
||||
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
|
||||
les règles » qui oriente vers le tutoriel ou la partie.
|
||||
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
|
||||
mauvais 0,06, très mauvais 0,12 d'équité, `arSkillLevel`), exposés par le
|
||||
moteur depuis 1a ; à calibrer sur de vraies parties.
|
||||
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
|
||||
|
||||
## Ordre de réalisation
|
||||
|
||||
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
|
||||
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étape a son plan dans `docs/plans/` et sa merge request.
|
||||
+16
-15
@@ -3,33 +3,34 @@
|
||||
Le travail restant, dans l'ordre. Une ligne réalisée est retirée : ce qui a été
|
||||
fait se lit dans les merge requests et le journal, ce qui a été décidé dans les
|
||||
ADR (`docs/adr/`). Les versions et leur contenu sont définis dans
|
||||
`docs/cadrage.md` ; le détail de l'étape en cours est dans `docs/etape-0.md`.
|
||||
`docs/cadrage.md`.
|
||||
|
||||
Légende : 🔄 en cours · ⏳ à venir · ⏸ conditionnel
|
||||
|
||||
## Étape 0 — Prototype technique
|
||||
## Étape 0 — Prototype technique : terminée
|
||||
|
||||
Acquis : socle du dépôt, organisation (ADR 0001, 0003, 0006), source du moteur
|
||||
(ADR 0005 : gnubg officiel, commit 14b6798), gnubg de référence et 32 positions
|
||||
de référence (0a.2), moteur embarqué et `engine-cli` (0a.3), comparaison
|
||||
automatique aux références, tolérance 0,002 (0a.4), moteur en WebAssembly avec
|
||||
page de test, page de mesure et guide de test (0b), mesures sur le Fairphone 4 et
|
||||
socle PWA en TypeScript (ADR 0009 ; 0c Android natif abandonnée), domaine
|
||||
« partie » en journal d'événements avec scénario exécuté sur le vrai moteur et
|
||||
garde-fous d'architecture (0d.1), déploiement sur https://ustagammon.sans.pub.
|
||||
Close le 2026-11-03 : partie complète jouée par Pierre sur le Fairphone 4, coups
|
||||
de l'ordinateur rapides, tous les tests au vert. Acquis : moteur gnubg officiel
|
||||
(ADR 0005) comparé à 32 positions de référence, moteur en WebAssembly, socle
|
||||
PWA en TypeScript (ADR 0009 ; Android natif abandonné), domaine « partie » en
|
||||
journal d'événements, plateau jouable hors ligne avec la charte graphique,
|
||||
publié sur https://ustagammon.sans.pub. Le détail est dans le journal et les
|
||||
merge requests.
|
||||
|
||||
## Retours du premier test sur téléphone (avant l'étape 1)
|
||||
|
||||
Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0d.2 | Plateau minimal jouable (PWA hors ligne), publié ; reste la vérification avec Pierre sur le Fairphone 4 (`docs/guide-test-etape-0.md`, section « Étape 0d ») | 🔄 | |
|
||||
|
||||
**Critère de fin** : une partie complète jouée sur le Fairphone 4, moins de 2 s
|
||||
par coup de l'IA en niveau standard, tous les tests au vert.
|
||||
| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 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 | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
| Étape | Contenu | État |
|
||||
| --- | --- | --- |
|
||||
| 1. V1 Jouer et comprendre | Plateau soigné, niveaux, coach, analyse, branches, tutoriel, prompts, sauvegarde | ⏳ |
|
||||
| 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 | ⏳ |
|
||||
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
|
||||
|
||||
|
||||
@@ -35,10 +35,12 @@ 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 |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Coup](coup.md) | Move |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
@@ -52,6 +54,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Jet](jet.md) | Roll |
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
| [Pion](pion.md) | Checker |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
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.
|
||||
|
||||
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
|
||||
@@ -1,171 +0,0 @@
|
||||
# Guide de test : Ustagammon sur le Fairphone 4 (étape 0)
|
||||
|
||||
Document temporaire de l'étape 0 : il sera supprimé avec `docs/etape-0.md`.
|
||||
|
||||
Deux tests sur le téléphone :
|
||||
|
||||
- **Étape 0d : jouer une partie** (section suivante), sur le site publié ;
|
||||
- **Étape 0b : mesurer le moteur** (fait le 2026-11-02), sur une page
|
||||
servie par le PC.
|
||||
|
||||
## Étape 0d : jouer une partie sur le téléphone
|
||||
|
||||
Le but : installer l'application sur le Fairphone 4, jouer une partie
|
||||
complète contre l'ordinateur, vérifier qu'elle marche sans réseau, et
|
||||
rapporter ce que vous avez constaté. Comptez une demi-heure.
|
||||
|
||||
### Ouvrir et installer l'application
|
||||
|
||||
1. Sur le Fairphone, ouvrez Chrome et allez sur https://ustagammon.sans.pub.
|
||||
2. Attendez que le plateau s'affiche et que le message « Chargement du
|
||||
moteur… » laisse place à une consigne (par exemple « À vous : lancez les
|
||||
dés. » ou « L'ordinateur réfléchit… »). La première fois, le moteur se
|
||||
télécharge (environ 7 Mo) : quelques secondes en Wi-Fi.
|
||||
3. Installez l'application : menu de Chrome (les trois points), puis
|
||||
« Installer l'application » ou « Ajouter à l'écran d'accueil ». Une icône
|
||||
(deux pions sur du bois) apparaît sur l'écran d'accueil.
|
||||
4. Fermez Chrome et ouvrez Ustagammon depuis cette icône : l'application
|
||||
s'ouvre en plein écran, sans barre d'adresse.
|
||||
|
||||
### Jouer une partie complète
|
||||
|
||||
Suivez le scénario « Jouer une partie contre l'ordinateur »
|
||||
(`docs/scenarios/jouer-une-partie.feature`), qui est aussi l'aide de
|
||||
l'application : bouton « Aide », en haut.
|
||||
|
||||
1. Une partie commence d'elle-même au premier lancement, par le jet
|
||||
d'ouverture (un dé chacun) : le message dit qui commence. « Nouvelle
|
||||
partie », en haut, en recommence une.
|
||||
2. Quand c'est à vous : touchez « Lancer les dés » (ou « Doubler », pour
|
||||
proposer de doubler avant de lancer).
|
||||
3. Pour jouer : touchez un pion entouré de vert, puis l'une des cases
|
||||
d'arrivée en surbrillance ; recommencez pour chaque dé. Pour sortir un
|
||||
pion, touchez le bac à droite, en bas. « Annuler » remet les pions du coup
|
||||
en cours à leur place.
|
||||
4. Le coup est envoyé dès qu'il est complet ; l'ordinateur joue ensuite, et
|
||||
le message dit son coup et son temps de calcul, par exemple
|
||||
« L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s) ». Les petits points
|
||||
orange marquent ses cases de départ et d'arrivée (dans sa numérotation :
|
||||
sa case 24 est votre case 1).
|
||||
5. Si l'ordinateur vous propose de doubler : « Prendre » ou « Passer ».
|
||||
Essayez au moins une fois de doubler vous-même.
|
||||
6. En fin de partie, le message donne le gagnant, la sorte de victoire
|
||||
(simple, gammon ou backgammon) et les points.
|
||||
|
||||
En cours de partie, fermez l'application (balayez-la dans la liste des
|
||||
applications ouvertes), puis rouvrez-la : la partie doit reprendre là où vous
|
||||
en étiez.
|
||||
|
||||
### Vérifier le hors ligne
|
||||
|
||||
1. Après avoir ouvert l'application au moins une fois (attendez une
|
||||
vingtaine de secondes après le premier chargement, le temps que tout soit
|
||||
mis de côté), fermez-la.
|
||||
2. Passez le téléphone en mode avion.
|
||||
3. Rouvrez Ustagammon depuis son icône : le plateau, l'ordinateur et l'aide
|
||||
doivent fonctionner comme avant.
|
||||
4. Repassez en mode normal.
|
||||
|
||||
### Ce qu'il faut rapporter
|
||||
|
||||
Dans la conversation avec Claude Code :
|
||||
|
||||
- les **temps des coups de l'ordinateur** : ceux affichés dans les messages
|
||||
(le plus long que vous avez vu), et votre ressenti (attente gênante ou
|
||||
non) ;
|
||||
- si la partie est allée **jusqu'au bout**, et son résultat ;
|
||||
- si la **reprise** après fermeture et le **mode avion** ont marché ;
|
||||
- tout ce qui gêne : plateau trop petit, case difficile à toucher, texte
|
||||
incompréhensible, bouton qui ne répond pas, couleurs peu lisibles (thème
|
||||
clair ou sombre du téléphone) ;
|
||||
- une **capture d'écran** (bouton marche + volume bas) de tout problème.
|
||||
|
||||
Si vous le pouvez, refaites l'essai dans Firefox sur le téléphone (sans
|
||||
l'installer) et dites si les temps de l'ordinateur sont nettement plus longs.
|
||||
|
||||
## Étape 0b : mesurer le moteur
|
||||
|
||||
Le but : ouvrir sur le téléphone une page web où le moteur de backgammon
|
||||
calcule, puis lancer une mesure et coller son rapport dans la conversation.
|
||||
Comptez un quart d'heure.
|
||||
|
||||
### 1. Préparer le PC
|
||||
|
||||
1. Ouvrez un terminal dans le dossier du projet (`~/Code/ustagammon`).
|
||||
2. Si ce n'est pas déjà fait, tapez `make setup` puis Entrée. Cela prépare les
|
||||
outils. La première fois, c'est long ; ensuite, c'est immédiat.
|
||||
3. Le PC et le téléphone doivent être sur **le même Wi-Fi** (la même box).
|
||||
|
||||
### 2. Lancer la page
|
||||
|
||||
1. Tapez `make deploy` puis Entrée.
|
||||
2. La première fois, le moteur se compile : quelques minutes.
|
||||
3. Le terminal affiche deux adresses, par exemple :
|
||||
|
||||
```
|
||||
Application : http://192.168.1.21:8080/
|
||||
Test du moteur : http://192.168.1.21:8080/moteur/
|
||||
Page de mesure : http://192.168.1.21:8080/moteur/bench.html
|
||||
```
|
||||
|
||||
Vos chiffres peuvent être différents : notez ceux de votre écran.
|
||||
4. Laissez ce terminal ouvert pendant tout le test. Pour arrêter la page à la
|
||||
fin : Ctrl+C dans le terminal.
|
||||
|
||||
### 3. Essayer un meilleur coup
|
||||
|
||||
1. Sur le Fairphone, ouvrez le navigateur (Chrome ou Firefox).
|
||||
2. Tapez l'adresse du **test du moteur** dans la barre d'adresse, exactement
|
||||
comme affichée (avec `http://` et `:8080`). Les pages de test sont aussi
|
||||
publiées : https://ustagammon.sans.pub/moteur/ et
|
||||
https://ustagammon.sans.pub/moteur/bench.html.
|
||||
3. Attendez le message « Moteur prêt, chargé en … ms ».
|
||||
4. Dans « Exemple », choisissez une position, par exemple « middlegame-1 ».
|
||||
5. Dans « Niveau », choisissez « fort (2 plis) ».
|
||||
6. Touchez « Meilleur coup ».
|
||||
7. La page affiche le coup conseillé et le **temps de calcul**. Notez ce temps
|
||||
si vous voulez, il sera aussi dans le rapport.
|
||||
|
||||
### 4. Lancer la mesure
|
||||
|
||||
1. En bas de la page, touchez « Page de mesure » (ou tapez l'adresse de la
|
||||
page de mesure).
|
||||
2. Touchez « Lancer la mesure ».
|
||||
3. Attendez « Mesure terminée » : de une à quelques minutes. Pendant ce temps,
|
||||
gardez l'écran allumé et la page ouverte, sans changer d'application.
|
||||
4. Un tableau résume les résultats : « OK » si le seuil est respecté,
|
||||
« dépassé » sinon.
|
||||
|
||||
### 5. Envoyer le rapport
|
||||
|
||||
1. Touchez « Copier les résultats ».
|
||||
2. Si la page dit « Rapport copié », c'est bon.
|
||||
3. Sinon, le texte du rapport est sélectionné : faites un appui long dessus,
|
||||
puis « Copier ».
|
||||
4. Collez le rapport dans la conversation avec Claude Code (sur le téléphone,
|
||||
ou envoyez-le vous sur le PC).
|
||||
|
||||
Si vous en avez le temps, refaites la mesure une deuxième fois (rechargez la
|
||||
page de mesure) et collez aussi ce second rapport : cela montre si les temps
|
||||
sont stables. Si les deux navigateurs sont installés, faites la mesure dans
|
||||
Chrome **et** dans Firefox : sur le PC, Firefox s'est montré nettement plus
|
||||
lent, et le rapport indique le navigateur utilisé.
|
||||
|
||||
### Si la page ne s'ouvre pas
|
||||
|
||||
- **Vérifiez l'adresse** : `http://` (et non `https://`), les chiffres exacts,
|
||||
et `:8080` à la fin.
|
||||
- **Vérifiez le Wi-Fi** : le téléphone doit être sur le même réseau que le PC,
|
||||
pas sur la 4G ni sur un Wi-Fi invité.
|
||||
- **Vérifiez que la page tourne** : sur le PC, ouvrez
|
||||
`http://localhost:8080/` dans un navigateur. Si elle ne s'ouvre pas non
|
||||
plus, relancez `make deploy` et lisez le message d'erreur.
|
||||
- **Le pare-feu du PC** peut bloquer le téléphone. S'il est actif
|
||||
(`sudo ufw status` affiche « Status: active »), autorisez le port le temps
|
||||
du test : `sudo ufw allow 8080/tcp`, puis à la fin `sudo ufw delete allow
|
||||
8080/tcp`.
|
||||
- **« Le moteur n'a pas pu se charger »** : notez le message exact et
|
||||
collez-le dans la conversation. Essayez aussi l'autre navigateur (Chrome ou
|
||||
Firefox), à jour.
|
||||
- **Le port 8080 est déjà pris** : un autre programme l'utilise. Lancez
|
||||
`make deploy WEB_PORT=8081` et utilisez `:8081` dans l'adresse.
|
||||
@@ -46,3 +46,26 @@ python3 -I docs/identite/generer-logo.py build/police/AlegreyaSans-Bold.ttf docs
|
||||
|
||||
Les PNG sont des rendus des SVG par Chrome sans interface
|
||||
(`google-chrome --headless --screenshot`), à la taille indiquée dans leur nom.
|
||||
|
||||
## Police de l'application
|
||||
|
||||
`web/fonts/` contient Alegreya Sans en cinq graisses, réduite aux caractères
|
||||
latins (français compris) et convertie en woff2, soit 143 Ko au lieu de
|
||||
1,3 Mo en TTF. Pour la refaire, depuis les TTF de
|
||||
https://github.com/google/fonts/tree/main/ofl/alegreyasans (Python 3,
|
||||
fontTools et brotli, par exemple dans l'image `emscripten/emsdk`) :
|
||||
|
||||
```sh
|
||||
pip install fonttools brotli
|
||||
for f in Regular Italic Medium Bold ExtraBold; do
|
||||
python3 -m fontTools.subset AlegreyaSans-$f.ttf \
|
||||
--output-file=web/fonts/AlegreyaSans-$f.woff2 --flavor=woff2 \
|
||||
--unicodes="U+0020-007E,U+00A0-00FF,U+0152-0153,U+0178,U+02BC,U+02C6,U+02DC,U+2009,U+2011,U+2013-2014,U+2018-201E,U+2022,U+2026,U+202F,U+2039-203A,U+20AC,U+2192,U+2212" \
|
||||
--layout-features='*' --name-IDs='*' --name-languages='*'
|
||||
done
|
||||
```
|
||||
|
||||
## Charte graphique
|
||||
|
||||
La charte complète (couleurs des deux thèmes, typographie, composants) est dans
|
||||
`charte/`, avec les liens vers le design system et les maquettes d'écrans.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Charte graphique d'Ustagammon
|
||||
|
||||
Copie de travail du design system, pour l'appliquer à la PWA sans dépendre
|
||||
d'un accès à claude.ai. La référence vivante, avec aperçus dans les deux thèmes :
|
||||
|
||||
- Design system : https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M
|
||||
- Maquettes des écrans (accueil, plateau, leçon, fin de partie, thème sombre) :
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe
|
||||
|
||||
Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
|
||||
|
||||
## Fichiers
|
||||
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
|
||||
| `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 |
|
||||
|
||||
## Appliquer la charte à `web/`
|
||||
|
||||
1. **Couleurs** : les variables du plateau de `web/app.css` (`--frame`, `--bed`,
|
||||
`--point-a`, `--checker-player`…) ont les mêmes noms dans `tokens.json` ;
|
||||
reprendre leurs valeurs, thème clair dans `:root`, thème sombre dans le bloc
|
||||
`prefers-color-scheme: dark`. Remplacer l'accent vert provisoire par la
|
||||
brique (`--accent`, `--highlight`, `--target`) et ajouter les couleurs
|
||||
nouvelles (`surface`, `accent-soft`, `verdict-*`, `focus`, `last-move` en laiton).
|
||||
2. **Police** : embarquer Alegreya Sans (Regular, Italic, Medium, Bold,
|
||||
ExtraBold) dans `web/`, avec sa licence OFL, en `@font-face`, et l'ajouter
|
||||
au cache du service worker (hors ligne).
|
||||
3. **Composants** : reprendre de `bundle.css` les boutons ronds
|
||||
(`radius-pill`), le filet intérieur des pions, l'anneau de focus et la zone
|
||||
de consigne.
|
||||
4. **Icônes et logo** : remplacer `web/icons/` par les fichiers de
|
||||
`../logo/` (icônes any et maskable, favicon), mettre le logo horizontal
|
||||
dans la barre haute, et la couleur `theme-color` du manifeste à `#4A2E1F`.
|
||||
|
||||
Les contrastes de tous les textes ont été vérifiés par calcul (4,5:1 au moins
|
||||
dans les deux thèmes) ; les marques du plateau, à 3:1 contre au moins un de
|
||||
leurs voisins.
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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 « Passer » à droite. 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 quand vous finissez, c'est un gammon :
|
||||
2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
|
||||
c'est un backgammon : 3 points. »
|
||||
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).
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Dessin de référence du plateau Ustagammon, en SVG, avec les classes de bundle.css.
|
||||
* Repère : 640 × 520, comme web/app.css (.board, aspect-ratio 640 / 520).
|
||||
* Numérotation du point de vue du joueur : 1 à 12 en bas (de droite à gauche),
|
||||
* 13 à 24 en haut (de gauche à droite). Le joueur a les pions ivoire. */
|
||||
window.UstaBoard = (function () {
|
||||
var W = 640, H = 520, SIDE = 16, TB = 28, PW = 43, BAR = 40, PH = 190, R = 20;
|
||||
var TRAY_X = 580, TRAY_W = 44;
|
||||
|
||||
function colX(col) { return SIDE + col * PW + (col >= 6 ? BAR : 0); }
|
||||
function place(point) { // point 1..24 → {col, top}
|
||||
return point <= 12 ? { col: 12 - point, top: false } : { col: point - 13, top: true };
|
||||
}
|
||||
function checker(cx, cy, side, extra) {
|
||||
var c = side === "player" ? "player" : "computer";
|
||||
return '<circle class="checker checker-' + c + (extra ? " " + extra : "") + '" cx="' + cx + '" cy="' + cy + '" r="' + R + '"/>' +
|
||||
'<circle class="checker-ring ring-' + c + '" cx="' + cx + '" cy="' + cy + '" r="12"/>';
|
||||
}
|
||||
function die(x, y, n, side) {
|
||||
var s = 36, p = { 1: [[.5, .5]], 2: [[.27, .27], [.73, .73]], 3: [[.27, .27], [.5, .5], [.73, .73]],
|
||||
4: [[.27, .27], [.73, .27], [.27, .73], [.73, .73]], 5: [[.27, .27], [.73, .27], [.5, .5], [.27, .73], [.73, .73]],
|
||||
6: [[.27, .25], [.73, .25], [.27, .5], [.73, .5], [.27, .75], [.73, .75]] }[n];
|
||||
var out = '<rect class="die-' + side + '" x="' + x + '" y="' + y + '" width="' + s + '" height="' + s + '" rx="6"/>';
|
||||
p.forEach(function (q) { out += '<circle class="pip-' + side + '" cx="' + (x + q[0] * s) + '" cy="' + (y + q[1] * s) + '" r="3.6"/>'; });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* state = { checkers: {point: [side, n]}, selected: point, movable: [points],
|
||||
* targets: [points], lastMove: [points], dice: [a, b], diceSide, cube } */
|
||||
function svg(state) {
|
||||
var o = '<svg class="board" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Plateau de backgammon" xmlns="http://www.w3.org/2000/svg">';
|
||||
o += '<rect class="board-frame" x="0" y="0" width="' + W + '" height="' + H + '" rx="10"/>';
|
||||
o += '<rect class="board-bed" x="' + SIDE + '" y="' + TB + '" width="' + (TRAY_X - 8 - SIDE) + '" height="' + (H - 2 * TB) + '"/>';
|
||||
o += '<rect class="board-bar" x="' + (SIDE + 6 * PW) + '" y="' + TB + '" width="' + BAR + '" height="' + (H - 2 * TB) + '"/>';
|
||||
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 = 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)
|
||||
o += '<polygon class="target-zone" points="' + x + ',' + base + ' ' + (x + PW) + ',' + base + ' ' + (x + PW / 2) + ',' + apex + '"/>';
|
||||
if ((state.lastMove || []).indexOf(point) >= 0)
|
||||
o += '<rect class="last-move" x="' + (x + 6) + '" y="' + (pl.top ? TB : H - TB - 5) + '" width="' + (PW - 12) + '" height="5" rx="2.5"/>';
|
||||
o += '<text class="point-number" x="' + (x + PW / 2) + '" y="' + (pl.top ? 19 : H - 9) + '">' + point + '</text>';
|
||||
var c = (state.checkers || {})[point];
|
||||
if (c) {
|
||||
var shown = Math.min(c[1], 5);
|
||||
for (var i = 0; i < shown; i++) {
|
||||
var cy = pl.top ? TB + R + 1 + i * (2 * R + 1) : H - TB - R - 1 - i * (2 * R + 1);
|
||||
var extra = "";
|
||||
if (i === shown - 1 && state.selected === point) extra = "selected";
|
||||
else if (i === shown - 1 && (state.movable || []).indexOf(point) >= 0) extra = "movable";
|
||||
o += checker(x + PW / 2, cy, c[0], extra);
|
||||
if (i === shown - 1 && c[1] > 5)
|
||||
o += '<text class="checker-count count-' + c[0] + '" x="' + (x + PW / 2) + '" y="' + (cy + 6) + '">' + c[1] + '</text>';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.dice) {
|
||||
var dx = colX(8) + 6, dy = H / 2 - 18, side = state.diceSide || "player";
|
||||
o += die(dx, dy, state.dice[0], side) + die(dx + 46, dy, state.dice[1], side);
|
||||
}
|
||||
if (state.cube) {
|
||||
var bx = SIDE + 6 * PW + 2;
|
||||
o += '<rect class="cube-face" x="' + bx + '" y="' + (H / 2 - 18) + '" width="36" height="36" rx="6"/>';
|
||||
o += '<text class="cube-value" x="' + (bx + 18) + '" y="' + (H / 2 + 6) + '">' + state.cube + '</text>';
|
||||
}
|
||||
return o + '</svg>';
|
||||
}
|
||||
|
||||
var OPENING = { 24: ["player", 2], 13: ["player", 5], 8: ["player", 3], 6: ["player", 5],
|
||||
1: ["computer", 2], 12: ["computer", 5], 17: ["computer", 3], 19: ["computer", 5] };
|
||||
return { svg: svg, OPENING: OPENING };
|
||||
})();
|
||||
@@ -0,0 +1,165 @@
|
||||
/* Ustagammon — styles des composants.
|
||||
* Toutes les valeurs viennent de tokens.json (variables CSS du même nom).
|
||||
* Les noms de classes reprennent ceux de web/app.css : ce fichier peut être
|
||||
* recopié dans l'application presque tel quel. */
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-underline-offset: 3px; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Barre haute */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
.topbar .brand { flex: 1; display: flex; align-items: center; min-width: 0; }
|
||||
.topbar .brand img { height: 32px; width: auto; display: block; }
|
||||
.topbar h1 { flex: 1; margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
|
||||
|
||||
/* ---------------------------------------------------------------- Boutons */
|
||||
|
||||
button, .button {
|
||||
min-height: var(--button-height);
|
||||
padding: 0 20px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary, .button.secondary {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
button.quiet, .button.quiet {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.topbar button { min-height: 44px; padding: 0 14px; font-size: 15px; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.actions button { flex: 1; }
|
||||
|
||||
/* ------------------------------------------------------- Zone de consigne */
|
||||
|
||||
.pips { margin: var(--space-1) 0 0; font-size: 15px; line-height: 20px; color: var(--muted); text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.info, .prompt { min-height: 25px; margin: 6px 0; }
|
||||
.info { color: var(--muted); }
|
||||
.prompt { font-weight: 700; }
|
||||
.prompt.thinking::after { content: ""; animation: thinking 1.2s steps(4, end) infinite; }
|
||||
@keyframes thinking { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }
|
||||
@media (prefers-reduced-motion: reduce) { .prompt.thinking::after { animation: none; content: "…"; } }
|
||||
|
||||
/* ---------------------------------------------------------------- Plateau */
|
||||
|
||||
.board { display: block; width: 100%; height: auto; user-select: none; -webkit-tap-highlight-color: transparent; }
|
||||
.board-frame { fill: var(--frame); }
|
||||
.board-bed { fill: var(--bed); }
|
||||
.board-bar { fill: var(--bar); }
|
||||
.board-tray { fill: var(--tray); }
|
||||
.point-a { fill: var(--point-a); }
|
||||
.point-b { fill: var(--point-b); }
|
||||
.point-number { fill: var(--point-number); font-size: 13px; font-weight: 500; text-anchor: middle; font-family: var(--font-sans); }
|
||||
.checker { stroke-width: 2; }
|
||||
.checker-player { fill: var(--checker-player); stroke: var(--checker-player-edge); }
|
||||
.checker-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); }
|
||||
.checker-ring { fill: none; stroke-width: 1.5; }
|
||||
.checker-player + .checker-ring, .ring-player { stroke: var(--checker-player-edge); }
|
||||
.ring-computer { stroke: var(--checker-computer-edge); }
|
||||
.checker.movable { stroke: var(--highlight); stroke-width: 3; }
|
||||
.checker.selected { stroke: var(--highlight); stroke-width: 6; }
|
||||
.checker-count { font-size: 18px; font-weight: 700; text-anchor: middle; font-family: var(--font-sans); }
|
||||
.count-player { fill: var(--count-player); }
|
||||
.count-computer { fill: var(--count-computer); }
|
||||
.target-zone { fill: var(--target); }
|
||||
.last-move { fill: var(--last-move); }
|
||||
.die-player { fill: var(--die-player); stroke: var(--checker-player-edge); stroke-width: 1.5; }
|
||||
.die-computer { fill: var(--die-computer); stroke: var(--checker-computer-edge); stroke-width: 1.5; }
|
||||
.pip-player { fill: var(--pip-player); }
|
||||
.pip-computer { fill: var(--pip-computer); }
|
||||
.cube-face { fill: var(--cube); stroke: var(--checker-player-edge); stroke-width: 2; }
|
||||
.cube-value { fill: var(--cube-text); font-size: 17px; font-weight: 800; text-anchor: middle; font-family: var(--font-sans); }
|
||||
|
||||
/* ------------------------------------------------------------------ Cartes */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-raised);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.overline { font-size: 13px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
|
||||
|
||||
/* --------------------------------------------------- Appréciation d'un coup */
|
||||
|
||||
.verdict {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.verdict svg { width: 18px; height: 18px; flex: none; }
|
||||
.verdict-good { color: var(--verdict-good); }
|
||||
.verdict-doubtful { color: var(--verdict-doubtful); }
|
||||
.verdict-bad { color: var(--verdict-bad); }
|
||||
|
||||
/* -------------------------------------------------------- Fiche de glossaire */
|
||||
|
||||
.glossary-card h3 { margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; }
|
||||
.glossary-card .en { color: var(--muted); font-size: 15px; line-height: 20px; font-style: italic; }
|
||||
.glossary-card p { margin: 0; }
|
||||
.glossary-card .see { font-size: 15px; line-height: 20px; color: var(--muted); }
|
||||
|
||||
/* ------------------------------------------------ Demander à une IA (prompt) */
|
||||
|
||||
.ask-ai { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.ask-ai pre {
|
||||
margin: 0;
|
||||
max-height: 7.5em;
|
||||
overflow: auto;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-md);
|
||||
font: 15px/20px var(--font-sans);
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.ask-ai .done { color: var(--verdict-good); font-weight: 700; font-size: 15px; }
|
||||
|
||||
/* ---------------------------------------------------------- Carte de leçon */
|
||||
|
||||
.lesson-card { flex-direction: row; align-items: center; gap: var(--space-3); }
|
||||
.lesson-card .body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.lesson-card h3 { margin: 0; font-size: 17px; line-height: 25px; font-weight: 700; }
|
||||
.lesson-card .state { font-size: 15px; line-height: 20px; color: var(--muted); }
|
||||
.progress { height: 6px; border-radius: var(--radius-pill); background: var(--surface-sunken); overflow: hidden; }
|
||||
.progress > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||
@@ -0,0 +1,65 @@
|
||||
Ustagammon apprend le backgammon comme on l'apprend à une table de café : quelqu'un qui sait montre à quelqu'un qui découvre. L'interface est **chaleureuse, artisanale, claire et intemporelle**, discrètement turque par la matière (le bois, l'ivoire, la brique), jamais par le folklore. Elle doit donner envie à un débutant sans paraître naïve à un joueur confirmé : un maître bienveillant, pas un logiciel d'analyse, pas un casino.
|
||||
|
||||
Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont exactement ceux des variables de `web/app.css` ; `components/bundle.css` reprend ses noms de classes et peut y être recopié.
|
||||
|
||||
## Contenus et ton
|
||||
|
||||
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
|
||||
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
|
||||
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
|
||||
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
|
||||
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
|
||||
- **Ni émoji, ni point d'exclamation en série, ni majuscules de réclame.** Les capitales sont réservées au style `overline`.
|
||||
- Le nom s'écrit **Ustagammon**, en un mot, majuscule initiale.
|
||||
|
||||
## Couleurs
|
||||
|
||||
La palette, c'est le bois, les pions et le café, avec **un seul accent : le rouge brique** (`accent`).
|
||||
|
||||
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
|
||||
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
|
||||
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
|
||||
- `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 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.
|
||||
|
||||
## Typographie
|
||||
|
||||
Une seule famille, **Alegreya Sans** (licence SIL OFL, fichiers dans `fonts/`), sans empattement humaniste, chaleureuse et lisible en petit. À embarquer dans l'app : elle doit fonctionner hors ligne.
|
||||
|
||||
- `display` (32px, 800) : un seul par écran, accueil et fin de partie.
|
||||
- `title` (24px) : titre d'écran ; `heading` (20px) : titre de section ou de carte.
|
||||
- `body` (17px) : tout le texte à lire, jamais plus petit. `body-strong` pour la consigne du moment et les boutons. `body-italic` pour la parole du maître ou un exemple.
|
||||
- `caption` (15px) : compte de points, légendes. `overline` (13px, capitales espacées) : étiquette au-dessus d'un titre, avec parcimonie.
|
||||
- Chiffres alignés (`font-variant-numeric: tabular-nums`) dès que des nombres se comparent (compte de points, scores).
|
||||
|
||||
## Formes, espace et profondeur
|
||||
|
||||
- **Boutons ronds comme des pions** : `radius-pill`, hauteur `button-height` (48px). Aucune cible tactile sous 44px.
|
||||
- Cartes et fiches : `radius-md`, contour `border`, ombre `shadow-raised` et rien de plus profond. Dés et videau : `radius-sm`.
|
||||
- Espacements : `space-1` à `space-8`. Gouttière de page `space-4` sur téléphone ; écart entre blocs `space-6`. Contenu limité à `page-width` sur grand écran.
|
||||
- Le plateau occupe toute la largeur sur téléphone ; c'est lui le héros de l'écran de jeu, pas les panneaux autour.
|
||||
- Focus clavier : anneau `focus` plein de 3px, décalé de 2px, sur tous les éléments interactifs.
|
||||
|
||||
## Mouvement
|
||||
|
||||
Sobre et utile : un pion glisse vers sa flèche (150 à 250 ms, sans rebond), les dés roulent brièvement. Les trois points de « l'ordinateur réfléchit » sont la seule animation en boucle. Tout s'arrête avec `prefers-reduced-motion`.
|
||||
|
||||
## Iconographie
|
||||
|
||||
- Le **symbole** (deux pions sur une flèche, le 8 caché) et le **logotype** (les « mm » en flèches) sont dans le groupe Logos ; versions couleur, sombre, noire et blanche. Ne jamais les redessiner, déformer ou recolorer.
|
||||
- Barre haute : `logo-horizontal-couleur.svg` en thème clair, `logo-horizontal-sombre.svg` en thème sombre, 32px de haut.
|
||||
- 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`), 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,45 @@
|
||||
# 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).
|
||||
- [ ] 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 « Nouvelle partie ».
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,563 @@
|
||||
{
|
||||
"name": "Ustagammon",
|
||||
"version": 1,
|
||||
"color": {
|
||||
"themes": [
|
||||
{
|
||||
"id": "light",
|
||||
"name": "Clair"
|
||||
},
|
||||
{
|
||||
"id": "dark",
|
||||
"name": "Sombre"
|
||||
}
|
||||
],
|
||||
"tokens": [
|
||||
{
|
||||
"name": "background",
|
||||
"value": {
|
||||
"light": "#F5EFE5",
|
||||
"dark": "#17110D"
|
||||
},
|
||||
"usage": "Fond de page (papier ivoire / café torréfié)."
|
||||
},
|
||||
{
|
||||
"name": "surface",
|
||||
"value": {
|
||||
"light": "#FFFCF6",
|
||||
"dark": "#221913"
|
||||
},
|
||||
"usage": "Fond des cartes, fiches de glossaire, panneaux posés sur `background`."
|
||||
},
|
||||
{
|
||||
"name": "surface-sunken",
|
||||
"value": {
|
||||
"light": "#ECE3D4",
|
||||
"dark": "#2C211A"
|
||||
},
|
||||
"usage": "Zones en retrait : bloc de prompt à copier, piste de progression."
|
||||
},
|
||||
{
|
||||
"name": "text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#EFE5D5"
|
||||
},
|
||||
"usage": "Texte courant et titres, sur `background`, `surface` et `surface-sunken`."
|
||||
},
|
||||
{
|
||||
"name": "muted",
|
||||
"value": {
|
||||
"light": "#6B5C50",
|
||||
"dark": "#A99886"
|
||||
},
|
||||
"usage": "Texte secondaire (compte de points, légendes, aide), sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "border",
|
||||
"value": {
|
||||
"light": "#DED2C0",
|
||||
"dark": "#3A2D24"
|
||||
},
|
||||
"usage": "Séparateurs et contours décoratifs de cartes (ne porte aucun sens)."
|
||||
},
|
||||
{
|
||||
"name": "border-strong",
|
||||
"value": {
|
||||
"light": "#8E7D6A",
|
||||
"dark": "#7D6B5B"
|
||||
},
|
||||
"usage": "Contour des boutons secondaires et des champs : 3:1 sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "accent",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#D0694B"
|
||||
},
|
||||
"usage": "Rouge brique, l'accent unique : bouton principal, liens, case où le pion peut aller, focus. Texte en brique sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "on-accent",
|
||||
"value": {
|
||||
"light": "#FFFFFF",
|
||||
"dark": "#1A110C"
|
||||
},
|
||||
"usage": "Texte et icônes posés sur un aplat `accent`."
|
||||
},
|
||||
{
|
||||
"name": "accent-soft",
|
||||
"value": {
|
||||
"light": "#F1D9CF",
|
||||
"dark": "#4A2519"
|
||||
},
|
||||
"usage": "Fond léger d'accent : puce « à toi de jouer », sélection dans une liste. Texte `text` dessus."
|
||||
},
|
||||
{
|
||||
"name": "focus",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#E58A6E"
|
||||
},
|
||||
"usage": "Anneau de focus clavier, 3 px plein, décalé de 2 px : 3:1 sur tous les fonds."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"value": {
|
||||
"light": "#8C1C3A",
|
||||
"dark": "#F08FA6"
|
||||
},
|
||||
"usage": "Message d'erreur technique (texte), toujours accompagné d'un mot. Lie-de-vin, distinct de la brique."
|
||||
},
|
||||
{
|
||||
"name": "verdict-good",
|
||||
"value": {
|
||||
"light": "#2F6B4F",
|
||||
"dark": "#7CCBA4"
|
||||
},
|
||||
"usage": "« Bon coup » : texte et icône, toujours avec le mot. Sur `background` et `surface`."
|
||||
},
|
||||
{
|
||||
"name": "verdict-doubtful",
|
||||
"value": {
|
||||
"light": "#7E5A0F",
|
||||
"dark": "#E0B04F"
|
||||
},
|
||||
"usage": "« Imprécision » (doubtful) : texte et icône, toujours avec le mot."
|
||||
},
|
||||
{
|
||||
"name": "verdict-bad",
|
||||
"value": {
|
||||
"light": "#8C1C3A",
|
||||
"dark": "#F08FA6"
|
||||
},
|
||||
"usage": "« Erreur » et « grosse erreur » (bad, very bad) : texte et icône, toujours avec le mot."
|
||||
},
|
||||
{
|
||||
"name": "brand-noyer",
|
||||
"value": {
|
||||
"light": "#4A2E1F",
|
||||
"dark": "#4A2E1F"
|
||||
},
|
||||
"usage": "Noyer du logo : pion maître. Ne sert pas au texte de l'interface."
|
||||
},
|
||||
{
|
||||
"name": "brand-ivoire",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
},
|
||||
"usage": "Ivoire du logo : pion apprenti, fond de l'icône d'application."
|
||||
},
|
||||
{
|
||||
"name": "brand-laiton",
|
||||
"value": {
|
||||
"light": "#8F6A1C",
|
||||
"dark": "#D6A64A"
|
||||
},
|
||||
"usage": "Laiton, comme les charnières d'un plateau de tavla : marque du dernier coup joué, rien d'autre."
|
||||
},
|
||||
{
|
||||
"name": "frame",
|
||||
"value": {
|
||||
"light": "#4A2E1F",
|
||||
"dark": "#2A1A10"
|
||||
},
|
||||
"usage": "Cadre du plateau (noyer)."
|
||||
},
|
||||
{
|
||||
"name": "bed",
|
||||
"value": {
|
||||
"light": "#E9DCC4",
|
||||
"dark": "#4A3626"
|
||||
},
|
||||
"usage": "Fond du plateau, entre les flèches."
|
||||
},
|
||||
{
|
||||
"name": "bar",
|
||||
"value": {
|
||||
"light": "#3F2619",
|
||||
"dark": "#24170E"
|
||||
},
|
||||
"usage": "Barre centrale."
|
||||
},
|
||||
{
|
||||
"name": "tray",
|
||||
"value": {
|
||||
"light": "#3A2418",
|
||||
"dark": "#22150D"
|
||||
},
|
||||
"usage": "Plateaux de sortie, à droite."
|
||||
},
|
||||
{
|
||||
"name": "point-a",
|
||||
"value": {
|
||||
"light": "#6B4430",
|
||||
"dark": "#3A2519"
|
||||
},
|
||||
"usage": "Flèches impaires (1, 3, 5…), noyer clair."
|
||||
},
|
||||
{
|
||||
"name": "point-b",
|
||||
"value": {
|
||||
"light": "#C9AE88",
|
||||
"dark": "#7E6142"
|
||||
},
|
||||
"usage": "Flèches paires (2, 4, 6…), chêne."
|
||||
},
|
||||
{
|
||||
"name": "point-number",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#CDBFA8"
|
||||
},
|
||||
"usage": "Numéros des flèches, posés sur `frame`."
|
||||
},
|
||||
{
|
||||
"name": "checker-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Pions du joueur : ivoire, l'apprenti."
|
||||
},
|
||||
{
|
||||
"name": "checker-player-edge",
|
||||
"value": {
|
||||
"light": "#8C7A62",
|
||||
"dark": "#8E7C64"
|
||||
},
|
||||
"usage": "Contour et filet des pions du joueur."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
},
|
||||
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer-edge",
|
||||
"value": {
|
||||
"light": "#120A05",
|
||||
"dark": "#A8957D"
|
||||
},
|
||||
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
|
||||
},
|
||||
{
|
||||
"name": "count-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions du joueur."
|
||||
},
|
||||
{
|
||||
"name": "count-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "die-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "pip-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Points des dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "die-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
},
|
||||
"usage": "Dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "pip-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Points des dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "cube",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
},
|
||||
"usage": "Face du videau."
|
||||
},
|
||||
{
|
||||
"name": "cube-text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
},
|
||||
"usage": "Valeur du videau (2, 4, 8…)."
|
||||
},
|
||||
{
|
||||
"name": "highlight",
|
||||
"value": {
|
||||
"light": "#A8432B",
|
||||
"dark": "#E07A5A"
|
||||
},
|
||||
"usage": "Contour d'un pion jouable ou sélectionné du joueur : la brique. Il contraste à 3:1 au moins avec le pion ivoire (clair) ou avec le fond du plateau (sombre)."
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"value": {
|
||||
"light": "rgba(168,67,43,0.32)",
|
||||
"dark": "rgba(224,122,90,0.38)"
|
||||
},
|
||||
"usage": "Voile sur la flèche où le pion sélectionné peut aller."
|
||||
},
|
||||
{
|
||||
"name": "last-move",
|
||||
"value": {
|
||||
"light": "{brand-laiton}",
|
||||
"dark": "{brand-laiton}"
|
||||
},
|
||||
"usage": "Marque du dernier coup joué (laiton)."
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"fonts": [
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Regular.ttf",
|
||||
"weight": "400"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Italic.ttf",
|
||||
"weight": "400",
|
||||
"style": "italic"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Medium.ttf",
|
||||
"weight": "500"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-Bold.ttf",
|
||||
"weight": "700"
|
||||
},
|
||||
{
|
||||
"family": "Alegreya Sans",
|
||||
"file": "fonts/AlegreyaSans-ExtraBold.ttf",
|
||||
"weight": "800"
|
||||
}
|
||||
],
|
||||
"families": {
|
||||
"sans": "\"Alegreya Sans\", \"Gill Sans\", \"Segoe UI\", system-ui, sans-serif"
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"name": "Titres",
|
||||
"family": "sans",
|
||||
"styles": [
|
||||
{
|
||||
"name": "display",
|
||||
"fontSize": "32px",
|
||||
"lineHeight": "36px",
|
||||
"fontWeight": 800,
|
||||
"letterSpacing": "-0.01em",
|
||||
"sample": "Une partie contre le maître",
|
||||
"usage": "Titre d'accueil et de fin de partie, une fois par écran."
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"fontSize": "24px",
|
||||
"lineHeight": "30px",
|
||||
"fontWeight": 700,
|
||||
"sample": "Le point 8",
|
||||
"usage": "Titre d'écran (leçon, aide)."
|
||||
},
|
||||
{
|
||||
"name": "heading",
|
||||
"fontSize": "20px",
|
||||
"lineHeight": "26px",
|
||||
"fontWeight": 700,
|
||||
"sample": "Les ouvertures",
|
||||
"usage": "Sous-titre de section, titre de carte."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Texte",
|
||||
"family": "sans",
|
||||
"styles": [
|
||||
{
|
||||
"name": "body",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 400,
|
||||
"sample": "Ce pion peut rejoindre votre point 8.",
|
||||
"usage": "Texte courant ; jamais en dessous de 17px pour le contenu à lire."
|
||||
},
|
||||
{
|
||||
"name": "body-strong",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 700,
|
||||
"sample": "À vous de jouer : 6 et 1.",
|
||||
"usage": "Consigne du moment (zone prompt), boutons."
|
||||
},
|
||||
{
|
||||
"name": "body-italic",
|
||||
"fontSize": "17px",
|
||||
"lineHeight": "25px",
|
||||
"fontWeight": 400,
|
||||
"fontStyle": "italic",
|
||||
"sample": "Philippe aurait joué 13/7 8/7.",
|
||||
"usage": "Parole du maître, citation, exemple."
|
||||
},
|
||||
{
|
||||
"name": "caption",
|
||||
"fontSize": "15px",
|
||||
"lineHeight": "20px",
|
||||
"fontWeight": 400,
|
||||
"sample": "Vous : 167 · Ordinateur : 152",
|
||||
"usage": "Compte de points, légendes, mentions."
|
||||
},
|
||||
{
|
||||
"name": "overline",
|
||||
"fontSize": "13px",
|
||||
"lineHeight": "16px",
|
||||
"fontWeight": 700,
|
||||
"letterSpacing": "0.08em",
|
||||
"sample": "LEÇON 3 SUR 12",
|
||||
"usage": "Étiquette en capitales au-dessus d'un titre ; avec parcimonie."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"spacing": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "space-1",
|
||||
"value": "4px",
|
||||
"usage": "Écart entre une icône et son texte."
|
||||
},
|
||||
{
|
||||
"name": "space-2",
|
||||
"value": "8px",
|
||||
"usage": "Écart entre boutons voisins, marges internes serrées."
|
||||
},
|
||||
{
|
||||
"name": "space-3",
|
||||
"value": "12px",
|
||||
"usage": "Marge interne des puces et du bloc prompt."
|
||||
},
|
||||
{
|
||||
"name": "space-4",
|
||||
"value": "16px",
|
||||
"usage": "Gouttière latérale de page sur téléphone, marge interne des cartes."
|
||||
},
|
||||
{
|
||||
"name": "space-6",
|
||||
"value": "24px",
|
||||
"usage": "Écart entre blocs d'un écran."
|
||||
},
|
||||
{
|
||||
"name": "space-8",
|
||||
"value": "32px",
|
||||
"usage": "Écart entre sections."
|
||||
},
|
||||
{
|
||||
"name": "button-height",
|
||||
"value": "48px",
|
||||
"usage": "Hauteur des boutons ; aucune cible tactile sous 44px."
|
||||
},
|
||||
{
|
||||
"name": "page-width",
|
||||
"value": "40rem",
|
||||
"usage": "Largeur maximale du contenu sur grand écran."
|
||||
}
|
||||
]
|
||||
},
|
||||
"radius": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "radius-sm",
|
||||
"value": "6px",
|
||||
"usage": "Dés, videau, puces de verdict."
|
||||
},
|
||||
{
|
||||
"name": "radius-md",
|
||||
"value": "12px",
|
||||
"usage": "Cartes, fiches, bloc prompt."
|
||||
},
|
||||
{
|
||||
"name": "radius-pill",
|
||||
"value": "999px",
|
||||
"usage": "Boutons : ronds comme des pions."
|
||||
}
|
||||
]
|
||||
},
|
||||
"shadow": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "shadow-raised",
|
||||
"value": {
|
||||
"light": "0 1px 2px rgba(43,29,21,0.10)",
|
||||
"dark": "0 1px 2px rgba(0,0,0,0.40)"
|
||||
},
|
||||
"usage": "Seule ombre permise : cartes posées sur le fond. Rien de plus profond."
|
||||
}
|
||||
]
|
||||
},
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -89,3 +89,77 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
- Sous Firefox sur PC, un tour de l'ordinateur a pris jusqu'à 5,1 s
|
||||
(Chromium : 0,4 s) ; Firefox sur le téléphone reste à mesurer.
|
||||
- Validation par Pierre de Playwright (Apache-2.0), outil de `make test-browser`.
|
||||
|
||||
## 2026-11-03 (nuit) : identité visuelle
|
||||
|
||||
**Fait**
|
||||
- Identité choisie par Pierre en trois tours sur une planche de travail :
|
||||
accent brique, symbole « P3d » (deux pions sur une flèche, le 8 caché),
|
||||
logotype « L3 » (les « mm » en flèches), icône sur fond ivoire. ADR 0010,
|
||||
fichiers du logo et des icônes dans `docs/identite/logo/` (MR !12).
|
||||
- Design system publié (https://claude.ai/artifact/K2Z7MPE58XXJzv19n5r11M) :
|
||||
couleurs des deux thèmes reprenant les noms de variables de `web/app.css`,
|
||||
Alegreya Sans, onze composants, contrastes vérifiés par calcul. Copie dans
|
||||
`docs/identite/charte/`.
|
||||
- Maquettes des quatre écrans et du thème sombre
|
||||
(https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe).
|
||||
|
||||
**Reste à faire**
|
||||
- Appliquer la charte à la PWA (couleurs, police embarquée, icônes, logo) :
|
||||
à planifier pour l'agent développeur, voir `docs/identite/charte/README.md`.
|
||||
- Les maquettes proposent des éléments de produit qui ne sont pas encore
|
||||
décidés (leçons, « Conseil du maître », bilan de fin de partie) : à valider
|
||||
par Pierre avant toute mise en œuvre.
|
||||
|
||||
## 2026-11-03 (matin) : fin de l'étape 0, retours du test sur téléphone
|
||||
|
||||
**Fait**
|
||||
- Pierre a joué une partie complète sur le Fairphone 4 : « très rapide de la
|
||||
part de l'ordinateur ». Avec les tests au vert, le critère de fin de l'étape 0
|
||||
est atteint. Le guide de test et `docs/etape-0.md`, temporaires, sont
|
||||
supprimés ; leurs décisions sont dans les ADR 0005 et 0009.
|
||||
- Playwright validé par Pierre (amendement de l'ADR 0004, MR !20).
|
||||
- Douze retours de Pierre après l'essai, plus l'installation en un toucher et
|
||||
les retours haptiques, rangés en sous-étapes 0e dans la feuille de route
|
||||
(MR !20). Lancés : la saisie du coup (0e.1) et une revue UX par un designer
|
||||
UI (0e.2).
|
||||
- Coordination avec la session « identité » : elle propose et maquette le
|
||||
design (mode immersif, compte de points, accueil, tutoriel), Pierre tranche,
|
||||
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**
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Accueil, installation, tutoriel des règles, aide (0e.5)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.5 (feuille de route), qui couvre aussi le tutoriel de 1g
|
||||
- **Statut** : prévu (après la fusion de 0e.4)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'installation en un toucher :
|
||||
- une page d'accueil ;
|
||||
- l'installation proposée par l'app ;
|
||||
- un tutoriel des règles très didactique, illustré par le plateau ;
|
||||
- une aide plus humaine.
|
||||
|
||||
## Références
|
||||
|
||||
- Règles : `docs/identite/charte/accueil-tutoriel-aide.md`. Le texte du tutoriel y figure en première version, à faire valider par Pierre.
|
||||
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangée « Étape 0e.5 » et artboard « Accueil ».
|
||||
- ADR 0008 (source unique), ADR 0013 (positions par identifiants gnubg).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Accueil**, selon la charte :
|
||||
- au premier lancement, et une seule fois, le choix entre « Je découvre » et « Je connais les règles » ;
|
||||
- ensuite : la partie en cours, la carte d'installation, la prochaine leçon et la progression résumée. La leçon et la progression restent en attente tant que 1g et 1h n'existent pas : n'afficher que ce qui existe.
|
||||
2. **Installation** :
|
||||
- sous Chrome, garder l'invite reçue avec `beforeinstallprompt` ;
|
||||
- sous Firefox et sur iPhone, une feuille en 3 étapes ;
|
||||
- la carte n'apparaît qu'à partir de la première partie terminée. « Plus tard » la masque, et elle ne revient qu'une fois, après 3 parties. Le menu garde une ligne tant que l'app n'est pas installée ;
|
||||
- plus rien en `display-mode: standalone` ni après l'événement `appinstalled`.
|
||||
3. **Tutoriel**, en 8 étapes :
|
||||
- le texte vit dans un fichier source unique sous `docs/` (par exemple `docs/tutoriel/`), copié dans le site par `make site` comme le glossaire, sous licence CC BY-SA 4.0 ;
|
||||
- positions données par leurs identifiants gnubg ;
|
||||
- sur les écrans « à vous », le moteur vérifie le coup joué en le comparant aux coups attendus de l'étape ;
|
||||
- le coup illustré se joue seul après 1 s, avec « Revoir » ;
|
||||
- un test vérifie que chaque position du tutoriel est valide pour le moteur et que chaque coup attendu est légal ;
|
||||
- ajouter la fiche du glossaire « camp ».
|
||||
4. **Aide**, en trois onglets :
|
||||
- « Jouer » : les scénarios deviennent des questions repliables. Le titre est la question et la description la réponse ; les étapes Gherkin sont cachées. Reformuler les titres des `.feature` sans casser les tests ;
|
||||
- « Les mots du jeu » : les cartes du glossaire, avec une recherche ;
|
||||
- « À propos » : licence, crédits, version ;
|
||||
- le tutoriel est proposé en tête de l'aide.
|
||||
5. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
|
||||
6. **`make test-browser`** :
|
||||
- premier lancement, tutoriel complet avec ses écrans « à vous », aide ;
|
||||
- `beforeinstallprompt` simulé dans Chromium ;
|
||||
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
|
||||
|
||||
Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a son adresse et s'ouvre par un vrai lien, selon l'amendement de l'ADR 0013 ; le routeur vient de 0e.4.
|
||||
|
||||
`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Un débutant suit le tutoriel jusqu'au bout sur le téléphone, et chaque écran « à vous » accepte le bon coup et refuse les autres.
|
||||
- Les trois commandes passent, et les captures sont livrées pour la recette.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Conseil du maître (1d)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1d (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1c)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
|
||||
après chaque coup et chaque décision de videau du joueur, jamais avant
|
||||
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
|
||||
du moteur.
|
||||
|
||||
## Références
|
||||
|
||||
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
|
||||
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
|
||||
de la révision UX.
|
||||
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
|
||||
(gnubg : 0,03 / 0,06 / 0,12).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
|
||||
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
|
||||
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
|
||||
elles ne citent que des faits du moteur (coup recommandé, rang, pions
|
||||
frappés, écart) : aucune explication inventée. Première version à faire
|
||||
valider par Pierre (ton).
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
|
||||
désactivé, décision de videau commentée) avec positions et dés imposés ;
|
||||
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
|
||||
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
|
||||
recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après une erreur volontaire dans une position connue, la ligne du maître dit
|
||||
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
|
||||
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Match, niveaux et réglages (1c)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1c (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après la révision UX et 1b)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Choisir sa partie : partie libre ou match en N points, avec les règles de
|
||||
Crawford et de Jacoby, contre l'ordinateur au niveau voulu.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg
|
||||
(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C.
|
||||
|
||||
1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford
|
||||
et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en
|
||||
partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le
|
||||
journal d'événements reste rétrocompatible (les parties existantes se
|
||||
rechargent).
|
||||
2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford)
|
||||
ou remplacé par le Match ID, sans casser les appels existants.
|
||||
3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau
|
||||
choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de
|
||||
déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double
|
||||
facultatif à 0 pli, avec citation des sources.
|
||||
4. **Écran de nouvelle partie** (maquettes de la session « identité » si
|
||||
disponibles, sinon sobre et conforme à la charte) : type de partie, niveau
|
||||
(noms montrés au joueur à proposer simplement : Débutant, Amateur,
|
||||
Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux
|
||||
de gnubg documentée), conseil du maître activé ou non. Par défaut : partie
|
||||
libre, Intermédiaire, conseil activé. Le réglage est mémorisé.
|
||||
5. Affichage du score de match et de la partie Crawford sur le plateau.
|
||||
6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés,
|
||||
Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si
|
||||
absentes). `make lint`, `make test`, `make test-browser` (un match court).
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford
|
||||
appliqué ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Parties enregistrées, branches et export SGF (1b)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1b (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (lancé après la fusion de 0e.1)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Garder toutes les parties du joueur sur le téléphone, avec leurs branches, et
|
||||
les exporter au format SGF de GNU Backgammon.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 (domaine pur, stockage derrière une interface), ADR 0008 (scénarios
|
||||
et glossaire). Aucune nouvelle dépendance : l'API IndexedDB du navigateur
|
||||
directement.
|
||||
|
||||
1. **Stockage IndexedDB** derrière l'interface de `app/src/storage/` : une
|
||||
liste de parties (identifiant, date de début et de dernière action,
|
||||
adversaire et niveau, score, état en cours ou finie, résultat) et le
|
||||
journal d'événements de chacune. Migration transparente de la partie
|
||||
en cours enregistrée aujourd'hui dans `localStorage`. Si IndexedDB est
|
||||
indisponible (navigation privée), repli sur le comportement actuel.
|
||||
2. **Branches** : le journal est déjà un arbre (événements avec parent,
|
||||
`goBackTo`). Exposer dans le domaine la liste des branches d'une partie
|
||||
(feuilles de l'arbre, avec le coup d'origine) et le passage de l'une à
|
||||
l'autre. « Rejouer d'ici » depuis un coup passé crée une branche ; la
|
||||
branche d'origine reste intacte et rechargeable. Pas d'écran d'analyse ici
|
||||
(1e) : un accès minimal suffit pour le test (menu « Mes parties » :
|
||||
reprendre une partie, voir ses branches).
|
||||
3. **Nouvelle partie annulable** : l'arbitrage UX (plan
|
||||
`2026-11-03-revision-ux.md`, point 3) prévoit « Reprendre l'ancienne »
|
||||
pendant 10 s. Fournir l'API de stockage qui le permet (reprendre une partie
|
||||
par son identifiant) ; l'interface viendra avec la révision UX.
|
||||
4. **Export SGF** : une partie (et ses branches en variantes) au format SGF
|
||||
de GNU Backgammon, téléchargeable depuis « Mes parties ». Vérification par
|
||||
le gnubg de bureau de référence (Docker existant) : le fichier exporté se
|
||||
charge sans erreur et ses positions successives sont les mêmes que celles
|
||||
du journal, sur des parties générées par les tests.
|
||||
5. Scénarios Gherkin : reprendre une partie parmi plusieurs, rejouer d'ici
|
||||
sans perdre l'original, exporter une partie. Fiches du glossaire pour les
|
||||
nouveaux termes (branche, SGF…).
|
||||
|
||||
`make lint`, `make test` et `make test-browser` passent (ajouter au test
|
||||
navigateur : deux parties enregistrées, reprise de la première après
|
||||
rechargement). Commits fréquents ; pas de push ni de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- Après rechargement, la liste des parties et chaque branche sont intactes.
|
||||
- Un SGF exporté se charge dans gnubg de bureau avec les mêmes positions.
|
||||
- Les trois commandes de test passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Une adresse pour chaque écran, et pour chaque position (0e.4)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.4 (feuille de route)
|
||||
- **Statut** : prévu (après la fusion de la révision UX et de 1b)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer l'ADR 0013 et son amendement du 2026-11-03 : chaque écran (aide,
|
||||
glossaire, menu, mes parties…) a son adresse et s'ouvre par un vrai lien ;
|
||||
chaque position du plateau a un lien direct.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
|
||||
|
||||
0. **Routeur** : un petit module pur (sans dépendance) qui traduit le
|
||||
fragment en écran et inversement, testé unitairement ; l'app dessine
|
||||
l'écran demandé par l'adresse, sur `hashchange`. Tous les écrans existants
|
||||
(aide et ses questions, glossaire et ses termes, à propos, menu, mes
|
||||
parties de 1b) passent par lui, avec de vrais liens `<a href>` ; le bouton
|
||||
retour du navigateur ramène à l'écran précédent. Les actions de jeu restent
|
||||
des boutons.
|
||||
1. Lecture de `#/position/<Position ID>/<Match ID>` ; validation par le
|
||||
moteur ; message clair si illisible.
|
||||
2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle
|
||||
partie depuis la position, la partie en cours restant reprenable) et
|
||||
« Retour à ma partie ».
|
||||
3. Domaine : démarrer une partie depuis une position et un Match ID donnés
|
||||
(événement GameStarted étendu de façon rétrocompatible, ou équivalent
|
||||
justifié), sans règle réimplémentée.
|
||||
4. « Copier le lien de la position » depuis le menu de la partie et depuis la
|
||||
vue du coup de l'ordinateur ; le lien copié rouvre exactement la même
|
||||
position (test aller-retour).
|
||||
5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la
|
||||
position »), fiches du glossaire (Position ID, Match ID si absentes).
|
||||
6. `make lint`, `make test`, `make test-browser` : chaque adresse du tableau
|
||||
de l'ADR ouverte directement dans un nouvel onglet montre le bon écran ;
|
||||
« retour » du navigateur ; liens reconnus comme liens (rôle `link`) ;
|
||||
position : ouvrir un lien, jouer à partir d'ici, revenir, lien invalide.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même
|
||||
position ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -47,3 +47,4 @@ Règles d'écriture :
|
||||
| Fichier | Tâche | Depuis |
|
||||
| --- | --- | --- |
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
|
||||
|
||||
@@ -11,11 +11,40 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Et le premier jet de dés désigne qui commence
|
||||
|
||||
Scénario: Jouer un coup permis
|
||||
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
|
||||
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
|
||||
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
|
||||
ou de l'autre du plateau, ou « Jouer ce coup ».
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5 6/5
|
||||
Alors votre coup attend votre validation
|
||||
Et vos dés sont tous marqués comme joués
|
||||
Quand vous validez votre coup
|
||||
Alors votre coup est accepté
|
||||
Et c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Un pion qui n'a qu'une case d'arrivée
|
||||
Après 8/5, il ne vous reste que le 1 : chaque pion jouable n'a plus
|
||||
qu'une case d'arrivée, et il y va dès que vous le touchez.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
Scénario: Reprendre un pion avant de valider
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5 6/5
|
||||
Et que vous annulez le dernier demi-coup
|
||||
Alors il ne reste de joué que 8/5
|
||||
Quand vous annulez le dernier demi-coup
|
||||
Alors la position n'a pas changé
|
||||
Et aucun de vos dés n'est marqué comme joué
|
||||
|
||||
Scénario: Un coup interdit est refusé
|
||||
La case 12 est tenue par l'ordinateur, qui y a cinq pions : vous ne
|
||||
pouvez pas vous y poser.
|
||||
@@ -30,6 +59,56 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Quand ses dés sont tirés
|
||||
Alors il joue le coup recommandé par le moteur en moins de 2 secondes
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché, barré, avec un message. L'application ne joue jamais à votre
|
||||
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
|
||||
(ou « Continuer »).
|
||||
|
||||
Étant donné que votre pion sur la barre ne peut pas rentrer
|
||||
Quand vous lancez les dés
|
||||
Alors aucun coup n'est possible avec votre jet
|
||||
Et c'est toujours à vous
|
||||
Quand vous laissez passer votre tour
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
étaient. « Revoir » (la flèche circulaire) rejoue son coup.
|
||||
|
||||
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
|
||||
Quand l'ordinateur a joué
|
||||
Alors son coup vous est montré case par case
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Voir le compte de points
|
||||
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
|
||||
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
|
||||
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
|
||||
comptes sont aux deux bouts de la barre.
|
||||
|
||||
Quand vous commencez une nouvelle partie
|
||||
Alors chaque camp a un compte de points de 167
|
||||
Et personne ne mène la course
|
||||
Quand vous jouez 13/8 13/11
|
||||
Alors vous menez la course de 7
|
||||
|
||||
Scénario: Doubler, après confirmation
|
||||
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
|
||||
« Doubler ». L'application vous demande de confirmer : une fois proposé,
|
||||
un double ne se reprend pas.
|
||||
|
||||
Étant donné que vous pouvez doubler
|
||||
Quand vous demandez à doubler
|
||||
Mais que vous changez d'avis
|
||||
Alors rien n'est proposé à l'ordinateur
|
||||
Quand vous demandez à doubler
|
||||
Et que vous confirmez
|
||||
Alors l'ordinateur prend ou passe, comme le recommande le moteur
|
||||
|
||||
Scénario: Prendre le double de l'ordinateur
|
||||
Étant donné que l'ordinateur vous propose de doubler
|
||||
Quand vous prenez
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Régler l'application
|
||||
|
||||
Le menu, qui monte du bas de l'écran, regroupe ce qui ne sert pas à chaque
|
||||
coup : commencer une nouvelle partie, passer en plein écran, ouvrir l'aide
|
||||
et le glossaire, choisir le thème, couper les vibrations, et laisser ou non
|
||||
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
|
||||
question : ce qui se fait se rattrape.
|
||||
|
||||
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
|
||||
Une nouvelle partie commence tout de suite, sans question. Pendant 10
|
||||
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
|
||||
vous venez de quitter, exactement là où vous l'aviez laissée.
|
||||
|
||||
Étant donné qu'une partie est en cours depuis quelques coups
|
||||
Quand vous commencez une nouvelle partie depuis le menu
|
||||
Alors une nouvelle partie commence aussitôt
|
||||
Et l'ancienne partie peut être reprise pendant 10 secondes
|
||||
Quand vous reprenez l'ancienne partie
|
||||
Alors vous retrouvez exactement la partie quittée
|
||||
|
||||
Scénario: Couper les vibrations
|
||||
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
|
||||
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
|
||||
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
|
||||
ne vibre quand vous lancez les dés. Elles se coupent dans le menu.
|
||||
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Quand un pion est frappé
|
||||
Alors le téléphone vibre deux fois, brièvement
|
||||
Quand vous coupez les vibrations dans le menu
|
||||
Et qu'un pion est frappé
|
||||
Alors le téléphone ne vibre pas
|
||||
|
||||
Scénario: Garder l'écran allumé pendant la partie
|
||||
Pour que le téléphone ne se mette pas en veille pendant que vous
|
||||
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
|
||||
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Alors l'écran reste allumé pendant une partie
|
||||
Quand vous coupez « Écran allumé pendant la partie » dans le menu
|
||||
Alors l'écran peut se mettre en veille comme d'habitude
|
||||
|
||||
Scénario: Choisir le thème
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Alors le thème suit celui du téléphone
|
||||
Quand vous changez de thème dans le menu
|
||||
Alors il passe à clair, puis à sombre, puis revient à automatique
|
||||
+199
-10
@@ -5,7 +5,7 @@
|
||||
* Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
*
|
||||
* Usage :
|
||||
* engine-cli [--ply N] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
* engine-cli [--ply N | --level NIVEAU] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
*
|
||||
* Commandes :
|
||||
* bestmove POSITION_ID MATCH_ID DÉS meilleur coup (DÉS : ex. 31)
|
||||
@@ -27,8 +27,23 @@
|
||||
* référence (tests/reference/README.md) :
|
||||
* évaluation, puis classement des coups
|
||||
* avec DÉS, décision de videau sans
|
||||
* levels niveaux de jeu, seuils d'appréciation
|
||||
* et de chance de gnubg
|
||||
* analysemove POSITION_ID MATCH_ID DÉS POSITION_JOUÉE
|
||||
* analyse du coup joué (POSITION_JOUÉE :
|
||||
* position après le coup, vue du joueur
|
||||
* qui l'a joué, comme position_id dans
|
||||
* moves et legal)
|
||||
* analysecube POSITION_ID MATCH_ID DÉCISION
|
||||
* analyse d'une décision de videau :
|
||||
* no_double ou double (joueur au trait),
|
||||
* take ou pass (son adversaire)
|
||||
* luck POSITION_ID MATCH_ID DÉS chance du jet pour le joueur au trait
|
||||
*
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4.
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint »,
|
||||
* analyses comme l'analyse de partie de gnubg).
|
||||
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves
|
||||
* et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris.
|
||||
* --data : dossier des données ; par défaut « data » à côté du programme.
|
||||
*
|
||||
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
|
||||
@@ -55,20 +70,32 @@
|
||||
#define NUMBER "%.9g"
|
||||
|
||||
/* Liste des commandes, pour les messages d'erreur. */
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference"
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \
|
||||
"levels, analysemove, analysecube ou luck"
|
||||
|
||||
/* Coups gardés dans le classement de la commande reference (comme hint). */
|
||||
#define REFERENCE_MAX_MOVES 10
|
||||
|
||||
/* Arguments de la ligne de commande, une fois lus. */
|
||||
#define MAX_ARGS 4
|
||||
|
||||
typedef struct {
|
||||
int ply;
|
||||
int level; /* -1 : pas de --level */
|
||||
const char *data_dir;
|
||||
const char *command;
|
||||
const char *args[3];
|
||||
const char *args[MAX_ARGS];
|
||||
int arg_count;
|
||||
} options;
|
||||
|
||||
/* Noms JSON des appréciations (engine_skill), des chances
|
||||
* (engine_luck_rating) et des décisions de videau (engine_cube_action). */
|
||||
static const char *const skill_names[] = { "none", "doubtful", "bad", "very_bad" };
|
||||
static const char *const luck_names[] = {
|
||||
"very_unlucky", "unlucky", "none", "lucky", "very_lucky"
|
||||
};
|
||||
static const char *const cube_action_names[] = { "no_double", "double", "take", "pass" };
|
||||
|
||||
/* Code d'erreur JSON pour chaque engine_status. */
|
||||
static const char *
|
||||
status_code(engine_status status)
|
||||
@@ -92,6 +119,12 @@ status_code(engine_status status)
|
||||
return "too_many_moves";
|
||||
case ENGINE_ERROR_INTERNAL:
|
||||
return "internal_error";
|
||||
case ENGINE_ERROR_LEVEL:
|
||||
return "invalid_level";
|
||||
case ENGINE_ERROR_MOVE:
|
||||
return "invalid_move";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "invalid_cube_action";
|
||||
}
|
||||
return "unknown_error";
|
||||
}
|
||||
@@ -178,6 +211,18 @@ print_reference_evaluation(const engine_evaluation *e)
|
||||
printf(", \"equity\": " NUMBER "}", e->cubeless_equity);
|
||||
}
|
||||
|
||||
/* « , "ply": N » ou « , "level": "NOM" », selon les options. */
|
||||
static void
|
||||
print_level_or_ply(const options *opt)
|
||||
{
|
||||
engine_level_settings settings;
|
||||
|
||||
if (opt->level >= 0 && engine_level_settings_of(opt->level, &settings) == ENGINE_OK)
|
||||
printf(", \"level\": \"%s\"", settings.name);
|
||||
else
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
}
|
||||
|
||||
/* Lit des dés au format « 31 » ; renvoie 0 si le format est faux. */
|
||||
static int
|
||||
parse_dice(const char *text, int *die1, int *die2)
|
||||
@@ -208,7 +253,10 @@ run_moves_command(const options *opt)
|
||||
moves = malloc(sizeof *moves * ENGINE_MAX_MOVES);
|
||||
if (!moves)
|
||||
return print_error("out_of_memory", "mémoire insuffisante", EXIT_ENGINE_ERROR);
|
||||
if (evaluate)
|
||||
if (evaluate && opt->level >= 0)
|
||||
status = engine_level_best_moves(position_id, match_id, die1, die2, opt->level,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else if (evaluate)
|
||||
status = engine_best_moves(position_id, match_id, die1, die2, opt->ply,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else
|
||||
@@ -222,7 +270,7 @@ run_moves_command(const options *opt)
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d]",
|
||||
position_id, match_id, die1, die2);
|
||||
if (evaluate)
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
print_level_or_ply(opt);
|
||||
if (best_only) {
|
||||
printf(", \"best_move\": ");
|
||||
if (count > 0)
|
||||
@@ -270,11 +318,15 @@ run_position_command(const options *opt)
|
||||
e.cubeless_equity, e.cubeful_equity);
|
||||
} else {
|
||||
engine_cube c;
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (opt->level >= 0)
|
||||
status = engine_level_cube_decision(position_id, match_id, opt->level, &c);
|
||||
else
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"match\": ",
|
||||
position_id, match_id, opt->ply);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
|
||||
print_level_or_ply(opt);
|
||||
printf(", \"match\": ");
|
||||
print_match(&match);
|
||||
printf(", \"probabilities\": ");
|
||||
print_probabilities(c.probabilities);
|
||||
@@ -507,6 +559,122 @@ run_reference_command(const options *opt)
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* levels : niveaux de jeu, seuils d'appréciation et de chance. */
|
||||
static int
|
||||
run_levels_command(const options *opt)
|
||||
{
|
||||
engine_level_settings s;
|
||||
int level;
|
||||
|
||||
if (opt->arg_count != 0)
|
||||
return usage_error("levels n'attend pas d'argument");
|
||||
printf("{\"levels\": [");
|
||||
for (level = 0; level < ENGINE_LEVEL_COUNT; level++) {
|
||||
engine_level_settings_of(level, &s);
|
||||
printf("%s{\"name\": \"%s\", \"plies\": %d, \"noise\": " NUMBER
|
||||
", \"deterministic\": %s, \"prune\": %s}", level ? ", " : "", s.name, s.plies,
|
||||
s.noise, s.deterministic ? "true" : "false", s.prune ? "true" : "false");
|
||||
}
|
||||
printf("], \"skill_thresholds\": {\"doubtful\": " NUMBER ", \"bad\": " NUMBER
|
||||
", \"very_bad\": " NUMBER "}, \"luck_thresholds\": {\"lucky\": " NUMBER
|
||||
", \"very_lucky\": " NUMBER "}}\n",
|
||||
ENGINE_SKILL_DOUBTFUL_THRESHOLD, ENGINE_SKILL_BAD_THRESHOLD,
|
||||
ENGINE_SKILL_VERY_BAD_THRESHOLD, ENGINE_LUCK_THRESHOLD, ENGINE_LUCK_VERY_THRESHOLD);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysemove : analyse du coup joué. */
|
||||
static int
|
||||
run_analyse_move_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
const char *played_id = opt->args[3];
|
||||
engine_move_analysis a;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 4)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_analyse_move(position_id, match_id, die1, die2, played_id, opt->ply, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"ply\": %d, "
|
||||
"\"played_position_id\": \"%s\", \"move_count\": %d",
|
||||
position_id, match_id, die1, die2, opt->ply, played_id, a.move_count);
|
||||
if (a.move_count == 0) {
|
||||
printf(", \"rank\": null, \"played\": null, \"best\": null");
|
||||
} else {
|
||||
printf(", \"rank\": %d, \"played\": ", a.rank);
|
||||
print_move(&a.played, 1);
|
||||
printf(", \"best\": ");
|
||||
print_move(&a.best, 1);
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysecube : analyse d'une décision de videau. */
|
||||
static int
|
||||
run_analyse_cube_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_cube_analysis a;
|
||||
engine_status status;
|
||||
int action;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉCISION");
|
||||
for (action = 0; action < 4 && strcmp(opt->args[2], cube_action_names[action]) != 0; action++)
|
||||
;
|
||||
if (action == 4)
|
||||
return usage_error("DÉCISION attend no_double, double, take ou pass");
|
||||
status = engine_analyse_cube(position_id, match_id, opt->ply, (engine_cube_action) action, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"action\": \"%s\", "
|
||||
"\"analysed\": %s, \"cube\": ", position_id, match_id, opt->ply,
|
||||
cube_action_names[action], a.analysed ? "true" : "false");
|
||||
if (a.analysed) {
|
||||
printf("{\"probabilities\": ");
|
||||
print_probabilities(a.cube.probabilities);
|
||||
printf(", ");
|
||||
print_cube_fields(&a.cube);
|
||||
printf("}");
|
||||
} else {
|
||||
printf("null");
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* luck : chance du jet pour le joueur au trait. */
|
||||
static int
|
||||
run_luck_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_luck luck;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_luck_of(position_id, match_id, die1, die2, &luck);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"luck\": " NUMBER
|
||||
", \"rating\": \"%s\"}\n", position_id, match_id, die1, die2, luck.luck,
|
||||
luck_names[luck.rating]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* Dossier « data » à côté du programme. */
|
||||
static char *
|
||||
default_data_dir(const char *program)
|
||||
@@ -531,20 +699,28 @@ parse_options(int argc, char **argv, options *opt)
|
||||
{
|
||||
int i;
|
||||
|
||||
int has_ply = 0;
|
||||
|
||||
memset(opt, 0, sizeof *opt);
|
||||
opt->level = -1;
|
||||
for (i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--ply") == 0 && i + 1 < argc) {
|
||||
char *end;
|
||||
opt->ply = (int) strtol(argv[++i], &end, 10);
|
||||
if (*end || end == argv[i])
|
||||
return "--ply attend un nombre";
|
||||
has_ply = 1;
|
||||
} else if (strcmp(argv[i], "--level") == 0 && i + 1 < argc) {
|
||||
if (engine_level_from_name(argv[++i], &opt->level) != ENGINE_OK)
|
||||
return "--level attend un niveau : beginner, casual, intermediate, advanced, "
|
||||
"0ply, 1ply, 2ply, 3ply ou 4ply";
|
||||
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
|
||||
opt->data_dir = argv[++i];
|
||||
} else if (argv[i][0] == '-' && argv[i][1] == '-') {
|
||||
return "option inconnue ou sans valeur";
|
||||
} else if (!opt->command) {
|
||||
opt->command = argv[i];
|
||||
} else if (opt->arg_count < 3) {
|
||||
} else if (opt->arg_count < MAX_ARGS) {
|
||||
opt->args[opt->arg_count++] = argv[i];
|
||||
} else {
|
||||
return "trop d'arguments";
|
||||
@@ -552,6 +728,11 @@ parse_options(int argc, char **argv, options *opt)
|
||||
}
|
||||
if (!opt->command)
|
||||
return "commande attendue : " COMMANDS;
|
||||
if (opt->level >= 0 && has_ply)
|
||||
return "--ply et --level ne vont pas ensemble";
|
||||
if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0
|
||||
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0)
|
||||
return "--level ne s'applique qu'à bestmove, moves et cube";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -591,6 +772,14 @@ main(int argc, char **argv)
|
||||
result = run_half_moves_command(&opt);
|
||||
else if (strcmp(opt.command, "matchid") == 0)
|
||||
result = run_match_id_command(&opt);
|
||||
else if (strcmp(opt.command, "levels") == 0)
|
||||
result = run_levels_command(&opt);
|
||||
else if (strcmp(opt.command, "analysemove") == 0)
|
||||
result = run_analyse_move_command(&opt);
|
||||
else if (strcmp(opt.command, "analysecube") == 0)
|
||||
result = run_analyse_cube_command(&opt);
|
||||
else if (strcmp(opt.command, "luck") == 0)
|
||||
result = run_luck_command(&opt);
|
||||
else
|
||||
result = usage_error("commande inconnue : " COMMANDS);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// bench.mjs -- la mesure de la page bench.html, sous Node, sur le PC
|
||||
// bench.mjs -- mesure du moteur WebAssembly sous Node, sur le PC
|
||||
// (`make bench`). Premier ordre de grandeur seulement : le chiffre qui compte
|
||||
// est celui du téléphone.
|
||||
//
|
||||
@@ -10,7 +10,7 @@ import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import createEngineModule from "../build/wasm/engine.mjs";
|
||||
import { DOWNLOADED_FILES, formatReport, measureTimes } from "../web/moteur/benchmark.mjs";
|
||||
import { DOWNLOADED_FILES, formatReport, measureTimes } from "./benchmark.mjs";
|
||||
import { commands, loadEngine } from "../web/engine-wasm.mjs";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
@@ -35,6 +35,7 @@ const { positions } = JSON.parse(readFileSync(sitePath("wasm/positions.json"), "
|
||||
const times = await measureTimes({
|
||||
bestMove: (...args) => engine.run(commands.bestMove(...args)),
|
||||
cubeDecision: (...args) => engine.run(commands.cubeDecision(...args)),
|
||||
analyseMove: (...args) => engine.run(commands.analyseMove(...args)),
|
||||
}, positions, (done, total) => process.stderr.write("\r " + done + "/" + total + " calculs"));
|
||||
process.stderr.write("\n");
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// benchmark.mjs -- mesure du moteur WebAssembly, partagée par la page de
|
||||
// mesure (bench.html, dans le navigateur) et `make bench` (sous Node).
|
||||
// benchmark.mjs -- mesure du moteur WebAssembly sous Node, pour `make bench`
|
||||
// (engine-wasm/bench.mjs). Elle servait aussi à la page de mesure du site,
|
||||
// retirée avec les pages de test du moteur (ADR 0009, amendement du
|
||||
// 2026-11-03).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -7,7 +9,7 @@
|
||||
// Sur les positions de référence (tests/reference/positions.json) : temps du
|
||||
// meilleur coup à 0 et 2 plis pour les positions avec dés, temps de la
|
||||
// décision de videau à 2 plis pour les autres. Le rapport compare aux seuils
|
||||
// de l'étape 0b (docs/etape-0.md).
|
||||
// de l'étape 0b (ADR 0009).
|
||||
|
||||
// Seuils de l'étape 0b. Un temps respecte son seuil si son maximum, sur
|
||||
// toutes les positions, est en dessous (la médiane est donnée en plus).
|
||||
@@ -15,18 +17,20 @@ export const TIME_THRESHOLDS = [
|
||||
{ key: "move0", label: "Meilleur coup, 0 pli", limitMs: 300 },
|
||||
{ key: "move2", label: "Meilleur coup, 2 plis", limitMs: 3000 },
|
||||
{ key: "cube2", label: "Décision de videau, 2 plis", limitMs: 3000 },
|
||||
// Étape 1a : l'analyse du coup joué, pour le conseil du maître, calculée
|
||||
// en arrière-plan ; même repère que le meilleur coup (ADR 0009).
|
||||
{ key: "analysis2", label: "Analyse du coup joué, 2 plis", limitMs: 3000 },
|
||||
];
|
||||
|
||||
// Taille totale à télécharger, compressée : moins de 40 Mo.
|
||||
export const DOWNLOAD_LIMIT_BYTES = 40e6;
|
||||
|
||||
// Fichiers téléchargés pour faire tourner le moteur dans la page de test,
|
||||
// chemins relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
|
||||
// Fichiers téléchargés pour faire tourner le moteur dans l'application
|
||||
// (hors code de l'application elle-même, quelques dizaines de Ko), chemins
|
||||
// relatifs à la racine du site (web/ ; wasm/ est build/wasm/).
|
||||
export const DOWNLOADED_FILES = [
|
||||
"moteur/index.html",
|
||||
"moteur/style.css",
|
||||
"moteur/page.mjs",
|
||||
"moteur/engine-client.mjs",
|
||||
"index.html",
|
||||
"app.css",
|
||||
"engine-worker.mjs",
|
||||
"engine-wasm.mjs",
|
||||
"wasm/engine.mjs",
|
||||
@@ -34,19 +38,29 @@ export const DOWNLOADED_FILES = [
|
||||
"wasm/engine.data",
|
||||
];
|
||||
|
||||
// engine : objet avec bestMove(positionId, matchId, dice, ply) et
|
||||
// cubeDecision(positionId, matchId, ply), qui renvoient (ou promettent)
|
||||
// { result, ms }.
|
||||
// engine : objet avec bestMove(positionId, matchId, dice, ply),
|
||||
// cubeDecision(positionId, matchId, ply) et analyseMove(positionId, matchId,
|
||||
// dice, playedPositionId, ply), qui renvoient (ou promettent) { result, ms }.
|
||||
// Le coup analysé est le meilleur coup à 0 pli : celui qu'un joueur aurait
|
||||
// pu jouer, analysé à 2 plis comme pour le conseil du maître.
|
||||
// positions : le tableau « positions » de positions.json.
|
||||
// onProgress(done, total) : appelé après chaque calcul.
|
||||
// Renvoie, pour chaque clé de TIME_THRESHOLDS, la liste des
|
||||
// { id, ms } mesurés.
|
||||
export async function measureTimes(engine, positions, onProgress = () => {}) {
|
||||
const tasks = [];
|
||||
const played = new Map();
|
||||
for (const p of positions) {
|
||||
if (p.type === "move") {
|
||||
tasks.push(["move0", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 0)]);
|
||||
tasks.push(["move0", p, async () => {
|
||||
const answer = await engine.bestMove(p.position_id, p.match_id, p.dice, 0);
|
||||
// Sans coup possible, n'importe quelle position fait l'affaire.
|
||||
played.set(p.id, answer.result.best_move?.position_id ?? p.position_id);
|
||||
return answer;
|
||||
}]);
|
||||
tasks.push(["move2", p, () => engine.bestMove(p.position_id, p.match_id, p.dice, 2)]);
|
||||
tasks.push(["analysis2", p, () => engine.analyseMove(p.position_id, p.match_id, p.dice,
|
||||
played.get(p.id), 2)]);
|
||||
} else {
|
||||
tasks.push(["cube2", p, () => engine.cubeDecision(p.position_id, p.match_id, 2)]);
|
||||
}
|
||||
+482
-26
@@ -26,8 +26,12 @@
|
||||
#define DEFAULT_BEAVERS 3
|
||||
|
||||
/* Seuil d'erreur « douteuse » de gnubg (arSkillLevel[SKILL_DOUBTFUL] dans
|
||||
* gnubg.c), que la commande « hint » passe à FindnSaveBestMoves. */
|
||||
#define DOUBTFUL_THRESHOLD 0.03f
|
||||
* gnubg.c), que la commande « hint » et l'analyse passent à
|
||||
* FindnSaveBestMoves. */
|
||||
#define DOUBTFUL_THRESHOLD ENGINE_SKILL_DOUBTFUL_THRESHOLD
|
||||
|
||||
/* Position de départ : la même vue des deux joueurs. */
|
||||
#define START_POSITION_ID "4HPwATDgc/ABMA"
|
||||
|
||||
/* Fichier de table d'équité de match : celui de gnubg de bureau par défaut. */
|
||||
#define MET_FILE "Kazaross-XG2.xml"
|
||||
@@ -146,6 +150,12 @@ engine_status_message(engine_status status)
|
||||
return "tableau de coups trop petit";
|
||||
case ENGINE_ERROR_INTERNAL:
|
||||
return "erreur interne de gnubg";
|
||||
case ENGINE_ERROR_LEVEL:
|
||||
return "niveau de jeu inconnu";
|
||||
case ENGINE_ERROR_MOVE:
|
||||
return "coup joué absent des coups légaux";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "décision de videau inconnue";
|
||||
}
|
||||
return "erreur inconnue";
|
||||
}
|
||||
@@ -242,16 +252,127 @@ check_dice(int die1, int die2)
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Réglages prédéfinis de gnubg pour un niveau, sans bruit. */
|
||||
/*
|
||||
* Vide le cache d'évaluation de gnubg avant chaque calcul : un résultat ne
|
||||
* dépend ainsi que de ses arguments, comme les références (gnubg de bureau,
|
||||
* « clear cache » avant chaque position). Sans cela, une recherche à 2 plis
|
||||
* avec élagage laisse dans le cache des évaluations à 0 pli faites avec les
|
||||
* réseaux d'élagage, rangées sous la même clé que les évaluations à 0 pli
|
||||
* ordinaires (EvalKey, eval.c l. 2302 : l'élagage n'y compte qu'à partir
|
||||
* d'1 pli) ; une chance de jet ou un coup à 0 pli calculé ensuite les
|
||||
* reprendrait (écarts mesurés jusqu'à 2,5e-2, tests/reference/README.md).
|
||||
* Coût mesuré : environ 3 ms par appel sous Node sur le PC.
|
||||
*/
|
||||
static void
|
||||
start_evaluation(void)
|
||||
{
|
||||
EvalCacheFlush();
|
||||
}
|
||||
|
||||
/* Réglages prédéfinis de gnubg pour un niveau, sans bruit ; prépare le
|
||||
* calcul. */
|
||||
static engine_status
|
||||
eval_context_for_ply(int ply, evalcontext *context)
|
||||
{
|
||||
if (ply < 0 || ply > ENGINE_MAX_PLY)
|
||||
return ENGINE_ERROR_PLY;
|
||||
start_evaluation();
|
||||
*context = aecSettings[SETTINGS_0PLY + ply];
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Niveaux de jeu */
|
||||
|
||||
/*
|
||||
* Les niveaux d'engine_level sont, dans le même ordre, les réglages
|
||||
* prédéfinis de gnubg (aecSettings, eval.c l. 318-328, et noms de
|
||||
* aszSettings, l. 305-315, où « casual play » devient « casual »).
|
||||
*/
|
||||
static const char *const level_names[ENGINE_LEVEL_COUNT] = {
|
||||
"beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"
|
||||
};
|
||||
|
||||
/* Réglages d'évaluation d'un niveau (bruit compris) ; prépare le calcul. */
|
||||
static engine_status
|
||||
eval_context_for_level(int level, evalcontext *context)
|
||||
{
|
||||
if (level < 0 || level >= ENGINE_LEVEL_COUNT)
|
||||
return ENGINE_ERROR_LEVEL;
|
||||
start_evaluation();
|
||||
*context = aecSettings[level];
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Filtre de coups d'un niveau : aiSettingsMoveFilter (eval.c l. 331) vaut
|
||||
* « Normal » à partir d'1 pli, et « sans objet » à 0 pli, où aucun filtre ne
|
||||
* sert ; le joueur de gnubg garde alors son filtre par défaut, « Normal »
|
||||
* aussi (ap dans gnubg.c l. 461). */
|
||||
static movefilter (*move_filter_for_level(int level))[MAX_FILTER_PLIES]
|
||||
{
|
||||
int index = aiSettingsMoveFilter[level];
|
||||
|
||||
return aaamfMoveFilterSettings[index < 0 ? 0 : index];
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_settings_of(int level, engine_level_settings *settings)
|
||||
{
|
||||
evalcontext context;
|
||||
engine_status status = eval_context_for_level(level, &context);
|
||||
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
settings->name = level_names[level];
|
||||
settings->plies = (int) context.nPlies;
|
||||
settings->noise = context.rNoise;
|
||||
settings->deterministic = (int) context.fDeterministic;
|
||||
settings->prune = (int) context.fUsePrune;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_from_name(const char *name, int *level)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; name && i < ENGINE_LEVEL_COUNT; i++)
|
||||
if (strcmp(name, level_names[i]) == 0) {
|
||||
*level = i;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
return ENGINE_ERROR_LEVEL;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Appréciation et chance */
|
||||
|
||||
/* Skill de gnubg (analysis.c l. 288), avec les seuils de arSkillLevel. */
|
||||
engine_skill
|
||||
engine_skill_of(float equity_loss)
|
||||
{
|
||||
if (equity_loss < -ENGINE_SKILL_VERY_BAD_THRESHOLD)
|
||||
return ENGINE_SKILL_VERY_BAD;
|
||||
if (equity_loss < -ENGINE_SKILL_BAD_THRESHOLD)
|
||||
return ENGINE_SKILL_BAD;
|
||||
if (equity_loss < -ENGINE_SKILL_DOUBTFUL_THRESHOLD)
|
||||
return ENGINE_SKILL_DOUBTFUL;
|
||||
return ENGINE_SKILL_NONE;
|
||||
}
|
||||
|
||||
/* Luck de gnubg (analysis.c l. 272), avec les seuils de arLuckLevel. */
|
||||
engine_luck_rating
|
||||
engine_luck_rating_of(float luck)
|
||||
{
|
||||
if (luck > ENGINE_LUCK_VERY_THRESHOLD)
|
||||
return ENGINE_LUCK_VERY_LUCKY;
|
||||
if (luck > ENGINE_LUCK_THRESHOLD)
|
||||
return ENGINE_LUCK_LUCKY;
|
||||
if (luck < -ENGINE_LUCK_VERY_THRESHOLD)
|
||||
return ENGINE_LUCK_VERY_UNLUCKY;
|
||||
if (luck < -ENGINE_LUCK_THRESHOLD)
|
||||
return ENGINE_LUCK_UNLUCKY;
|
||||
return ENGINE_LUCK_NONE;
|
||||
}
|
||||
|
||||
/* En match, gnubg calcule en chances de gain du match : on convertit. */
|
||||
static float
|
||||
normalized_equity(float score, const cubeinfo *cube)
|
||||
@@ -307,6 +428,25 @@ engine_legal_moves(const char *position_id, const char *match_id, int die1, int
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Copie une liste classée par gnubg, puis la libère. */
|
||||
static engine_status
|
||||
copy_ranked_moves(movelist *list, const position *pos, engine_move *moves, int max_moves,
|
||||
int *count)
|
||||
{
|
||||
engine_status status = ENGINE_OK;
|
||||
unsigned int i;
|
||||
|
||||
if (list->cMoves > (unsigned int) max_moves)
|
||||
status = ENGINE_ERROR_TOO_MANY_MOVES;
|
||||
else {
|
||||
for (i = 0; i < list->cMoves; i++)
|
||||
fill_move(&moves[i], &list->amMoves[i], pos, 1);
|
||||
*count = (int) list->cMoves;
|
||||
}
|
||||
g_free(list->amMoves);
|
||||
return status;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_best_moves(const char *position_id, const char *match_id, int die1, int die2, int ply,
|
||||
engine_move *moves, int max_moves, int *count)
|
||||
@@ -315,7 +455,6 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
unsigned int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
@@ -336,15 +475,94 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d
|
||||
aaamfMoveFilterSettings[0]) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
RefreshMoveList(&list, NULL);
|
||||
return copy_ranked_moves(&list, &pos, moves, max_moves, count);
|
||||
}
|
||||
|
||||
status = ENGINE_OK;
|
||||
if (list.cMoves > (unsigned int) max_moves)
|
||||
status = ENGINE_ERROR_TOO_MANY_MOVES;
|
||||
else {
|
||||
for (i = 0; i < list.cMoves; i++)
|
||||
fill_move(&moves[i], &list.amMoves[i], &pos, 1);
|
||||
*count = (int) list.cMoves;
|
||||
}
|
||||
engine_status
|
||||
engine_level_best_moves(const char *position_id, const char *match_id, int die1, int die2,
|
||||
int level, engine_move *moves, int max_moves, int *count)
|
||||
{
|
||||
position pos;
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Comme l'ordinateur de gnubg quand c'est son tour (ComputerTurn dans
|
||||
* play.c, l. 1436-1453) : FindnSaveBestMoves sans coup à garder, sans
|
||||
* analyse et sans seuil, avec les réglages et le filtre du joueur, puis
|
||||
* RefreshMoveList ; il joue le premier coup de la liste.
|
||||
*/
|
||||
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, FALSE, 0.0f,
|
||||
&pos.cube, &context, move_filter_for_level(level)) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
RefreshMoveList(&list, NULL);
|
||||
return copy_ranked_moves(&list, &pos, moves, max_moves, count);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Analyse d'un coup joué */
|
||||
|
||||
engine_status
|
||||
engine_analyse_move(const char *position_id, const char *match_id, int die1, int die2,
|
||||
const char *played_position_id, int ply, engine_move_analysis *analysis)
|
||||
{
|
||||
position pos;
|
||||
TanBoard played_board;
|
||||
positionkey played_key;
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
unsigned int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
if (!is_base64_id(played_position_id, L_POSITIONID)
|
||||
|| !PositionFromID(played_board, played_position_id))
|
||||
return ENGINE_ERROR_MOVE;
|
||||
PositionKey((ConstTanBoard) played_board, &played_key);
|
||||
|
||||
/*
|
||||
* Comme l'analyse de partie de gnubg (AnalyzeMove dans analysis.c,
|
||||
* l. 685-735) : FindnSaveBestMoves avec la clé du coup joué (évalué au
|
||||
* niveau le plus profond), fAnalyse vrai, seuil « douteux » et filtre
|
||||
* d'analyse (aamfAnalysis, « Normal » par défaut, gnubg.c l. 406) ; pas
|
||||
* de RefreshMoveList. Le rang est la place du coup joué dans cette
|
||||
* liste, et la perte rChequerSkill (l. 726) son équité moins celle du
|
||||
* premier.
|
||||
*/
|
||||
memset(analysis, 0, sizeof *analysis);
|
||||
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE,
|
||||
DOUBTFUL_THRESHOLD, &pos.cube, &context,
|
||||
aaamfMoveFilterSettings[0]) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
if (list.cMoves == 0)
|
||||
return ENGINE_OK; /* aucun coup possible : rien à analyser */
|
||||
|
||||
status = ENGINE_ERROR_MOVE;
|
||||
for (i = 0; i < list.cMoves; i++)
|
||||
if (EqualKeys(played_key, list.amMoves[i].key)) {
|
||||
analysis->move_count = (int) list.cMoves;
|
||||
analysis->rank = (int) i + 1;
|
||||
fill_move(&analysis->played, &list.amMoves[i], &pos, 1);
|
||||
fill_move(&analysis->best, &list.amMoves[0], &pos, 1);
|
||||
analysis->equity_loss = list.amMoves[i].rScore - list.amMoves[0].rScore;
|
||||
analysis->skill = engine_skill_of(analysis->equity_loss);
|
||||
status = ENGINE_OK;
|
||||
break;
|
||||
}
|
||||
g_free(list.amMoves);
|
||||
return status;
|
||||
}
|
||||
@@ -387,27 +605,19 @@ static const char *const cube_decision_names[] = {
|
||||
"OPTIONAL_DOUBLE_BEAVER", "OPTIONAL_DOUBLE_PASS", "OPTIONAL_REDOUBLE_PASS"
|
||||
};
|
||||
|
||||
engine_status
|
||||
engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube)
|
||||
/* Décision de videau de la position avec ces réglages d'évaluation. */
|
||||
static engine_status
|
||||
cube_decision(position *pos, const evalcontext *context, engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
float output[2][NUM_ROLLOUT_OUTPUTS];
|
||||
float equities[NUM_CUBEFUL_OUTPUTS];
|
||||
cubedecision decision;
|
||||
engine_status status;
|
||||
int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos.board, &pos.cube, &context, NULL) < 0)
|
||||
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
/* FindCubeDecision renvoie des équités déjà normalisées. */
|
||||
decision = FindCubeDecision(equities, output, &pos.cube);
|
||||
decision = FindCubeDecision(equities, output, &pos->cube);
|
||||
if ((size_t) decision >= sizeof cube_decision_names / sizeof cube_decision_names[0])
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
|
||||
@@ -419,13 +629,259 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng
|
||||
cube->optimal = equities[OUTPUT_OPTIMAL];
|
||||
cube->decision = cube_decision_names[decision];
|
||||
cube->decision_text = GetCubeRecommendation(decision);
|
||||
GetDPEq(&cube->available, NULL, &pos.cube);
|
||||
GetDPEq(&cube->available, NULL, &pos->cube);
|
||||
cube->should_double = decision == DOUBLE_TAKE || decision == DOUBLE_PASS
|
||||
|| decision == DOUBLE_BEAVER || decision == REDOUBLE_TAKE || decision == REDOUBLE_PASS;
|
||||
cube->should_take = cube->double_take <= cube->double_pass;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
return cube_decision(&pos, &context, cube);
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_cube_decision(const char *position_id, const char *match_id, int level,
|
||||
engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
return cube_decision(&pos, &context, cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- Analyse d'une décision de videau */
|
||||
|
||||
/* Le plus petit de deux nombres. */
|
||||
static float
|
||||
min_float(float a, float b)
|
||||
{
|
||||
return a < b ? a : b;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_analyse_cube(const char *position_id, const char *match_id, int ply,
|
||||
engine_cube_action action, engine_cube_analysis *analysis)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
int can_double;
|
||||
float take, pass, optimal;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status == ENGINE_OK && (action < ENGINE_CUBE_ACTION_NO_DOUBLE || action > ENGINE_CUBE_ACTION_PASS))
|
||||
status = ENGINE_ERROR_CUBE_ACTION;
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Quand gnubg analyse-t-il la décision (AnalyzeMove dans analysis.c) ?
|
||||
* - ne pas doubler (cas MOVE_NORMAL, l. 655) : hors de la position de
|
||||
* départ, et si le joueur au trait a accès au videau (GetDPEq) ;
|
||||
* - doubler, prendre, passer (cas MOVE_DOUBLE, l. 760) : si GetDPEq le
|
||||
* permet, ou si le videau est au centre ou au joueur au trait.
|
||||
*/
|
||||
memset(analysis, 0, sizeof *analysis);
|
||||
can_double = GetDPEq(NULL, NULL, &pos.cube);
|
||||
if (action == ENGINE_CUBE_ACTION_NO_DOUBLE)
|
||||
analysis->analysed = can_double && strcmp(position_id, START_POSITION_ID) != 0;
|
||||
else
|
||||
analysis->analysed = can_double || pos.cube.fCubeOwner < 0
|
||||
|| pos.cube.fCubeOwner == pos.cube.fMove;
|
||||
if (!analysis->analysed)
|
||||
return ENGINE_OK;
|
||||
|
||||
status = cube_decision(&pos, &context, &analysis->cube);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Pertes de gnubg (AnalyzeMove), à partir des équités de FindCubeDecision
|
||||
* vues du joueur au trait :
|
||||
* - ne pas doubler : sans double moins optimale (l. 672) ;
|
||||
* - doubler : la moins bonne des réponses moins optimale (l. 777) ;
|
||||
* - prendre : Skill(-doubleError) avec doubleError = prend moins passe
|
||||
* (l. 772 et 803), soit passe moins prend pour l'adversaire ;
|
||||
* - passer : Skill(doubleError) (l. 823), soit prend moins passe.
|
||||
* Les deux dernières ne sont des erreurs que négatives : un écart positif
|
||||
* est un bon choix, ramené à 0.
|
||||
*/
|
||||
take = analysis->cube.double_take;
|
||||
pass = analysis->cube.double_pass;
|
||||
optimal = analysis->cube.optimal;
|
||||
switch (action) {
|
||||
case ENGINE_CUBE_ACTION_NO_DOUBLE:
|
||||
analysis->equity_loss = analysis->cube.no_double - optimal;
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_DOUBLE:
|
||||
analysis->equity_loss = (take < pass ? take : pass) - optimal;
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_TAKE:
|
||||
analysis->equity_loss = min_float(0.0f, pass - take);
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_PASS:
|
||||
analysis->equity_loss = min_float(0.0f, take - pass);
|
||||
break;
|
||||
}
|
||||
analysis->skill = engine_skill_of(analysis->equity_loss);
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Chance d'un jet */
|
||||
|
||||
/*
|
||||
* Réglages de l'analyse de la chance : ecLuck de gnubg (analysis.c l. 89),
|
||||
* 0 pli avec videau, sans bruit.
|
||||
*/
|
||||
static const evalcontext luck_context = {
|
||||
.fCubeful = TRUE, .nPlies = 0, .fUsePrune = FALSE, .fDeterministic = TRUE, .rNoise = 0.0f
|
||||
};
|
||||
|
||||
/*
|
||||
* Équité, pour le joueur au trait (cube), après le meilleur coup du jet
|
||||
* die1, die2 à 0 pli ; s'il ne peut pas jouer, équité de la position vue
|
||||
* de l'adversaire (opponent), changée de signe. Repris de la boucle de
|
||||
* LuckNormal (analysis.c l. 213-243). Renvoie ERR_VAL en cas d'échec.
|
||||
*/
|
||||
static float
|
||||
equity_after_best_move(ConstTanBoard board, int die1, int die2, const cubeinfo *cube,
|
||||
cubeinfo *opponent)
|
||||
{
|
||||
TanBoard swapped;
|
||||
float output[NUM_ROLLOUT_OUTPUTS];
|
||||
movelist list;
|
||||
float equity;
|
||||
|
||||
if (FindnSaveBestMoves(&list, die1, die2, board, NULL, FALSE, 0.0f, cube, &luck_context,
|
||||
defaultFilters) < 0) {
|
||||
g_free(list.amMoves);
|
||||
return ERR_VAL;
|
||||
}
|
||||
if (list.cMoves) {
|
||||
equity = list.amMoves[0].rScore;
|
||||
g_free(list.amMoves);
|
||||
return equity;
|
||||
}
|
||||
memcpy(swapped, board, sizeof swapped);
|
||||
SwapSides(swapped);
|
||||
if (GeneralEvaluationE(output, (ConstTanBoard) swapped, opponent, &luck_context) < 0)
|
||||
return ERR_VAL;
|
||||
return -normalized_equity(output[OUTPUT_CUBEFUL_EQUITY], opponent);
|
||||
}
|
||||
|
||||
/*
|
||||
* LuckNormal (analysis.c l. 200-247) : équité du jet moins la moyenne des
|
||||
* 36 jets. i et j sont les dés moins 1, i >= j.
|
||||
*/
|
||||
static float
|
||||
luck_normal(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
|
||||
{
|
||||
cubeinfo opponent = *cube;
|
||||
float equities[6][6], mean = 0.0f;
|
||||
int i, j;
|
||||
|
||||
opponent.fMove = !cube->fMove;
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = 0; j <= i; j++) {
|
||||
equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent);
|
||||
if (equities[i][j] == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
mean += i == j ? equities[i][j] : equities[i][j] * 2.0f;
|
||||
}
|
||||
return equities[i0][j0] - mean / 36.0f;
|
||||
}
|
||||
|
||||
/*
|
||||
* LuckFirst (analysis.c l. 104-197) : au premier jet de la partie, les
|
||||
* doubles sont impossibles et le jet peut échoir à l'un ou l'autre joueur.
|
||||
* Équité du jet moins la moyenne des 30 cas : les 15 jets pour le joueur au
|
||||
* trait, et les 15 pour l'adversaire (meilleur coup de l'adversaire, changé
|
||||
* de signe). i et j sont les dés moins 1, i > j.
|
||||
*/
|
||||
static float
|
||||
luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
|
||||
{
|
||||
cubeinfo opponent = *cube;
|
||||
TanBoard swapped;
|
||||
float equities[6][6], mean = 0.0f, equity;
|
||||
int i, j;
|
||||
|
||||
opponent.fMove = !cube->fMove;
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = 0; j < i; j++) {
|
||||
equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent);
|
||||
if (equities[i][j] == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
mean += equities[i][j];
|
||||
}
|
||||
|
||||
/* L'adversaire au trait : son meilleur coup, vu de lui, changé de signe.
|
||||
* gnubg, s'il ne peut pas jouer (impossible au départ), reprend
|
||||
* l'évaluation du joueur au trait (l. 168-181). */
|
||||
memcpy(swapped, board, sizeof swapped);
|
||||
SwapSides(swapped);
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = i + 1; j < 6; j++) {
|
||||
equity = equity_after_best_move((ConstTanBoard) swapped, i + 1, j + 1, &opponent, cube);
|
||||
if (equity == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
equities[i][j] = -equity;
|
||||
mean += equities[i][j];
|
||||
}
|
||||
return equities[i0][j0] - mean / 30.0f;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_luck_of(const char *position_id, const char *match_id, int die1, int die2,
|
||||
engine_luck *luck)
|
||||
{
|
||||
position pos;
|
||||
engine_status status;
|
||||
int high, low;
|
||||
float value;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
start_evaluation();
|
||||
|
||||
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
|
||||
* faible ; LuckFirst à la position de départ hors doubles. */
|
||||
high = (die1 > die2 ? die1 : die2) - 1;
|
||||
low = (die1 > die2 ? die2 : die1) - 1;
|
||||
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
|
||||
value = luck_first((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
else
|
||||
value = luck_normal((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
if (value == ERR_VAL)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
luck->luck = value;
|
||||
luck->rating = engine_luck_rating_of(value);
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Plateau et fin de partie */
|
||||
|
||||
static void
|
||||
|
||||
+177
-1
@@ -48,11 +48,93 @@ typedef enum {
|
||||
ENGINE_ERROR_DICE, /* dés hors de 1..6 */
|
||||
ENGINE_ERROR_PLY, /* niveau hors de 0..ENGINE_MAX_PLY */
|
||||
ENGINE_ERROR_TOO_MANY_MOVES, /* tableau de coups trop petit */
|
||||
ENGINE_ERROR_INTERNAL /* échec signalé par gnubg */
|
||||
ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */
|
||||
ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */
|
||||
ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */
|
||||
ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */
|
||||
} engine_status;
|
||||
|
||||
#define ENGINE_MAX_PLY 4
|
||||
|
||||
/*
|
||||
* Niveaux de jeu de l'ordinateur : les réglages prédéfinis de gnubg, dans
|
||||
* l'ordre de son tableau aecSettings (engine/gnubg/eval.c, l. 305-328, noms
|
||||
* de aszSettings). Les quatre premiers évaluent à 0 pli et ajoutent un
|
||||
* bruit (rNoise, écart type en équité) qui fait commettre des erreurs ; ce
|
||||
* bruit est « déterministe » (fDeterministic) : il est tiré d'un hachage MD5
|
||||
* de la position (Noise, eval.c l. 2241), donc la même position donne
|
||||
* toujours le même coup. Les suivants sont sans bruit, de 0 à 4 plis, avec
|
||||
* le filtre de coups « Normal » (aiSettingsMoveFilter, eval.c l. 331).
|
||||
*
|
||||
* Les anciennes versions de gnubg nommaient « expert » le niveau 0 pli et
|
||||
* « world class » (champion du monde) le niveau 2 plis.
|
||||
*/
|
||||
typedef enum {
|
||||
ENGINE_LEVEL_BEGINNER = 0, /* « beginner » : 0 pli, bruit 0,060 */
|
||||
ENGINE_LEVEL_CASUAL, /* « casual play » : 0 pli, bruit 0,050 */
|
||||
ENGINE_LEVEL_INTERMEDIATE, /* « intermediate » : 0 pli, bruit 0,040 */
|
||||
ENGINE_LEVEL_ADVANCED, /* « advanced » : 0 pli, bruit 0,015 */
|
||||
ENGINE_LEVEL_0PLY, /* « 0ply » (ancien « expert ») */
|
||||
ENGINE_LEVEL_1PLY, /* « 1ply » */
|
||||
ENGINE_LEVEL_2PLY, /* « 2ply » (ancien « world class ») */
|
||||
ENGINE_LEVEL_3PLY, /* « 3ply » */
|
||||
ENGINE_LEVEL_4PLY, /* « 4ply » */
|
||||
ENGINE_LEVEL_COUNT
|
||||
} engine_level;
|
||||
|
||||
/* Réglages d'un niveau, tels que gnubg les définit. */
|
||||
typedef struct {
|
||||
const char *name; /* identifiant : « beginner », « casual »,
|
||||
* « intermediate », « advanced », « 0ply »… « 4ply » */
|
||||
int plies; /* profondeur, en plis de gnubg */
|
||||
float noise; /* écart type du bruit (0 : aucun) */
|
||||
int deterministic; /* bruit tiré de la position, reproductible */
|
||||
int prune; /* élagage des réponses (gnubg : à partir d'1 pli) */
|
||||
} engine_level_settings;
|
||||
|
||||
/*
|
||||
* Appréciation d'un coup ou d'une décision de videau : seuils de gnubg sur
|
||||
* l'équité perdue, normalisée pour un videau de 1 (arSkillLevel dans
|
||||
* gnubg.c l. 280-285 et Skill dans analysis.c l. 288). Une perte
|
||||
* strictement plus grande que le seuil fait passer à la catégorie.
|
||||
*/
|
||||
#define ENGINE_SKILL_DOUBTFUL_THRESHOLD 0.03f /* douteux */
|
||||
#define ENGINE_SKILL_BAD_THRESHOLD 0.06f /* mauvais */
|
||||
#define ENGINE_SKILL_VERY_BAD_THRESHOLD 0.12f /* très mauvais */
|
||||
|
||||
typedef enum {
|
||||
ENGINE_SKILL_NONE = 0, /* rien à signaler */
|
||||
ENGINE_SKILL_DOUBTFUL, /* douteux */
|
||||
ENGINE_SKILL_BAD, /* mauvais */
|
||||
ENGINE_SKILL_VERY_BAD /* très mauvais */
|
||||
} engine_skill;
|
||||
|
||||
/*
|
||||
* Chance d'un jet : seuils de gnubg sur l'équité gagnée ou perdue par
|
||||
* rapport à la moyenne des jets (arLuckLevel dans gnubg.c l. 274-279 et Luck
|
||||
* dans analysis.c l. 272). Un écart strictement plus grand que le seuil
|
||||
* fait passer à la catégorie.
|
||||
*/
|
||||
#define ENGINE_LUCK_THRESHOLD 0.3f /* chanceux, malchanceux */
|
||||
#define ENGINE_LUCK_VERY_THRESHOLD 0.6f /* très chanceux, très malchanceux */
|
||||
|
||||
typedef enum {
|
||||
ENGINE_LUCK_VERY_UNLUCKY = 0, /* très malchanceux */
|
||||
ENGINE_LUCK_UNLUCKY, /* malchanceux */
|
||||
ENGINE_LUCK_NONE, /* ordinaire */
|
||||
ENGINE_LUCK_LUCKY, /* chanceux */
|
||||
ENGINE_LUCK_VERY_LUCKY /* très chanceux */
|
||||
} engine_luck_rating;
|
||||
|
||||
/* Décision de videau prise par un joueur. Les deux premières sont celles
|
||||
* du joueur au trait, les deux dernières sa réponse par l'adversaire. */
|
||||
typedef enum {
|
||||
ENGINE_CUBE_ACTION_NO_DOUBLE = 0,
|
||||
ENGINE_CUBE_ACTION_DOUBLE,
|
||||
ENGINE_CUBE_ACTION_TAKE,
|
||||
ENGINE_CUBE_ACTION_PASS
|
||||
} engine_cube_action;
|
||||
|
||||
/* Nombre maximal de coups légaux pour un jet (MAX_MOVES de gnubg). */
|
||||
#define ENGINE_MAX_MOVES 3060
|
||||
|
||||
@@ -124,6 +206,39 @@ typedef struct {
|
||||
* (double_take <= double_pass) */
|
||||
} engine_cube;
|
||||
|
||||
/* Analyse d'un coup joué, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
int move_count; /* nombre de coups légaux (0 : aucun coup
|
||||
* possible, rien d'autre n'est rempli) */
|
||||
int rank; /* rang du coup joué dans le classement, à
|
||||
* partir de 1 */
|
||||
engine_move played; /* le coup joué, évalué */
|
||||
engine_move best; /* le meilleur coup, évalué */
|
||||
float equity_loss; /* équité du coup joué moins celle du meilleur :
|
||||
* 0 ou négative */
|
||||
engine_skill skill; /* appréciation de gnubg pour cette perte */
|
||||
} engine_move_analysis;
|
||||
|
||||
/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
int analysed; /* gnubg analyse cette décision ; sinon (videau
|
||||
* inaccessible, position de départ…), perte
|
||||
* nulle et cube non rempli */
|
||||
engine_cube cube; /* la décision de videau du joueur au trait */
|
||||
float equity_loss; /* équité perdue par celui qui décide, du point
|
||||
* de vue de celui-ci, normalisée pour un videau
|
||||
* de 1 : 0 ou négative */
|
||||
engine_skill skill; /* appréciation de gnubg pour cette perte */
|
||||
} engine_cube_analysis;
|
||||
|
||||
/* Chance d'un jet, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
float luck; /* équité gagnée (positive) ou perdue par ce jet
|
||||
* par rapport à la moyenne des jets, du point
|
||||
* de vue du joueur au trait */
|
||||
engine_luck_rating rating;
|
||||
} engine_luck;
|
||||
|
||||
/* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de
|
||||
* la sortie). */
|
||||
typedef struct {
|
||||
@@ -237,6 +352,67 @@ engine_status engine_evaluate(const char *position_id, const char *match_id,
|
||||
engine_status engine_cube_decision(const char *position_id, const char *match_id,
|
||||
int ply, engine_cube *cube);
|
||||
|
||||
/* ------------------------------------------------------ Niveaux de jeu */
|
||||
|
||||
/* Réglages d'un niveau (engine_level). */
|
||||
engine_status engine_level_settings_of(int level, engine_level_settings *settings);
|
||||
|
||||
/* Le niveau qui porte ce nom, ou ENGINE_ERROR_LEVEL. */
|
||||
engine_status engine_level_from_name(const char *name, int *level);
|
||||
|
||||
/*
|
||||
* Coups légaux classés comme les classe l'ordinateur de gnubg quand il joue
|
||||
* à ce niveau (ComputerTurn dans play.c : FindnSaveBestMoves sans analyse
|
||||
* ni seuil, filtre « Normal », puis RefreshMoveList) ; il joue le premier.
|
||||
* Les équités incluent le bruit du niveau.
|
||||
*/
|
||||
engine_status engine_level_best_moves(const char *position_id, const char *match_id,
|
||||
int die1, int die2, int level,
|
||||
engine_move *moves, int max_moves, int *count);
|
||||
|
||||
/* Décision de videau évaluée avec les réglages de ce niveau, bruit compris. */
|
||||
engine_status engine_level_cube_decision(const char *position_id, const char *match_id,
|
||||
int level, engine_cube *cube);
|
||||
|
||||
/* ------------------------------------------------------------- Analyse */
|
||||
|
||||
/* Appréciation de gnubg pour une perte d'équité (0 ou négative). */
|
||||
engine_skill engine_skill_of(float equity_loss);
|
||||
|
||||
/* Catégorie de chance de gnubg pour une chance de jet. */
|
||||
engine_luck_rating engine_luck_rating_of(float luck);
|
||||
|
||||
/*
|
||||
* Analyse le coup joué par le joueur au trait avec le jet die1, die2, au
|
||||
* niveau ply, comme l'analyse de partie de gnubg (AnalyzeMove dans
|
||||
* analysis.c : FindnSaveBestMoves avec le coup joué, seuil « douteux »,
|
||||
* filtre « Normal », sans RefreshMoveList). played_position_id est la
|
||||
* position après le coup, vue du joueur qui vient de le jouer (champ
|
||||
* position_id d'engine_move). ENGINE_ERROR_MOVE si ce n'est pas un coup
|
||||
* légal ; si aucun coup n'est possible, move_count vaut 0.
|
||||
*/
|
||||
engine_status engine_analyse_move(const char *position_id, const char *match_id,
|
||||
int die1, int die2, const char *played_position_id, int ply,
|
||||
engine_move_analysis *analysis);
|
||||
|
||||
/*
|
||||
* Analyse une décision de videau au niveau ply, comme l'analyse de partie de
|
||||
* gnubg (AnalyzeMove, cas MOVE_NORMAL, MOVE_DOUBLE, MOVE_TAKE et
|
||||
* MOVE_DROP). La position et le Match ID sont ceux du joueur au trait, qui
|
||||
* double ou non ; pour TAKE et PASS, la perte est celle de son adversaire.
|
||||
*/
|
||||
engine_status engine_analyse_cube(const char *position_id, const char *match_id, int ply,
|
||||
engine_cube_action action, engine_cube_analysis *analysis);
|
||||
|
||||
/*
|
||||
* Chance du jet die1, die2 pour le joueur au trait, comme l'analyse de
|
||||
* partie de gnubg (LuckAnalysis dans analysis.c : meilleur coup de chaque
|
||||
* jet à 0 pli, avec videau ; à la position de départ, le premier jet ne
|
||||
* peut pas être un double).
|
||||
*/
|
||||
engine_status engine_luck_of(const char *position_id, const char *match_id,
|
||||
int die1, int die2, engine_luck *luck);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Produit les icônes PNG de l'application (web/icons/icon-192.png et
|
||||
# icon-512.png) à partir des formes de web/icons/icon.svg : un fond de bois
|
||||
# et deux pions. Icônes provisoires, en attendant l'identité définitive.
|
||||
#
|
||||
# Python seul, sans bibliothèque : chaque pixel est échantillonné 4 × 4 fois
|
||||
# pour adoucir les bords. À relancer seulement si les formes changent, dans
|
||||
# la chaîne C (Python 3) :
|
||||
# docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src" -w /src \
|
||||
# ustagammon/build-c:2 python3 scripts/make-icons.py
|
||||
#
|
||||
# Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
# Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
BACKGROUND = (0x6B, 0x44, 0x26)
|
||||
# Pions dessinés dans l'ordre : (centre x, centre y, rayon, remplissage,
|
||||
# bord), en unités d'une icône de 512 pixels ; bord de 10 unités.
|
||||
CHECKERS = [
|
||||
(300, 300, 120, (0x2B, 0x22, 0x1C), (0x0D, 0x0A, 0x08)),
|
||||
(212, 212, 120, (0xFB, 0xF6, 0xEA), (0x7D, 0x6A, 0x52)),
|
||||
]
|
||||
EDGE = 10
|
||||
SAMPLES = 4
|
||||
|
||||
|
||||
def color_at(x, y):
|
||||
"""Couleur d'un point de l'icône, en unités de 512."""
|
||||
color = BACKGROUND
|
||||
for cx, cy, r, fill, edge in CHECKERS:
|
||||
d2 = (x - cx) ** 2 + (y - cy) ** 2
|
||||
if d2 <= (r + EDGE / 2) ** 2:
|
||||
color = fill if d2 <= (r - EDGE / 2) ** 2 else edge
|
||||
return color
|
||||
|
||||
|
||||
def render(size):
|
||||
scale = 512 / size
|
||||
rows = []
|
||||
for py in range(size):
|
||||
row = bytearray([0]) # filtre PNG « aucun »
|
||||
for px in range(size):
|
||||
total = [0, 0, 0]
|
||||
for sy in range(SAMPLES):
|
||||
for sx in range(SAMPLES):
|
||||
x = (px + (sx + 0.5) / SAMPLES) * scale
|
||||
y = (py + (sy + 0.5) / SAMPLES) * scale
|
||||
for i, c in enumerate(color_at(x, y)):
|
||||
total[i] += c
|
||||
row += bytes(round(t / SAMPLES**2) for t in total)
|
||||
rows.append(bytes(row))
|
||||
return b"".join(rows)
|
||||
|
||||
|
||||
def png(size, pixels):
|
||||
def chunk(kind, data):
|
||||
body = kind + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
|
||||
|
||||
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # RVB, 8 bits
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", header)
|
||||
+ chunk(b"IDAT", zlib.compress(pixels, 9))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
for size in (192, 512):
|
||||
with open(f"web/icons/icon-{size}.png", "wb") as f:
|
||||
f.write(png(size, render(size)))
|
||||
+4
-5
@@ -18,11 +18,10 @@ browsers="${BROWSERS:-chromium firefox}"
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
|
||||
site_image="ustagammon/site-test:local"
|
||||
docker build -q -t "$site_image" build/deploy >/dev/null
|
||||
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
|
||||
trap 'docker stop "$container" >/dev/null' EXIT
|
||||
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
# Image de production lancée, étiquette propre à ce dépôt : site_image,
|
||||
# container, port.
|
||||
# shellcheck source=tests/site-image.sh
|
||||
. tests/site-image.sh
|
||||
|
||||
# Le paquet playwright, à la version des navigateurs de l'image.
|
||||
tools=build/browser-tools
|
||||
|
||||
+923
-49
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -29,10 +29,11 @@ for f in "${fichiers[@]}"; do
|
||||
if grep -q $'\r' "$f"; then
|
||||
signaler "$f : fins de ligne Windows (CRLF)"
|
||||
fi
|
||||
# Copies fidèles de GNU Backgammon (sources et données) : on ne
|
||||
# reformate pas leurs espaces, pour rester identique à l'original.
|
||||
# Copies fidèles de GNU Backgammon (sources et données) et de la licence
|
||||
# de la police : on ne reformate pas leurs espaces, pour rester
|
||||
# identique à l'original.
|
||||
case "$f" in
|
||||
engine/gnubg/* | engine/data/*) continue ;;
|
||||
engine/gnubg/* | engine/data/* | web/fonts/OFL.txt) continue ;;
|
||||
esac
|
||||
if grep -nE '[[:blank:]]+$' "$f" >/dev/null; then
|
||||
signaler "$f : espaces en fin de ligne (lignes $(grep -nE '[[:blank:]]+$' "$f" | cut -d: -f1 | paste -sd, -))"
|
||||
|
||||
@@ -11,14 +11,16 @@ d'un fichier de résultat.
|
||||
| --- | --- |
|
||||
| `positions.json` | Positions d'entrée, versionnées, écrites à la main |
|
||||
| `results/<id>.json` | Résultats de gnubg, un fichier par position, générés |
|
||||
| `generate.py` | Script exécuté dans gnubg qui produit `results/` |
|
||||
| `compare.py` | Compare le moteur embarqué à `results/` (dernière section) |
|
||||
| `analyses.json` | Analyses d'entrée : coups joués, décisions de videau, niveaux de jeu |
|
||||
| `analysis/<id>.json` | Résultats de l'analyse de partie de gnubg, générés (section « Analyses ») |
|
||||
| `generate.py` | Script exécuté dans gnubg qui produit `results/` et `analysis/` |
|
||||
| `compare.py` | Compare le moteur embarqué à `results/` et `analysis/` (dernière section) |
|
||||
|
||||
Commandes :
|
||||
|
||||
```
|
||||
make reference-image # construit l'image Docker du gnubg de référence
|
||||
make reference # régénère results/ (une vingtaine de secondes)
|
||||
make reference # régénère results/ et analysis/ (une quarantaine de secondes)
|
||||
make test # vérifie la cohérence (tests/test_reference.sh) et
|
||||
# compare le moteur embarqué (tests/test_engine_reference.sh)
|
||||
```
|
||||
@@ -222,6 +224,69 @@ Sortie de `gnubg.cfevaluate` (`GeneralCubeDecisionE` puis
|
||||
`TOOGOOD_PASS`, `REDOUBLE_TAKE`, `NODOUBLE_DEADCUBE`…) ;
|
||||
- `decision_text` : le libellé anglais correspondant de gnubg.
|
||||
|
||||
## Analyses : `analyses.json` et `analysis/<id>.json`
|
||||
|
||||
Ce que gnubg dit d'un coup joué, d'une décision de videau et d'un jet de dés
|
||||
(sa commande « analyse game »), et le coup que joue son ordinateur à chaque
|
||||
niveau. Pour chaque cas, `generate.py` rejoue dans gnubg une courte partie
|
||||
entre deux joueurs humains : la position (`set gnubgid`), puis le coup ou la
|
||||
décision, puis l'analyse. Rien n'est calculé dans le script : les valeurs
|
||||
viennent des enregistrements de la partie analysée et des statistiques que
|
||||
gnubg en tire.
|
||||
|
||||
`analyses.json` a trois listes :
|
||||
|
||||
- `moves` : `position_id`, `match_id`, `dice` et `played`, le coup joué en
|
||||
notation de gnubg (`null` : le meilleur coup selon gnubg, pour les cas qui
|
||||
ne servent qu'à la chance du jet) ;
|
||||
- `cubes` : `position_id`, `match_id` (sans dés) et `action`, la décision :
|
||||
`no_double` ou `double` pour le joueur au trait, `take` ou `pass` pour son
|
||||
adversaire ;
|
||||
- `levels` : `position_id`, `match_id` et `dice` (`null` : décision de
|
||||
videau).
|
||||
|
||||
Un fichier de résultat reprend le cas, avec `type` (`move`, `cube` ou
|
||||
`level`), `gnubg_commit` et `met`, puis, aux niveaux d'analyse 0 et 2 plis
|
||||
(clés de `plies`, réglages prédéfinis de gnubg sans bruit, filtre
|
||||
« Normal ») :
|
||||
|
||||
- **coup joué** : `played_move` et `played_position_id` (position après le
|
||||
coup, vue de celui qui l'a joué), `move_count` (coups légaux), `rank` (place
|
||||
du coup joué dans le classement de l'analyse, à partir de 1 ; `imove` de
|
||||
gnubg plus 1), `played_equity`, `best_move`, `best_equity`, `equity_loss`
|
||||
(perte du coup joué, 0 ou négative : statistique « error-skill » de gnubg
|
||||
changée de signe), `skill` (`none`, `doubtful`, `bad`, `very_bad`) et
|
||||
`ranking` (les dix premiers coups, et jusqu'au coup joué) ; plus, une fois
|
||||
pour toutes, `luck` : `value` (chance du jet) et `rating`
|
||||
(`very_unlucky`, `unlucky`, `none`, `lucky`, `very_lucky`) ;
|
||||
- **videau** : `analysed` (gnubg analyse-t-il cette décision ? pas à la
|
||||
partie Crawford ni quand l'adversaire possède le videau), `cube` (la
|
||||
décision de videau de gnubg au même niveau, format de la section
|
||||
précédente, `null` si non analysée), `equity_loss` (perte de celui qui
|
||||
décide, statistique « error-skill » changée de signe) et `skill` ;
|
||||
- **niveaux** (`levels`, à la place de `plies`) : pour les niveaux
|
||||
`beginner`, `casual`, `intermediate`, `advanced`, `0ply`, `1ply` et `2ply`
|
||||
(3 et 4 plis sont trop lents pour la suite de tests), `plies` et `noise`
|
||||
(réglages de gnubg), puis `move`, le coup que joue l'ordinateur de gnubg
|
||||
réglé à ce niveau (commande « play »), ou la décision de videau de gnubg
|
||||
avec ces réglages, bruit compris.
|
||||
|
||||
Deux précautions, faute de quoi gnubg donne des valeurs qui dépendent de ce
|
||||
qu'il a calculé avant :
|
||||
|
||||
- **le cache d'évaluation est vidé avant chaque analyse**, et l'analyse du
|
||||
videau est désactivée pour les coups joués. Une évaluation à 2 plis avec
|
||||
élagage range dans le cache des évaluations à 0 pli faites avec les réseaux
|
||||
d'élagage, sous la même clé que les évaluations à 0 pli ordinaires (la clé
|
||||
d'`EvalKey`, dans `eval.c`, n'inclut l'élagage qu'à partir d'1 pli). Une
|
||||
analyse à 0 pli, ou l'analyse de la chance qui suit l'analyse du videau à
|
||||
2 plis, les reprend : écarts observés de 5e-3 à 2,5e-2. Le moteur embarqué
|
||||
a le même cache ; `compare.py` l'interroge par un processus neuf à chaque
|
||||
commande ;
|
||||
- **le coup choisi pour un cas sans `played` vient de `findbestmove`** et non
|
||||
de « hint », dont gnubg garde l'analyse dans l'enregistrement du coup :
|
||||
« analyse game » la reprendrait au lieu de calculer au niveau demandé.
|
||||
|
||||
## Comparaison avec le moteur embarqué
|
||||
|
||||
`make test` compare le moteur embarqué à ces résultats
|
||||
@@ -264,6 +329,22 @@ Pour chaque position et chaque niveau :
|
||||
- même décision de videau (`decision` et `decision_text`), et les quatre
|
||||
équités du videau à la tolérance près.
|
||||
|
||||
Pour chaque analyse (`engine-cli analysemove`, `analysecube`, `luck`, et
|
||||
`--level NIVEAU bestmove` ou `cube`) :
|
||||
|
||||
- coup joué : même nombre de coups légaux, même notation du coup joué, même
|
||||
rang sauf égalité (les coups entre les deux rangs ont pour gnubg la même
|
||||
équité que le coup joué, à la tolérance près : remarque), même meilleur coup
|
||||
sauf égalité, et `played_equity`, `best_equity`, `equity_loss` à la
|
||||
tolérance près ;
|
||||
- chance : `value` à la tolérance près ;
|
||||
- videau : même `analysed`, même décision et équités à la tolérance près,
|
||||
`equity_loss` à la tolérance près ;
|
||||
- appréciation (`skill`) et chance (`rating`) identiques, sauf si la valeur
|
||||
de gnubg est à moins de la tolérance d'un seuil (remarque) ;
|
||||
- niveaux : mêmes réglages que gnubg (`engine-cli levels`), même coup choisi,
|
||||
ou même décision de videau et équités à la tolérance près.
|
||||
|
||||
Le rapport donne, pour chaque écart hors critères, la position et le niveau,
|
||||
le champ, la valeur attendue (gnubg), la valeur obtenue (moteur) et l'écart ;
|
||||
il donne toujours l'écart maximal observé.
|
||||
@@ -309,6 +390,11 @@ qu'affiche gnubg (trois décimales). Les vrais défauts (mauvais niveau,
|
||||
mauvais contexte de videau, mauvaise conversion d'équité) donnent des écarts
|
||||
d'un ou plusieurs centièmes.
|
||||
|
||||
Mesure du 2026-11-03, avec les 37 analyses (2 890 nombres comparés, natif et
|
||||
WebAssembly) : écart maximal 1,64e-3, sur `move-jacoby` à 2 plis (équité du
|
||||
coup joué `8/2 5/1*`, et donc sa perte), même phénomène de bascule à 2 plis ; tous les autres
|
||||
nombres des analyses restent sous 1e-3. La tolérance n'a pas été changée.
|
||||
|
||||
Sur téléphone (NEON) les écarts seront à mesurer de la même façon : avec
|
||||
`-ffast-math`, gnubg y remplace aussi la division par une approximation
|
||||
(`vrecpeq_f32` et une itération de Newton), différente de celle de SSE2.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"moves": [
|
||||
{
|
||||
"id": "move-opening-31-best",
|
||||
"description": "Ouverture 3-1 jouée au mieux (8/5 6/5) ; chance du premier jet.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "8/5 6/5"
|
||||
},
|
||||
{
|
||||
"id": "move-opening-31-error",
|
||||
"description": "Ouverture 3-1 jouée 24/23 13/10 au lieu de faire le point 5.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "24/23 13/10"
|
||||
},
|
||||
{
|
||||
"id": "move-opening-65-error",
|
||||
"description": "Ouverture 6-5 jouée 13/8 13/7 au lieu de courir 24/13.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cAkXAAAAAAAA",
|
||||
"dice": [6, 5],
|
||||
"played": "13/8 13/7"
|
||||
},
|
||||
{
|
||||
"id": "move-middlegame-close",
|
||||
"description": "Milieu de partie, 6-4 : un coup proche du meilleur.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/2 6/2"
|
||||
},
|
||||
{
|
||||
"id": "move-middlegame-blunder",
|
||||
"description": "Milieu de partie, 6-4 : un des pires coups.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/4 8/2"
|
||||
},
|
||||
{
|
||||
"id": "move-match",
|
||||
"description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cImuACAAEAAE",
|
||||
"dice": [5, 3],
|
||||
"played": "24/16"
|
||||
},
|
||||
{
|
||||
"id": "move-race",
|
||||
"description": "Course, 5-4 : sortie maladroite.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [5, 4],
|
||||
"played": "6/1 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-blitz-doubles",
|
||||
"description": "Attaque, double 3 : un coup parmi 110.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [3, 3],
|
||||
"played": "13/10 13/10 5/2 4/1"
|
||||
},
|
||||
{
|
||||
"id": "move-crawford",
|
||||
"description": "Match en 5 points, partie Crawford, 4-3.",
|
||||
"position_id": "4Dn4AGiynYMHAA",
|
||||
"match_id": "8AmuAEAAEAAE",
|
||||
"dice": [4, 3],
|
||||
"played": "5/2 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-jacoby",
|
||||
"description": "Partie libre avec la règle Jacoby, 6-4.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/2 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-bearoff-doubles",
|
||||
"description": "Sortie, double 6 : coup unique ; chance d'un double.",
|
||||
"position_id": "xgAA4MgAAAAAAA",
|
||||
"match_id": "cAkbAAAAAAAA",
|
||||
"dice": [6, 6],
|
||||
"played": "6/off 6/off 4/off 1/off"
|
||||
},
|
||||
{
|
||||
"id": "move-forced",
|
||||
"description": "Rentrée forcée depuis la barre, 3-1.",
|
||||
"position_id": "tm3ABwBw5+ABYA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "bar/24"
|
||||
},
|
||||
{
|
||||
"id": "move-luck-unlucky",
|
||||
"description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [5, 4],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-luck-unlucky-bearoff",
|
||||
"description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "TQAAYAsAAAAAAA",
|
||||
"match_id": "cAkFAAAAAAAA",
|
||||
"dice": [2, 1],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-luck-lucky",
|
||||
"description": "Course, double 4 : jet chanceux, joué au mieux.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [4, 4],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-opening-double",
|
||||
"description": "Position de départ et double 6 : pas un premier jet, chance comptée sur les 36 jets.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [6, 6],
|
||||
"played": null
|
||||
}
|
||||
],
|
||||
"cubes": [
|
||||
{
|
||||
"id": "cube-double-take-no-double",
|
||||
"description": "Double/prend : le joueur au trait ne double pas.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-double",
|
||||
"description": "Double/prend : le joueur au trait double.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-take",
|
||||
"description": "Double/prend : l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-pass",
|
||||
"description": "Double/prend : l'adversaire passe à tort.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-pass-take",
|
||||
"description": "Double/passe : l'adversaire prend à tort.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-pass-pass",
|
||||
"description": "Double/passe : l'adversaire passe.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-no-double-double",
|
||||
"description": "Pas de double : le joueur au trait double trop tôt.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-no-double-no-double",
|
||||
"description": "Pas de double : le joueur au trait ne double pas.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-redouble-take",
|
||||
"description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "UQkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-match-double",
|
||||
"description": "Match en 7 points, 3-5 : le joueur au trait double.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-match-pass",
|
||||
"description": "Match en 7 points, 3-5 : l'adversaire passe.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-post-crawford-double",
|
||||
"description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAmgAEAAEAAE",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-crawford-not-analysed",
|
||||
"description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "8AmgAEAAEAAE",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-opponent-owns-not-analysed",
|
||||
"description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "EQEAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
}
|
||||
],
|
||||
"levels": [
|
||||
{
|
||||
"id": "level-opening-31",
|
||||
"description": "Ouverture 3-1.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1]
|
||||
},
|
||||
{
|
||||
"id": "level-middlegame-1",
|
||||
"description": "Milieu de partie, 6-4 : le bruit change le coup choisi.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4]
|
||||
},
|
||||
{
|
||||
"id": "level-middlegame-2",
|
||||
"description": "Milieu de partie avec contact, 5-3.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [5, 3]
|
||||
},
|
||||
{
|
||||
"id": "level-prime-vs-prime",
|
||||
"description": "Prime contre prime, 4-2.",
|
||||
"position_id": "4LZtAAbAbZsBAw",
|
||||
"match_id": "cAkKAAAAAAAA",
|
||||
"dice": [4, 2]
|
||||
},
|
||||
{
|
||||
"id": "level-blitz-doubles",
|
||||
"description": "Attaque, double 3.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [3, 3]
|
||||
},
|
||||
{
|
||||
"id": "level-cube-double-take",
|
||||
"description": "Décision de videau double/prend, à chaque niveau.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null
|
||||
},
|
||||
{
|
||||
"id": "level-cube-no-double",
|
||||
"description": "Décision de videau sans double, à chaque niveau.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "cube-crawford-not-analysed",
|
||||
"description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "8AmgAEAAEAAE",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-pass-pass",
|
||||
"description": "Double/passe : l'adversaire passe.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9999689,
|
||||
"double_take": 2.0565755,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 1.0,
|
||||
"double_take": 2.0675642,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-pass-take",
|
||||
"description": "Double/passe : l'adversaire prend à tort.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9999689,
|
||||
"double_take": 2.0565755,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -1.0565755,
|
||||
"skill": "very_bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 1.0,
|
||||
"double_take": 2.0675642,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -1.0675642,
|
||||
"skill": "very_bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-double",
|
||||
"description": "Double/prend : le joueur au trait double.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-no-double",
|
||||
"description": "Double/prend : le joueur au trait ne double pas.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.06289035,
|
||||
"skill": "bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.09125525,
|
||||
"skill": "bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-pass",
|
||||
"description": "Double/prend : l'adversaire passe à tort.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.11814976,
|
||||
"skill": "bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.07818359,
|
||||
"skill": "bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-take",
|
||||
"description": "Double/prend : l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-match-double",
|
||||
"description": "Match en 7 points, 3-5 : le joueur au trait double.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.93459773,
|
||||
"double_take": 1.1651278,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9405141,
|
||||
"double_take": 1.1657741,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-match-pass",
|
||||
"description": "Match en 7 points, 3-5 : l'adversaire passe.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.93459773,
|
||||
"double_take": 1.1651278,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9405141,
|
||||
"double_take": 1.1657741,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-no-double-double",
|
||||
"description": "Pas de double : le joueur au trait double trop tôt.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.1721948,
|
||||
"skill": "very_bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.17361593,
|
||||
"skill": "very_bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-no-double-no-double",
|
||||
"description": "Pas de double : le joueur au trait ne double pas.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "cube-opponent-owns-not-analysed",
|
||||
"description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "EQEAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-post-crawford-double",
|
||||
"description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAmgAEAAEAAE",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.26475775,
|
||||
"double_take": 0.2732438,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.2732438,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.22408937,
|
||||
"double_take": 0.22408937,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.22408937,
|
||||
"decision": "OPTIONAL_DOUBLE_TAKE",
|
||||
"decision_text": "Optional double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-redouble-take",
|
||||
"description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "UQkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.840925,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "REDOUBLE_TAKE",
|
||||
"decision_text": "Redouble, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.84627455,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "REDOUBLE_TAKE",
|
||||
"decision_text": "Redouble, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-blitz-doubles",
|
||||
"description": "Attaque, double 3.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/10 6/3 4/1*(2)"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/10 6/3 4/1*(2)"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/10(2) 8/5 6/3"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/7 13/10 6/3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "level-cube-double-take",
|
||||
"description": "Décision de videau double/prend, à chaque niveau.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null,
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"no_double": 0.42431837,
|
||||
"double_take": 0.26415053,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.42431837,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"no_double": 0.49009216,
|
||||
"double_take": 0.36710066,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.49009216,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"no_double": 0.55586565,
|
||||
"double_take": 0.47005057,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.55586565,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"no_double": 0.7202995,
|
||||
"double_take": 0.7274252,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.7274252,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.79611415,
|
||||
"double_take": 0.8986737,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.8986737,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "level-cube-no-double",
|
||||
"description": "Décision de videau sans double, à chaque niveau.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null,
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"no_double": -0.16227412,
|
||||
"double_take": -0.6363357,
|
||||
"double_pass": 1.0,
|
||||
"optimal": -0.16227412,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"no_double": -0.075523466,
|
||||
"double_take": -0.4993003,
|
||||
"double_pass": 1.0,
|
||||
"optimal": -0.075523466,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"no_double": 0.011226978,
|
||||
"double_take": -0.36226535,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.011226978,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"no_double": 0.22810358,
|
||||
"double_take": -0.019677103,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.22810358,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.33552775,
|
||||
"double_take": 0.15480106,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.33552775,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-middlegame-1",
|
||||
"description": "Milieu de partie, 6-4 : le bruit change le coup choisi.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
4
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "24/14"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-middlegame-2",
|
||||
"description": "Milieu de partie avec contact, 5-3.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "13/5"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "13/5"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "13/5"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/5"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-opening-31",
|
||||
"description": "Ouverture 3-1.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-prime-vs-prime",
|
||||
"description": "Prime contre prime, 4-2.",
|
||||
"position_id": "4LZtAAbAbZsBAw",
|
||||
"match_id": "cAkKAAAAAAAA",
|
||||
"dice": [
|
||||
4,
|
||||
2
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/9 7/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/9 7/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/11 13/9"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "move-bearoff-doubles",
|
||||
"description": "Sortie, double 6 : coup unique ; chance d'un double.",
|
||||
"position_id": "xgAA4MgAAAAAAA",
|
||||
"match_id": "cAkbAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
6
|
||||
],
|
||||
"played": "6/off 6/off 4/off 1/off",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "6/off(2) 4/off 1/off",
|
||||
"played_position_id": "xgAAYAAAAAAAAA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": 0.8888838,
|
||||
"best_move": "6/off(2) 4/off 1/off",
|
||||
"best_equity": 0.8888838,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "6/off(2) 4/off 1/off",
|
||||
"equity": 0.8888838
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "6/off(2) 4/off 1/off",
|
||||
"played_position_id": "xgAAYAAAAAAAAA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": 0.8888838,
|
||||
"best_move": "6/off(2) 4/off 1/off",
|
||||
"best_equity": 0.8888838,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "6/off(2) 4/off 1/off",
|
||||
"equity": 0.8888838
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 1.6398247,
|
||||
"rating": "very_lucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
{
|
||||
"id": "move-blitz-doubles",
|
||||
"description": "Attaque, double 3 : un coup parmi 110.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"played": "13/10 13/10 5/2 4/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "13/10(2) 5/2 4/1*",
|
||||
"played_position_id": "4HPwAWBVXTMGAA",
|
||||
"move_count": 110,
|
||||
"rank": 109,
|
||||
"played_equity": 0.084023334,
|
||||
"best_move": "13/10 6/3 4/1*(2)",
|
||||
"best_equity": 0.9465638,
|
||||
"equity_loss": -0.8625404,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/10 6/3 4/1*(2)",
|
||||
"equity": 0.9465638
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3 4/1*(2)",
|
||||
"equity": 0.9422797
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 6/3",
|
||||
"equity": 0.94128984
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 6/3",
|
||||
"equity": 0.9370698
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 6/3",
|
||||
"equity": 0.93615985
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 6/3",
|
||||
"equity": 0.9330082
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/4 6/3",
|
||||
"equity": 0.932737
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 6/3",
|
||||
"equity": 0.9313148
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/1* 6/3",
|
||||
"equity": 0.93129885
|
||||
},
|
||||
{
|
||||
"move": "13/4 6/3",
|
||||
"equity": 0.92980635
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 6/3",
|
||||
"equity": 0.9264537
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 6/3",
|
||||
"equity": 0.9262361
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 4/1*(2)",
|
||||
"equity": 0.92529553
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 7/4 6/3",
|
||||
"equity": 0.9252107
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3 4/1",
|
||||
"equity": 0.92395884
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 5/2(2)",
|
||||
"equity": 0.9204093
|
||||
},
|
||||
{
|
||||
"move": "13/7 4/1*(2)",
|
||||
"equity": 0.9190106
|
||||
},
|
||||
{
|
||||
"move": "13/7(2)",
|
||||
"equity": 0.9184398
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 4/1*(2)",
|
||||
"equity": 0.9182539
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3 5/2(2)",
|
||||
"equity": 0.91752934
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10(2)",
|
||||
"equity": 0.91483337
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 6/3",
|
||||
"equity": 0.912639
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 8/5",
|
||||
"equity": 0.91152656
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/1*",
|
||||
"equity": 0.9115156
|
||||
},
|
||||
{
|
||||
"move": "13/1*",
|
||||
"equity": 0.9111869
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3 4/1*",
|
||||
"equity": 0.91055506
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 6/3",
|
||||
"equity": 0.9091699
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/4 6/3",
|
||||
"equity": 0.90897554
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5(2)",
|
||||
"equity": 0.90817225
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3 4/1*",
|
||||
"equity": 0.90714073
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 7/4",
|
||||
"equity": 0.9070756
|
||||
},
|
||||
{
|
||||
"move": "8/2(2)",
|
||||
"equity": 0.9065211
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/1*",
|
||||
"equity": 0.90571
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 7/4",
|
||||
"equity": 0.9040942
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 6/3 4/1*",
|
||||
"equity": 0.90343916
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 4/1*(2)",
|
||||
"equity": 0.90325713
|
||||
},
|
||||
{
|
||||
"move": "5/2(2) 4/1*(2)",
|
||||
"equity": 0.90183336
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 4/1",
|
||||
"equity": 0.9013542
|
||||
},
|
||||
{
|
||||
"move": "13/4 7/4",
|
||||
"equity": 0.9010268
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/2(2)",
|
||||
"equity": 0.90037674
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 7/1*",
|
||||
"equity": 0.89956194
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 7/4",
|
||||
"equity": 0.8988826
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3 5/2",
|
||||
"equity": 0.8984738
|
||||
},
|
||||
{
|
||||
"move": "13/4 13/10",
|
||||
"equity": 0.89749956
|
||||
},
|
||||
{
|
||||
"move": "6/3(2) 4/1*(2)",
|
||||
"equity": 0.8951012
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5(2)",
|
||||
"equity": 0.89493525
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/2",
|
||||
"equity": 0.8946096
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3(2)",
|
||||
"equity": 0.89403474
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/2(2)",
|
||||
"equity": 0.8924208
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/1* 4/1",
|
||||
"equity": 0.891961
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3(2)",
|
||||
"equity": 0.89077055
|
||||
},
|
||||
{
|
||||
"move": "13/4 8/5",
|
||||
"equity": 0.8907301
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 6/3 4/1*",
|
||||
"equity": 0.8900244
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2(2)",
|
||||
"equity": 0.8890666
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3(3)",
|
||||
"equity": 0.8890004
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 5/2(2)",
|
||||
"equity": 0.8888954
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 7/4",
|
||||
"equity": 0.8876569
|
||||
},
|
||||
{
|
||||
"move": "13/10(4)",
|
||||
"equity": 0.88716996
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3(2)",
|
||||
"equity": 0.8856077
|
||||
},
|
||||
{
|
||||
"move": "8/2 4/1*(2)",
|
||||
"equity": 0.8853629
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(3)",
|
||||
"equity": 0.8853096
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3 5/2",
|
||||
"equity": 0.8831558
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 7/4",
|
||||
"equity": 0.8819176
|
||||
},
|
||||
{
|
||||
"move": "7/1* 4/1(2)",
|
||||
"equity": 0.88113916
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 6/3(2)",
|
||||
"equity": 0.88064635
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 8/5",
|
||||
"equity": 0.88049895
|
||||
},
|
||||
{
|
||||
"move": "6/3 5/2(2) 4/1*",
|
||||
"equity": 0.8790827
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/2",
|
||||
"equity": 0.8782023
|
||||
},
|
||||
{
|
||||
"move": "6/3 5/2 4/1*(2)",
|
||||
"equity": 0.8774586
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3(3)",
|
||||
"equity": 0.8774268
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/1*",
|
||||
"equity": 0.8772669
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 4/1*",
|
||||
"equity": 0.87700987
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 6/3(2)",
|
||||
"equity": 0.8757389
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 8/5",
|
||||
"equity": 0.87530077
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3 5/2",
|
||||
"equity": 0.87215745
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 4/1*",
|
||||
"equity": 0.87202764
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/4 6/3(2)",
|
||||
"equity": 0.86896765
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3 5/2",
|
||||
"equity": 0.8687051
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 6/3 5/2",
|
||||
"equity": 0.8679464
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3 4/1*",
|
||||
"equity": 0.86750233
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 4/1*",
|
||||
"equity": 0.86701244
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 6/3(2)",
|
||||
"equity": 0.86167765
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 4/1*",
|
||||
"equity": 0.8568015
|
||||
},
|
||||
{
|
||||
"move": "6/3(2) 5/2(2)",
|
||||
"equity": 0.85501754
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/4 5/2",
|
||||
"equity": 0.8537997
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/2(2)",
|
||||
"equity": 0.8524791
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 4/1*",
|
||||
"equity": 0.84969926
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 5/2",
|
||||
"equity": 0.8466199
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3(2)",
|
||||
"equity": 0.80814815
|
||||
},
|
||||
{
|
||||
"move": "13/10 5/2(2) 4/1*",
|
||||
"equity": 0.8037353
|
||||
},
|
||||
{
|
||||
"move": "13/10 5/2 4/1*(2)",
|
||||
"equity": 0.77350885
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(2) 4/1*",
|
||||
"equity": 0.74067366
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 5/2",
|
||||
"equity": 0.72703636
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 5/2",
|
||||
"equity": 0.7053956
|
||||
},
|
||||
{
|
||||
"move": "6/3(3) 4/1*",
|
||||
"equity": 0.66575944
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 5/2",
|
||||
"equity": 0.6462204
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3(2) 4/1*",
|
||||
"equity": 0.6374039
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 4/1*",
|
||||
"equity": 0.62353325
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 4/1*",
|
||||
"equity": 0.61257124
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 5/2 4/1*",
|
||||
"equity": 0.5349428
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/2 4/1",
|
||||
"equity": 0.45721298
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/2 4/1*",
|
||||
"equity": 0.44362232
|
||||
},
|
||||
{
|
||||
"move": "13/4 5/2",
|
||||
"equity": 0.43828273
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 5/2",
|
||||
"equity": 0.2798175
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(2) 5/2",
|
||||
"equity": 0.24207813
|
||||
},
|
||||
{
|
||||
"move": "6/3(3) 5/2",
|
||||
"equity": 0.23385207
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3(2) 5/2",
|
||||
"equity": 0.22096781
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/2 4/1*",
|
||||
"equity": 0.18849733
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2 4/1*",
|
||||
"equity": 0.084023334
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "13/10(2) 5/2 4/1*",
|
||||
"played_position_id": "4HPwAWBVXTMGAA",
|
||||
"move_count": 110,
|
||||
"rank": 7,
|
||||
"played_equity": -0.022027964,
|
||||
"best_move": "13/10 7/1* 6/3",
|
||||
"best_equity": 1.0,
|
||||
"equity_loss": -1.022028,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/10 7/1* 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2 4/1*",
|
||||
"equity": -0.022027964
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 4/1*(2)",
|
||||
"equity": 0.99692726
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.011263669,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-crawford",
|
||||
"description": "Match en 5 points, partie Crawford, 4-3.",
|
||||
"position_id": "4Dn4AGiynYMHAA",
|
||||
"match_id": "8AmuAEAAEAAE",
|
||||
"dice": [
|
||||
4,
|
||||
3
|
||||
],
|
||||
"played": "5/2 5/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "5/2 5/1*",
|
||||
"played_position_id": "4Dn4AHDNnIMHAA",
|
||||
"move_count": 20,
|
||||
"rank": 2,
|
||||
"played_equity": 0.7229391,
|
||||
"best_move": "8/5 6/2",
|
||||
"best_equity": 0.7345603,
|
||||
"equity_loss": -0.011621237,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/2",
|
||||
"equity": 0.7345603
|
||||
},
|
||||
{
|
||||
"move": "5/2 5/1*",
|
||||
"equity": 0.7229391
|
||||
},
|
||||
{
|
||||
"move": "8/1*",
|
||||
"equity": 0.71705425
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/2",
|
||||
"equity": 0.7142835
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/5",
|
||||
"equity": 0.6963019
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/4",
|
||||
"equity": 0.68610644
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/4",
|
||||
"equity": 0.6855368
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/9",
|
||||
"equity": 0.6818689
|
||||
},
|
||||
{
|
||||
"move": "13/6",
|
||||
"equity": 0.6633271
|
||||
},
|
||||
{
|
||||
"move": "13/9 5/2",
|
||||
"equity": 0.6538005
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "5/2 5/1*",
|
||||
"played_position_id": "4Dn4AHDNnIMHAA",
|
||||
"move_count": 20,
|
||||
"rank": 4,
|
||||
"played_equity": 0.6911188,
|
||||
"best_move": "8/5 6/2",
|
||||
"best_equity": 0.7697691,
|
||||
"equity_loss": -0.078650296,
|
||||
"skill": "bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/2",
|
||||
"equity": 0.7697691
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/2",
|
||||
"equity": 0.7615671
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/5",
|
||||
"equity": 0.69608325
|
||||
},
|
||||
{
|
||||
"move": "5/2 5/1*",
|
||||
"equity": 0.6911188
|
||||
},
|
||||
{
|
||||
"move": "8/1*",
|
||||
"equity": 0.6836242
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/9",
|
||||
"equity": 0.6807338
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/4",
|
||||
"equity": 0.6733323
|
||||
},
|
||||
{
|
||||
"move": "13/6",
|
||||
"equity": 0.66055346
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/4",
|
||||
"equity": 0.6556259
|
||||
},
|
||||
{
|
||||
"move": "13/9 5/2",
|
||||
"equity": 0.645951
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.050364614,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "move-forced",
|
||||
"description": "Rentrée forcée depuis la barre, 3-1.",
|
||||
"position_id": "tm3ABwBw5+ABYA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"played": "bar/24",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "bar/24",
|
||||
"played_position_id": "tm3ABwBw5+ABUA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": -1.0,
|
||||
"best_move": "bar/24",
|
||||
"best_equity": -1.0,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "bar/24",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "bar/24",
|
||||
"played_position_id": "tm3ABwBw5+ABUA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": -1.0,
|
||||
"best_move": "bar/24",
|
||||
"best_equity": -1.0,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "bar/24",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.0,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"id": "move-jacoby",
|
||||
"description": "Partie libre avec la règle Jacoby, 6-4.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
4
|
||||
],
|
||||
"played": "8/2 5/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "8/2 5/1*",
|
||||
"played_position_id": "4HPwAWDVuoIHAA",
|
||||
"move_count": 14,
|
||||
"rank": 14,
|
||||
"played_equity": 0.50889754,
|
||||
"best_move": "13/3",
|
||||
"best_equity": 0.9328213,
|
||||
"equity_loss": -0.42392373,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": 0.9328213
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.9175232
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/3",
|
||||
"equity": 0.91263336
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/4",
|
||||
"equity": 0.90736973
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/1*",
|
||||
"equity": 0.9053596
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.9017469
|
||||
},
|
||||
{
|
||||
"move": "8/4 7/1*",
|
||||
"equity": 0.89993495
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/2",
|
||||
"equity": 0.8962312
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/1",
|
||||
"equity": 0.8907822
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/2",
|
||||
"equity": 0.87481093
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/2",
|
||||
"equity": 0.87251335
|
||||
},
|
||||
{
|
||||
"move": "8/4 8/2",
|
||||
"equity": 0.8716274
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/1*",
|
||||
"equity": 0.87005275
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/1*",
|
||||
"equity": 0.50889754
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "8/2 5/1*",
|
||||
"played_position_id": "4HPwAWDVuoIHAA",
|
||||
"move_count": 14,
|
||||
"rank": 7,
|
||||
"played_equity": 0.3304975,
|
||||
"best_move": "13/3",
|
||||
"best_equity": 1.0,
|
||||
"equity_loss": -0.6695025,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.99894416
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/3",
|
||||
"equity": 0.99641544
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/4",
|
||||
"equity": 0.98718643
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/2",
|
||||
"equity": 0.9808178
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/1*",
|
||||
"equity": 0.9452111
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/1*",
|
||||
"equity": 0.3304975
|
||||
},
|
||||
{
|
||||
"move": "8/4 7/1*",
|
||||
"equity": 0.93114305
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/1",
|
||||
"equity": 0.91711015
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.9160911
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.002478838,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-luck-lucky",
|
||||
"description": "Course, double 4 : jet chanceux, joué au mieux.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [
|
||||
4,
|
||||
4
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "13/5 11/3",
|
||||
"played_position_id": "cLezAQDk3ZwEAA",
|
||||
"move_count": 106,
|
||||
"rank": 1,
|
||||
"played_equity": 0.16933289,
|
||||
"best_move": "13/5 11/3",
|
||||
"best_equity": 0.16933289,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5 11/3",
|
||||
"equity": 0.16933289
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/3",
|
||||
"equity": 0.16701066
|
||||
},
|
||||
{
|
||||
"move": "13/5 9/5 7/3",
|
||||
"equity": 0.16552198
|
||||
},
|
||||
{
|
||||
"move": "13/5(2)",
|
||||
"equity": 0.16473158
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 11/7",
|
||||
"equity": 0.16365689
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 7/3",
|
||||
"equity": 0.16245355
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7 9/5",
|
||||
"equity": 0.16167049
|
||||
},
|
||||
{
|
||||
"move": "11/3 9/5(2)",
|
||||
"equity": 0.16043647
|
||||
},
|
||||
{
|
||||
"move": "13/9 11/3 11/7",
|
||||
"equity": 0.1603657
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7(2)",
|
||||
"equity": 0.1602792
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "13/5 11/3",
|
||||
"played_position_id": "cLezAQDk3ZwEAA",
|
||||
"move_count": 106,
|
||||
"rank": 1,
|
||||
"played_equity": 0.15129912,
|
||||
"best_move": "13/5 11/3",
|
||||
"best_equity": 0.15129912,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5 11/3",
|
||||
"equity": 0.15129912
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 11/7",
|
||||
"equity": 0.15059008
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/3",
|
||||
"equity": 0.14950007
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7 9/5",
|
||||
"equity": 0.14858595
|
||||
},
|
||||
{
|
||||
"move": "11/3 11/7 9/5",
|
||||
"equity": 0.14829463
|
||||
},
|
||||
{
|
||||
"move": "13/9 11/3 11/7",
|
||||
"equity": 0.1463402
|
||||
},
|
||||
{
|
||||
"move": "13/5 9/5 7/3",
|
||||
"equity": 0.13938732
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7(2)",
|
||||
"equity": 0.13921705
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/7(2)",
|
||||
"equity": 0.13873637
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 6/2",
|
||||
"equity": 0.13852078
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.43559298,
|
||||
"rating": "lucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "move-luck-unlucky-bearoff",
|
||||
"description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "TQAAYAsAAAAAAA",
|
||||
"match_id": "cAkFAAAAAAAA",
|
||||
"dice": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "2/off 1/off",
|
||||
"played_position_id": "TQAAoAIAAAAAAA",
|
||||
"move_count": 4,
|
||||
"rank": 2,
|
||||
"played_equity": -0.8607614,
|
||||
"best_move": "3/off",
|
||||
"best_equity": -0.8607614,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "3/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/off 1/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/1 2/off",
|
||||
"equity": -0.95561147
|
||||
},
|
||||
{
|
||||
"move": "3/1 2/1",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "2/off 1/off",
|
||||
"played_position_id": "TQAAoAIAAAAAAA",
|
||||
"move_count": 4,
|
||||
"rank": 2,
|
||||
"played_equity": -0.8607614,
|
||||
"best_move": "3/off",
|
||||
"best_equity": -0.8607614,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "3/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/off 1/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/1 2/off",
|
||||
"equity": -0.95561147
|
||||
},
|
||||
{
|
||||
"move": "3/1 2/1",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.32329792,
|
||||
"rating": "unlucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-luck-unlucky",
|
||||
"description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [
|
||||
5,
|
||||
4
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "18/9",
|
||||
"played_position_id": "xGfhATDQ28IDIA",
|
||||
"move_count": 24,
|
||||
"rank": 1,
|
||||
"played_equity": 0.2544027,
|
||||
"best_move": "18/9",
|
||||
"best_equity": 0.2544027,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "18/9",
|
||||
"equity": 0.2544027
|
||||
},
|
||||
{
|
||||
"move": "18/14 13/8",
|
||||
"equity": 0.22496489
|
||||
},
|
||||
{
|
||||
"move": "24/20 18/13",
|
||||
"equity": 0.20747662
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/8",
|
||||
"equity": 0.1158272
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": 0.09201278
|
||||
},
|
||||
{
|
||||
"move": "24/15",
|
||||
"equity": 0.061017293
|
||||
},
|
||||
{
|
||||
"move": "18/13 6/2",
|
||||
"equity": 0.03581623
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.028887823
|
||||
},
|
||||
{
|
||||
"move": "13/8 6/2",
|
||||
"equity": -0.033572298
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/4",
|
||||
"equity": -0.09662818
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "18/9",
|
||||
"played_position_id": "xGfhATDQ28IDIA",
|
||||
"move_count": 24,
|
||||
"rank": 1,
|
||||
"played_equity": 0.27687487,
|
||||
"best_move": "18/9",
|
||||
"best_equity": 0.27687487,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "18/9",
|
||||
"equity": 0.27687487
|
||||
},
|
||||
{
|
||||
"move": "18/14 13/8",
|
||||
"equity": 0.21801135
|
||||
},
|
||||
{
|
||||
"move": "24/20 18/13",
|
||||
"equity": 0.21000355
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/8",
|
||||
"equity": 0.1144446
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": 0.09412725
|
||||
},
|
||||
{
|
||||
"move": "24/15",
|
||||
"equity": 0.07892294
|
||||
},
|
||||
{
|
||||
"move": "18/13 6/2",
|
||||
"equity": -0.020656409
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.028887823
|
||||
},
|
||||
{
|
||||
"move": "13/8 6/2",
|
||||
"equity": -0.033572298
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/4",
|
||||
"equity": -0.09662818
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.3615805,
|
||||
"rating": "unlucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-match",
|
||||
"description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cImuACAAEAAE",
|
||||
"dice": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"played": "24/16",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "24/16*",
|
||||
"played_position_id": "xGfwAFjQ2+CRAA",
|
||||
"move_count": 28,
|
||||
"rank": 3,
|
||||
"played_equity": 0.4581785,
|
||||
"best_move": "13/5",
|
||||
"best_equity": 0.7553502,
|
||||
"equity_loss": -0.29717168,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5",
|
||||
"equity": 0.7553502
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/5",
|
||||
"equity": 0.5814649
|
||||
},
|
||||
{
|
||||
"move": "24/16*",
|
||||
"equity": 0.4581785
|
||||
},
|
||||
{
|
||||
"move": "18/10",
|
||||
"equity": 0.31719947
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/3",
|
||||
"equity": 0.31371918
|
||||
},
|
||||
{
|
||||
"move": "18/15 13/8",
|
||||
"equity": 0.26037523
|
||||
},
|
||||
{
|
||||
"move": "24/21 18/13",
|
||||
"equity": 0.23547156
|
||||
},
|
||||
{
|
||||
"move": "8/3 6/3",
|
||||
"equity": 0.15432969
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/8",
|
||||
"equity": 0.13837479
|
||||
},
|
||||
{
|
||||
"move": "18/13 5/2",
|
||||
"equity": 0.12815686
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "24/16*",
|
||||
"played_position_id": "xGfwAFjQ2+CRAA",
|
||||
"move_count": 28,
|
||||
"rank": 2,
|
||||
"played_equity": 0.46805125,
|
||||
"best_move": "13/5",
|
||||
"best_equity": 0.85497326,
|
||||
"equity_loss": -0.386922,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5",
|
||||
"equity": 0.85497326
|
||||
},
|
||||
{
|
||||
"move": "24/16*",
|
||||
"equity": 0.46805125
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/5",
|
||||
"equity": 0.6511238
|
||||
},
|
||||
{
|
||||
"move": "18/10",
|
||||
"equity": 0.31719947
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/3",
|
||||
"equity": 0.31371918
|
||||
},
|
||||
{
|
||||
"move": "18/15 13/8",
|
||||
"equity": 0.26037523
|
||||
},
|
||||
{
|
||||
"move": "24/21 18/13",
|
||||
"equity": 0.23547156
|
||||
},
|
||||
{
|
||||
"move": "8/3 6/3",
|
||||
"equity": 0.15432969
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/8",
|
||||
"equity": 0.13837479
|
||||
},
|
||||
{
|
||||
"move": "18/13 5/2",
|
||||
"equity": 0.12815686
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.026781201,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user