Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 5c75f29f32 Plans 1e à 1i : bilan et analyse, prompts, leçons, progression, plateau soigné
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:03:46 +01:00
Pierre MARTINandClaude Opus 5.5 3cdd017768 Fusion de la MR !37 : parties enregistrées, branches et export SGF (étape 1b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:17 +01:00
Pierre MARTINandClaude Opus 5.5 19a54193f9 ADR 0007 : section Amendements
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:00 +01:00
Pierre MARTINandClaude Opus 5.5 2c4448549d Parties enregistrées : plan clos ; stockage déclaré format durable (ADR 0007)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:51:01 +01:00
Pierre MARTINandClaude Opus 5.5 d228d70387 Mes parties : la feuille se ferme à la reprise ; plan 1b : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:50:33 +01:00
Pierre MARTINandClaude Opus 5.5 e3aaa0b3a3 Stockage ouvert pendant le chargement du moteur ; test navigateur : nombre de parties
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:29:18 +01:00
Pierre MARTINandClaude Opus 5.5 71ee3605d4 Mes parties : suppression en deux touchers, sans boîte du navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:24:24 +01:00
Pierre MARTINandClaude Opus 5.5 196ccd4b9f Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement
« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son
identifiant et supprime la nouvelle (GameSession).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:17:53 +01:00
pierre ae8a2d6ad2 Fusion de la MR !36 : liens 0e.5 2026-11-03 19:15:06 +00:00
Pierre MARTINandClaude Opus 5.5 9420bf8bd6 Charte : liens des écrans d'accueil, du tutoriel et de l'aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:15:03 +01:00
Pierre MARTINandClaude Opus 5.5 231dd02ac0 Fusion de la MR !35 : révision UX, portrait stable, compte de points, menu, paysage plateau = écran
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:07:18 +01:00
Pierre MARTINandClaude Opus 5.5 6408b4b03e Fusion de main dans revision-ux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:03:47 +01:00
Pierre MARTINandClaude Opus 5.5 4d9a6d69db Test navigateur : menu essayé au moment de lancer les dés ; menu ouvert devant un panneau
Le panneau du double de l'ordinateur ne s'affiche qu'en paysage (en portrait,
la barre d'actions pose la question) : la rotation change alors l'écran, à
raison. Le test ouvrait le menu juste après un coup, parfois pendant un double.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:00:24 +01:00
Pierre MARTINandClaude Opus 5.5 d268111609 Fusion de la MR !34 : une adresse pour chaque écran, de vrais liens (ADR 0013)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:56:04 +01:00
Pierre MARTINandClaude Opus 5.5 342011ad0d ADR 0013 amendé : une adresse pour chaque écran, de vrais liens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:55:57 +01:00
Pierre MARTINandClaude Opus 5.5 0b1bd97342 Révision UX : plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:49:12 +01:00
Pierre MARTIN c04479c18f Fusion de main dans revision-ux 2026-11-03 15:49:12 +01:00
Pierre MARTINandClaude Opus 5.5 155a953c3b Plein écran : attente bornée, repli tourné si l'écran reste en portrait, paysage forcé en mode immersif ; plan : résultat et corrections de la recette
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:40:53 +01:00
Pierre MARTINandClaude Opus 5.5 31815134e5 Mes parties : scénarios, glossaire, test navigateur sur IndexedDB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:36:54 +01:00
Pierre MARTINandClaude Opus 5.5 274fbf6252 Parties enregistrées : IndexedDB, branches, export SGF relu par gnubg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:29:01 +01:00
Pierre MARTINandClaude Opus 5.5 b8c2997ec0 Recette design : consigne en texte nu, cases d'arrivée en triangle et anneau, flèches sous les pions, panneau de fin structuré
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:24:03 +01:00
Pierre MARTINandClaude Opus 5.5 ebaa3b0020 Tests : suppression synchrone du conteneur du site avant celle de son image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:18:26 +01:00
Pierre MARTINandClaude Opus 5.5 2c56b0a2b1 Recette automatisée de la révision UX dans make test-browser ; chiffres du plateau à 13 px au moins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:17:16 +01:00
Pierre MARTINandClaude Opus 5.5 5f66535051 Fusion de la MR !33 : plans 1c et 1d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:15:05 +01:00
Pierre MARTINandClaude Opus 5.5 7fe3d53034 Plans 1c (match, niveaux, réglages) et 1d (conseil du maître)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:14:51 +01:00
Pierre MARTINandClaude Opus 5.5 08cb89735f Fusion de la MR !32 : moteur pour l'analyse et les niveaux (étape 1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:09:37 +01:00
Pierre MARTINandClaude Opus 5.5 091cb4a8b0 Fusion de main dans moteur-analyse ; plan 1a clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:06:09 +01:00
Pierre MARTINandClaude Opus 5.5 c6649f94c0 Révision UX : portrait stable, bandeaux, menu, paysage plateau = écran, vibrations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:05:27 +01:00
Pierre MARTINandClaude Opus 5.5 2be5bdcb4f Tests : image du site propre à chaque copie de travail, supprimée en fin de test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:05:27 +01:00
Pierre MARTINandClaude Opus 5.5 fa558667f7 Plan 1a : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:04:50 +01:00
Pierre MARTINandClaude Opus 5.5 cbef27ae9c Fusion de la MR !31 : plan 0e.5, accueil, installation, tutoriel, aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:02:19 +01:00
Pierre MARTINandClaude Opus 5.5 c061b6d41b Plan 0e.5 : accueil, installation, tutoriel, aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:02:01 +01:00
Pierre MARTINandClaude Opus 5.5 6b5e0d2b5e Mesure : temps de l'analyse du coup joué à 2 plis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:00:39 +01:00
Pierre MARTINandClaude Opus 5.5 23f4d47fcf Domaine et adaptateur : niveaux, analyses et chance ; cache vidé à chaque calcul
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:59:07 +01:00
pierre 31159a5707 Fusion de la MR !30 : accueil, tutoriel, aide 2026-11-03 13:58:27 +00:00
Pierre MARTINandClaude Opus 5.5 f24315fb98 Charte : accueil, installation, tutoriel des règles, aide en questions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:58:25 +01:00
Pierre MARTINandClaude Opus 5.5 2f6eb86972 Tests du moteur : niveaux, analyses et chance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:08 +01:00
Pierre MARTINandClaude Opus 5.5 7e958fab8e Références : format des analyses documenté et vérifié
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:41 +01:00
Pierre MARTINandClaude Opus 5.5 e2249ecb85 Révision UX : ajouts A, B et C au plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:25 +01:00
Pierre MARTINandClaude Opus 5.5 7c1163054a Fusion de la MR !29 : ADR 0013, position dans l'adresse ; plan 0e.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:02 +01:00
Pierre MARTINandClaude Opus 5.5 242a2433d2 Références : analyses de coups, de videau, chance et niveaux, comparées à gnubg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:23:18 +01:00
Pierre MARTINandClaude Opus 5.5 34c3eaeca5 Moteur : niveaux de jeu, analyse d'un coup et du videau, chance d'un jet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:04:51 +01:00
123 changed files with 12624 additions and 1043 deletions
+4 -3
View File
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
REFERENCE_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
@@ -163,8 +164,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
+91
View File
@@ -0,0 +1,91 @@
// Les branches d'une partie : les lignes de jeu de son journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
// deux enfants d'un même événement, le plus ancien a le plus petit indice
// dans le journal, et c'est lui qui continue la ligne d'origine.
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
import { replay } from "./state.js";
/**
* Une branche de la partie : la suite d'événements du début jusqu'à head.
*
* Glossaire : docs/glossaire/branche.md
*/
export interface Branch {
/** Le dernier événement de la branche (une feuille de l'arbre). */
readonly head: string;
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
* null pour la ligne d'origine de la partie. */
readonly forkedFrom: string | null;
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
* la ligne d'origine. */
readonly forkMoveCount: number;
/** Les coups joués sur toute la branche, depuis le début. */
readonly moveCount: number;
/** La partie est finie au bout de cette branche. */
readonly over: boolean;
/** L'événement courant de la partie est sur cette branche. */
readonly containsHead: boolean;
}
/**
* Les branches de la partie, dans l'ordre de leur création (celui de leur
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
* journal vide.
*/
export function branches(journal: GameJournal): Branch[] {
const order = new Map(journal.events.map((event, index) => [event.id, index]));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
const leaves = journal.events.filter((event) => !children.has(event.id));
return leaves
.map((leaf) => {
const line = currentLine({ events: journal.events, head: leaf.id });
// Le point de départ de la branche : le parent de l'événement le
// plus récent du chemin qui n'est pas le premier enfant de son
// parent (la ligne d'origine n'en a aucun).
let forkIndex = -1;
for (let i = line.length - 1; i > 0; i--) {
const event = line[i] as GameEvent;
const first = children.get(event.parent as string)?.[0];
if (first !== undefined && first.id !== event.id) {
forkIndex = i - 1;
break;
}
}
const moves = (events: readonly GameEvent[]) =>
events.filter((event) => event.type === "MovePlayed").length;
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
const start = line[forkIndex + 1] as GameEvent;
return {
created: order.get(start.id) ?? 0,
head: leaf.id,
forkedFrom: fork?.id ?? null,
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
moveCount: moves(line),
over: replay(line).phase === "over",
containsHead: line.some((event) => event.id === journal.head),
};
})
.sort((a, b) => a.created - b.created)
.map(({ created: _, ...branch }) => branch);
}
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
if (!branches(journal).some((branch) => branch.head === head)) {
throw new Error(`branche introuvable : ${head}`);
}
return goBackTo(journal, head);
}
+135
View File
@@ -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;
}
/**
+14
View File
@@ -8,6 +8,7 @@
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import {
@@ -415,6 +416,19 @@ export class Game {
this.#journal = goBackTo(this.#journal, eventId);
}
/** Les branches de la partie (voir branches.ts), la ligne d'origine
* d'abord. */
get branches(): Branch[] {
return branches(this.#journal);
}
/** Passe à une autre branche, désignée par son dernier événement : la
* partie reprend au bout de celle-ci ; les autres restent intactes. */
switchBranch(head: string): void {
this.cancelHalfMoves();
this.#journal = switchToBranch(this.#journal, head);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
+64
View File
@@ -0,0 +1,64 @@
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
// relire tous les journaux.
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import { type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
*
* Glossaire : docs/glossaire/partie-enregistree.md
*/
export interface SavedGameSummary {
/** Identifiant de la partie, unique sur l'appareil. */
readonly id: string;
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
readonly startedAt: number;
readonly updatedAt: number;
/** Niveau de l'ordinateur, l'adversaire. */
readonly level: Level;
/** En cours ou finie, sur la branche courante. */
readonly status: "playing" | "over";
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
readonly result: GameResult | null;
/** Coups joués sur la branche courante. */
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
* journal. */
export interface SavedGameDetails {
readonly level: Level;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
id: string,
journal: GameJournal,
details: SavedGameDetails,
times: { readonly startedAt: number; readonly updatedAt: number },
): SavedGameSummary {
const line = currentLine(journal);
const state = replay(line);
return {
id,
startedAt: times.startedAt,
updatedAt: times.updatedAt,
level: details.level,
status: state.phase === "over" ? "over" : "playing",
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
};
}
+226
View File
@@ -0,0 +1,226 @@
// Export d'une partie au format SGF de GNU Backgammon.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
// devient une variante. GNU Backgammon ne lit que la première variante de
// chaque embranchement ; la branche courante de la partie vient donc en
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
//
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
// - [double], [take], [drop] pour le videau ;
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
// PL, le videau par CV et CP.
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
// et une position de départ particulière est décodée par le moteur.
import type { Engine } from "./engine.js";
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
import type { Side } from "./side.js";
import { replay } from "./state.js";
export interface SgfOptions {
/** Nom du joueur (PB). */
readonly playerName: string;
/** Nom de l'ordinateur (PW). */
readonly computerName: string;
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
readonly date?: string;
}
/** Un nœud SGF et les variantes qui le suivent. */
interface SgfNode {
readonly properties: string;
readonly children: SgfNode[];
}
/**
* La partie au format SGF de GNU Backgammon, branches comprises (en
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
* une position de départ particulière. (Le format : docs/glossaire/sgf.md.)
*/
export async function exportSgf(
engine: Engine,
journal: GameJournal,
options: SgfOptions,
): Promise<string> {
const start = journal.events[0];
if (start?.type !== "GameStarted") {
throw new Error("journal de partie vide ou mal formé");
}
const byId = new Map(journal.events.map((event) => [event.id, event]));
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
// La branche courante d'abord, puis les autres dans l'ordre du journal.
for (const list of children.values()) {
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
}
/** Les variantes qui suivent un événement. */
const variations = (id: string): SgfNode[] =>
(children.get(id) ?? []).flatMap((child) => {
const properties = nodeProperties(child, byId, children.has(child.id));
const below = variations(child.id);
return properties === null ? below : [{ properties, children: below }];
});
// La partie telle que la voit gnubg : la ligne courante.
const result = replay(currentLine(journal)).result;
const root = [
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
"MI[length:0][game:0][ws:0][bs:0]",
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
].join("");
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
const setup = await setupProperties(engine, start);
const tree = setup.reduceRight<SgfNode[]>(
(below, properties) => [{ properties, children: below }],
variations(start.id),
);
return `(;${root}${sequence(tree)})\n`;
}
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
* chaque variante entre parenthèses. */
function sequence(nodes: readonly SgfNode[]): string {
if (nodes.length === 1) {
const [node] = nodes as [SgfNode];
return `\n;${node.properties}${sequence(node.children)}`;
}
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
}
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
* (début, dés suivis d'un coup, fin de partie). */
function nodeProperties(
event: GameEvent,
byId: ReadonlyMap<string, GameEvent>,
hasChildren: boolean,
): string | null {
switch (event.type) {
case "MovePlayed": {
const rolled = event.parent === null ? undefined : byId.get(event.parent);
if (rolled?.type !== "DiceRolled") {
throw new Error(`coup sans jet de dés : ${event.id}`);
}
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
}
case "CubeOffered":
return `${letter(event.side)}[double]`;
case "CubeTaken":
return `${letter(event.side)}[take]`;
case "CubeDropped":
return `${letter(event.side)}[drop]`;
case "DiceRolled": {
// Des dés pas encore joués, au bout d'une branche.
if (hasChildren) {
return null;
}
const [a, b] = event.roll;
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
}
case "GameStarted":
case "GameEnded":
return null;
}
}
/** Les propriétés des nœuds qui posent une position de départ particulière
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
* faire). */
async function setupProperties(
engine: Engine,
start: Extract<GameEvent, { type: "GameStarted" }>,
): Promise<string[]> {
const usual =
start.position === START_POSITION &&
start.firstTurn === null &&
start.cube.value === 1 &&
start.cube.owner === null;
if (usual) {
return [];
}
const onRoll: Side = start.firstTurn ?? "player";
const owner =
start.cube.owner === null
? "centered"
: start.cube.owner === onRoll
? "onRoll"
: "opponent";
const board = await engine.board(start.position, { value: start.cube.value, owner });
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
// 'x' - (c - 1), la barre en 'y' (comme les coups).
const stones = (side: Side, points: readonly number[], bar: number) => {
const out: string[] = [];
points.forEach((count, i) => {
for (let k = 0; k < count; k++) {
out.push(`[${pointLetter(i + 1, side)}]`);
}
});
for (let k = 0; k < bar; k++) {
out.push("[y]");
}
return out.join("");
};
const other: Side = onRoll === "player" ? "computer" : "player";
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
Side,
typeof board.onRoll
>;
const white = stones("computer", sides.computer.points, sides.computer.bar);
const black = stones("player", sides.player.points, sides.player.bar);
const nodes = [
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
];
if (start.cube.value !== 1) {
nodes.push(`CV[${start.cube.value}]`);
}
if (start.cube.owner !== null) {
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
}
return nodes;
}
function letter(side: Side): "B" | "W" {
return side === "player" ? "B" : "W";
}
function halfMove(h: HalfMove, side: Side): string {
return pointLetter(h.from, side) + pointLetter(h.to, side);
}
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
function pointLetter(point: number, side: Side): string {
if (point === BAR) {
return "y";
}
if (point === OFF) {
return "z";
}
const code =
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
return String.fromCharCode(code);
}
/** Échappe un texte libre dans une valeur SGF. */
function escapeText(text: string): string {
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
}
+132 -2
View File
@@ -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 };
}
+80 -16
View File
@@ -6,33 +6,88 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { Game } from "./domain/game/game.js";
import { exportSgf } from "./domain/game/sgf.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
// Chaque accès au stockage du navigateur peut lever une exception (stockage
// interdit) : on fait alors sans.
const localStorage = attempt(() => window.localStorage);
const settings = createSettingsStore(localStorage ?? memoryStorage());
// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes
// pendant que le moteur se charge : le plateau est dessiné sans les
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
const opening = (async () => {
const { library } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage,
});
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
const session = createGameSession(
library,
{ start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) },
"2ply",
);
const saved = await session.restore();
return { library, session, saved };
})();
let stored: Awaited<typeof opening> | null = null;
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
function storage(): Awaited<typeof opening> {
if (stored === null) {
throw new Error("stockage pas encore ouvert");
}
return stored;
}
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
savedGame: () => {
const journal = store.load();
if (journal === null) {
return null;
}
try {
return Game.resume(engine, journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
engineReady: Promise.all([
worker.ready,
opening.then((opened) => {
stored = opened;
}),
]),
newGame: () => storage().session.newGame(),
savedGame: () => storage().saved,
resumePrevious: () => storage().session.resumePrevious(),
saveGame: (journal) => storage().session.save(journal),
games: {
list: () => storage().library.list(),
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
shownId: () => storage().session.shownId,
open: (id, branchHead) => storage().session.open(id, branchHead),
remove: (id) => storage().library.remove(id),
exportSgf: async (id) => {
const game = await storage().library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
const started = new Date(game.summary.startedAt);
const two = (n: number) => String(n).padStart(2, "0");
const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`;
const text = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
date,
});
const time = `${two(started.getHours())}${two(started.getMinutes())}`;
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
},
},
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
@@ -46,3 +101,12 @@ if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
}
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}
+140
View File
@@ -0,0 +1,140 @@
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
// d'événements, et laquelle est la partie en cours.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Deux stockages derrière la même interface (GameLibrary) :
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
// indisponible, par exemple en navigation privée de certains navigateurs.
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
// appels : un enregistrement lancé sans être attendu est donc toujours fait
// avant la lecture qui suit.
import type { GameJournal } from "../domain/game/events.js";
import {
type SavedGameDetails,
type SavedGameSummary,
summarizeGame,
} from "../domain/game/saved-game.js";
/** Une partie enregistrée : son résumé et son journal. */
export interface SavedGame {
readonly summary: SavedGameSummary;
readonly journal: GameJournal;
}
export interface GameLibrary {
/** Les parties, la dernière jouée d'abord. */
list(): Promise<SavedGameSummary[]>;
/** Une partie, ou null si elle n'existe pas ou est illisible. */
load(id: string): Promise<SavedGame | null>;
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
const id = await backend.currentId();
const chosen = id === undefined ? null : await load(id);
if (chosen !== null) {
return chosen;
}
const latest = (await list())[0];
return latest === undefined ? null : load(latest.id);
}),
setCurrent: (id) =>
enqueue(async () => {
if ((await backend.summary(id)) === undefined) {
throw new Error(`partie introuvable : ${id}`);
}
await backend.setCurrentId(id);
}),
};
}
+130
View File
@@ -0,0 +1,130 @@
// La partie à l'écran et son enregistrement : quel identifiant elle porte,
// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la
// reprise d'une partie enregistrée ou de l'une de ses branches.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle
// quelle, sur un stockage en mémoire.
import { switchToBranch } from "../domain/game/branches.js";
import type { Level } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { type GameLibrary, newGameId } from "./game-library.js";
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
* le journal est illisible). */
export interface GameFactory {
readonly start: () => Game;
readonly resume: (journal: GameJournal) => Game;
}
export interface GameSession {
/** L'identifiant de la partie à l'écran (null avant la première). */
readonly shownId: string | null;
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
* si elle est illisible. */
restore(): Promise<Game | null>;
/** Une nouvelle partie ; celle qu'elle remplace reste reprenable par
* resumePrevious, jusqu'au prochain changement de partie. */
newGame(): Game;
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
* un échec est ignoré : la partie continue). */
save(journal: GameJournal): void;
/** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans
* le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */
resumePrevious(): Promise<Game | null>;
/** Reprend une partie enregistrée, au bout de la branche branchHead si
* elle est donnée. Erreur si elle n'existe pas ou est illisible. */
open(id: string, branchHead?: string): Promise<Game>;
}
export function createGameSession(
library: GameLibrary,
games: GameFactory,
level: Level,
): GameSession {
let shownId: string | null = null;
let previousId: string | null = null;
/** Une partie enregistrée, reprise ; null si absente ou illisible. */
const resume = async (id: string): Promise<Game | null> => {
const saved = await library.load(id);
if (saved === null) {
return null;
}
try {
return games.resume(saved.journal);
} catch {
return null;
}
};
return {
get shownId() {
return shownId;
},
async restore() {
const saved = await library.current().catch(() => null);
if (saved === null) {
return null;
}
try {
const game = games.resume(saved.journal);
shownId = saved.summary.id;
return game;
} catch {
return null; // partie illisible : on en commencera une nouvelle
}
},
newGame() {
previousId = shownId;
shownId = newGameId();
return games.start();
},
save(journal) {
if (shownId !== null) {
library.save(shownId, journal, { level }).catch(() => {});
}
},
async resumePrevious() {
const id = previousId;
if (id === null) {
return null;
}
const resumed = await resume(id);
if (resumed === null) {
return null;
}
const abandoned = shownId;
previousId = null;
shownId = id;
await library.setCurrent(id);
if (abandoned !== null) {
await library.remove(abandoned);
}
return resumed;
},
async open(id, branchHead) {
const saved = await library.load(id);
if (saved === null) {
throw new Error("partie introuvable");
}
const journal =
branchHead === undefined
? saved.journal
: switchToBranch(saved.journal, branchHead);
const game = games.resume(journal);
previousId = null;
shownId = id;
if (branchHead === undefined) {
await library.setCurrent(id);
} else {
await library.save(id, journal, { level: saved.summary.level });
}
return game;
},
};
}
-47
View File
@@ -1,47 +0,0 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+81
View File
@@ -0,0 +1,81 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current »).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+96
View File
@@ -0,0 +1,96 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return {
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
kind: "localStorage",
};
}
return {
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
kind: "memory",
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+83
View File
@@ -0,0 +1,83 @@
// Ce que l'application garde en plus de la partie, dans le même stockage
// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier
// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que
// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Cube } from "../domain/game/cube.js";
import type { SideBoards } from "../domain/game/game.js";
import type { KeyValueStorage } from "./key-value-backend.js";
const SETTINGS_KEY = "ustagammon.settings";
const BOARD_KEY = "ustagammon.last-board";
/** Le dernier plateau affiché, tel que le moteur l'a décodé. */
export interface CachedBoard {
readonly sides: SideBoards;
readonly cube: Cube;
}
export interface SettingsStore {
/** Le texte JSON des réglages (null : jamais enregistrés). */
loadSettings(): string | null;
saveSettings(json: string): void;
loadBoard(): CachedBoard | null;
saveBoard(board: CachedBoard): void;
}
function isSide(value: unknown): boolean {
const side = value as Record<string, unknown> | null;
return (
side !== null &&
typeof side === "object" &&
Array.isArray(side.points) &&
side.points.length === 24 &&
side.points.every((n) => Number.isInteger(n) && n >= 0) &&
Number.isInteger(side.bar) &&
Number.isInteger(side.off) &&
Number.isInteger(side.pipCount)
);
}
export function createSettingsStore(
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
): SettingsStore {
const write = (key: string, value: string) => {
try {
storage.setItem(key, value);
} catch {
// Stockage plein ou interdit : l'application marche sans.
}
};
return {
loadSettings() {
try {
return storage.getItem(SETTINGS_KEY);
} catch {
return null;
}
},
saveSettings(json) {
write(SETTINGS_KEY, json);
},
loadBoard() {
try {
const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null");
const valid =
board !== null &&
isSide(board.sides?.player) &&
isSide(board.sides?.computer) &&
Number.isInteger(board.cube?.value) &&
[null, "player", "computer"].includes(board.cube?.owner);
return valid ? board : null;
} catch {
return null;
}
},
saveBoard(board) {
write(BOARD_KEY, JSON.stringify(board));
},
};
}
+983 -189
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -155,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 };
}
+420 -173
View File
@@ -7,7 +7,14 @@
// 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";
@@ -18,6 +25,8 @@ import {
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é. */
@@ -30,8 +39,17 @@ export interface ShownDie {
readonly used: boolean;
}
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
export type DiceAction = "roll" | "confirm";
/** 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. */
export interface BoardDecor {
@@ -44,12 +62,16 @@ export interface BoardDecor {
readonly targets: ReadonlySet<Place>;
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
readonly trace: MoveTrace | null;
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
* sont dessinés. */
/** Ce que fait un toucher sur la bande centrale, d'un côté ou de
* l'autre du plateau (null : rien). En portrait, pour « roll », les dés
* du joueur en attente sont dessinés. */
readonly diceAction: DiceAction | null;
/** Le joueur peut doubler : le videau se touche. */
readonly canDouble: boolean;
/** Paysage : la consigne courte, dans la bande centrale. */
readonly band: string | null;
/** Paysage : la colonne de commandes (null : pas de colonne). */
readonly controls: BoardControls | null;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
@@ -62,30 +84,7 @@ export const NO_MARKS = {
canDouble: false,
} as const;
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
* (640 unités). */
const MIN_TARGET = 88;
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
const WIDTH = 640;
const HEIGHT = 520;
const FRAME = 8;
const POINT_WIDTH = 44;
const HALF_WIDTH = 6 * POINT_WIDTH;
const BAR_LEFT = FRAME + HALF_WIDTH;
const BAR_WIDTH = 40;
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
const TRAY_WIDTH = 48;
const POINT_HEIGHT = 190;
const MIDDLE = HEIGHT / 2;
const RADIUS = 20;
const STEP = 42; // d'un pion au suivant dans une pile
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
const OFF_SLAB = 12; // épaisseur d'un pion sorti
const DIE = 36;
const CUBE = 34;
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -103,11 +102,6 @@ function add(parent: Element, name: string, attributes: Attributes, text?: strin
return element;
}
/** Abscisse du bord gauche d'une colonne (0 à 11). */
function columnLeft(column: number): number {
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
}
/** Un point du dessin. */
export interface Spot {
readonly x: number;
@@ -120,27 +114,24 @@ export interface Spot {
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
*/
function slotCenter(place: Place, index: number): Spot {
function slotCenter(g: Geometry, place: Place, index: number): Spot {
const middle = g.height / 2;
if (place === "bar-player" || place === "bar-computer") {
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step;
return {
x: BAR_LEFT + BAR_WIDTH / 2,
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
x: g.barLeft + g.bar / 2,
y: place === "bar-player" ? middle - offset : middle + offset,
};
}
if (place === "off-player" || place === "off-computer") {
const y =
place === "off-computer"
? FRAME + index * OFF_SLAB
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
? g.edge + index * OFF_SLAB
: g.height - g.edge - (index + 1) * OFF_SLAB;
return { x: g.trayLeft + g.tray / 2, y: y + OFF_SLAB / 2 };
}
const { top, column } = pointSlot(Number(place.slice(1)));
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
return {
x: columnLeft(column) + POINT_WIDTH / 2,
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
};
return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) };
}
/** Le nombre de pions de side à un endroit. */
@@ -155,63 +146,110 @@ function countAt(board: BoardModel, side: Side, place: Place): number {
return point?.owner === side ? point.count : 0;
}
/** Le nombre de pions dessinés au plus à un endroit. */
function stackLimit(g: Geometry, place: Place): number {
return place.startsWith("bar-") ? g.barStack : MAX_STACK;
}
/** Redessine tout le plateau dans svg. */
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
export function renderBoard(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
decor: BoardDecor,
): void {
svg.replaceChildren();
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
drawFrame(svg);
svg.setAttribute("viewBox", `0 0 ${g.width} ${g.height}`);
svg.dataset.layout = g.layout;
drawFrame(svg, g);
const hits = add(svg, "g", {});
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
for (const point of board.points) {
drawPoint(svg, hits, board, point.number, decor);
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
drawBar(svg, hits, board, decor);
drawTray(svg, hits, board, decor);
drawDice(svg, decor.dice);
const cube = drawCube(svg, decor.cube);
drawBar(svg, pieces, g, hits, board, decor);
drawTray(svg, pieces, g, hits, board, decor);
if (decor.trace !== null) {
drawTrace(svg, board, decor.trace);
drawTraceArrows(svg, g, board, decor.trace);
}
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") {
waitingDice(svg);
if (decor.diceAction === "roll" && g.layout === "portrait") {
waitingDice(svg, g);
}
// Une zone sur chaque moitié du plateau : les dés en attente (à
// gauche) comme le dernier jet (à droite) se touchent.
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
// 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(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
}
if (decor.controls !== null) {
drawControls(svg, g, hits, decor.controls);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
function drawFrame(svg: SVGSVGElement): void {
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
for (const left of [FRAME, RIGHT_HALF]) {
function drawFrame(svg: SVGSVGElement, g: Geometry): void {
const landscape = g.layout === "landscape";
add(svg, "rect", {
class: "board-frame",
x: 0,
y: 0,
width: g.width,
height: g.height,
rx: landscape ? 0 : 6,
});
for (const left of [g.x0, g.rightHalf]) {
add(svg, "rect", {
class: "board-bed",
x: left,
y: FRAME,
width: HALF_WIDTH,
height: HEIGHT - 2 * FRAME,
y: g.edge,
width: 6 * g.pointWidth,
height: g.height - 2 * g.edge,
});
}
add(svg, "rect", {
class: "board-tray",
x: TRAY_LEFT,
y: FRAME,
width: TRAY_WIDTH,
height: HEIGHT - 2 * FRAME,
x: g.trayLeft,
y: g.edge,
width: g.tray,
height: g.height - 2 * g.edge,
rx: landscape ? 4 : 0,
});
if (g.left > 0) {
add(svg, "rect", {
class: "board-tray",
x: g.frame,
y: g.edge,
width: g.left - 4,
height: g.height - 2 * g.edge,
rx: 4,
});
}
}
function drawPoint(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
number: number,
@@ -222,38 +260,54 @@ function drawPoint(
return;
}
const { top, column } = pointSlot(number);
const left = columnLeft(column);
const center = left + POINT_WIDTH / 2;
const base = top ? FRAME : HEIGHT - FRAME;
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
const left = columnLeft(g, column);
const center = left + g.pointWidth / 2;
const middle = g.height / 2;
const base = top ? g.edge : g.height - g.edge;
const length = middle - g.edge - g.band / 2 + 6;
const tip = top ? base + length : base - length;
add(svg, "polygon", {
class: pointColor(number),
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
"data-point": number,
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
});
// Numéros : sur le cadre en paysage, dans la bande centrale en
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
// du dessin : c'est une mesure du dessin, comme x et y.
const size = g.numberSize;
let y: number;
if (g.layout === "landscape") {
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
} else {
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
}
add(
svg,
"text",
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
{
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
x: center,
y,
"font-size": size,
},
String(number),
);
// Case d'arrivée possible : la flèche seule est voilée (son triangle) ;
// l'endroit où le pion se posera est marqué plus loin (drawLandings).
if (decor.targets.has(point.place)) {
add(svg, "rect", {
add(svg, "polygon", {
class: "target-zone",
x: left + 2,
y: top ? FRAME : MIDDLE,
width: POINT_WIDTH - 4,
height: HEIGHT / 2 - FRAME,
rx: 4,
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
});
}
if (point.owner !== null) {
drawStack(svg, point.owner, point.count, point.place, decor);
drawStack(pieces, g, point.owner, point.count, point.place, decor);
}
addHit(hits, point.place, decor, {
x: left,
y: top ? 0 : MIDDLE,
width: POINT_WIDTH,
height: MIDDLE,
y: top ? 0 : middle,
width: g.pointWidth,
height: middle,
});
}
@@ -261,13 +315,15 @@ function drawPoint(
* pour les montrer tous. Le pion du dessus porte son endroit dans
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
function drawStack(
svg: SVGSVGElement,
svg: Element,
g: Geometry,
side: Side,
count: number,
place: Place,
decor: BoardDecor,
): void {
const shown = Math.min(count, MAX_STACK);
const limit = stackLimit(g, place);
const shown = Math.min(count, limit);
for (let i = 0; i < shown; i++) {
const last = i === shown - 1;
const classes = [`checker checker-${side}`];
@@ -276,119 +332,270 @@ function drawStack(
} else if (last && decor.movable.has(place)) {
classes.push("movable");
}
const { x, y } = slotCenter(place, i);
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
const { x, y } = slotCenter(g, place, i);
const checker = add(svg, "circle", {
class: classes.join(" "),
cx: x,
cy: y,
r: g.radius,
});
if (last) {
checker.setAttribute("data-checker", place);
}
if (last && count > MAX_STACK) {
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
if (last && count > limit) {
add(
svg,
"text",
{
class: `checker-count count-${side}`,
x,
y: y + g.countFont * 0.35,
"font-size": g.countFont,
},
String(count),
);
}
}
}
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
add(svg, "rect", {
class: "board-bar",
x: BAR_LEFT,
y: 0,
width: BAR_WIDTH,
height: HEIGHT,
});
function drawBar(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
decor: BoardDecor,
): void {
add(svg, "rect", { class: "board-bar", x: g.barLeft, y: 0, width: g.bar, height: g.height });
// Les pions du joueur sur la barre entrent en haut à droite : on les met
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
for (const side of ["player", "computer"] as const) {
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor);
}
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
if (g.layout === "landscape") {
drawChips(svg, g, board);
}
addHit(hits, "bar-player", decor, {
x: g.barLeft,
y: 0,
width: g.bar,
height: g.height / 2,
});
}
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
/** Paysage : les comptes de points en pastilles aux deux bouts de la barre,
* aux couleurs du camp (l'ordinateur en haut, vous en bas). */
function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void {
const width = g.bar - 6;
const h = g.chipHeight;
for (const side of ["computer", "player"] as const) {
const y = side === "computer" ? g.edge + 4 : g.height - g.edge - 4 - h;
add(svg, "rect", {
class: `pip-chip chip-${side}`,
x: g.barLeft + 3,
y,
width,
height: h,
rx: h / 2,
});
add(
svg,
"text",
{
class: `pip-chip-text chip-text-${side}`,
"data-pips": side,
x: g.barLeft + g.bar / 2,
y: y + h / 2 + g.chipFont * 0.35,
"font-size": g.chipFont,
},
String(board.pipCount[side]),
);
}
}
function drawTray(
svg: SVGSVGElement,
pieces: Element,
g: Geometry,
hits: Element,
board: BoardModel,
decor: BoardDecor,
): void {
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
for (const side of ["player", "computer"] as const) {
const count = board.off[side];
for (let i = 0; i < count; i++) {
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
const slab = addSlab(
pieces,
g,
`checker checker-${side}`,
slotCenter(g, `off-${side}`, i),
);
if (i === count - 1) {
slab.setAttribute("data-checker", `off-${side}`);
}
}
}
const middle = g.height / 2;
if (decor.targets.has("off-player")) {
add(svg, "rect", {
class: "target-zone",
x: TRAY_LEFT + 2,
y: MIDDLE,
width: TRAY_WIDTH - 4,
height: MIDDLE - FRAME,
x: g.trayLeft + 2,
y: middle,
width: g.tray - 4,
height: middle - g.edge,
rx: 4,
});
}
addHit(hits, "off-player", decor, {
x: TRAY_LEFT,
y: MIDDLE,
width: TRAY_WIDTH,
height: MIDDLE,
x: g.trayLeft,
y: middle,
width: g.width - g.trayLeft,
height: middle,
});
}
/** Une tranche de pion sorti, centrée en spot. */
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
return add(svg, "rect", {
class: cssClass,
x: TRAY_LEFT + 4,
x: g.trayLeft + 4,
y: spot.y - OFF_SLAB / 2 + 1,
width: TRAY_WIDTH - 8,
width: g.tray - 8,
height: OFF_SLAB - 2,
rx: 3,
});
}
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
function addRing(
svg: Element,
g: Geometry,
cssClass: string,
place: Place,
spot: Spot,
r: number,
): void {
if (place.startsWith("off-")) {
addSlab(svg, cssClass, spot);
addSlab(svg, g, cssClass, spot);
} else {
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
}
}
/** Paysage : la consigne courte, dans la moitié gauche de la bande
* centrale, sur une pastille accent-soft. */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
const middle = g.height / 2;
const width = 6 * g.pointWidth - 24;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
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 : un pion fantôme (contour seul) là où chaque pion
* Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : un pion fantôme (contour seul) là où chaque pion
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
* (sur la barre), et une flèche par demi-coup.
*/
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
function drawTraceRings(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
trace: MoveTrace,
): void {
const side = trace.side;
const R = g.radius;
for (const [place, n] of trace.left) {
// Au-dessus des pions restés sur la case.
const remaining = countAt(board, side, place);
for (let k = 0; k < n; k++) {
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
addRing(svg, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1);
}
}
for (const [place, n] of trace.landed) {
const top = topIndex(board, side, place);
const top = topIndex(g, board, side, place);
for (let k = 0; k < n && top - k >= 0; k++) {
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
addRing(svg, g, "trace-landed", place, slotCenter(g, place, top - k), R + 3);
}
}
const opponent: Side = side === "player" ? "computer" : "player";
const bar: Place = `bar-${opponent}`;
const top = topIndex(board, opponent, bar);
const top = topIndex(g, board, opponent, bar);
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3);
}
}
/** Les flèches d'un coup, une par demi-coup : sous les pions. */
function drawTraceArrows(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
trace: MoveTrace,
): void {
const side = trace.side;
const defs = add(svg, "defs", {});
const marker = add(defs, "marker", {
id: "trace-head",
viewBox: "0 0 10 10",
refX: 8,
refY: 5,
markerWidth: 3.5,
markerHeight: 3.5,
markerWidth: 3,
markerHeight: 3,
orient: "auto",
});
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
@@ -398,20 +605,35 @@ function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): voi
// d'arrivée.
const k = departures.get(from) ?? 0;
departures.set(from, k + 1);
const start = slotCenter(from, countAt(board, side, from) + k);
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
const start = slotCenter(g, from, countAt(board, side, from) + k);
const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0));
drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4);
}
}
/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se
* posera (sur la pile du joueur, ou au pied de la flèche). */
function drawLandings(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
targets: ReadonlySet<Place>,
): void {
for (const place of targets) {
const count = countAt(board, "player", place);
const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1);
addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius);
}
}
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
function topIndex(board: BoardModel, side: Side, place: Place): number {
function topIndex(g: Geometry, board: BoardModel, side: Side, place: Place): number {
const count = countAt(board, side, place);
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
return place.startsWith("off-") ? count - 1 : Math.min(count, stackLimit(g, place)) - 1;
}
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
function drawArrow(svg: SVGSVGElement, g: Geometry, start: Spot, end: Spot, gap: number): void {
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.hypot(dx, dy);
@@ -422,6 +644,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi
y1: start.y,
x2: start.x + dx * keep,
y2: start.y + dy * keep,
"stroke-width": g.arrowWidth,
"marker-end": "url(#trace-head)",
});
}
@@ -440,6 +663,9 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
}
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
/**
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
@@ -464,7 +690,7 @@ export async function slideChecker(
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
{ transform: "translate(0px, 0px)" },
],
{ duration: ms, easing: "ease-in-out" },
{ duration: ms, easing: EASING_STANDARD },
);
await animation.finished.catch(() => undefined);
}
@@ -505,70 +731,90 @@ const PIPS: Record<number, [number, number][]> = {
};
/** Les dés, au milieu de la moitié droite. */
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
const center = RIGHT_HALF + HALF_WIDTH / 2;
function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): void {
const center = g.rightHalf + 3 * g.pointWidth;
const size = g.die;
const gap = 8;
const total = dice.length * DIE + (dice.length - 1) * gap;
const total = dice.length * size + (dice.length - 1) * gap;
dice.forEach((die, i) => {
const x = center - total / 2 + i * (DIE + gap);
const y = MIDDLE - DIE / 2;
const x = center - total / 2 + i * (size + gap);
const y = g.height / 2 - size / 2;
const group = add(svg, "g", {
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
.filter(Boolean)
.join(" "),
"data-value": die.value,
});
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
add(group, "rect", { class: `die-${die.side}`, x, y, width: size, height: size, rx: 6 });
for (const [px, py] of PIPS[die.value] ?? []) {
add(group, "circle", {
class: `pip-${die.side}`,
cx: x + px * DIE,
cy: y + py * DIE,
r: 3.5,
cx: x + px * size,
cy: y + py * size,
r: size / 10,
});
}
if (die.blocked) {
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
add(group, "line", { class: "die-strike", x1: x, y1: y + size, x2: x + size, y2: y });
}
});
}
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
* sinon du côté de son propriétaire. Au centre, il montre 64. */
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
const center = cubeCenter(cube);
const x = center.x - CUBE / 2;
const y = center.y - CUBE / 2;
/** Le videau : dans la colonne des sorties en portrait, dans la colonne de
* commandes en paysage ; au milieu s'il est au centre (il montre alors 64),
* sinon du côté de son propriétaire. */
function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement {
const center = cubeCenter(g, cube);
const size = g.cube;
const x = center.x - size / 2;
const y = center.y - size / 2;
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 });
add(
group,
"text",
{ class: "cube-value", x: center.x, y: center.y + 6 },
{
class: "cube-value",
x: center.x,
y: center.y + g.cubeFont * 0.35,
"font-size": g.cubeFont,
},
String(cube.owner === null ? 64 : cube.value),
);
return group;
}
/** Le centre du videau dans le plateau de sortie. */
function cubeCenter(cube: Cube): Spot {
/** Le centre du videau. */
function cubeCenter(g: Geometry, cube: Cube): Spot {
const middle = g.height / 2;
if (g.layout === "landscape") {
const shift = g.target + 6;
const y =
cube.owner === "computer"
? middle - shift
: cube.owner === "player"
? middle + shift
: middle;
return { x: g.frame + (g.left - 4) / 2, y };
}
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
return { x: g.trayLeft + g.tray / 2, y: middle + shift };
}
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
function waitingDice(svg: SVGSVGElement): void {
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
/** Portrait : les dés du joueur en attente d'être lancés, faces vides, au
* milieu de la moitié gauche (la droite garde le dernier jet de
* l'ordinateur). */
function waitingDice(svg: SVGSVGElement, g: Geometry): void {
const center = g.x0 + 3 * g.pointWidth;
const gap = 8;
const group = add(svg, "g", { class: "die die-waiting" });
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
for (const x of [center - gap / 2 - g.die, center + gap / 2]) {
add(group, "rect", {
class: "die-player",
x,
y: MIDDLE - DIE / 2,
width: DIE,
height: DIE,
y: g.height / 2 - g.die / 2,
width: g.die,
height: g.die,
rx: 6,
});
}
@@ -576,27 +822,28 @@ function waitingDice(svg: SVGSVGElement): void {
/**
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
* valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce
* qu'on touche, avec un rôle et un nom accessibles. Les boutons restent
* pour le clavier.
* jouer le coup, doubler, menu…) : au moins g.target de côté (44 px à
* l'écran), centrée sur ce qu'on touche, avec un rôle et un nom accessibles.
*/
function addAction(
g: Geometry,
hits: Element,
action: string,
label: string,
center: Spot,
width: number,
): void {
const w = Math.max(width, g.target);
add(hits, "rect", {
class: "hit action",
"data-action": action,
role: "button",
"aria-label": label,
// Sans déborder du dessin à droite (le videau est contre le bord).
x: Math.min(center.x - width / 2, WIDTH - width),
y: center.y - MIN_TARGET / 2,
width,
height: MIN_TARGET,
// Sans déborder du dessin (le videau est contre le bord).
x: Math.max(0, Math.min(center.x - w / 2, g.width - w)),
y: center.y - g.target / 2,
width: w,
height: g.target,
});
}
+66
View File
@@ -0,0 +1,66 @@
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
// temps que l'écran rejoigne le menu de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { Side } from "../domain/game/side.js";
const RESULT_KIND: Record<ResultKind, string> = {
single: "partie simple",
gammon: "gammon",
backgammon: "backgammon",
};
function moves(n: number): string {
return n > 1 ? `${n} coups` : `${n} coup`;
}
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const GAMES_TEXTS = {
button: "Mes parties",
title: "Mes parties",
empty: "Aucune partie enregistrée.",
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
confirmRemove: "Supprimer pour de bon (branches comprises)",
shown: "partie affichée",
busy: "Attendez la fin du coup en cours.",
failed: (message: string) => `Erreur : ${message}`,
/** « 3 nov. 2026, 14:05 ». */
date: (ms: number) =>
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
status: (
result: { winner: Side; kind: ResultKind; points: number } | null,
moveCount: number,
) => {
if (result === null) {
return `En cours, ${moves(moveCount)}`;
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
},
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
/** Une branche : d'où elle part et où elle en est. */
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
const start =
forkMoveCount === null
? "Partie d'origine"
: forkMoveCount === 0
? "Rejouée depuis le début"
: `Rejouée après le coup ${forkMoveCount}`;
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
},
currentBranch: " (branche courante)",
};
+249
View File
@@ -0,0 +1,249 @@
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
import { branches } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { GAMES_TEXTS } from "./games-texts.js";
import { icon } from "./icons.js";
export interface GamesViewOptions {
/** Les parties, la dernière jouée d'abord. */
readonly list: () => Promise<SavedGameSummary[]>;
/** Le journal d'une partie (null si elle a disparu). */
readonly journal: (id: string) => Promise<GameJournal | null>;
/** L'identifiant de la partie affichée à l'écran. */
readonly shownId: () => string | null;
/** Reprend une partie, au bout de la branche branchHead si elle est
* donnée (sinon là où elle en était). */
readonly resume: (id: string, branchHead?: string) => Promise<void>;
readonly remove: (id: string) => Promise<void>;
/** Le fichier SGF d'une partie : son nom et son contenu. */
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
}
export interface GamesView {
/** Ouvre la liste, à jour. */
open(): Promise<void>;
close(): void;
readonly isOpen: boolean;
}
/** Crée la feuille « Mes parties » (fermée) dans la page. */
export function createGamesView(options: GamesViewOptions): GamesView {
const veil = element("div", "veil sheet-veil");
veil.id = "games-veil";
veil.hidden = true;
const sheet = element("div", "sheet games");
sheet.id = "games";
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "games-title");
const head = element("div", "sheet-head");
const title = element("h2", "sheet-title");
title.id = "games-title";
title.textContent = GAMES_TEXTS.title;
const close = button("", () => hide(), "icon-button");
close.id = "games-close";
close.setAttribute("aria-label", GAMES_TEXTS.close);
close.append(icon("close"));
head.append(title, close);
const list = element("ul", "games-list");
const message = element("p", "games-message");
message.setAttribute("aria-live", "polite");
sheet.append(head, message, list);
veil.append(sheet);
document.body.append(veil);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
hide();
}
});
function hide(): void {
veil.hidden = true;
}
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => async () => {
message.textContent = "";
try {
await action();
} catch (error) {
message.textContent = GAMES_TEXTS.failed(
error instanceof Error ? error.message : String(error),
);
}
};
async function render(): Promise<void> {
const games = await options.list();
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
if (games.length === 0) {
message.textContent = GAMES_TEXTS.empty;
}
}
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
const item = element("li", "games-item");
item.dataset.id = game.id;
const heading = element("p");
heading.textContent = [
GAMES_TEXTS.date(game.startedAt),
GAMES_TEXTS.status(game.result, game.moveCount),
GAMES_TEXTS.branchCount(game.branchCount),
shown ? GAMES_TEXTS.shown : "",
]
.filter((part) => part !== "")
.join(" · ");
const branchList = element("ul", "games-branches");
branchList.hidden = true;
const actions = element("div", "games-actions");
actions.append(
button(
GAMES_TEXTS.resume,
run(async () => {
await options.resume(game.id);
hide();
}),
"",
"resume",
),
button(
GAMES_TEXTS.showBranches,
run(async () => {
if (branchList.hidden) {
await renderBranches(game.id, branchList);
}
branchList.hidden = !branchList.hidden;
}),
"secondary",
"branches",
),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
"secondary",
"export",
),
// Supprimer se fait en deux touchers, sans boîte du navigateur :
// le premier change le bouton en demande de confirmation.
button(
GAMES_TEXTS.remove,
run(async () => {
const remove = item.querySelector<HTMLElement>('[data-action="remove"]');
if (remove?.dataset.armed !== "true") {
if (remove !== null) {
remove.dataset.armed = "true";
remove.textContent = GAMES_TEXTS.confirmRemove;
}
return;
}
await options.remove(game.id);
await render();
}),
"secondary",
"remove",
),
);
// La partie à l'écran ne se supprime pas : elle continue d'être
// enregistrée.
const remove = actions.querySelector<HTMLElement>('[data-action="remove"]');
if (remove !== null) {
remove.hidden = shown;
}
item.append(heading, actions, branchList);
return item;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
target.replaceChildren(
...all.map((branch) => {
const item = element("li", "games-branch");
item.dataset.head = branch.head;
const text = element("span");
text.textContent =
GAMES_TEXTS.branch(
branch.forkedFrom === null ? null : branch.forkMoveCount,
branch.moveCount,
branch.over,
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
item.append(
text,
button(
GAMES_TEXTS.resumeBranch,
run(async () => {
await options.resume(id, branch.head);
hide();
}),
"secondary",
"resume-branch",
),
);
return item;
}),
);
}
return {
async open() {
message.textContent = "";
await render();
veil.hidden = false;
close.focus();
},
close: hide,
get isOpen() {
return !veil.hidden;
},
};
}
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
): HTMLElementTagNameMap[K] {
const created = document.createElement(tag);
if (className !== "") {
created.className = className;
}
return created;
}
function button(
text: string,
onClick: () => unknown,
className = "",
action = "",
): HTMLButtonElement {
const created = element("button", className);
created.type = "button";
created.textContent = text;
if (action !== "") {
created.dataset.action = action;
}
created.onclick = () => void onClick();
return created;
}
/** Fait télécharger un fichier texte au navigateur. */
function download(file: { fileName: string; text: string }): void {
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
const link = element("a");
link.href = url;
link.download = file.fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
+191
View File
@@ -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;
}
+47
View File
@@ -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 -1
View File
@@ -15,7 +15,11 @@ 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/mes-parties.feature",
"docs/scenarios/regler-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
async function fetchText(url: string): Promise<string> {
@@ -32,6 +36,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,
+45
View File
@@ -0,0 +1,45 @@
// Les pictogrammes de l'interface (composant Icons de la charte,
// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en
// currentColor, dessinés en SVG. Les tracés viennent des maquettes
// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la
// classe « icon » (app.css) : aucun style dans le SVG (CSP).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export const ICON_PATHS = {
menu: "M4 7h16M4 12h16M4 17h16",
help: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5",
exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5",
undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5",
close: "M6 6l12 12M18 6L6 18",
chevron: "M9 6l6 6-6 6",
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
newGame: "M12 5v14M5 12h14",
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
screenOn: "M7 3h10v18H7zM11 18h2",
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;
}
+63
View File
@@ -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;
}
+92 -29
View File
@@ -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,57 +38,118 @@ export const TEXTS = {
double: "Doubler",
take: "Prendre",
drop: "Passer",
confirm: "Valider",
confirm: "Jouer ce coup",
cancel: "Annuler",
skip: "Continuer",
replay: "Revoir",
replayTitle: "Revoir le dernier coup de l'ordinateur",
undoTitle: "Annuler le demi-coup",
newGame: "Nouvelle partie",
reviewGame: "Revoir la partie",
help: "Aide",
menu: "Menu",
fullscreen: "Plein écran",
exitFullscreen: "Quitter le plein écran",
back: "Retour à la partie",
close: "Fermer",
/** Ce que fait un toucher sur les dés (nom accessible de la zone). */
diceActions: {
roll: "Lancer les dés",
confirm: "Jouer ce coup",
skip: "Passer votre tour",
},
moveHint: "Le coup fini, touchez les dés pour le jouer.",
// Menu
helpAndGlossary: "Aide et glossaire",
theme: "Thème",
themes: { auto: "Automatique", light: "Clair", dark: "Sombre" },
vibrations: "Vibrations",
keepScreenOn: "Écran allumé pendant la partie",
newGameStarted: "Nouvelle partie commencée.",
resumeOld: "Reprendre l'ancienne",
// Bandeaux des joueurs et compte de points
you: "Vous",
computer: "Ordinateur",
yourTurn: "à vous de jouer",
computerTurn: "joue",
lead: (n: number) => `mène de ${n}`,
pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`,
pipCountFile: "compte-de-points.md",
seeGlossary: "Voir le glossaire",
// Chargement
loading: "Chargement du moteur…",
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
actionFailed: (message: string) => `Erreur : ${message}`,
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
// Déroulement
// Déroulement (ligne d'information)
opening: (player: number, computer: number) =>
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
player > computer ? "Vous commencez." : "L'ordinateur commence."
}`,
yourRoll: "À vous : doublez ou lancez les dés.",
yourRollNoDouble: "À vous : lancez les dés.",
yourMove: (dice: readonly number[]) =>
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
confirmOrUndo:
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
thinking: "L'ordinateur réfléchit…",
computerMoved: (dice: readonly number[], move: string, ms: number) =>
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
computerMoved: (dice: readonly number[], move: string) =>
`L'ordinateur a joué ${roll(dice)} : ${move}.`,
computerCannotMove: (dice: readonly number[]) =>
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
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",
+172
View File
@@ -0,0 +1,172 @@
// Tests du domaine : les branches d'une partie et son export SGF, sur des
// journaux écrits à la main (sans moteur, sauf un faux pour une position de
// départ particulière). La relecture des SGF par le vrai GNU Backgammon est
// faite par tests/test_web.sh (tests/sgf/check.py).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { branches, switchToBranch } from "../src/domain/game/branches.js";
import type { Board, Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
type GameJournal,
goBackTo,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { exportSgf } from "../src/domain/game/sgf.js";
function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal {
return events.reduce(appendEvent, journal);
}
const start: NewGameEvent = {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
};
const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({
type: "MovePlayed",
side,
halfMoves: parseHalfMoves(text),
position,
});
const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({
type: "DiceRolled",
side,
roll: [a, b],
});
/**
* Une partie et ses branches :
* 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4,
* 5 coup 24/18 13/9, 6 dés joueur 2-1 ;
* rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même
* ouverture, autre coup) : 8 coup 24/23 13/10.
*/
function branchedGame(): GameJournal {
let j = add(
EMPTY_JOURNAL,
start,
roll(null, 3, 1),
move("player", "8/5 6/5"),
roll("computer", 6, 4),
move("computer", "24/18 13/9"),
roll("player", 2, 1),
);
j = add(goBackTo(j, "3"), roll("computer", 5, 5));
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
return j;
}
test("les branches : feuilles de l'arbre, avec leur point de départ", () => {
const j = branchedGame();
const all = branches(j);
assert.deepEqual(
all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]),
[
["6", null, 0, 2, false],
["7", "3", 1, 1, false],
["8", "2", 0, 1, true],
],
);
assert.deepEqual(branches(EMPTY_JOURNAL), []);
// Passer d'une branche à l'autre ne perd rien.
const back = switchToBranch(j, "6");
assert.equal(back.events, j.events);
assert.deepEqual(
currentLine(back).map((e) => e.id),
["1", "2", "3", "4", "5", "6"],
);
assert.throws(() => switchToBranch(j, "4"), /branche introuvable/);
});
test("une branche rejouée puis continuée garde son ordre de création", () => {
// La ligne d'origine continue après la création d'une branche : elle
// reste la première.
let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"));
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
j = add(goBackTo(j, "3"), roll("computer", 6, 4));
assert.deepEqual(
branches(j).map((b) => [b.head, b.forkedFrom]),
[
["5", null],
["4", "2"],
],
);
});
/** Un faux moteur qui ne sait que décoder un plateau. */
function boardEngine(board: Board): Engine {
return { board: async () => board } as unknown as Engine;
}
const noEngine = boardEngine({} as Board);
const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" };
test("SGF : les coups, les variantes, la branche courante d'abord", async () => {
const sgf = await exportSgf(noEngine, branchedGame(), names);
assert.equal(
sgf,
"(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" +
"PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" +
// Branche courante : même ouverture, autre coup.
"\n;B[31ablo])(" +
// Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)).
"\n;B[31qtst](" +
// Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et
// des dés lancés, pas joués.
"\n;W[64xrmi]\n;PL[B]DI[21])(" +
"\n;PL[W]DI[55])))\n",
);
});
test("SGF : barre, sortie, coup impossible, videau et résultat", async () => {
const j = add(
EMPTY_JOURNAL,
start,
roll(null, 1, 6),
move("computer", "bar/19 6/off"),
{ type: "CubeOffered", side: "player" },
{ type: "CubeTaken", side: "computer" },
roll("player", 6, 6),
move("player", ""),
{ type: "CubeOffered", side: "computer" },
{ type: "CubeDropped", side: "player" },
{ type: "GameEnded", winner: "computer", kind: "single", points: 2 },
);
const sgf = await exportSgf(noEngine, j, names);
assert.ok(sgf.includes("RE[W+2]"));
assert.ok(
sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"),
sgf,
);
});
test("SGF : une position de départ particulière est posée", async () => {
// Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ;
// l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur.
const points = (entries: Record<number, number>) =>
Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0);
const engine = boardEngine({
onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 },
opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 },
swappedPosition: "X",
result: null,
});
const j = add(EMPTY_JOURNAL, {
type: "GameStarted",
position: "ABC",
cube: { value: 4, owner: "computer" },
firstTurn: "player",
});
const sgf = await exportSgf(engine, j, names);
assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf);
await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/);
});
+126
View File
@@ -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"],
);
});
+10
View File
@@ -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,
};
}
+271
View File
@@ -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-]+$/);
}
});
+23 -1
View File
@@ -19,6 +19,7 @@ import {
} from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { boardModel, pipLead } from "../../src/ui/board-model.js";
import { loadNodeEngine } from "../support/node-engine.js";
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
@@ -283,7 +284,7 @@ Then("c'est toujours à vous", () => {
assert.equal(lastEvent().type, "DiceRolled");
});
When("vous continuez", async () => {
When("vous laissez passer votre tour", async () => {
await game.skipTurn();
const event = lastEvent();
assert.ok(event.type === "MovePlayed" && event.side === "player");
@@ -344,6 +345,27 @@ 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
+203
View File
@@ -0,0 +1,203 @@
// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles
// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le
// stockage des parties sur un localStorage simulé en mémoire. Fermer puis
// rouvrir l'application revient à rouvrir ce stockage et à reprendre la
// partie en cours, comme main.ts au démarrage.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { exportSgf } from "../../src/domain/game/sgf.js";
import type { GameLibrary } from "../../src/storage/game-library.js";
import type { KeyValueStorage } from "../../src/storage/key-value-backend.js";
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, seededDice } from "../support/play.js";
let engine: Engine;
let storage: KeyValueStorage;
let library: GameLibrary;
let game: Game;
let shownId: string;
/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */
let played: { id: string; journal: GameJournal }[];
/** La partie d'origine, avant « Rejouer d'ici ». */
let original: { head: string; ids: string[] };
/** Le coup du joueur d'où l'on rejoue. */
let replayed: MovePlayed;
let sgf: string;
let nextSeed = 1;
Before(async () => {
engine = await loadNodeEngine();
storage = memoryStorage();
library = (await openGameLibrary({ localStorage: storage })).library;
played = [];
});
/** Commence une partie, comme l'application : un nouvel identifiant. */
function startGame(): void {
game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 });
shownId = `partie-${nextSeed++}`;
}
/** Joue des coups, en enregistrant après chacun comme l'application. */
async function play(moves: number): Promise<void> {
for (let i = 0; i < moves; i++) {
await playMoves(game, engine, 1);
await library.save(shownId, game.journal, { level: "0ply" });
}
}
async function reopen(): Promise<void> {
library = (await openGameLibrary({ localStorage: storage })).library;
const saved = await library.current();
assert.ok(saved !== null, "aucune partie en cours");
game = Game.resume(engine, saved.journal, { ply: 0 });
shownId = saved.summary.id;
}
/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses
* dés déjà lancés). Renvoie ce coup. */
function goBackToSecondMove(): MovePlayed {
original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) };
const second = game.events.filter(
(e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player",
)[1];
assert.ok(second?.parent != null, "moins de deux coups du joueur");
game.goBackTo(second.parent);
return second;
}
/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */
async function playAnotherMove(first: MovePlayed): Promise<void> {
const state = game.state;
assert.ok(state.roll !== null);
const moves = await engine.legalMoves(state.position, state.roll);
// Move.position est vue de celui qui joue, MovePlayed.position de son
// adversaire : on compare les demi-coups.
const same = (m: (typeof moves)[number]) =>
m.halfMoves.length === first.halfMoves.length &&
m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from);
const move = moves.find((m) => !same(m));
assert.ok(move !== undefined, "un seul coup possible");
for (const halfMove of move.halfMoves) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
await library.save(shownId, game.journal, { level: "0ply" });
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed" && last.position !== first.position);
}
// ------------------------------------------- Reprendre une partie parmi plusieurs
Given("vous avez joué deux parties, l'une après l'autre", async () => {
for (const moves of [5, 3]) {
startGame();
await play(moves);
played.push({ id: shownId, journal: game.journal });
}
});
When("vous fermez l'application puis la rouvrez", reopen);
Then("la dernière partie jouée est à l'écran", () => {
const last = played.at(-1);
assert.equal(shownId, last?.id);
assert.deepEqual(game.journal, last?.journal);
});
Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => {
const ids = (await library.list()).map((s) => s.id);
assert.deepEqual(ids, played.map((p) => p.id).reverse());
});
When("vous reprenez la première partie", async () => {
await library.setCurrent(played[0]?.id as string);
await reopen();
});
Then("elle est à l'écran, telle que vous l'aviez laissée", () => {
assert.equal(shownId, played[0]?.id);
assert.deepEqual(game.journal, played[0]?.journal);
});
// ------------------------------------------- Rejouer d'ici sans perdre l'original
Given("vous avez joué plusieurs coups", async () => {
startGame();
await play(8);
});
When("vous rejouez d'ici à partir de votre deuxième coup", () => {
// Le retour seul : le coup suivant ouvrira la branche.
replayed = goBackToSecondMove();
});
When("vous choisissez un autre coup", async () => {
await playAnotherMove(replayed);
});
Then("la partie a deux branches", () => {
assert.equal(game.branches.length, 2);
});
Then("la partie d'origine est intacte", () => {
const origin = game.branches.find((b) => b.forkedFrom === null);
assert.equal(origin?.head, original.head);
const copy = Game.resume(engine, game.journal);
copy.switchBranch(original.head);
assert.deepEqual(
copy.events.map((e) => e.id),
original.ids,
);
});
Then("vous êtes sur la nouvelle branche", () => {
const current = game.branches.find((b) => b.containsHead);
assert.ok(current !== undefined && current.forkedFrom !== null);
assert.equal(current.head, game.journal.head);
});
Then("vous pouvez revenir à la partie d'origine, au coup près", () => {
const branch = game.branches.find((b) => b.forkedFrom === null);
assert.ok(branch !== undefined);
game.switchBranch(branch.head);
assert.deepEqual(
game.events.map((e) => e.id),
original.ids,
);
});
// ------------------------------------------------------------ Exporter une partie
Given("vous avez rejoué d'ici une autre suite", async () => {
await playAnotherMove(goBackToSecondMove());
await play(2);
});
When("vous exportez la partie", async () => {
sgf = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
});
});
Then("vous obtenez un fichier SGF de GNU Backgammon", () => {
assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40));
assert.ok(sgf.trimEnd().endsWith(")"));
});
Then("il contient vos coups, et l'autre branche en variante", () => {
// Un nœud par coup joué, toutes branches comprises (B ou W, puis les
// dés), et deux variantes à l'embranchement.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length;
assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves);
assert.equal(sgf.match(/\(\n;/g)?.length, 2);
});
@@ -0,0 +1,165 @@
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
// la partie reprise passe par le domaine et le vrai moteur, et par le même
// enregistrement que l'application ; les réglages et les vibrations par les
// modules de l'interface qui les portent (settings.ts, haptics.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { BANNER_MS } from "../../src/ui/app.js";
import { createHaptics } from "../../src/ui/haptics.js";
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
let engine: Engine;
let game: Game;
let oldJournal: GameJournal;
let oldBoard: unknown;
let settings: Settings;
let vibrations: number[][] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
// -------------------------------------------------- Nouvelle partie annulable
// Comme dans l'application : chaque partie est enregistrée par la session
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
// stockage par son identifiant.
let library: GameLibrary;
let session: GameSession;
let oldId: string | null;
Given("une partie est en cours depuis quelques coups", async () => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
session = createGameSession(
library,
{
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
},
"2ply",
);
game = session.newGame();
dice = () => 1 + Math.floor(Math.random() * 6);
for (const halfMove of parseHalfMoves("8/5 6/5")) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
await game.playComputerTurn();
session.save(game.journal);
oldId = session.shownId;
assert.equal(game.state.turn, "player");
});
When("vous commencez une nouvelle partie depuis le menu", async () => {
oldJournal = game.journal;
oldBoard = await game.displayedBoard();
game = session.newGame();
session.save(game.journal);
});
Then("une nouvelle partie commence aussitôt", () => {
assert.equal(game.state.position, START_POSITION);
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
});
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
assert.equal(BANNER_MS, seconds * 1000);
});
When("vous reprenez l'ancienne partie", async () => {
const old = await session.resumePrevious();
assert.ok(old !== null);
game = old;
session.save(game.journal);
});
Then("vous retrouvez exactement la partie quittée", async () => {
assert.equal(session.shownId, oldId);
assert.deepEqual((await library.current())?.journal, oldJournal);
// La nouvelle partie, abandonnée, est supprimée.
assert.deepEqual(
(await library.list()).map((s) => s.id),
[oldId],
);
assert.deepEqual(game.journal, oldJournal);
assert.deepEqual(await game.displayedBoard(), oldBoard);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
// --------------------------------------------------------------- Réglages
Given("vous ouvrez l'application pour la première fois", () => {
settings = parseSettings(null);
vibrations = [];
});
const haptic = createHaptics(
(pattern) => {
vibrations.push(pattern);
return true;
},
() => settings.vibrations,
);
When("un pion est frappé", () => {
vibrations = [];
haptic("checkerHit");
});
Then("le téléphone vibre deux fois, brièvement", () => {
assert.deepEqual(vibrations, [[25, 40, 25]]);
});
When("vous coupez les vibrations dans le menu", () => {
settings = { ...settings, vibrations: false };
});
Then("le téléphone ne vibre pas", () => {
assert.deepEqual(vibrations, []);
});
Then("l'écran reste allumé pendant une partie", () => {
assert.equal(settings.keepScreenOn, true);
});
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
});
Then("l'écran peut se mettre en veille comme d'habitude", () => {
assert.equal(settings.keepScreenOn, false);
});
Then("le thème suit celui du téléphone", () => {
assert.equal(settings.theme, "auto");
});
When("vous changez de thème dans le menu", () => {
// Rien : l'étape suivante fait le tour des thèmes.
});
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
const seen = [settings.theme];
for (let i = 0; i < 3; i++) {
seen.push(nextTheme(seen.at(-1) ?? "auto"));
}
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
});
+267
View File
@@ -0,0 +1,267 @@
// Tests du stockage des parties (src/storage/), sans navigateur : la
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
// de localStorage), la migration de la partie des versions précédentes et
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
// navigateur (tests/browser/play.mjs).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { branches } from "../src/domain/game/branches.js";
import type { Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
goBackTo,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
import { createGameSession } from "../src/storage/game-session.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "../src/storage/key-value-backend.js";
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
/** Une partie commencée, et éventuellement quelques événements de plus. */
function journal(extraRolls = 0): GameJournal {
let j = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
for (let i = 0; i < extraRolls; i++) {
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
}
return j;
}
/** Une horloge qui avance d'une seconde à chaque lecture. */
function clock(start = 1_000_000): () => number {
let t = start;
return () => {
t += 1000;
return t;
};
}
function library(storage: KeyValueStorage = memoryStorage()) {
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
}
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
const games = library();
assert.deepEqual(await games.list(), []);
assert.equal(await games.current(), null);
const a = await games.save("a", journal(), { level: "2ply" });
await games.save("b", journal(1), { level: "beginner" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b", "a"],
);
assert.equal(a.status, "playing");
assert.equal(a.result, null);
assert.equal(a.moveCount, 0);
assert.equal(a.branchCount, 1);
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
const again = await games.save("a", journal(2), { level: "2ply" });
assert.equal(again.startedAt, a.startedAt);
assert.ok(again.updatedAt > a.updatedAt);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["a", "b"],
);
assert.deepEqual((await games.load("a"))?.journal, journal(2));
assert.equal((await games.load("b"))?.summary.level, "beginner");
assert.equal(await games.load("inconnue"), null);
});
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
const games = library();
await games.save("ancienne", journal(), { level: "2ply" });
await games.save("nouvelle", journal(1), { level: "2ply" });
assert.equal((await games.current())?.summary.id, "nouvelle");
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
// être modifiée ; la nouvelle peut être supprimée.
const before = await games.load("ancienne");
await games.setCurrent("ancienne");
await games.remove("nouvelle");
assert.deepEqual(await games.current(), before);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["ancienne"],
);
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
// La partie en cours supprimée : la dernière jouée la remplace.
await games.save("autre", journal(), { level: "2ply" });
await games.setCurrent("ancienne");
await games.remove("ancienne");
assert.equal((await games.current())?.summary.id, "autre");
});
test("les opérations sont faites dans l'ordre des appels", async () => {
const games = library();
// Lancées sans attendre, comme l'enregistrement après chaque action.
void games.save("a", journal(), { level: "2ply" });
void games.save("a", journal(3), { level: "2ply" });
void games.remove("a");
void games.save("b", journal(), { level: "2ply" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b"],
);
});
test("les branches et le résultat sont résumés", async () => {
const games = library();
let j = journal(2);
j = goBackTo(j, "2");
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
j = appendEvent(j, {
type: "MovePlayed",
side: "player",
halfMoves: [],
position: START_POSITION,
});
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
const summary = await games.save("x", j, { level: "2ply" });
assert.equal(summary.branchCount, 2);
assert.equal(summary.moveCount, 1);
assert.equal(summary.status, "over");
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
});
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
const storage = memoryStorage();
const games = library(storage);
await games.save("a", journal(), { level: "2ply" });
storage.setItem("ustagammon.game.a", "{pas du JSON");
assert.equal(await games.load("a"), null);
assert.equal(isJournal({ events: [], head: 3 }), false);
assert.equal(isJournal(null), false);
const full = library({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
removeItem: () => {},
});
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
// La bibliothèque reste utilisable après un échec.
assert.deepEqual(await full.list(), []);
});
test("la partie des versions précédentes est migrée, une seule fois", async () => {
const storage = memoryStorage();
const old = journal(1);
storage.setItem("ustagammon.current-game", JSON.stringify(old));
const games = library(storage);
const id = await migrateLegacyGame(storage, games, 42);
assert.ok(id !== null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
const current = await games.current();
assert.equal(current?.summary.id, id);
assert.deepEqual(current?.journal, old);
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal((await games.list()).length, 1);
// Un ancien enregistrement illisible est seulement effacé.
storage.setItem("ustagammon.current-game", "{pas du JSON");
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
assert.equal((await games.list()).length, 1);
});
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
const storage = memoryStorage();
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
const opened = await openGameLibrary({ localStorage: storage });
assert.equal(opened.kind, "localStorage");
assert.equal((await opened.library.list()).length, 1);
const refused: KeyValueStorage = {
getItem: () => {
throw new Error("interdit");
},
setItem: () => {
throw new Error("interdit");
},
removeItem: () => {
throw new Error("interdit");
},
};
const memory = await openGameLibrary({ localStorage: refused });
assert.equal(memory.kind, "memory");
await memory.library.save("a", journal(), { level: "2ply" });
assert.equal((await memory.library.current())?.summary.id, "a");
// IndexedDB qui échoue à l'ouverture : repli.
const broken = {
open: () => {
throw new Error("indisponible");
},
} as unknown as IDBFactory;
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
});
test("les identifiants de partie sont distincts", () => {
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
assert.ok(ids.size > 95);
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
});
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
const games = library();
// Game.start et Game.resume n'interrogent pas le moteur.
const engine = {} as Engine;
const session = createGameSession(
games,
{
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
},
"2ply",
);
assert.equal(await session.restore(), null);
assert.equal(await session.resumePrevious(), null);
const first = session.newGame();
const firstId = session.shownId as string;
session.save(first.journal);
const second = session.newGame();
session.save(second.journal);
assert.notEqual(session.shownId, firstId);
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
const back = await session.resumePrevious();
assert.deepEqual(back?.journal, first.journal);
assert.equal(session.shownId, firstId);
assert.deepEqual(
(await games.list()).map((s) => s.id),
[firstId],
);
assert.equal(await session.resumePrevious(), null);
// Une branche reprise devient la ligne courante enregistrée.
let j = goBackTo(first.journal, "1");
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
await games.save(firstId, j, { level: "2ply" });
const original = branches(j).find((b) => b.forkedFrom === null);
assert.ok(original !== undefined);
const opened = await session.open(firstId, original.head);
assert.equal(opened.journal.head, original.head);
assert.equal((await games.current())?.journal.head, original.head);
await assert.rejects(session.open("inconnue"), /introuvable/);
});
+86
View File
@@ -0,0 +1,86 @@
// Jouer une partie du domaine sans interface, pour les tests, les
// scénarios et les parties d'exemple : les deux camps, avec des dés
// reproductibles.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Engine } from "../../src/domain/game/engine.js";
import type { Game } from "../../src/domain/game/game.js";
import type { DiceSource } from "../../src/domain/game/roll.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
export function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/**
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
* doubles si take, et double quand il le peut si double.
*/
export async function step(
game: Game,
engine: Engine,
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
): Promise<void> {
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
if (strategy.take ?? true) {
game.take();
} else {
game.drop();
}
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
if (strategy.double === true && (await game.canDouble())) {
await game.double();
} else {
await game.roll();
}
} else if (state.phase === "moving" && state.roll !== null) {
const moves = await engine.legalMoves(state.position, state.roll);
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
if (move === undefined) {
await game.skipTurn();
return;
}
for (const halfMove of move.halfMoves) {
if (!(await game.playHalfMove(halfMove))) {
throw new Error("coup légal refusé");
}
}
await game.confirmMove();
} else {
throw new Error(`phase inattendue : ${state.phase}`);
}
}
/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */
export async function playMoves(
game: Game,
engine: Engine,
count: number,
choice = 0,
): Promise<void> {
let played = 0;
while (played < count && game.state.phase !== "over") {
const before = game.events.length;
await step(game, engine, { choice });
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
}
}
export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
for (let i = 0; game.state.phase !== "over"; i++) {
if (i > 2000) {
throw new Error("la partie ne finit pas");
}
await step(game, engine, { choice });
}
}
+139
View File
@@ -0,0 +1,139 @@
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
// Backgammon de référence (tests/test_sgf_reference.sh).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
//
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
// parties variées : complètes, avec branches, avec videau, depuis une
// position particulière. Pour chaque branche de chaque partie, écrit
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Game } from "../../src/domain/game/game.js";
import { exportSgf } from "../../src/domain/game/sgf.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js";
/** Le dernier événement « coup joué » de la ligne courante. */
function lastMoveId(game: Game, skip = 0): string {
const moves = game.events.filter((event) => event.type === "MovePlayed");
const move = moves.at(-1 - skip);
if (move === undefined) {
throw new Error("aucun coup joué");
}
return move.id;
}
async function main(): Promise<void> {
const out = process.argv[2];
if (out === undefined) {
throw new Error("usage : sgf-samples.js <dossier>");
}
mkdirSync(out, { recursive: true });
const engine = await loadNodeEngine();
const games: [string, Game][] = [];
// Une partie complète, puis une autre où le joueur joue autrement.
for (const [seed, choice] of [
[3, 0],
[11, 2],
] as const) {
const game = Game.start(engine, { dice: seededDice(seed), ply: 0 });
await playToTheEnd(game, engine, choice);
games.push([`complete-${seed}`, game]);
}
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
// jet du joueur (même jet, autre coup), et une branche d'une branche.
{
const game = Game.start(engine, { dice: seededDice(5), ply: 0 });
await playMoves(game, engine, 14);
const original = game.events.at(-1)?.id as string;
game.goBackTo(lastMoveId(game, 6));
await playMoves(game, engine, 8);
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
if (rolled === undefined) {
throw new Error("aucun jet du joueur");
}
game.goBackTo(rolled.id);
await playMoves(game, engine, 5, 1);
game.switchBranch(original);
await playToTheEnd(game, engine);
games.push(["branches", game]);
}
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
{
const game = Game.start(engine, { dice: seededDice(9), ply: 0 });
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
await step(game, engine, { double: true });
}
games.push(["player-doubles", game]);
}
// Depuis une position particulière : l'ordinateur, au trait, double
// (position de référence « cube-double-take ») ; le joueur prend dans
// une branche, passe dans une autre.
{
const game = Game.start(engine, {
dice: seededDice(13),
ply: 0,
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
});
await step(game, engine);
const offered = game.events.at(-1)?.id as string;
await playMoves(game, engine, 6);
game.goBackTo(offered);
await step(game, engine, { take: false });
games.push(["setup-cube", game]);
}
// Depuis une position particulière, videau déjà doublé chez le joueur,
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
{
const game = Game.start(engine, {
dice: seededDice(17),
ply: 0,
setup: {
position: "t+0OAADg/wcAQA",
firstTurn: "player",
cube: { value: 2, owner: "player" },
},
});
await playMoves(game, engine, 4);
await game.roll();
games.push(["setup-unfinished", game]);
}
let files = 0;
for (const [name, game] of games) {
const original = game.journal;
for (const [i, branch] of game.branches.entries()) {
game.switchBranch(branch.head);
const sgf = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ordinateur",
date: "2026-11-03",
});
// Après chaque coup, la position vue de celui qui a alors la main,
// et le résultat.
const expected = {
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
result: game.state.result,
};
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
files++;
}
game.goBackTo(original.head as string);
}
console.log(`${files} parties exportées dans ${out}`);
}
await main();
+9 -41
View File
@@ -1,6 +1,6 @@
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
// l'aide, l'enregistrement de la partie.
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -9,9 +9,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { parseHalfMoves } from "../src/domain/game/position.js";
import {
boardModel,
moveTargets,
@@ -176,44 +174,14 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
const data = new Map<string, string>();
const storage = {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
};
const store = createGameStore(storage);
assert.equal(store.load(), null);
const journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
store.save(journal);
assert.deepEqual(store.load(), journal);
for (const key of data.keys()) {
data.set(key, "{pas du JSON");
}
assert.equal(store.load(), null);
// Un stockage plein ou interdit ne casse pas la partie.
const full = createGameStore({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.save(journal));
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
assert.equal(
TEXTS.youLose("single", 1),
"L'ordinateur gagne la partie simple : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",
);
});
@@ -94,3 +94,15 @@ les justifie pas.
dépendances seront fixés avec le socle, à la fin de l'étape 0.
- Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y
renvoie.
## Amendements
### 2026-11-03 — Le stockage des parties est un format durable
Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base
« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les
clés `localStorage` de l'application (`ustagammon.settings`,
`ustagammon.last-board`, et le repli des parties) rejoignent le journal
d'événements parmi les formats durables : toute modification passe par une
montée de version avec migration testée, jamais par une réécriture qui
perdrait les parties des joueurs.
+38
View File
@@ -47,3 +47,41 @@ moteur sait les lire et les écrire (`engine.h`).
- 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.
+5 -4
View File
@@ -17,8 +17,8 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| Sous-étape | Contenu | Dépend de |
| --- | --- | --- |
| 1a. Moteur pour l'analyse | 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 | — |
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 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 |
@@ -43,8 +43,9 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
- **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** : proposés d'après ceux de GNU Backgammon
(douteux 0,04, mauvais 0,08, très mauvais 0,16 d'équité), à calibrer.
- **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
+2 -3
View File
@@ -23,9 +23,8 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` |
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `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 | ⏳ | |
| 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
+5
View File
@@ -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,7 +54,9 @@ 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 |
| [Partie enregistrée](partie-enregistree.md) | Saved game |
| [Passer](passer.md) | Drop |
| [Pion](pion.md) | Checker |
| [Plateau](plateau.md) | Board |
@@ -61,5 +65,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Prendre](prendre.md) | Take |
| [Règle de Crawford](regle-de-crawford.md) | Crawford rule |
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
| [SGF](sgf.md) | SGF |
| [Sortie](sortie.md) | Bear-off |
| [Videau](videau.md) | Cube |
+14
View File
@@ -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)
+6 -1
View File
@@ -1,9 +1,14 @@
---
term: Branche
english: Branch
code: Branch
---
# Branche
**En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte.
Voir aussi : [Événement de partie](evenement-de-partie.md)
Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à
l'autre depuis « Mes parties », et l'export SGF les contient toutes, en
variantes.
Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md)
+15
View File
@@ -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)
+18
View File
@@ -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)
+14
View File
@@ -0,0 +1,14 @@
---
term: Partie enregistrée
english: Saved game
code: SavedGameSummary
---
# Partie enregistrée
**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ».
Chaque partie est enregistrée après chacune de vos actions : fermer
l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous
exportez une partie.
Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md)
+13
View File
@@ -0,0 +1,13 @@
---
term: SGF
english: SGF
---
# SGF
**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser.
SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches
d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche
courante.
Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md)
+1
View File
@@ -14,6 +14,7 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
| Fichier | Contenu |
| --- | --- |
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
| `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 |
@@ -0,0 +1,174 @@
# 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).
## Liens (ADR 0013, amendement : une adresse par écran)
Chaque élément qui mène ailleurs est un vrai lien (`<a href>`), qu'on peut
ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| Élément | Adresse |
| --- | --- |
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
- Les segments de la barre de progression du tutoriel sont des liens vers les
étapes déjà vues, pas vers les suivantes.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
@@ -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.
+46
View File
@@ -0,0 +1,46 @@
# Fin de partie et analyse (1e)
- **Date** : 2026-11-03
- **Étape** : 1e (`docs/etape-1.md`)
- **Statut** : prévu (après 1d)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
## Consignes
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
- le taux d'erreur à la manière de gnubg ;
- la chance aux dés (`luck`) pour chaque camp ;
- le décompte des coups par appréciation.
2. **Bilan**, dans le panneau de fin de partie existant :
- le résultat ;
- les coups par appréciation ;
- le taux d'erreur comparé à la chance, en phrases simples ;
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
- un emplacement pour le bouton « Demander à une IA » (1f) ;
- « Voir l'analyse ».
3. **Écran d'analyse** :
- un graphe d'équité où toucher un point ouvre la position ;
- la liste des erreurs, filtrable par appréciation ;
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
4. **Tests** :
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
## Critères de réussite
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
- L'analyse s'ouvre par son adresse.
- « Rejouer d'ici » crée une branche.
- Les trois commandes passent.
## Résultat
À remplir à la clôture.
@@ -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.
+36
View File
@@ -0,0 +1,36 @@
# Cinq premières leçons et accueil complet (1g)
- **Date** : 2026-11-03
- **Étape** : 1g (`docs/etape-1.md`) ; le tutoriel des règles est traité en 0e.5
- **Statut** : prévu (après 0e.5 et 1d)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque leçon est faite d'écrans courts : une explication, une position animée, puis « à vous de jouer ». La progression est stockée localement, et l'accueil propose « Continuer la leçon ».
## Consignes
1. **Contenus.** Ils vivent dans une source unique sous `docs/lecons/`, sous licence CC BY-SA 4.0, au même format que le tutoriel de 0e.5 (réutiliser son moteur d'écrans). Cinq leçons du parcours 2 du cadrage, au niveau débutant :
1. Les ouvertures : bien jouer ses premiers dés.
2. Construire ses cases et faire des points.
3. Frapper ou ne pas frapper : sécurité et risque.
4. La course et le compte de points.
5. Sortir ses pions sans se faire frapper.
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
5. **Tests.**
- Pour chaque exercice, la position est valide et la réponse attendue est bien le meilleur coup du moteur.
- Scénarios Gherkin.
- `make lint`, `make test`, `make test-browser`, avec captures.
## Critères de réussite
Les cinq leçons se suivent jusqu'au bout. Chaque exercice accepte le coup du moteur et commente les autres avec bienveillance. 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.
-60
View File
@@ -1,60 +0,0 @@
# Moteur pour l'analyse et les niveaux (1a)
- **Date** : 2026-11-03
- **Étape** : 1a (`docs/etape-1.md`)
- **Statut** : en cours
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Donner au domaine tout ce dont l'étape 1 a besoin du moteur : niveaux de jeu de
l'ordinateur, analyse d'un coup joué et d'une décision de videau, chance d'un
jet. Tout vient de GNU Backgammon, comparé à gnubg de bureau.
## Consignes
ADR 0005, 0007 et la méthode de comparaison de `tests/reference/` (README).
Ne pas modifier `engine/gnubg/` (copie fidèle). Pas de nouvelle dépendance.
1. **Niveaux de jeu** : les niveaux de GNU Backgammon (débutant, amateur,
intermédiaire, avancé, expert, champion du monde…), avec leurs plis, filtres
et bruit, repris des réglages prédéfinis de gnubg (pas inventés). Le bruit
doit être déterministe (option « deterministic » de gnubg) pour que les
tests soient reproductibles. API C (`engine.h`), `engine-cli`, adaptateur
WebAssembly et interface `Engine` du domaine (ajout seulement : ne pas
toucher au reste de `app/src/domain/game/`, qu'un autre agent modifie).
2. **Analyse d'un coup joué** : pour une position, un jet et un coup joué,
l'équité du coup joué, celle du meilleur coup, l'écart, et le rang du coup
joué, au niveau d'analyse demandé (comme l'analyse de partie de gnubg).
3. **Analyse d'une décision de videau** : équités sans double, double/prend,
double/passe, et l'écart de la décision prise (double, pas de double,
prendre, passer), comme gnubg.
4. **Chance d'un jet** : la chance (luck) d'un jet au sens de gnubg, et son
classement (très chanceux… très malchanceux) avec les seuils de gnubg.
5. **Seuils d'appréciation** : exposer les seuils de gnubg (douteux, mauvais,
très mauvais) comme constantes documentées ; l'interface les traduira.
6. **Références** : ajouter des positions de référence évaluées par gnubg de
bureau (Docker existant, `make reference`) pour l'analyse d'un coup, d'une
décision de videau et la chance, comparées par `make test` (natif et
WebAssembly) avec la tolérance existante. Les niveaux avec bruit : vérifier
au moins que le coup choisi est reproductible et identique à celui de gnubg
de bureau sur quelques positions.
7. Glossaire : fiches pour les nouveaux termes (niveau, chance, appréciation,
équité si absente…), reliées au code.
8. Mesurer sous Node (`make bench` ou équivalent) le temps d'une analyse de
coup au niveau prévu pour le conseil du maître (2 plis), pour vérifier
qu'elle tient en arrière-plan sur le téléphone (repère : ADR 0009).
Machine ancienne : `-j2`, ne recompiler le moteur que si nécessaire.
`make lint` et `make test` doivent passer. Commits fréquents ; `git add` avec
chemins explicites ; ne pas pousser, pas de MR.
## Critères de réussite
- `engine-cli` répond aux nouvelles commandes en JSON.
- Les nouvelles références concordent avec gnubg de bureau, natif et WebAssembly.
- `make lint` et `make test` passent.
## Résultat
À remplir à la clôture.
@@ -1,57 +0,0 @@
# 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.
+32
View File
@@ -0,0 +1,32 @@
# Plateau soigné : gestes et finitions (1i)
- **Date** : 2026-11-03
- **Étape** : 1i (`docs/etape-1.md`)
- **Statut** : prévu
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
- les gestes naturels (principe d'expérience 2) ;
- les écarts restants avec `docs/identite/charte/board.js`.
## Consignes
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
4. **Clavier.** Faire ce que la révision UX a laissé :
- le choix des pions aux flèches ;
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
5. **Tests.**
- Scénarios Gherkin si un nouveau comportement est visible.
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
## Critères de réussite
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
## Résultat
À remplir à la clôture.
@@ -1,20 +1,29 @@
# Position dans l'adresse (0e.4)
# 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, qui touche les mêmes fichiers)
- **Statut** : prévu
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Appliquer l'ADR 0013 : chaque position du plateau a un lien direct.
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.
1. Lecture du fragment `#position=…&match=…` au chargement et sur
`hashchange` ; validation par le moteur ; message clair si illisible.
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 ».
@@ -26,8 +35,10 @@ ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
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` (ouvrir un lien, jouer à
partir d'ici, revenir, lien invalide).
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
+30
View File
@@ -0,0 +1,30 @@
# Progression (1h)
- **Date** : 2026-11-03
- **Étape** : 1h (`docs/etape-1.md`)
- **Statut** : prévu (après 1e)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur.
## Consignes
1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre :
- la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ;
- la liste des parties, reliée à leur bilan et à leur analyse (1e) ;
- un résumé en une phrase.
2. **Accueil.** Il reçoit la « ligne de progression » de la charte.
3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan.
4. **Tests.**
- Scénarios Gherkin.
- `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures.
## Critères de réussite
Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent.
## Résultat
À remplir à la clôture.
+42
View File
@@ -0,0 +1,42 @@
# Prompts à copier (1f)
- **Date** : 2026-11-03
- **Étape** : 1f (`docs/etape-1.md`)
- **Statut** : prévu (après 1e)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
## Consignes
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
- « pourquoi ce coup est meilleur » ;
- « expliquer cette décision de videau » ;
- « bilan de ma partie » ;
- « expliquer ce concept » ;
- « créer des exercices », à garder pour la progression.
Chaque gabarit contient :
- le rôle et le niveau du joueur ;
- le Position ID et le Match ID ;
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
- les chiffres du moteur, avec le niveau d'évaluation ;
- la consigne de ne pas contredire ces chiffres ;
- la langue.
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
4. **Tests.**
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
## Critères de réussite
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
## Résultat
À remplir à la clôture.
-63
View File
@@ -1,63 +0,0 @@
# Révision UX : portrait stable, compte de points, menu, paysage « plateau = écran »
- **Date** : 2026-11-03
- **Étape** : 0e.3 (feuille de route)
- **Statut** : prévu
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Appliquer la revue UX (0e.2) telle qu'arbitrée par la session « identité » sur
délégation de Pierre le 2026-11-03, et l'amendement de l'ADR 0012.
## Références
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
« Révision UX » en bas du canevas.
- Règles : section « Interaction » du design system, copiée dans
`docs/identite/charte/interaction.md` (durées, courbes, motifs de vibration,
touches) et composant Icons (10 pictogrammes).
- Rapport du designer UI : conservé dans la description de la MR qui clôt ce
plan.
## Décisions à appliquer
1. **Portrait stable** (maquette « Portrait révisé ») :
- une barre haute avec le symbole et trois pictogrammes, libellés de 13 px dessous : Aide, Plein écran, Menu ;
- le bandeau de l'ordinateur, puis le plateau, puis le bandeau du joueur ;
- une ligne réservée de 44 px : le dernier coup de l'ordinateur avec ↻, ou la ligne du maître ;
- la consigne ;
- une barre d'actions collée en bas, l'action secondaire à gauche et la principale à droite (« Jouer ce coup »). Toucher les dés valide aussi ;
- la durée de calcul affichée disparaît.
2. **Compte de points** :
- en portrait, des bandeaux. Les chiffres sont en 20 px gras, alignés. Le bandeau de celui qui a la main est en accent-soft (« Vous · à vous de jouer »). L'écart s'écrit « mène de 8 », en muted, du côté de celui qui mène. Toucher le chiffre ouvre une bulle avec le texte de la fiche du glossaire (source unique, ADR 0008) ;
- en paysage, des pastilles aux bouts de la barre.
3. **Menu en feuille** qui monte du bas :
- il contient Nouvelle partie, Plein écran, Aide et glossaire, Thème, et les interrupteurs Vibrations et « Écran allumé pendant la partie » ;
- « Nouvelle partie » agit sans confirmation. Un bandeau « Nouvelle partie commencée. Reprendre l'ancienne » reste 10 s avec une barre de temps, et l'ancienne partie se restaure depuis son journal ;
- plus de `window.confirm`.
4. **Pas de passage automatique du tour** quand aucun coup n'est possible : les dés sont barrés avec le message, et le joueur passe en touchant les dés (ou Espace). Ajouter `overscroll-behavior: none` et `user-select: none`. L'écran reste allumé pendant une partie (Wake Lock), activé par défaut, avec un interrupteur.
5. **Paysage, « le plateau est l'écran »** (ADR 0012 amendé) :
- la vue s'applique dès la rotation, sur `100dvh` ;
- la géométrie suit l'écran, par une fonction pure testée unitairement, d'après les formules du composant Board des maquettes ;
- une colonne de commandes à gauche dans le cadre : menu en haut, videau au milieu, ↶ en bas, remplacé par ↻ quand le coup de l'ordinateur est montré ;
- les sorties restent libres ;
- numéros des flèches à 12 px au moins ;
- la fin de partie s'affiche dans un panneau centré sur un voile ;
- le plein écran s'ouvre sur un geste (pictogramme ou menu), avec `requestFullscreen` puis `screen.orientation.lock` et un repli par rotation CSS. Ce n'est pas le réglage par défaut de l'app installée.
6. **Vibrations** (`navigator.vibrate`, si disponible) aux quatre moments, avec les motifs d'`interaction.md` : pion posé, pion frappé, double proposé par l'ordinateur, fin de partie. Elles sont activées par défaut, rien au lancer des dés, et elles doublent toujours un signal visible.
7. **Pictogrammes** du composant Icons, en SVG dans le site (CSP : pas de style en ligne).
8. **Consigne** : forme longue pendant les trois premières parties, puis forme courte.
9. **Mouvement** :
- durées de 90, 160 et 240 ms, avec la courbe cubic-bezier(0.2, 0, 0, 1) ;
- l'ordinateur joue pion par pion : 160 ms par pion, 120 ms de pause ;
- `prefers-reduced-motion` est respecté.
10. **Clavier** (priorité basse) : prévoir dans l'architecture une table d'actions commune au toucher et au clavier. Les touches d'`interaction.md` peuvent venir dans une MR ultérieure.
## Consignes
ADR 0007 : ni le domaine ni le moteur ne changent pour la mise en page. ADR 0008 : scénarios Gherkin pour les comportements visibles nouveaux (nouvelle partie annulable, menu). Aucune nouvelle dépendance. `tests/browser/play.mjs` étendu aux deux orientations, avec des captures Playwright en 412×915, 915×412 et 844×320, en thème clair et sombre, rangées dans un dossier indiqué par la session principale pour la revue design. `make lint`, `make test` et `make test-browser` passent.
## Résultat
À remplir à la clôture.
+2
View File
@@ -47,3 +47,5 @@ Règles d'écriture :
| Fichier | Tâche | Depuis |
| --- | --- | --- |
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
+18 -5
View File
@@ -14,7 +14,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
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 « Valider ».
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
@@ -61,21 +61,22 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Scénario: Aucun coup possible avec votre jet
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
que vous avez touché « Continuer ».
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 continuez
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. Le bouton « Revoir » rejoue son coup.
é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é
@@ -83,6 +84,18 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
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é,
+36
View File
@@ -0,0 +1,36 @@
# language: fr
Fonctionnalité: Retrouver mes parties
Toutes vos parties restent sur votre téléphone, finies ou en cours, avec
leurs branches : chacune est enregistrée après chacune de vos actions, et
fermer l'application ne perd rien. Dans « Mes parties », vous en reprenez
une, vous passez d'une branche à l'autre, ou vous l'exportez au format SGF
pour l'ouvrir dans GNU Backgammon.
Scénario: Reprendre une partie parmi plusieurs
Étant donné que vous avez joué deux parties, l'une après l'autre
Quand vous fermez l'application puis la rouvrez
Alors la dernière partie jouée est à l'écran
Et « Mes parties » montre vos deux parties, la dernière jouée en premier
Quand vous reprenez la première partie
Alors elle est à l'écran, telle que vous l'aviez laissée
Scénario: Rejouer d'ici sans perdre l'original
Revenez à un moment passé de la partie et jouez autre chose : une
nouvelle branche commence là, et la partie d'origine reste entière.
Étant donné que vous avez joué plusieurs coups
Quand vous rejouez d'ici à partir de votre deuxième coup
Et que vous choisissez un autre coup
Alors la partie a deux branches
Et la partie d'origine est intacte
Quand vous fermez l'application puis la rouvrez
Alors vous êtes sur la nouvelle branche
Et vous pouvez revenir à la partie d'origine, au coup près
Scénario: Exporter une partie
Étant donné que vous avez joué plusieurs coups
Et que vous avez rejoué d'ici une autre suite
Quand vous exportez la partie
Alors vous obtenez un fichier SGF de GNU Backgammon
Et il contient vos coups, et l'autre branche en variante
@@ -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
View File
@@ -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
View File
@@ -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");
+17 -4
View File
@@ -17,6 +17,9 @@ 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.
@@ -35,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
View File
@@ -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
View File
@@ -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
+4 -5
View File
@@ -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
+814 -87
View File
File diff suppressed because it is too large Load Diff
+89 -3
View File
@@ -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.
+283
View File
@@ -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"
}
}
+126
View File
@@ -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"
}
}
+54
View File
@@ -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"
}
}

Some files were not shown because too many files have changed in this diff Show More