Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31815134e5 | ||
|
|
274fbf6252 | ||
|
|
08cb89735f | ||
|
|
091cb4a8b0 | ||
|
|
fa558667f7 | ||
|
|
cbef27ae9c | ||
|
|
6b5e0d2b5e | ||
|
|
23f4d47fcf | ||
|
|
2f6eb86972 | ||
|
|
7e958fab8e | ||
|
|
242a2433d2 | ||
|
|
34c3eaeca5 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Les branches d'une partie : les lignes de jeu de son journal.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
|
||||
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
|
||||
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
|
||||
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
|
||||
// deux enfants d'un même événement, le plus ancien a le plus petit indice
|
||||
// dans le journal, et c'est lui qui continue la ligne d'origine.
|
||||
|
||||
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Une branche de la partie : la suite d'événements du début jusqu'à head.
|
||||
*
|
||||
* Glossaire : docs/glossaire/branche.md
|
||||
*/
|
||||
export interface Branch {
|
||||
/** Le dernier événement de la branche (une feuille de l'arbre). */
|
||||
readonly head: string;
|
||||
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
|
||||
* null pour la ligne d'origine de la partie. */
|
||||
readonly forkedFrom: string | null;
|
||||
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
|
||||
* la ligne d'origine. */
|
||||
readonly forkMoveCount: number;
|
||||
/** Les coups joués sur toute la branche, depuis le début. */
|
||||
readonly moveCount: number;
|
||||
/** La partie est finie au bout de cette branche. */
|
||||
readonly over: boolean;
|
||||
/** L'événement courant de la partie est sur cette branche. */
|
||||
readonly containsHead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les branches de la partie, dans l'ordre de leur création (celui de leur
|
||||
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
|
||||
* journal vide.
|
||||
*/
|
||||
export function branches(journal: GameJournal): Branch[] {
|
||||
const order = new Map(journal.events.map((event, index) => [event.id, index]));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
const leaves = journal.events.filter((event) => !children.has(event.id));
|
||||
return leaves
|
||||
.map((leaf) => {
|
||||
const line = currentLine({ events: journal.events, head: leaf.id });
|
||||
// Le point de départ de la branche : le parent de l'événement le
|
||||
// plus récent du chemin qui n'est pas le premier enfant de son
|
||||
// parent (la ligne d'origine n'en a aucun).
|
||||
let forkIndex = -1;
|
||||
for (let i = line.length - 1; i > 0; i--) {
|
||||
const event = line[i] as GameEvent;
|
||||
const first = children.get(event.parent as string)?.[0];
|
||||
if (first !== undefined && first.id !== event.id) {
|
||||
forkIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const moves = (events: readonly GameEvent[]) =>
|
||||
events.filter((event) => event.type === "MovePlayed").length;
|
||||
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
|
||||
const start = line[forkIndex + 1] as GameEvent;
|
||||
return {
|
||||
created: order.get(start.id) ?? 0,
|
||||
head: leaf.id,
|
||||
forkedFrom: fork?.id ?? null,
|
||||
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
|
||||
moveCount: moves(line),
|
||||
over: replay(line).phase === "over",
|
||||
containsHead: line.some((event) => event.id === journal.head),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.created - b.created)
|
||||
.map(({ created: _, ...branch }) => branch);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
|
||||
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
|
||||
if (!branches(journal).some((branch) => branch.head === head)) {
|
||||
throw new Error(`branche introuvable : ${head}`);
|
||||
}
|
||||
return goBackTo(journal, head);
|
||||
}
|
||||
@@ -38,6 +38,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+100
-7
@@ -5,36 +5,120 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { switchToBranch } from "./domain/game/branches.js";
|
||||
import type { Level } from "./domain/game/engine.js";
|
||||
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 { newGameId } from "./storage/game-library.js";
|
||||
import { openGameLibrary } from "./storage/open-library.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
import { GAMES_TEXTS } from "./ui/games-texts.js";
|
||||
import { createGamesView } from "./ui/games-view.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);
|
||||
|
||||
// Les parties enregistrées (IndexedDB, ou localStorage en repli). Chaque
|
||||
// accès au stockage du navigateur peut lever une exception (stockage
|
||||
// interdit) : on fait alors sans.
|
||||
const { library } = await openGameLibrary({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage: attempt(() => window.localStorage),
|
||||
});
|
||||
/** Le niveau de l'ordinateur (celui de Game.start, STANDARD_PLY). */
|
||||
const LEVEL: Level = "2ply";
|
||||
const saved = await library.current().catch(() => null);
|
||||
// La partie à l'écran ; un nouvel identifiant à chaque nouvelle partie.
|
||||
let shownId = saved?.summary.id ?? newGameId();
|
||||
// Vrai une fois qu'on recharge la page pour reprendre une autre partie :
|
||||
// plus rien n'est enregistré sous l'identifiant de celle qu'on quitte.
|
||||
let leaving = false;
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => Game.start(engine),
|
||||
newGame: () => {
|
||||
shownId = newGameId();
|
||||
return Game.start(engine);
|
||||
},
|
||||
savedGame: () => {
|
||||
const journal = store.load();
|
||||
if (journal === null) {
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, journal);
|
||||
return Game.resume(engine, saved.journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
saveGame: (journal) => store.save(journal),
|
||||
saveGame: (journal) => {
|
||||
if (!leaving) {
|
||||
// Stockage plein ou interdit : la partie continue, elle ne
|
||||
// pourra seulement pas être reprise.
|
||||
library.save(shownId, journal, { level: LEVEL }).catch(() => {});
|
||||
}
|
||||
},
|
||||
confirm: (question) => window.confirm(question),
|
||||
});
|
||||
|
||||
// « Mes parties ». Point d'accroche provisoire : un bouton ajouté devant
|
||||
// celui de l'aide, en attendant le menu de l'application (révision UX).
|
||||
// Reprendre une partie la désigne comme partie en cours, puis recharge la
|
||||
// page, qui l'ouvre.
|
||||
const gamesView = createGamesView({
|
||||
list: () => library.list(),
|
||||
journal: async (id) => (await library.load(id))?.journal ?? null,
|
||||
shownId: () => shownId,
|
||||
resume: async (id, branchHead) => {
|
||||
const game = await library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
leaving = true;
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
const journal = switchToBranch(game.journal, branchHead);
|
||||
await library.save(id, journal, { level: game.summary.level });
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
remove: (id) => library.remove(id),
|
||||
exportSgf: async (id) => {
|
||||
const game = await library.load(id);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const started = new Date(game.summary.startedAt);
|
||||
const date = [started.getFullYear(), started.getMonth() + 1, started.getDate()]
|
||||
.map((n) => String(n).padStart(2, "0"))
|
||||
.join("-");
|
||||
const time = [started.getHours(), started.getMinutes()]
|
||||
.map((n) => String(n).padStart(2, "0"))
|
||||
.join("");
|
||||
const text = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
date,
|
||||
});
|
||||
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
|
||||
},
|
||||
confirm: (question) => window.confirm(question),
|
||||
});
|
||||
const helpButton = document.getElementById("help-button");
|
||||
if (helpButton !== null) {
|
||||
const gamesButton = document.createElement("button");
|
||||
gamesButton.id = "games-button";
|
||||
gamesButton.type = "button";
|
||||
gamesButton.className = "secondary";
|
||||
gamesButton.textContent = GAMES_TEXTS.button;
|
||||
gamesButton.onclick = () => void gamesView.open();
|
||||
helpButton.before(gamesButton);
|
||||
}
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
// refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
@@ -46,3 +130,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
|
||||
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
|
||||
// (localStorage dans le navigateur).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
|
||||
load(): GameJournal | null;
|
||||
save(journal: GameJournal): void;
|
||||
}
|
||||
|
||||
const KEY = "ustagammon.current-game";
|
||||
|
||||
export function createGameStore(storage: KeyValueStorage): GameStore {
|
||||
return {
|
||||
load() {
|
||||
try {
|
||||
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
|
||||
const valid =
|
||||
journal !== null &&
|
||||
Array.isArray(journal.events) &&
|
||||
(journal.head === null || typeof journal.head === "string");
|
||||
return valid ? journal : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save(journal) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(journal));
|
||||
} catch {
|
||||
// Stockage plein ou interdit (navigation privée) : la partie
|
||||
// continue, elle ne pourra seulement pas être reprise.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Les parties dans IndexedDB, le stockage de données du navigateur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Base « ustagammon », trois magasins d'objets :
|
||||
// - games : les résumés des parties (SavedGameSummary), clé id ;
|
||||
// - journals : leurs journaux, { id, journal }, clé id ;
|
||||
// - settings : { key, value }, dont la partie en cours (key « current »).
|
||||
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
|
||||
// L'API IndexedDB est utilisée directement, sans bibliothèque.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
const DATABASE = "ustagammon";
|
||||
const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
|
||||
function committed(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
|
||||
* indisponible ou refusé. */
|
||||
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
|
||||
const request = factory.open(DATABASE, VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
db.createObjectStore(GAMES, { keyPath: "id" });
|
||||
db.createObjectStore(JOURNALS, { keyPath: "id" });
|
||||
db.createObjectStore(SETTINGS, { keyPath: "key" });
|
||||
};
|
||||
const db = await done(request);
|
||||
|
||||
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
|
||||
done(db.transaction(store).objectStore(store).get(key));
|
||||
|
||||
return {
|
||||
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
|
||||
summary: (id) => get<SavedGameSummary>(GAMES, id),
|
||||
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
|
||||
write: async (summary: SavedGameSummary, journal: GameJournal) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
|
||||
transaction.objectStore(GAMES).put(summary);
|
||||
await committed(transaction);
|
||||
},
|
||||
remove: async (id) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(GAMES).delete(id);
|
||||
transaction.objectStore(JOURNALS).delete(id);
|
||||
await committed(transaction);
|
||||
},
|
||||
currentId: async () => {
|
||||
const setting = await get<{ value: unknown }>(SETTINGS, "current");
|
||||
return typeof setting?.value === "string" ? setting.value : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
|
||||
// repli quand IndexedDB est indisponible, et la lecture de la partie
|
||||
// enregistrée par les versions précédentes, pour la migrer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
|
||||
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
|
||||
* stockage est interdit. */
|
||||
function read(storage: KeyValueStorage, key: string): unknown {
|
||||
try {
|
||||
const text = storage.getItem(key);
|
||||
return text === null ? undefined : JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
|
||||
const summaries = (): SavedGameSummary[] => {
|
||||
const value = read(storage, SUMMARIES);
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
const writeSummaries = (list: readonly SavedGameSummary[]) =>
|
||||
storage.setItem(SUMMARIES, JSON.stringify(list));
|
||||
return {
|
||||
summaries: async () => summaries(),
|
||||
summary: async (id) => summaries().find((s) => s.id === id),
|
||||
journal: async (id) => read(storage, journalKey(id)),
|
||||
write: async (summary, journal) => {
|
||||
// Le journal d'abord : un résumé ne désigne jamais un journal
|
||||
// absent.
|
||||
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
|
||||
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
|
||||
},
|
||||
remove: async (id) => {
|
||||
writeSummaries(summaries().filter((s) => s.id !== id));
|
||||
storage.removeItem(journalKey(id));
|
||||
},
|
||||
currentId: async () => {
|
||||
const id = read(storage, CURRENT);
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend dans library la partie enregistrée par les versions précédentes
|
||||
* (une seule partie, sous une clé de localStorage), qui devient la partie
|
||||
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
|
||||
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
|
||||
*/
|
||||
export async function migrateLegacyGame(
|
||||
storage: KeyValueStorage,
|
||||
library: GameLibrary,
|
||||
now: number = Date.now(),
|
||||
): Promise<string | null> {
|
||||
let present: boolean;
|
||||
try {
|
||||
present = storage.getItem(LEGACY_GAME) !== null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!present) {
|
||||
return null;
|
||||
}
|
||||
const journal: unknown = read(storage, LEGACY_GAME);
|
||||
let id: string | null = null;
|
||||
if (isJournal(journal) && journal.events.length > 0) {
|
||||
id = newGameId(now);
|
||||
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
|
||||
await library.save(id, journal as GameJournal, { level: "2ply" });
|
||||
}
|
||||
try {
|
||||
storage.removeItem(LEGACY_GAME);
|
||||
} catch {
|
||||
// Stockage interdit : la migration sera refaite, sans dommage.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
|
||||
// sinon localStorage, puis migration de la partie des versions précédentes.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
readonly indexedDB?: IDBFactory;
|
||||
readonly localStorage?: KeyValueStorage;
|
||||
}
|
||||
|
||||
export interface OpenedLibrary {
|
||||
readonly library: GameLibrary;
|
||||
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
|
||||
* mémoire de la page (rien n'est gardé après sa fermeture). */
|
||||
readonly kind: "indexeddb" | "localStorage" | "memory";
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
* doit pas empêcher de jouer). */
|
||||
const OPEN_TIMEOUT_MS = 5000;
|
||||
|
||||
export async function openGameLibrary(
|
||||
storage: BrowserStorage,
|
||||
options: LibraryOptions = {},
|
||||
): Promise<OpenedLibrary> {
|
||||
const opened = await open(storage, options);
|
||||
if (storage.localStorage !== undefined) {
|
||||
try {
|
||||
await migrateLegacyGame(storage.localStorage, opened.library);
|
||||
} catch {
|
||||
// Migration impossible (stockage plein ou refusé) : l'ancienne
|
||||
// partie reste où elle est, on la retentera au prochain démarrage.
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
|
||||
if (storage.indexedDB !== undefined) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const backend = await Promise.race([
|
||||
openIndexedDbBackend(storage.indexedDB),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
}
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage peut exister et refuser toute écriture. */
|
||||
function usable(storage: KeyValueStorage): boolean {
|
||||
try {
|
||||
storage.setItem("ustagammon.probe", "1");
|
||||
storage.removeItem("ustagammon.probe");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un stockage clé-valeur en mémoire. */
|
||||
export function memoryStorage(): KeyValueStorage {
|
||||
const items = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => items.get(key) ?? null,
|
||||
setItem: (key, value) => void items.set(key, value),
|
||||
removeItem: (key) => void items.delete(key),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 définitivement cette partie et toutes ses branches ?",
|
||||
shown: "partie affichée",
|
||||
failed: (message: string) => `Erreur : ${message}`,
|
||||
|
||||
/** « 3 nov. 2026, 14:05 ». */
|
||||
date: (ms: number) =>
|
||||
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
||||
|
||||
status: (
|
||||
result: { winner: Side; kind: ResultKind; points: number } | null,
|
||||
moveCount: number,
|
||||
) => {
|
||||
if (result === null) {
|
||||
return `En cours, ${moves(moveCount)}`;
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
|
||||
},
|
||||
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
|
||||
|
||||
/** Une branche : d'où elle part et où elle en est. */
|
||||
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
|
||||
const start =
|
||||
forkMoveCount === null
|
||||
? "Partie d'origine"
|
||||
: forkMoveCount === 0
|
||||
? "Rejouée depuis le début"
|
||||
: `Rejouée après le coup ${forkMoveCount}`;
|
||||
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
|
||||
},
|
||||
currentBranch: " (branche courante)",
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
// « 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).
|
||||
//
|
||||
// Un écran autonome, dans une fenêtre (<dialog>) qu'il crée lui-même : il ne
|
||||
// dépend ni de index.html ni de l'écran de jeu (app.ts). Comme tout l'écran,
|
||||
// il ne connaît ni le stockage ni le moteur : main.ts lui passe les
|
||||
// opérations (GamesViewOptions).
|
||||
|
||||
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";
|
||||
|
||||
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 }>;
|
||||
readonly confirm: (question: string) => boolean;
|
||||
}
|
||||
|
||||
export interface GamesView {
|
||||
/** Ouvre la liste, à jour. */
|
||||
open(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Crée la fenêtre « Mes parties » (fermée) dans la page. */
|
||||
export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
const dialog = element("dialog", "confirm games");
|
||||
dialog.id = "games-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "games-title");
|
||||
const title = element("h2");
|
||||
title.id = "games-title";
|
||||
title.textContent = GAMES_TEXTS.title;
|
||||
const list = element("ul", "games-list");
|
||||
const message = element("p", "games-message");
|
||||
message.setAttribute("aria-live", "polite");
|
||||
const close = button(GAMES_TEXTS.close, () => dialog.close(), "secondary");
|
||||
close.id = "games-close";
|
||||
dialog.append(title, message, list, close);
|
||||
document.body.append(dialog);
|
||||
|
||||
/** 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", "confirm-actions");
|
||||
actions.append(
|
||||
button(
|
||||
GAMES_TEXTS.resume,
|
||||
run(() => options.resume(game.id)),
|
||||
"",
|
||||
"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",
|
||||
),
|
||||
button(
|
||||
GAMES_TEXTS.remove,
|
||||
run(async () => {
|
||||
if (options.confirm(GAMES_TEXTS.confirmRemove)) {
|
||||
await options.remove(game.id);
|
||||
await render();
|
||||
}
|
||||
}),
|
||||
"secondary",
|
||||
"remove",
|
||||
),
|
||||
);
|
||||
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(() => options.resume(id, branch.head)),
|
||||
"secondary",
|
||||
"resume-branch",
|
||||
),
|
||||
);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async open() {
|
||||
message.textContent = "";
|
||||
await render();
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ 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"];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Tests du domaine : les branches d'une partie et son export SGF, sur des
|
||||
// journaux écrits à la main (sans moteur, sauf un faux pour une position de
|
||||
// départ particulière). La relecture des SGF par le vrai GNU Backgammon est
|
||||
// faite par tests/test_web.sh (tests/sgf/check.py).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { branches, switchToBranch } from "../src/domain/game/branches.js";
|
||||
import type { Board, Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
type NewGameEvent,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { exportSgf } from "../src/domain/game/sgf.js";
|
||||
|
||||
function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal {
|
||||
return events.reduce(appendEvent, journal);
|
||||
}
|
||||
|
||||
const start: NewGameEvent = {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
};
|
||||
const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({
|
||||
type: "MovePlayed",
|
||||
side,
|
||||
halfMoves: parseHalfMoves(text),
|
||||
position,
|
||||
});
|
||||
const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({
|
||||
type: "DiceRolled",
|
||||
side,
|
||||
roll: [a, b],
|
||||
});
|
||||
|
||||
/**
|
||||
* Une partie et ses branches :
|
||||
* 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4,
|
||||
* 5 coup 24/18 13/9, 6 dés joueur 2-1 ;
|
||||
* rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même
|
||||
* ouverture, autre coup) : 8 coup 24/23 13/10.
|
||||
*/
|
||||
function branchedGame(): GameJournal {
|
||||
let j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 3, 1),
|
||||
move("player", "8/5 6/5"),
|
||||
roll("computer", 6, 4),
|
||||
move("computer", "24/18 13/9"),
|
||||
roll("player", 2, 1),
|
||||
);
|
||||
j = add(goBackTo(j, "3"), roll("computer", 5, 5));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
return j;
|
||||
}
|
||||
|
||||
test("les branches : feuilles de l'arbre, avec leur point de départ", () => {
|
||||
const j = branchedGame();
|
||||
const all = branches(j);
|
||||
assert.deepEqual(
|
||||
all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]),
|
||||
[
|
||||
["6", null, 0, 2, false],
|
||||
["7", "3", 1, 1, false],
|
||||
["8", "2", 0, 1, true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(branches(EMPTY_JOURNAL), []);
|
||||
|
||||
// Passer d'une branche à l'autre ne perd rien.
|
||||
const back = switchToBranch(j, "6");
|
||||
assert.equal(back.events, j.events);
|
||||
assert.deepEqual(
|
||||
currentLine(back).map((e) => e.id),
|
||||
["1", "2", "3", "4", "5", "6"],
|
||||
);
|
||||
assert.throws(() => switchToBranch(j, "4"), /branche introuvable/);
|
||||
});
|
||||
|
||||
test("une branche rejouée puis continuée garde son ordre de création", () => {
|
||||
// La ligne d'origine continue après la création d'une branche : elle
|
||||
// reste la première.
|
||||
let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
j = add(goBackTo(j, "3"), roll("computer", 6, 4));
|
||||
assert.deepEqual(
|
||||
branches(j).map((b) => [b.head, b.forkedFrom]),
|
||||
[
|
||||
["5", null],
|
||||
["4", "2"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/** Un faux moteur qui ne sait que décoder un plateau. */
|
||||
function boardEngine(board: Board): Engine {
|
||||
return { board: async () => board } as unknown as Engine;
|
||||
}
|
||||
const noEngine = boardEngine({} as Board);
|
||||
const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" };
|
||||
|
||||
test("SGF : les coups, les variantes, la branche courante d'abord", async () => {
|
||||
const sgf = await exportSgf(noEngine, branchedGame(), names);
|
||||
assert.equal(
|
||||
sgf,
|
||||
"(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" +
|
||||
"PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" +
|
||||
// Branche courante : même ouverture, autre coup.
|
||||
"\n;B[31ablo])(" +
|
||||
// Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)).
|
||||
"\n;B[31qtst](" +
|
||||
// Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et
|
||||
// des dés lancés, pas joués.
|
||||
"\n;W[64xrmi]\n;PL[B]DI[21])(" +
|
||||
"\n;PL[W]DI[55])))\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : barre, sortie, coup impossible, videau et résultat", async () => {
|
||||
const j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 1, 6),
|
||||
move("computer", "bar/19 6/off"),
|
||||
{ type: "CubeOffered", side: "player" },
|
||||
{ type: "CubeTaken", side: "computer" },
|
||||
roll("player", 6, 6),
|
||||
move("player", ""),
|
||||
{ type: "CubeOffered", side: "computer" },
|
||||
{ type: "CubeDropped", side: "player" },
|
||||
{ type: "GameEnded", winner: "computer", kind: "single", points: 2 },
|
||||
);
|
||||
const sgf = await exportSgf(noEngine, j, names);
|
||||
assert.ok(sgf.includes("RE[W+2]"));
|
||||
assert.ok(
|
||||
sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"),
|
||||
sgf,
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : une position de départ particulière est posée", async () => {
|
||||
// Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ;
|
||||
// l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur.
|
||||
const points = (entries: Record<number, number>) =>
|
||||
Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0);
|
||||
const engine = boardEngine({
|
||||
onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 },
|
||||
opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 },
|
||||
swappedPosition: "X",
|
||||
result: null,
|
||||
});
|
||||
const j = add(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: "ABC",
|
||||
cube: { value: 4, owner: "computer" },
|
||||
firstTurn: "player",
|
||||
});
|
||||
const sgf = await exportSgf(engine, j, names);
|
||||
assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf);
|
||||
await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// L'adaptateur du moteur WebAssembly pour les niveaux, l'analyse et la
|
||||
// chance (étape 1a), sur le vrai moteur. La concordance des nombres avec
|
||||
// gnubg de bureau est vérifiée par tests/test_wasm_reference.sh ; ici, la
|
||||
// traduction des réponses en types du domaine.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type EngineCube, LUCK_THRESHOLDS, SKILL_THRESHOLDS } from "../src/domain/game/engine.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const centered: EngineCube = { value: 1, owner: "centered" };
|
||||
const middlegame = "mM/gATCw5+ABMA";
|
||||
|
||||
test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meilleur coup", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const analysis = await engine.analyseMove(
|
||||
START_POSITION,
|
||||
centered,
|
||||
[3, 1],
|
||||
"4HPwATDgc+IBKA",
|
||||
2,
|
||||
);
|
||||
assert.ok(analysis !== null);
|
||||
assert.equal(analysis.moveCount, 16);
|
||||
assert.equal(analysis.rank, 2);
|
||||
assert.equal(analysis.played.notation, "24/23 13/10");
|
||||
assert.equal(analysis.best.notation, "8/5 6/5");
|
||||
assert.equal(analysis.skill, "veryBad");
|
||||
assert.ok(
|
||||
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
|
||||
);
|
||||
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
|
||||
});
|
||||
|
||||
test("sans coup possible, il n'y a rien à analyser", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const position = "27YBBwAcz8EDQA";
|
||||
assert.equal(await engine.analyseMove(position, centered, [5, 5], position, 0), null);
|
||||
});
|
||||
|
||||
test("double/prend : passer est une erreur de l'adversaire, prendre n'en est pas une", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const position = "6O5uAAC4uzsAAA";
|
||||
const pass = await engine.analyseCube(position, centered, "pass", 2);
|
||||
assert.equal(pass.analysed, true);
|
||||
assert.equal(pass.skill, "bad");
|
||||
assert.ok(pass.equities !== null && pass.equities.doubleTake < pass.equities.doublePass);
|
||||
const take = await engine.analyseCube(position, centered, "take", 2);
|
||||
assert.deepEqual([take.skill, take.equityLoss], ["none", 0]);
|
||||
const opponentOwns = await engine.analyseCube(
|
||||
position,
|
||||
{ value: 2, owner: "opponent" },
|
||||
"noDouble",
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(opponentOwns, {
|
||||
analysed: false,
|
||||
equities: null,
|
||||
equityLoss: 0,
|
||||
skill: "none",
|
||||
});
|
||||
});
|
||||
|
||||
test("la chance d'un jet et sa catégorie", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const opening = await engine.luck(START_POSITION, centered, [3, 1]);
|
||||
assert.ok(Math.abs(opening.value - 0.2126) < 2e-3);
|
||||
assert.equal(opening.rating, "none");
|
||||
const doubleSix = await engine.luck(START_POSITION, centered, [6, 6]);
|
||||
assert.ok(doubleSix.value > LUCK_THRESHOLDS.lucky);
|
||||
assert.equal(doubleSix.rating, "lucky");
|
||||
});
|
||||
|
||||
// Régression : sans cache vidé à chaque appel (engine.c, start_evaluation),
|
||||
// une analyse à 2 plis changeait la chance calculée ensuite (évaluations à
|
||||
// 0 pli des réseaux d'élagage restées dans le cache de gnubg).
|
||||
test("un résultat ne dépend pas des calculs faits avant", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const before = await engine.luck(middlegame, centered, [6, 4]);
|
||||
await engine.analyseCube(middlegame, centered, "noDouble", 2);
|
||||
await engine.analyseMove(middlegame, centered, [6, 4], "mM/gATDGzuABMA", 2);
|
||||
assert.deepEqual(await engine.luck(middlegame, centered, [6, 4]), before);
|
||||
});
|
||||
|
||||
test("le niveau débutant joue toujours le même coup, différent du meilleur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const move = await engine.levelMove(middlegame, centered, [6, 4], "beginner");
|
||||
assert.equal(move?.notation, "24/20 24/18");
|
||||
}
|
||||
assert.equal((await engine.levelMove(middlegame, centered, [6, 4], "2ply"))?.notation, "24/14");
|
||||
const cube = await engine.levelCubeDecision("6O5uAAC4uzsAAA", centered, "beginner");
|
||||
assert.equal(cube.double, false);
|
||||
});
|
||||
|
||||
test("les seuils du domaine sont ceux du moteur", async () => {
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const module = await import(new URL("build/wasm/engine.mjs", root).href);
|
||||
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
|
||||
const raw = await wrapper.loadEngine(
|
||||
module.default,
|
||||
fileURLToPath(new URL("build/wasm/", root)),
|
||||
);
|
||||
const { result } = raw.run(["levels"]) as {
|
||||
result: {
|
||||
levels: { name: string }[];
|
||||
skill_thresholds: { doubtful: number; bad: number; very_bad: number };
|
||||
luck_thresholds: { lucky: number; very_lucky: number };
|
||||
};
|
||||
};
|
||||
const close = (a: number, b: number) => Math.abs(a - b) < 1e-6;
|
||||
assert.ok(close(result.skill_thresholds.doubtful, SKILL_THRESHOLDS.doubtful));
|
||||
assert.ok(close(result.skill_thresholds.bad, SKILL_THRESHOLDS.bad));
|
||||
assert.ok(close(result.skill_thresholds.very_bad, SKILL_THRESHOLDS.veryBad));
|
||||
assert.ok(close(result.luck_thresholds.lucky, LUCK_THRESHOLDS.lucky));
|
||||
assert.ok(close(result.luck_thresholds.very_lucky, LUCK_THRESHOLDS.veryLucky));
|
||||
assert.deepEqual(
|
||||
result.levels.map((l) => l.name),
|
||||
["beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"],
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,218 @@
|
||||
// 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 {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.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}$/);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
+2
-34
@@ -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,36 +174,6 @@ 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(
|
||||
|
||||
+4
-3
@@ -17,7 +17,7 @@ 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 | — |
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Appréciation
|
||||
english: Skill
|
||||
code: Skill
|
||||
---
|
||||
# Appréciation
|
||||
|
||||
**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix.
|
||||
|
||||
GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus
|
||||
de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous,
|
||||
il n'y a rien à signaler.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md)
|
||||
@@ -1,9 +1,14 @@
|
||||
---
|
||||
term: Branche
|
||||
english: Branch
|
||||
code: Branch
|
||||
---
|
||||
# Branche
|
||||
|
||||
**En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md)
|
||||
Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à
|
||||
l'autre depuis « Mes parties », et l'export SGF les contient toutes, en
|
||||
variantes.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Chance
|
||||
english: Luck
|
||||
code: Luck
|
||||
---
|
||||
# Chance
|
||||
|
||||
**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles.
|
||||
|
||||
Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la
|
||||
moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans
|
||||
l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si
|
||||
on a perdu à cause des dés ou de son jeu.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Jet](jet.md)
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
term: Niveau de jeu
|
||||
english: Level
|
||||
code: Level
|
||||
---
|
||||
# Niveau de jeu
|
||||
|
||||
**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance.
|
||||
|
||||
Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant,
|
||||
amateur, intermédiaire, avancé) jugent la position sans regarder les jets
|
||||
suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible :
|
||||
c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de
|
||||
la position seule : dans la même position, avec le même jet, l'ordinateur
|
||||
joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent
|
||||
de 0 à 4 plis.
|
||||
|
||||
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
|
||||
@@ -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)
|
||||
@@ -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,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.
|
||||
@@ -47,3 +47,4 @@ Règles d'écriture :
|
||||
| Fichier | Tâche | Depuis |
|
||||
| --- | --- | --- |
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
|
||||
|
||||
@@ -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
|
||||
+199
-10
@@ -5,7 +5,7 @@
|
||||
* Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
*
|
||||
* Usage :
|
||||
* engine-cli [--ply N] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
* engine-cli [--ply N | --level NIVEAU] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
*
|
||||
* Commandes :
|
||||
* bestmove POSITION_ID MATCH_ID DÉS meilleur coup (DÉS : ex. 31)
|
||||
@@ -27,8 +27,23 @@
|
||||
* référence (tests/reference/README.md) :
|
||||
* évaluation, puis classement des coups
|
||||
* avec DÉS, décision de videau sans
|
||||
* levels niveaux de jeu, seuils d'appréciation
|
||||
* et de chance de gnubg
|
||||
* analysemove POSITION_ID MATCH_ID DÉS POSITION_JOUÉE
|
||||
* analyse du coup joué (POSITION_JOUÉE :
|
||||
* position après le coup, vue du joueur
|
||||
* qui l'a joué, comme position_id dans
|
||||
* moves et legal)
|
||||
* analysecube POSITION_ID MATCH_ID DÉCISION
|
||||
* analyse d'une décision de videau :
|
||||
* no_double ou double (joueur au trait),
|
||||
* take ou pass (son adversaire)
|
||||
* luck POSITION_ID MATCH_ID DÉS chance du jet pour le joueur au trait
|
||||
*
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4.
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint »,
|
||||
* analyses comme l'analyse de partie de gnubg).
|
||||
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves
|
||||
* et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris.
|
||||
* --data : dossier des données ; par défaut « data » à côté du programme.
|
||||
*
|
||||
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
|
||||
@@ -55,20 +70,32 @@
|
||||
#define NUMBER "%.9g"
|
||||
|
||||
/* Liste des commandes, pour les messages d'erreur. */
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference"
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \
|
||||
"levels, analysemove, analysecube ou luck"
|
||||
|
||||
/* Coups gardés dans le classement de la commande reference (comme hint). */
|
||||
#define REFERENCE_MAX_MOVES 10
|
||||
|
||||
/* Arguments de la ligne de commande, une fois lus. */
|
||||
#define MAX_ARGS 4
|
||||
|
||||
typedef struct {
|
||||
int ply;
|
||||
int level; /* -1 : pas de --level */
|
||||
const char *data_dir;
|
||||
const char *command;
|
||||
const char *args[3];
|
||||
const char *args[MAX_ARGS];
|
||||
int arg_count;
|
||||
} options;
|
||||
|
||||
/* Noms JSON des appréciations (engine_skill), des chances
|
||||
* (engine_luck_rating) et des décisions de videau (engine_cube_action). */
|
||||
static const char *const skill_names[] = { "none", "doubtful", "bad", "very_bad" };
|
||||
static const char *const luck_names[] = {
|
||||
"very_unlucky", "unlucky", "none", "lucky", "very_lucky"
|
||||
};
|
||||
static const char *const cube_action_names[] = { "no_double", "double", "take", "pass" };
|
||||
|
||||
/* Code d'erreur JSON pour chaque engine_status. */
|
||||
static const char *
|
||||
status_code(engine_status status)
|
||||
@@ -92,6 +119,12 @@ status_code(engine_status status)
|
||||
return "too_many_moves";
|
||||
case ENGINE_ERROR_INTERNAL:
|
||||
return "internal_error";
|
||||
case ENGINE_ERROR_LEVEL:
|
||||
return "invalid_level";
|
||||
case ENGINE_ERROR_MOVE:
|
||||
return "invalid_move";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "invalid_cube_action";
|
||||
}
|
||||
return "unknown_error";
|
||||
}
|
||||
@@ -178,6 +211,18 @@ print_reference_evaluation(const engine_evaluation *e)
|
||||
printf(", \"equity\": " NUMBER "}", e->cubeless_equity);
|
||||
}
|
||||
|
||||
/* « , "ply": N » ou « , "level": "NOM" », selon les options. */
|
||||
static void
|
||||
print_level_or_ply(const options *opt)
|
||||
{
|
||||
engine_level_settings settings;
|
||||
|
||||
if (opt->level >= 0 && engine_level_settings_of(opt->level, &settings) == ENGINE_OK)
|
||||
printf(", \"level\": \"%s\"", settings.name);
|
||||
else
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
}
|
||||
|
||||
/* Lit des dés au format « 31 » ; renvoie 0 si le format est faux. */
|
||||
static int
|
||||
parse_dice(const char *text, int *die1, int *die2)
|
||||
@@ -208,7 +253,10 @@ run_moves_command(const options *opt)
|
||||
moves = malloc(sizeof *moves * ENGINE_MAX_MOVES);
|
||||
if (!moves)
|
||||
return print_error("out_of_memory", "mémoire insuffisante", EXIT_ENGINE_ERROR);
|
||||
if (evaluate)
|
||||
if (evaluate && opt->level >= 0)
|
||||
status = engine_level_best_moves(position_id, match_id, die1, die2, opt->level,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else if (evaluate)
|
||||
status = engine_best_moves(position_id, match_id, die1, die2, opt->ply,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else
|
||||
@@ -222,7 +270,7 @@ run_moves_command(const options *opt)
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d]",
|
||||
position_id, match_id, die1, die2);
|
||||
if (evaluate)
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
print_level_or_ply(opt);
|
||||
if (best_only) {
|
||||
printf(", \"best_move\": ");
|
||||
if (count > 0)
|
||||
@@ -270,11 +318,15 @@ run_position_command(const options *opt)
|
||||
e.cubeless_equity, e.cubeful_equity);
|
||||
} else {
|
||||
engine_cube c;
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (opt->level >= 0)
|
||||
status = engine_level_cube_decision(position_id, match_id, opt->level, &c);
|
||||
else
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"match\": ",
|
||||
position_id, match_id, opt->ply);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
|
||||
print_level_or_ply(opt);
|
||||
printf(", \"match\": ");
|
||||
print_match(&match);
|
||||
printf(", \"probabilities\": ");
|
||||
print_probabilities(c.probabilities);
|
||||
@@ -507,6 +559,122 @@ run_reference_command(const options *opt)
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* levels : niveaux de jeu, seuils d'appréciation et de chance. */
|
||||
static int
|
||||
run_levels_command(const options *opt)
|
||||
{
|
||||
engine_level_settings s;
|
||||
int level;
|
||||
|
||||
if (opt->arg_count != 0)
|
||||
return usage_error("levels n'attend pas d'argument");
|
||||
printf("{\"levels\": [");
|
||||
for (level = 0; level < ENGINE_LEVEL_COUNT; level++) {
|
||||
engine_level_settings_of(level, &s);
|
||||
printf("%s{\"name\": \"%s\", \"plies\": %d, \"noise\": " NUMBER
|
||||
", \"deterministic\": %s, \"prune\": %s}", level ? ", " : "", s.name, s.plies,
|
||||
s.noise, s.deterministic ? "true" : "false", s.prune ? "true" : "false");
|
||||
}
|
||||
printf("], \"skill_thresholds\": {\"doubtful\": " NUMBER ", \"bad\": " NUMBER
|
||||
", \"very_bad\": " NUMBER "}, \"luck_thresholds\": {\"lucky\": " NUMBER
|
||||
", \"very_lucky\": " NUMBER "}}\n",
|
||||
ENGINE_SKILL_DOUBTFUL_THRESHOLD, ENGINE_SKILL_BAD_THRESHOLD,
|
||||
ENGINE_SKILL_VERY_BAD_THRESHOLD, ENGINE_LUCK_THRESHOLD, ENGINE_LUCK_VERY_THRESHOLD);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysemove : analyse du coup joué. */
|
||||
static int
|
||||
run_analyse_move_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
const char *played_id = opt->args[3];
|
||||
engine_move_analysis a;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 4)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_analyse_move(position_id, match_id, die1, die2, played_id, opt->ply, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"ply\": %d, "
|
||||
"\"played_position_id\": \"%s\", \"move_count\": %d",
|
||||
position_id, match_id, die1, die2, opt->ply, played_id, a.move_count);
|
||||
if (a.move_count == 0) {
|
||||
printf(", \"rank\": null, \"played\": null, \"best\": null");
|
||||
} else {
|
||||
printf(", \"rank\": %d, \"played\": ", a.rank);
|
||||
print_move(&a.played, 1);
|
||||
printf(", \"best\": ");
|
||||
print_move(&a.best, 1);
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysecube : analyse d'une décision de videau. */
|
||||
static int
|
||||
run_analyse_cube_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_cube_analysis a;
|
||||
engine_status status;
|
||||
int action;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉCISION");
|
||||
for (action = 0; action < 4 && strcmp(opt->args[2], cube_action_names[action]) != 0; action++)
|
||||
;
|
||||
if (action == 4)
|
||||
return usage_error("DÉCISION attend no_double, double, take ou pass");
|
||||
status = engine_analyse_cube(position_id, match_id, opt->ply, (engine_cube_action) action, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"action\": \"%s\", "
|
||||
"\"analysed\": %s, \"cube\": ", position_id, match_id, opt->ply,
|
||||
cube_action_names[action], a.analysed ? "true" : "false");
|
||||
if (a.analysed) {
|
||||
printf("{\"probabilities\": ");
|
||||
print_probabilities(a.cube.probabilities);
|
||||
printf(", ");
|
||||
print_cube_fields(&a.cube);
|
||||
printf("}");
|
||||
} else {
|
||||
printf("null");
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* luck : chance du jet pour le joueur au trait. */
|
||||
static int
|
||||
run_luck_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_luck luck;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_luck_of(position_id, match_id, die1, die2, &luck);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"luck\": " NUMBER
|
||||
", \"rating\": \"%s\"}\n", position_id, match_id, die1, die2, luck.luck,
|
||||
luck_names[luck.rating]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* Dossier « data » à côté du programme. */
|
||||
static char *
|
||||
default_data_dir(const char *program)
|
||||
@@ -531,20 +699,28 @@ parse_options(int argc, char **argv, options *opt)
|
||||
{
|
||||
int i;
|
||||
|
||||
int has_ply = 0;
|
||||
|
||||
memset(opt, 0, sizeof *opt);
|
||||
opt->level = -1;
|
||||
for (i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--ply") == 0 && i + 1 < argc) {
|
||||
char *end;
|
||||
opt->ply = (int) strtol(argv[++i], &end, 10);
|
||||
if (*end || end == argv[i])
|
||||
return "--ply attend un nombre";
|
||||
has_ply = 1;
|
||||
} else if (strcmp(argv[i], "--level") == 0 && i + 1 < argc) {
|
||||
if (engine_level_from_name(argv[++i], &opt->level) != ENGINE_OK)
|
||||
return "--level attend un niveau : beginner, casual, intermediate, advanced, "
|
||||
"0ply, 1ply, 2ply, 3ply ou 4ply";
|
||||
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
|
||||
opt->data_dir = argv[++i];
|
||||
} else if (argv[i][0] == '-' && argv[i][1] == '-') {
|
||||
return "option inconnue ou sans valeur";
|
||||
} else if (!opt->command) {
|
||||
opt->command = argv[i];
|
||||
} else if (opt->arg_count < 3) {
|
||||
} else if (opt->arg_count < MAX_ARGS) {
|
||||
opt->args[opt->arg_count++] = argv[i];
|
||||
} else {
|
||||
return "trop d'arguments";
|
||||
@@ -552,6 +728,11 @@ parse_options(int argc, char **argv, options *opt)
|
||||
}
|
||||
if (!opt->command)
|
||||
return "commande attendue : " COMMANDS;
|
||||
if (opt->level >= 0 && has_ply)
|
||||
return "--ply et --level ne vont pas ensemble";
|
||||
if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0
|
||||
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0)
|
||||
return "--level ne s'applique qu'à bestmove, moves et cube";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -591,6 +772,14 @@ main(int argc, char **argv)
|
||||
result = run_half_moves_command(&opt);
|
||||
else if (strcmp(opt.command, "matchid") == 0)
|
||||
result = run_match_id_command(&opt);
|
||||
else if (strcmp(opt.command, "levels") == 0)
|
||||
result = run_levels_command(&opt);
|
||||
else if (strcmp(opt.command, "analysemove") == 0)
|
||||
result = run_analyse_move_command(&opt);
|
||||
else if (strcmp(opt.command, "analysecube") == 0)
|
||||
result = run_analyse_cube_command(&opt);
|
||||
else if (strcmp(opt.command, "luck") == 0)
|
||||
result = run_luck_command(&opt);
|
||||
else
|
||||
result = usage_error("commande inconnue : " COMMANDS);
|
||||
|
||||
|
||||
@@ -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,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
@@ -26,8 +26,12 @@
|
||||
#define DEFAULT_BEAVERS 3
|
||||
|
||||
/* Seuil d'erreur « douteuse » de gnubg (arSkillLevel[SKILL_DOUBTFUL] dans
|
||||
* gnubg.c), que la commande « hint » passe à FindnSaveBestMoves. */
|
||||
#define DOUBTFUL_THRESHOLD 0.03f
|
||||
* gnubg.c), que la commande « hint » et l'analyse passent à
|
||||
* FindnSaveBestMoves. */
|
||||
#define DOUBTFUL_THRESHOLD ENGINE_SKILL_DOUBTFUL_THRESHOLD
|
||||
|
||||
/* Position de départ : la même vue des deux joueurs. */
|
||||
#define START_POSITION_ID "4HPwATDgc/ABMA"
|
||||
|
||||
/* Fichier de table d'équité de match : celui de gnubg de bureau par défaut. */
|
||||
#define MET_FILE "Kazaross-XG2.xml"
|
||||
@@ -146,6 +150,12 @@ engine_status_message(engine_status status)
|
||||
return "tableau de coups trop petit";
|
||||
case ENGINE_ERROR_INTERNAL:
|
||||
return "erreur interne de gnubg";
|
||||
case ENGINE_ERROR_LEVEL:
|
||||
return "niveau de jeu inconnu";
|
||||
case ENGINE_ERROR_MOVE:
|
||||
return "coup joué absent des coups légaux";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "décision de videau inconnue";
|
||||
}
|
||||
return "erreur inconnue";
|
||||
}
|
||||
@@ -242,16 +252,127 @@ check_dice(int die1, int die2)
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Réglages prédéfinis de gnubg pour un niveau, sans bruit. */
|
||||
/*
|
||||
* Vide le cache d'évaluation de gnubg avant chaque calcul : un résultat ne
|
||||
* dépend ainsi que de ses arguments, comme les références (gnubg de bureau,
|
||||
* « clear cache » avant chaque position). Sans cela, une recherche à 2 plis
|
||||
* avec élagage laisse dans le cache des évaluations à 0 pli faites avec les
|
||||
* réseaux d'élagage, rangées sous la même clé que les évaluations à 0 pli
|
||||
* ordinaires (EvalKey, eval.c l. 2302 : l'élagage n'y compte qu'à partir
|
||||
* d'1 pli) ; une chance de jet ou un coup à 0 pli calculé ensuite les
|
||||
* reprendrait (écarts mesurés jusqu'à 2,5e-2, tests/reference/README.md).
|
||||
* Coût mesuré : environ 3 ms par appel sous Node sur le PC.
|
||||
*/
|
||||
static void
|
||||
start_evaluation(void)
|
||||
{
|
||||
EvalCacheFlush();
|
||||
}
|
||||
|
||||
/* Réglages prédéfinis de gnubg pour un niveau, sans bruit ; prépare le
|
||||
* calcul. */
|
||||
static engine_status
|
||||
eval_context_for_ply(int ply, evalcontext *context)
|
||||
{
|
||||
if (ply < 0 || ply > ENGINE_MAX_PLY)
|
||||
return ENGINE_ERROR_PLY;
|
||||
start_evaluation();
|
||||
*context = aecSettings[SETTINGS_0PLY + ply];
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Niveaux de jeu */
|
||||
|
||||
/*
|
||||
* Les niveaux d'engine_level sont, dans le même ordre, les réglages
|
||||
* prédéfinis de gnubg (aecSettings, eval.c l. 318-328, et noms de
|
||||
* aszSettings, l. 305-315, où « casual play » devient « casual »).
|
||||
*/
|
||||
static const char *const level_names[ENGINE_LEVEL_COUNT] = {
|
||||
"beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"
|
||||
};
|
||||
|
||||
/* Réglages d'évaluation d'un niveau (bruit compris) ; prépare le calcul. */
|
||||
static engine_status
|
||||
eval_context_for_level(int level, evalcontext *context)
|
||||
{
|
||||
if (level < 0 || level >= ENGINE_LEVEL_COUNT)
|
||||
return ENGINE_ERROR_LEVEL;
|
||||
start_evaluation();
|
||||
*context = aecSettings[level];
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Filtre de coups d'un niveau : aiSettingsMoveFilter (eval.c l. 331) vaut
|
||||
* « Normal » à partir d'1 pli, et « sans objet » à 0 pli, où aucun filtre ne
|
||||
* sert ; le joueur de gnubg garde alors son filtre par défaut, « Normal »
|
||||
* aussi (ap dans gnubg.c l. 461). */
|
||||
static movefilter (*move_filter_for_level(int level))[MAX_FILTER_PLIES]
|
||||
{
|
||||
int index = aiSettingsMoveFilter[level];
|
||||
|
||||
return aaamfMoveFilterSettings[index < 0 ? 0 : index];
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_settings_of(int level, engine_level_settings *settings)
|
||||
{
|
||||
evalcontext context;
|
||||
engine_status status = eval_context_for_level(level, &context);
|
||||
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
settings->name = level_names[level];
|
||||
settings->plies = (int) context.nPlies;
|
||||
settings->noise = context.rNoise;
|
||||
settings->deterministic = (int) context.fDeterministic;
|
||||
settings->prune = (int) context.fUsePrune;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_from_name(const char *name, int *level)
|
||||
{
|
||||
int i;
|
||||
|
||||
for (i = 0; name && i < ENGINE_LEVEL_COUNT; i++)
|
||||
if (strcmp(name, level_names[i]) == 0) {
|
||||
*level = i;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
return ENGINE_ERROR_LEVEL;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Appréciation et chance */
|
||||
|
||||
/* Skill de gnubg (analysis.c l. 288), avec les seuils de arSkillLevel. */
|
||||
engine_skill
|
||||
engine_skill_of(float equity_loss)
|
||||
{
|
||||
if (equity_loss < -ENGINE_SKILL_VERY_BAD_THRESHOLD)
|
||||
return ENGINE_SKILL_VERY_BAD;
|
||||
if (equity_loss < -ENGINE_SKILL_BAD_THRESHOLD)
|
||||
return ENGINE_SKILL_BAD;
|
||||
if (equity_loss < -ENGINE_SKILL_DOUBTFUL_THRESHOLD)
|
||||
return ENGINE_SKILL_DOUBTFUL;
|
||||
return ENGINE_SKILL_NONE;
|
||||
}
|
||||
|
||||
/* Luck de gnubg (analysis.c l. 272), avec les seuils de arLuckLevel. */
|
||||
engine_luck_rating
|
||||
engine_luck_rating_of(float luck)
|
||||
{
|
||||
if (luck > ENGINE_LUCK_VERY_THRESHOLD)
|
||||
return ENGINE_LUCK_VERY_LUCKY;
|
||||
if (luck > ENGINE_LUCK_THRESHOLD)
|
||||
return ENGINE_LUCK_LUCKY;
|
||||
if (luck < -ENGINE_LUCK_VERY_THRESHOLD)
|
||||
return ENGINE_LUCK_VERY_UNLUCKY;
|
||||
if (luck < -ENGINE_LUCK_THRESHOLD)
|
||||
return ENGINE_LUCK_UNLUCKY;
|
||||
return ENGINE_LUCK_NONE;
|
||||
}
|
||||
|
||||
/* En match, gnubg calcule en chances de gain du match : on convertit. */
|
||||
static float
|
||||
normalized_equity(float score, const cubeinfo *cube)
|
||||
@@ -307,6 +428,25 @@ engine_legal_moves(const char *position_id, const char *match_id, int die1, int
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Copie une liste classée par gnubg, puis la libère. */
|
||||
static engine_status
|
||||
copy_ranked_moves(movelist *list, const position *pos, engine_move *moves, int max_moves,
|
||||
int *count)
|
||||
{
|
||||
engine_status status = ENGINE_OK;
|
||||
unsigned int i;
|
||||
|
||||
if (list->cMoves > (unsigned int) max_moves)
|
||||
status = ENGINE_ERROR_TOO_MANY_MOVES;
|
||||
else {
|
||||
for (i = 0; i < list->cMoves; i++)
|
||||
fill_move(&moves[i], &list->amMoves[i], pos, 1);
|
||||
*count = (int) list->cMoves;
|
||||
}
|
||||
g_free(list->amMoves);
|
||||
return status;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_best_moves(const char *position_id, const char *match_id, int die1, int die2, int ply,
|
||||
engine_move *moves, int max_moves, int *count)
|
||||
@@ -315,7 +455,6 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
unsigned int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
@@ -336,15 +475,94 @@ engine_best_moves(const char *position_id, const char *match_id, int die1, int d
|
||||
aaamfMoveFilterSettings[0]) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
RefreshMoveList(&list, NULL);
|
||||
return copy_ranked_moves(&list, &pos, moves, max_moves, count);
|
||||
}
|
||||
|
||||
status = ENGINE_OK;
|
||||
if (list.cMoves > (unsigned int) max_moves)
|
||||
status = ENGINE_ERROR_TOO_MANY_MOVES;
|
||||
else {
|
||||
for (i = 0; i < list.cMoves; i++)
|
||||
fill_move(&moves[i], &list.amMoves[i], &pos, 1);
|
||||
*count = (int) list.cMoves;
|
||||
}
|
||||
engine_status
|
||||
engine_level_best_moves(const char *position_id, const char *match_id, int die1, int die2,
|
||||
int level, engine_move *moves, int max_moves, int *count)
|
||||
{
|
||||
position pos;
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Comme l'ordinateur de gnubg quand c'est son tour (ComputerTurn dans
|
||||
* play.c, l. 1436-1453) : FindnSaveBestMoves sans coup à garder, sans
|
||||
* analyse et sans seuil, avec les réglages et le filtre du joueur, puis
|
||||
* RefreshMoveList ; il joue le premier coup de la liste.
|
||||
*/
|
||||
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, NULL, FALSE, 0.0f,
|
||||
&pos.cube, &context, move_filter_for_level(level)) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
RefreshMoveList(&list, NULL);
|
||||
return copy_ranked_moves(&list, &pos, moves, max_moves, count);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Analyse d'un coup joué */
|
||||
|
||||
engine_status
|
||||
engine_analyse_move(const char *position_id, const char *match_id, int die1, int die2,
|
||||
const char *played_position_id, int ply, engine_move_analysis *analysis)
|
||||
{
|
||||
position pos;
|
||||
TanBoard played_board;
|
||||
positionkey played_key;
|
||||
movelist list;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
unsigned int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
if (!is_base64_id(played_position_id, L_POSITIONID)
|
||||
|| !PositionFromID(played_board, played_position_id))
|
||||
return ENGINE_ERROR_MOVE;
|
||||
PositionKey((ConstTanBoard) played_board, &played_key);
|
||||
|
||||
/*
|
||||
* Comme l'analyse de partie de gnubg (AnalyzeMove dans analysis.c,
|
||||
* l. 685-735) : FindnSaveBestMoves avec la clé du coup joué (évalué au
|
||||
* niveau le plus profond), fAnalyse vrai, seuil « douteux » et filtre
|
||||
* d'analyse (aamfAnalysis, « Normal » par défaut, gnubg.c l. 406) ; pas
|
||||
* de RefreshMoveList. Le rang est la place du coup joué dans cette
|
||||
* liste, et la perte rChequerSkill (l. 726) son équité moins celle du
|
||||
* premier.
|
||||
*/
|
||||
memset(analysis, 0, sizeof *analysis);
|
||||
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE,
|
||||
DOUBTFUL_THRESHOLD, &pos.cube, &context,
|
||||
aaamfMoveFilterSettings[0]) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
if (list.cMoves == 0)
|
||||
return ENGINE_OK; /* aucun coup possible : rien à analyser */
|
||||
|
||||
status = ENGINE_ERROR_MOVE;
|
||||
for (i = 0; i < list.cMoves; i++)
|
||||
if (EqualKeys(played_key, list.amMoves[i].key)) {
|
||||
analysis->move_count = (int) list.cMoves;
|
||||
analysis->rank = (int) i + 1;
|
||||
fill_move(&analysis->played, &list.amMoves[i], &pos, 1);
|
||||
fill_move(&analysis->best, &list.amMoves[0], &pos, 1);
|
||||
analysis->equity_loss = list.amMoves[i].rScore - list.amMoves[0].rScore;
|
||||
analysis->skill = engine_skill_of(analysis->equity_loss);
|
||||
status = ENGINE_OK;
|
||||
break;
|
||||
}
|
||||
g_free(list.amMoves);
|
||||
return status;
|
||||
}
|
||||
@@ -387,27 +605,19 @@ static const char *const cube_decision_names[] = {
|
||||
"OPTIONAL_DOUBLE_BEAVER", "OPTIONAL_DOUBLE_PASS", "OPTIONAL_REDOUBLE_PASS"
|
||||
};
|
||||
|
||||
engine_status
|
||||
engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube)
|
||||
/* Décision de videau de la position avec ces réglages d'évaluation. */
|
||||
static engine_status
|
||||
cube_decision(position *pos, const evalcontext *context, engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
float output[2][NUM_ROLLOUT_OUTPUTS];
|
||||
float equities[NUM_CUBEFUL_OUTPUTS];
|
||||
cubedecision decision;
|
||||
engine_status status;
|
||||
int i;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos.board, &pos.cube, &context, NULL) < 0)
|
||||
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
/* FindCubeDecision renvoie des équités déjà normalisées. */
|
||||
decision = FindCubeDecision(equities, output, &pos.cube);
|
||||
decision = FindCubeDecision(equities, output, &pos->cube);
|
||||
if ((size_t) decision >= sizeof cube_decision_names / sizeof cube_decision_names[0])
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
|
||||
@@ -419,13 +629,259 @@ engine_cube_decision(const char *position_id, const char *match_id, int ply, eng
|
||||
cube->optimal = equities[OUTPUT_OPTIMAL];
|
||||
cube->decision = cube_decision_names[decision];
|
||||
cube->decision_text = GetCubeRecommendation(decision);
|
||||
GetDPEq(&cube->available, NULL, &pos.cube);
|
||||
GetDPEq(&cube->available, NULL, &pos->cube);
|
||||
cube->should_double = decision == DOUBLE_TAKE || decision == DOUBLE_PASS
|
||||
|| decision == DOUBLE_BEAVER || decision == REDOUBLE_TAKE || decision == REDOUBLE_PASS;
|
||||
cube->should_take = cube->double_take <= cube->double_pass;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_cube_decision(const char *position_id, const char *match_id, int ply, engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
return cube_decision(&pos, &context, cube);
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_level_cube_decision(const char *position_id, const char *match_id, int level,
|
||||
engine_cube *cube)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
return cube_decision(&pos, &context, cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- Analyse d'une décision de videau */
|
||||
|
||||
/* Le plus petit de deux nombres. */
|
||||
static float
|
||||
min_float(float a, float b)
|
||||
{
|
||||
return a < b ? a : b;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_analyse_cube(const char *position_id, const char *match_id, int ply,
|
||||
engine_cube_action action, engine_cube_analysis *analysis)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
engine_status status;
|
||||
int can_double;
|
||||
float take, pass, optimal;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_ply(ply, &context);
|
||||
if (status == ENGINE_OK && (action < ENGINE_CUBE_ACTION_NO_DOUBLE || action > ENGINE_CUBE_ACTION_PASS))
|
||||
status = ENGINE_ERROR_CUBE_ACTION;
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Quand gnubg analyse-t-il la décision (AnalyzeMove dans analysis.c) ?
|
||||
* - ne pas doubler (cas MOVE_NORMAL, l. 655) : hors de la position de
|
||||
* départ, et si le joueur au trait a accès au videau (GetDPEq) ;
|
||||
* - doubler, prendre, passer (cas MOVE_DOUBLE, l. 760) : si GetDPEq le
|
||||
* permet, ou si le videau est au centre ou au joueur au trait.
|
||||
*/
|
||||
memset(analysis, 0, sizeof *analysis);
|
||||
can_double = GetDPEq(NULL, NULL, &pos.cube);
|
||||
if (action == ENGINE_CUBE_ACTION_NO_DOUBLE)
|
||||
analysis->analysed = can_double && strcmp(position_id, START_POSITION_ID) != 0;
|
||||
else
|
||||
analysis->analysed = can_double || pos.cube.fCubeOwner < 0
|
||||
|| pos.cube.fCubeOwner == pos.cube.fMove;
|
||||
if (!analysis->analysed)
|
||||
return ENGINE_OK;
|
||||
|
||||
status = cube_decision(&pos, &context, &analysis->cube);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/*
|
||||
* Pertes de gnubg (AnalyzeMove), à partir des équités de FindCubeDecision
|
||||
* vues du joueur au trait :
|
||||
* - ne pas doubler : sans double moins optimale (l. 672) ;
|
||||
* - doubler : la moins bonne des réponses moins optimale (l. 777) ;
|
||||
* - prendre : Skill(-doubleError) avec doubleError = prend moins passe
|
||||
* (l. 772 et 803), soit passe moins prend pour l'adversaire ;
|
||||
* - passer : Skill(doubleError) (l. 823), soit prend moins passe.
|
||||
* Les deux dernières ne sont des erreurs que négatives : un écart positif
|
||||
* est un bon choix, ramené à 0.
|
||||
*/
|
||||
take = analysis->cube.double_take;
|
||||
pass = analysis->cube.double_pass;
|
||||
optimal = analysis->cube.optimal;
|
||||
switch (action) {
|
||||
case ENGINE_CUBE_ACTION_NO_DOUBLE:
|
||||
analysis->equity_loss = analysis->cube.no_double - optimal;
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_DOUBLE:
|
||||
analysis->equity_loss = (take < pass ? take : pass) - optimal;
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_TAKE:
|
||||
analysis->equity_loss = min_float(0.0f, pass - take);
|
||||
break;
|
||||
case ENGINE_CUBE_ACTION_PASS:
|
||||
analysis->equity_loss = min_float(0.0f, take - pass);
|
||||
break;
|
||||
}
|
||||
analysis->skill = engine_skill_of(analysis->equity_loss);
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Chance d'un jet */
|
||||
|
||||
/*
|
||||
* Réglages de l'analyse de la chance : ecLuck de gnubg (analysis.c l. 89),
|
||||
* 0 pli avec videau, sans bruit.
|
||||
*/
|
||||
static const evalcontext luck_context = {
|
||||
.fCubeful = TRUE, .nPlies = 0, .fUsePrune = FALSE, .fDeterministic = TRUE, .rNoise = 0.0f
|
||||
};
|
||||
|
||||
/*
|
||||
* Équité, pour le joueur au trait (cube), après le meilleur coup du jet
|
||||
* die1, die2 à 0 pli ; s'il ne peut pas jouer, équité de la position vue
|
||||
* de l'adversaire (opponent), changée de signe. Repris de la boucle de
|
||||
* LuckNormal (analysis.c l. 213-243). Renvoie ERR_VAL en cas d'échec.
|
||||
*/
|
||||
static float
|
||||
equity_after_best_move(ConstTanBoard board, int die1, int die2, const cubeinfo *cube,
|
||||
cubeinfo *opponent)
|
||||
{
|
||||
TanBoard swapped;
|
||||
float output[NUM_ROLLOUT_OUTPUTS];
|
||||
movelist list;
|
||||
float equity;
|
||||
|
||||
if (FindnSaveBestMoves(&list, die1, die2, board, NULL, FALSE, 0.0f, cube, &luck_context,
|
||||
defaultFilters) < 0) {
|
||||
g_free(list.amMoves);
|
||||
return ERR_VAL;
|
||||
}
|
||||
if (list.cMoves) {
|
||||
equity = list.amMoves[0].rScore;
|
||||
g_free(list.amMoves);
|
||||
return equity;
|
||||
}
|
||||
memcpy(swapped, board, sizeof swapped);
|
||||
SwapSides(swapped);
|
||||
if (GeneralEvaluationE(output, (ConstTanBoard) swapped, opponent, &luck_context) < 0)
|
||||
return ERR_VAL;
|
||||
return -normalized_equity(output[OUTPUT_CUBEFUL_EQUITY], opponent);
|
||||
}
|
||||
|
||||
/*
|
||||
* LuckNormal (analysis.c l. 200-247) : équité du jet moins la moyenne des
|
||||
* 36 jets. i et j sont les dés moins 1, i >= j.
|
||||
*/
|
||||
static float
|
||||
luck_normal(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
|
||||
{
|
||||
cubeinfo opponent = *cube;
|
||||
float equities[6][6], mean = 0.0f;
|
||||
int i, j;
|
||||
|
||||
opponent.fMove = !cube->fMove;
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = 0; j <= i; j++) {
|
||||
equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent);
|
||||
if (equities[i][j] == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
mean += i == j ? equities[i][j] : equities[i][j] * 2.0f;
|
||||
}
|
||||
return equities[i0][j0] - mean / 36.0f;
|
||||
}
|
||||
|
||||
/*
|
||||
* LuckFirst (analysis.c l. 104-197) : au premier jet de la partie, les
|
||||
* doubles sont impossibles et le jet peut échoir à l'un ou l'autre joueur.
|
||||
* Équité du jet moins la moyenne des 30 cas : les 15 jets pour le joueur au
|
||||
* trait, et les 15 pour l'adversaire (meilleur coup de l'adversaire, changé
|
||||
* de signe). i et j sont les dés moins 1, i > j.
|
||||
*/
|
||||
static float
|
||||
luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
|
||||
{
|
||||
cubeinfo opponent = *cube;
|
||||
TanBoard swapped;
|
||||
float equities[6][6], mean = 0.0f, equity;
|
||||
int i, j;
|
||||
|
||||
opponent.fMove = !cube->fMove;
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = 0; j < i; j++) {
|
||||
equities[i][j] = equity_after_best_move(board, i + 1, j + 1, cube, &opponent);
|
||||
if (equities[i][j] == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
mean += equities[i][j];
|
||||
}
|
||||
|
||||
/* L'adversaire au trait : son meilleur coup, vu de lui, changé de signe.
|
||||
* gnubg, s'il ne peut pas jouer (impossible au départ), reprend
|
||||
* l'évaluation du joueur au trait (l. 168-181). */
|
||||
memcpy(swapped, board, sizeof swapped);
|
||||
SwapSides(swapped);
|
||||
for (i = 0; i < 6; i++)
|
||||
for (j = i + 1; j < 6; j++) {
|
||||
equity = equity_after_best_move((ConstTanBoard) swapped, i + 1, j + 1, &opponent, cube);
|
||||
if (equity == ERR_VAL)
|
||||
return ERR_VAL;
|
||||
equities[i][j] = -equity;
|
||||
mean += equities[i][j];
|
||||
}
|
||||
return equities[i0][j0] - mean / 30.0f;
|
||||
}
|
||||
|
||||
engine_status
|
||||
engine_luck_of(const char *position_id, const char *match_id, int die1, int die2,
|
||||
engine_luck *luck)
|
||||
{
|
||||
position pos;
|
||||
engine_status status;
|
||||
int high, low;
|
||||
float value;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = check_dice(die1, die2);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
start_evaluation();
|
||||
|
||||
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
|
||||
* faible ; LuckFirst à la position de départ hors doubles. */
|
||||
high = (die1 > die2 ? die1 : die2) - 1;
|
||||
low = (die1 > die2 ? die2 : die1) - 1;
|
||||
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
|
||||
value = luck_first((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
else
|
||||
value = luck_normal((ConstTanBoard) pos.board, high, low, &pos.cube);
|
||||
if (value == ERR_VAL)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
luck->luck = value;
|
||||
luck->rating = engine_luck_rating_of(value);
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Plateau et fin de partie */
|
||||
|
||||
static void
|
||||
|
||||
+177
-1
@@ -48,11 +48,93 @@ typedef enum {
|
||||
ENGINE_ERROR_DICE, /* dés hors de 1..6 */
|
||||
ENGINE_ERROR_PLY, /* niveau hors de 0..ENGINE_MAX_PLY */
|
||||
ENGINE_ERROR_TOO_MANY_MOVES, /* tableau de coups trop petit */
|
||||
ENGINE_ERROR_INTERNAL /* échec signalé par gnubg */
|
||||
ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */
|
||||
ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */
|
||||
ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */
|
||||
ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */
|
||||
} engine_status;
|
||||
|
||||
#define ENGINE_MAX_PLY 4
|
||||
|
||||
/*
|
||||
* Niveaux de jeu de l'ordinateur : les réglages prédéfinis de gnubg, dans
|
||||
* l'ordre de son tableau aecSettings (engine/gnubg/eval.c, l. 305-328, noms
|
||||
* de aszSettings). Les quatre premiers évaluent à 0 pli et ajoutent un
|
||||
* bruit (rNoise, écart type en équité) qui fait commettre des erreurs ; ce
|
||||
* bruit est « déterministe » (fDeterministic) : il est tiré d'un hachage MD5
|
||||
* de la position (Noise, eval.c l. 2241), donc la même position donne
|
||||
* toujours le même coup. Les suivants sont sans bruit, de 0 à 4 plis, avec
|
||||
* le filtre de coups « Normal » (aiSettingsMoveFilter, eval.c l. 331).
|
||||
*
|
||||
* Les anciennes versions de gnubg nommaient « expert » le niveau 0 pli et
|
||||
* « world class » (champion du monde) le niveau 2 plis.
|
||||
*/
|
||||
typedef enum {
|
||||
ENGINE_LEVEL_BEGINNER = 0, /* « beginner » : 0 pli, bruit 0,060 */
|
||||
ENGINE_LEVEL_CASUAL, /* « casual play » : 0 pli, bruit 0,050 */
|
||||
ENGINE_LEVEL_INTERMEDIATE, /* « intermediate » : 0 pli, bruit 0,040 */
|
||||
ENGINE_LEVEL_ADVANCED, /* « advanced » : 0 pli, bruit 0,015 */
|
||||
ENGINE_LEVEL_0PLY, /* « 0ply » (ancien « expert ») */
|
||||
ENGINE_LEVEL_1PLY, /* « 1ply » */
|
||||
ENGINE_LEVEL_2PLY, /* « 2ply » (ancien « world class ») */
|
||||
ENGINE_LEVEL_3PLY, /* « 3ply » */
|
||||
ENGINE_LEVEL_4PLY, /* « 4ply » */
|
||||
ENGINE_LEVEL_COUNT
|
||||
} engine_level;
|
||||
|
||||
/* Réglages d'un niveau, tels que gnubg les définit. */
|
||||
typedef struct {
|
||||
const char *name; /* identifiant : « beginner », « casual »,
|
||||
* « intermediate », « advanced », « 0ply »… « 4ply » */
|
||||
int plies; /* profondeur, en plis de gnubg */
|
||||
float noise; /* écart type du bruit (0 : aucun) */
|
||||
int deterministic; /* bruit tiré de la position, reproductible */
|
||||
int prune; /* élagage des réponses (gnubg : à partir d'1 pli) */
|
||||
} engine_level_settings;
|
||||
|
||||
/*
|
||||
* Appréciation d'un coup ou d'une décision de videau : seuils de gnubg sur
|
||||
* l'équité perdue, normalisée pour un videau de 1 (arSkillLevel dans
|
||||
* gnubg.c l. 280-285 et Skill dans analysis.c l. 288). Une perte
|
||||
* strictement plus grande que le seuil fait passer à la catégorie.
|
||||
*/
|
||||
#define ENGINE_SKILL_DOUBTFUL_THRESHOLD 0.03f /* douteux */
|
||||
#define ENGINE_SKILL_BAD_THRESHOLD 0.06f /* mauvais */
|
||||
#define ENGINE_SKILL_VERY_BAD_THRESHOLD 0.12f /* très mauvais */
|
||||
|
||||
typedef enum {
|
||||
ENGINE_SKILL_NONE = 0, /* rien à signaler */
|
||||
ENGINE_SKILL_DOUBTFUL, /* douteux */
|
||||
ENGINE_SKILL_BAD, /* mauvais */
|
||||
ENGINE_SKILL_VERY_BAD /* très mauvais */
|
||||
} engine_skill;
|
||||
|
||||
/*
|
||||
* Chance d'un jet : seuils de gnubg sur l'équité gagnée ou perdue par
|
||||
* rapport à la moyenne des jets (arLuckLevel dans gnubg.c l. 274-279 et Luck
|
||||
* dans analysis.c l. 272). Un écart strictement plus grand que le seuil
|
||||
* fait passer à la catégorie.
|
||||
*/
|
||||
#define ENGINE_LUCK_THRESHOLD 0.3f /* chanceux, malchanceux */
|
||||
#define ENGINE_LUCK_VERY_THRESHOLD 0.6f /* très chanceux, très malchanceux */
|
||||
|
||||
typedef enum {
|
||||
ENGINE_LUCK_VERY_UNLUCKY = 0, /* très malchanceux */
|
||||
ENGINE_LUCK_UNLUCKY, /* malchanceux */
|
||||
ENGINE_LUCK_NONE, /* ordinaire */
|
||||
ENGINE_LUCK_LUCKY, /* chanceux */
|
||||
ENGINE_LUCK_VERY_LUCKY /* très chanceux */
|
||||
} engine_luck_rating;
|
||||
|
||||
/* Décision de videau prise par un joueur. Les deux premières sont celles
|
||||
* du joueur au trait, les deux dernières sa réponse par l'adversaire. */
|
||||
typedef enum {
|
||||
ENGINE_CUBE_ACTION_NO_DOUBLE = 0,
|
||||
ENGINE_CUBE_ACTION_DOUBLE,
|
||||
ENGINE_CUBE_ACTION_TAKE,
|
||||
ENGINE_CUBE_ACTION_PASS
|
||||
} engine_cube_action;
|
||||
|
||||
/* Nombre maximal de coups légaux pour un jet (MAX_MOVES de gnubg). */
|
||||
#define ENGINE_MAX_MOVES 3060
|
||||
|
||||
@@ -124,6 +206,39 @@ typedef struct {
|
||||
* (double_take <= double_pass) */
|
||||
} engine_cube;
|
||||
|
||||
/* Analyse d'un coup joué, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
int move_count; /* nombre de coups légaux (0 : aucun coup
|
||||
* possible, rien d'autre n'est rempli) */
|
||||
int rank; /* rang du coup joué dans le classement, à
|
||||
* partir de 1 */
|
||||
engine_move played; /* le coup joué, évalué */
|
||||
engine_move best; /* le meilleur coup, évalué */
|
||||
float equity_loss; /* équité du coup joué moins celle du meilleur :
|
||||
* 0 ou négative */
|
||||
engine_skill skill; /* appréciation de gnubg pour cette perte */
|
||||
} engine_move_analysis;
|
||||
|
||||
/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
int analysed; /* gnubg analyse cette décision ; sinon (videau
|
||||
* inaccessible, position de départ…), perte
|
||||
* nulle et cube non rempli */
|
||||
engine_cube cube; /* la décision de videau du joueur au trait */
|
||||
float equity_loss; /* équité perdue par celui qui décide, du point
|
||||
* de vue de celui-ci, normalisée pour un videau
|
||||
* de 1 : 0 ou négative */
|
||||
engine_skill skill; /* appréciation de gnubg pour cette perte */
|
||||
} engine_cube_analysis;
|
||||
|
||||
/* Chance d'un jet, comme l'analyse de partie de gnubg. */
|
||||
typedef struct {
|
||||
float luck; /* équité gagnée (positive) ou perdue par ce jet
|
||||
* par rapport à la moyenne des jets, du point
|
||||
* de vue du joueur au trait */
|
||||
engine_luck_rating rating;
|
||||
} engine_luck;
|
||||
|
||||
/* Un camp sur le plateau, vu de lui-même (sa case 1 est la plus proche de
|
||||
* la sortie). */
|
||||
typedef struct {
|
||||
@@ -237,6 +352,67 @@ engine_status engine_evaluate(const char *position_id, const char *match_id,
|
||||
engine_status engine_cube_decision(const char *position_id, const char *match_id,
|
||||
int ply, engine_cube *cube);
|
||||
|
||||
/* ------------------------------------------------------ Niveaux de jeu */
|
||||
|
||||
/* Réglages d'un niveau (engine_level). */
|
||||
engine_status engine_level_settings_of(int level, engine_level_settings *settings);
|
||||
|
||||
/* Le niveau qui porte ce nom, ou ENGINE_ERROR_LEVEL. */
|
||||
engine_status engine_level_from_name(const char *name, int *level);
|
||||
|
||||
/*
|
||||
* Coups légaux classés comme les classe l'ordinateur de gnubg quand il joue
|
||||
* à ce niveau (ComputerTurn dans play.c : FindnSaveBestMoves sans analyse
|
||||
* ni seuil, filtre « Normal », puis RefreshMoveList) ; il joue le premier.
|
||||
* Les équités incluent le bruit du niveau.
|
||||
*/
|
||||
engine_status engine_level_best_moves(const char *position_id, const char *match_id,
|
||||
int die1, int die2, int level,
|
||||
engine_move *moves, int max_moves, int *count);
|
||||
|
||||
/* Décision de videau évaluée avec les réglages de ce niveau, bruit compris. */
|
||||
engine_status engine_level_cube_decision(const char *position_id, const char *match_id,
|
||||
int level, engine_cube *cube);
|
||||
|
||||
/* ------------------------------------------------------------- Analyse */
|
||||
|
||||
/* Appréciation de gnubg pour une perte d'équité (0 ou négative). */
|
||||
engine_skill engine_skill_of(float equity_loss);
|
||||
|
||||
/* Catégorie de chance de gnubg pour une chance de jet. */
|
||||
engine_luck_rating engine_luck_rating_of(float luck);
|
||||
|
||||
/*
|
||||
* Analyse le coup joué par le joueur au trait avec le jet die1, die2, au
|
||||
* niveau ply, comme l'analyse de partie de gnubg (AnalyzeMove dans
|
||||
* analysis.c : FindnSaveBestMoves avec le coup joué, seuil « douteux »,
|
||||
* filtre « Normal », sans RefreshMoveList). played_position_id est la
|
||||
* position après le coup, vue du joueur qui vient de le jouer (champ
|
||||
* position_id d'engine_move). ENGINE_ERROR_MOVE si ce n'est pas un coup
|
||||
* légal ; si aucun coup n'est possible, move_count vaut 0.
|
||||
*/
|
||||
engine_status engine_analyse_move(const char *position_id, const char *match_id,
|
||||
int die1, int die2, const char *played_position_id, int ply,
|
||||
engine_move_analysis *analysis);
|
||||
|
||||
/*
|
||||
* Analyse une décision de videau au niveau ply, comme l'analyse de partie de
|
||||
* gnubg (AnalyzeMove, cas MOVE_NORMAL, MOVE_DOUBLE, MOVE_TAKE et
|
||||
* MOVE_DROP). La position et le Match ID sont ceux du joueur au trait, qui
|
||||
* double ou non ; pour TAKE et PASS, la perte est celle de son adversaire.
|
||||
*/
|
||||
engine_status engine_analyse_cube(const char *position_id, const char *match_id, int ply,
|
||||
engine_cube_action action, engine_cube_analysis *analysis);
|
||||
|
||||
/*
|
||||
* Chance du jet die1, die2 pour le joueur au trait, comme l'analyse de
|
||||
* partie de gnubg (LuckAnalysis dans analysis.c : meilleur coup de chaque
|
||||
* jet à 0 pli, avec videau ; à la position de départ, le premier jet ne
|
||||
* peut pas être un double).
|
||||
*/
|
||||
engine_status engine_luck_of(const char *position_id, const char *match_id,
|
||||
int die1, int die2, engine_luck *luck);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+85
-4
@@ -12,6 +12,7 @@
|
||||
// premier problème, ou si la page signale une erreur (dont une violation de
|
||||
// la politique de sécurité du contenu).
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
@@ -78,9 +79,49 @@ function check(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Les coups joués de chaque camp, d'après le journal enregistré. */
|
||||
/** Le journal de la partie en cours, tel qu'enregistré dans IndexedDB
|
||||
* (app/src/storage/indexeddb-backend.ts), ou null. */
|
||||
async function readJournal() {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onerror = () => reject(open.error);
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction(["settings", "journals"]);
|
||||
const current = tx.objectStore("settings").get("current");
|
||||
current.onsuccess = () => {
|
||||
const id = current.result?.value;
|
||||
const journal = id === undefined ? null : tx.objectStore("journals").get(id);
|
||||
if (journal === null) {
|
||||
db.close();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
journal.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(journal.result?.journal ?? null);
|
||||
};
|
||||
};
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Le journal enregistré, une fois les enregistrements en cours terminés
|
||||
* (l'application enregistre sans attendre) : deux lectures identiques. */
|
||||
async function savedJournal() {
|
||||
return page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.current-game")));
|
||||
let last = await readJournal();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const next = await readJournal();
|
||||
if (JSON.stringify(next) === JSON.stringify(last)) {
|
||||
return next;
|
||||
}
|
||||
last = next;
|
||||
}
|
||||
throw new Error("le journal enregistré ne se stabilise pas");
|
||||
}
|
||||
|
||||
function countMoves(journal, side) {
|
||||
@@ -123,8 +164,7 @@ async function waitForPlayer() {
|
||||
return (
|
||||
`${busy} ; « ${text("info")} » ; « ${text("prompt")} » ; moteur : ` +
|
||||
`${engine.sent} commandes, ${engine.received} réponses, dernière ` +
|
||||
`${JSON.stringify(engine.last)} ; journal ` +
|
||||
localStorage.getItem("ustagammon.current-game")
|
||||
`${JSON.stringify(engine.last)}`
|
||||
);
|
||||
})
|
||||
.catch((error) => `page illisible (${error.message})`);
|
||||
@@ -487,6 +527,47 @@ if (computerMs.length > 0) {
|
||||
}
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
step("mes parties");
|
||||
// Mes parties (IndexedDB) : une deuxième partie commencée, les deux
|
||||
// listées, la première exportée en SGF puis reprise, après rechargement.
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head, "la nouvelle partie n'a pas été enregistrée");
|
||||
await page.click("#games-button");
|
||||
await page.waitForSelector("#games-dialog[open] .games-item");
|
||||
const items = page.locator("#games-dialog .games-item");
|
||||
check((await items.count()) === 2, `${await items.count()} parties listées au lieu de 2`);
|
||||
const oldest = items.nth(1);
|
||||
await oldest.locator('[data-action="branches"]').click();
|
||||
await oldest.locator(".games-branch").first().waitFor();
|
||||
const branchCount = await oldest.locator(".games-branch").count();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
oldest.locator('[data-action="export"]').click(),
|
||||
]);
|
||||
const sgf = readFileSync(await download.path(), "utf8");
|
||||
writeFileSync(`${shots}/${browserName}-partie.sgf`, sgf);
|
||||
check(sgf.startsWith("(;FF[4]GM[6]"), `export SGF inattendu : ${sgf.slice(0, 40)}`);
|
||||
check(
|
||||
(sgf.match(/;[BW]\[\d\d/g) ?? []).length === countMoves(finished, "player") + countMoves(finished, "computer"),
|
||||
"export SGF : nombre de coups différent du journal",
|
||||
);
|
||||
await shot("4b-mes-parties");
|
||||
await collectViolations();
|
||||
await Promise.all([page.waitForEvent("load"), oldest.locator('[data-action="resume"]').click()]);
|
||||
await waitForPlayer();
|
||||
const resumed = await savedJournal();
|
||||
check(resumed.head === finished.head, "la première partie n'a pas été reprise");
|
||||
check((await page.textContent("#prompt")) === result, "écran différent après la reprise");
|
||||
log(
|
||||
`mes parties : 2 parties listées (${branchCount} branche(s) pour la première), ` +
|
||||
`SGF exporté (${sgf.length} octets), première partie reprise après rechargement`,
|
||||
);
|
||||
}
|
||||
|
||||
step("aide");
|
||||
// Aide : scénario et glossaire lus dans les copies de docs/.
|
||||
await page.click("#help-button");
|
||||
|
||||
@@ -11,14 +11,16 @@ d'un fichier de résultat.
|
||||
| --- | --- |
|
||||
| `positions.json` | Positions d'entrée, versionnées, écrites à la main |
|
||||
| `results/<id>.json` | Résultats de gnubg, un fichier par position, générés |
|
||||
| `generate.py` | Script exécuté dans gnubg qui produit `results/` |
|
||||
| `compare.py` | Compare le moteur embarqué à `results/` (dernière section) |
|
||||
| `analyses.json` | Analyses d'entrée : coups joués, décisions de videau, niveaux de jeu |
|
||||
| `analysis/<id>.json` | Résultats de l'analyse de partie de gnubg, générés (section « Analyses ») |
|
||||
| `generate.py` | Script exécuté dans gnubg qui produit `results/` et `analysis/` |
|
||||
| `compare.py` | Compare le moteur embarqué à `results/` et `analysis/` (dernière section) |
|
||||
|
||||
Commandes :
|
||||
|
||||
```
|
||||
make reference-image # construit l'image Docker du gnubg de référence
|
||||
make reference # régénère results/ (une vingtaine de secondes)
|
||||
make reference # régénère results/ et analysis/ (une quarantaine de secondes)
|
||||
make test # vérifie la cohérence (tests/test_reference.sh) et
|
||||
# compare le moteur embarqué (tests/test_engine_reference.sh)
|
||||
```
|
||||
@@ -222,6 +224,69 @@ Sortie de `gnubg.cfevaluate` (`GeneralCubeDecisionE` puis
|
||||
`TOOGOOD_PASS`, `REDOUBLE_TAKE`, `NODOUBLE_DEADCUBE`…) ;
|
||||
- `decision_text` : le libellé anglais correspondant de gnubg.
|
||||
|
||||
## Analyses : `analyses.json` et `analysis/<id>.json`
|
||||
|
||||
Ce que gnubg dit d'un coup joué, d'une décision de videau et d'un jet de dés
|
||||
(sa commande « analyse game »), et le coup que joue son ordinateur à chaque
|
||||
niveau. Pour chaque cas, `generate.py` rejoue dans gnubg une courte partie
|
||||
entre deux joueurs humains : la position (`set gnubgid`), puis le coup ou la
|
||||
décision, puis l'analyse. Rien n'est calculé dans le script : les valeurs
|
||||
viennent des enregistrements de la partie analysée et des statistiques que
|
||||
gnubg en tire.
|
||||
|
||||
`analyses.json` a trois listes :
|
||||
|
||||
- `moves` : `position_id`, `match_id`, `dice` et `played`, le coup joué en
|
||||
notation de gnubg (`null` : le meilleur coup selon gnubg, pour les cas qui
|
||||
ne servent qu'à la chance du jet) ;
|
||||
- `cubes` : `position_id`, `match_id` (sans dés) et `action`, la décision :
|
||||
`no_double` ou `double` pour le joueur au trait, `take` ou `pass` pour son
|
||||
adversaire ;
|
||||
- `levels` : `position_id`, `match_id` et `dice` (`null` : décision de
|
||||
videau).
|
||||
|
||||
Un fichier de résultat reprend le cas, avec `type` (`move`, `cube` ou
|
||||
`level`), `gnubg_commit` et `met`, puis, aux niveaux d'analyse 0 et 2 plis
|
||||
(clés de `plies`, réglages prédéfinis de gnubg sans bruit, filtre
|
||||
« Normal ») :
|
||||
|
||||
- **coup joué** : `played_move` et `played_position_id` (position après le
|
||||
coup, vue de celui qui l'a joué), `move_count` (coups légaux), `rank` (place
|
||||
du coup joué dans le classement de l'analyse, à partir de 1 ; `imove` de
|
||||
gnubg plus 1), `played_equity`, `best_move`, `best_equity`, `equity_loss`
|
||||
(perte du coup joué, 0 ou négative : statistique « error-skill » de gnubg
|
||||
changée de signe), `skill` (`none`, `doubtful`, `bad`, `very_bad`) et
|
||||
`ranking` (les dix premiers coups, et jusqu'au coup joué) ; plus, une fois
|
||||
pour toutes, `luck` : `value` (chance du jet) et `rating`
|
||||
(`very_unlucky`, `unlucky`, `none`, `lucky`, `very_lucky`) ;
|
||||
- **videau** : `analysed` (gnubg analyse-t-il cette décision ? pas à la
|
||||
partie Crawford ni quand l'adversaire possède le videau), `cube` (la
|
||||
décision de videau de gnubg au même niveau, format de la section
|
||||
précédente, `null` si non analysée), `equity_loss` (perte de celui qui
|
||||
décide, statistique « error-skill » changée de signe) et `skill` ;
|
||||
- **niveaux** (`levels`, à la place de `plies`) : pour les niveaux
|
||||
`beginner`, `casual`, `intermediate`, `advanced`, `0ply`, `1ply` et `2ply`
|
||||
(3 et 4 plis sont trop lents pour la suite de tests), `plies` et `noise`
|
||||
(réglages de gnubg), puis `move`, le coup que joue l'ordinateur de gnubg
|
||||
réglé à ce niveau (commande « play »), ou la décision de videau de gnubg
|
||||
avec ces réglages, bruit compris.
|
||||
|
||||
Deux précautions, faute de quoi gnubg donne des valeurs qui dépendent de ce
|
||||
qu'il a calculé avant :
|
||||
|
||||
- **le cache d'évaluation est vidé avant chaque analyse**, et l'analyse du
|
||||
videau est désactivée pour les coups joués. Une évaluation à 2 plis avec
|
||||
élagage range dans le cache des évaluations à 0 pli faites avec les réseaux
|
||||
d'élagage, sous la même clé que les évaluations à 0 pli ordinaires (la clé
|
||||
d'`EvalKey`, dans `eval.c`, n'inclut l'élagage qu'à partir d'1 pli). Une
|
||||
analyse à 0 pli, ou l'analyse de la chance qui suit l'analyse du videau à
|
||||
2 plis, les reprend : écarts observés de 5e-3 à 2,5e-2. Le moteur embarqué
|
||||
a le même cache ; `compare.py` l'interroge par un processus neuf à chaque
|
||||
commande ;
|
||||
- **le coup choisi pour un cas sans `played` vient de `findbestmove`** et non
|
||||
de « hint », dont gnubg garde l'analyse dans l'enregistrement du coup :
|
||||
« analyse game » la reprendrait au lieu de calculer au niveau demandé.
|
||||
|
||||
## Comparaison avec le moteur embarqué
|
||||
|
||||
`make test` compare le moteur embarqué à ces résultats
|
||||
@@ -264,6 +329,22 @@ Pour chaque position et chaque niveau :
|
||||
- même décision de videau (`decision` et `decision_text`), et les quatre
|
||||
équités du videau à la tolérance près.
|
||||
|
||||
Pour chaque analyse (`engine-cli analysemove`, `analysecube`, `luck`, et
|
||||
`--level NIVEAU bestmove` ou `cube`) :
|
||||
|
||||
- coup joué : même nombre de coups légaux, même notation du coup joué, même
|
||||
rang sauf égalité (les coups entre les deux rangs ont pour gnubg la même
|
||||
équité que le coup joué, à la tolérance près : remarque), même meilleur coup
|
||||
sauf égalité, et `played_equity`, `best_equity`, `equity_loss` à la
|
||||
tolérance près ;
|
||||
- chance : `value` à la tolérance près ;
|
||||
- videau : même `analysed`, même décision et équités à la tolérance près,
|
||||
`equity_loss` à la tolérance près ;
|
||||
- appréciation (`skill`) et chance (`rating`) identiques, sauf si la valeur
|
||||
de gnubg est à moins de la tolérance d'un seuil (remarque) ;
|
||||
- niveaux : mêmes réglages que gnubg (`engine-cli levels`), même coup choisi,
|
||||
ou même décision de videau et équités à la tolérance près.
|
||||
|
||||
Le rapport donne, pour chaque écart hors critères, la position et le niveau,
|
||||
le champ, la valeur attendue (gnubg), la valeur obtenue (moteur) et l'écart ;
|
||||
il donne toujours l'écart maximal observé.
|
||||
@@ -309,6 +390,11 @@ qu'affiche gnubg (trois décimales). Les vrais défauts (mauvais niveau,
|
||||
mauvais contexte de videau, mauvaise conversion d'équité) donnent des écarts
|
||||
d'un ou plusieurs centièmes.
|
||||
|
||||
Mesure du 2026-11-03, avec les 37 analyses (2 890 nombres comparés, natif et
|
||||
WebAssembly) : écart maximal 1,64e-3, sur `move-jacoby` à 2 plis (équité du
|
||||
coup joué `8/2 5/1*`, et donc sa perte), même phénomène de bascule à 2 plis ; tous les autres
|
||||
nombres des analyses restent sous 1e-3. La tolérance n'a pas été changée.
|
||||
|
||||
Sur téléphone (NEON) les écarts seront à mesurer de la même façon : avec
|
||||
`-ffast-math`, gnubg y remplace aussi la division par une approximation
|
||||
(`vrecpeq_f32` et une itération de Newton), différente de celle de SSE2.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"moves": [
|
||||
{
|
||||
"id": "move-opening-31-best",
|
||||
"description": "Ouverture 3-1 jouée au mieux (8/5 6/5) ; chance du premier jet.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "8/5 6/5"
|
||||
},
|
||||
{
|
||||
"id": "move-opening-31-error",
|
||||
"description": "Ouverture 3-1 jouée 24/23 13/10 au lieu de faire le point 5.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "24/23 13/10"
|
||||
},
|
||||
{
|
||||
"id": "move-opening-65-error",
|
||||
"description": "Ouverture 6-5 jouée 13/8 13/7 au lieu de courir 24/13.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cAkXAAAAAAAA",
|
||||
"dice": [6, 5],
|
||||
"played": "13/8 13/7"
|
||||
},
|
||||
{
|
||||
"id": "move-middlegame-close",
|
||||
"description": "Milieu de partie, 6-4 : un coup proche du meilleur.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/2 6/2"
|
||||
},
|
||||
{
|
||||
"id": "move-middlegame-blunder",
|
||||
"description": "Milieu de partie, 6-4 : un des pires coups.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/4 8/2"
|
||||
},
|
||||
{
|
||||
"id": "move-match",
|
||||
"description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cImuACAAEAAE",
|
||||
"dice": [5, 3],
|
||||
"played": "24/16"
|
||||
},
|
||||
{
|
||||
"id": "move-race",
|
||||
"description": "Course, 5-4 : sortie maladroite.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [5, 4],
|
||||
"played": "6/1 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-blitz-doubles",
|
||||
"description": "Attaque, double 3 : un coup parmi 110.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [3, 3],
|
||||
"played": "13/10 13/10 5/2 4/1"
|
||||
},
|
||||
{
|
||||
"id": "move-crawford",
|
||||
"description": "Match en 5 points, partie Crawford, 4-3.",
|
||||
"position_id": "4Dn4AGiynYMHAA",
|
||||
"match_id": "8AmuAEAAEAAE",
|
||||
"dice": [4, 3],
|
||||
"played": "5/2 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-jacoby",
|
||||
"description": "Partie libre avec la règle Jacoby, 6-4.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4],
|
||||
"played": "8/2 5/1"
|
||||
},
|
||||
{
|
||||
"id": "move-bearoff-doubles",
|
||||
"description": "Sortie, double 6 : coup unique ; chance d'un double.",
|
||||
"position_id": "xgAA4MgAAAAAAA",
|
||||
"match_id": "cAkbAAAAAAAA",
|
||||
"dice": [6, 6],
|
||||
"played": "6/off 6/off 4/off 1/off"
|
||||
},
|
||||
{
|
||||
"id": "move-forced",
|
||||
"description": "Rentrée forcée depuis la barre, 3-1.",
|
||||
"position_id": "tm3ABwBw5+ABYA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1],
|
||||
"played": "bar/24"
|
||||
},
|
||||
{
|
||||
"id": "move-luck-unlucky",
|
||||
"description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [5, 4],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-luck-unlucky-bearoff",
|
||||
"description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "TQAAYAsAAAAAAA",
|
||||
"match_id": "cAkFAAAAAAAA",
|
||||
"dice": [2, 1],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-luck-lucky",
|
||||
"description": "Course, double 4 : jet chanceux, joué au mieux.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [4, 4],
|
||||
"played": null
|
||||
},
|
||||
{
|
||||
"id": "move-opening-double",
|
||||
"description": "Position de départ et double 6 : pas un premier jet, chance comptée sur les 36 jets.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [6, 6],
|
||||
"played": null
|
||||
}
|
||||
],
|
||||
"cubes": [
|
||||
{
|
||||
"id": "cube-double-take-no-double",
|
||||
"description": "Double/prend : le joueur au trait ne double pas.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-double",
|
||||
"description": "Double/prend : le joueur au trait double.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-take",
|
||||
"description": "Double/prend : l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-take-pass",
|
||||
"description": "Double/prend : l'adversaire passe à tort.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-pass-take",
|
||||
"description": "Double/passe : l'adversaire prend à tort.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-double-pass-pass",
|
||||
"description": "Double/passe : l'adversaire passe.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-no-double-double",
|
||||
"description": "Pas de double : le joueur au trait double trop tôt.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-no-double-no-double",
|
||||
"description": "Pas de double : le joueur au trait ne double pas.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-redouble-take",
|
||||
"description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "UQkAAAAAAAAA",
|
||||
"action": "take"
|
||||
},
|
||||
{
|
||||
"id": "cube-match-double",
|
||||
"description": "Match en 7 points, 3-5 : le joueur au trait double.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-match-pass",
|
||||
"description": "Match en 7 points, 3-5 : l'adversaire passe.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "pass"
|
||||
},
|
||||
{
|
||||
"id": "cube-post-crawford-double",
|
||||
"description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAmgAEAAEAAE",
|
||||
"action": "double"
|
||||
},
|
||||
{
|
||||
"id": "cube-crawford-not-analysed",
|
||||
"description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "8AmgAEAAEAAE",
|
||||
"action": "no_double"
|
||||
},
|
||||
{
|
||||
"id": "cube-opponent-owns-not-analysed",
|
||||
"description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "EQEAAAAAAAAA",
|
||||
"action": "no_double"
|
||||
}
|
||||
],
|
||||
"levels": [
|
||||
{
|
||||
"id": "level-opening-31",
|
||||
"description": "Ouverture 3-1.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [3, 1]
|
||||
},
|
||||
{
|
||||
"id": "level-middlegame-1",
|
||||
"description": "Milieu de partie, 6-4 : le bruit change le coup choisi.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [6, 4]
|
||||
},
|
||||
{
|
||||
"id": "level-middlegame-2",
|
||||
"description": "Milieu de partie avec contact, 5-3.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [5, 3]
|
||||
},
|
||||
{
|
||||
"id": "level-prime-vs-prime",
|
||||
"description": "Prime contre prime, 4-2.",
|
||||
"position_id": "4LZtAAbAbZsBAw",
|
||||
"match_id": "cAkKAAAAAAAA",
|
||||
"dice": [4, 2]
|
||||
},
|
||||
{
|
||||
"id": "level-blitz-doubles",
|
||||
"description": "Attaque, double 3.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [3, 3]
|
||||
},
|
||||
{
|
||||
"id": "level-cube-double-take",
|
||||
"description": "Décision de videau double/prend, à chaque niveau.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null
|
||||
},
|
||||
{
|
||||
"id": "level-cube-no-double",
|
||||
"description": "Décision de videau sans double, à chaque niveau.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "cube-crawford-not-analysed",
|
||||
"description": "Partie Crawford : pas de double possible, gnubg n'analyse pas le videau.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "8AmgAEAAEAAE",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-pass-pass",
|
||||
"description": "Double/passe : l'adversaire passe.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9999689,
|
||||
"double_take": 2.0565755,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 1.0,
|
||||
"double_take": 2.0675642,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-pass-take",
|
||||
"description": "Double/passe : l'adversaire prend à tort.",
|
||||
"position_id": "cHd3AADvvQMAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9999689,
|
||||
"double_take": 2.0565755,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -1.0565755,
|
||||
"skill": "very_bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 1.0,
|
||||
"double_take": 2.0675642,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -1.0675642,
|
||||
"skill": "very_bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-double",
|
||||
"description": "Double/prend : le joueur au trait double.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-no-double",
|
||||
"description": "Double/prend : le joueur au trait ne double pas.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.06289035,
|
||||
"skill": "bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.09125525,
|
||||
"skill": "bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-pass",
|
||||
"description": "Double/prend : l'adversaire passe à tort.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.11814976,
|
||||
"skill": "bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.07818359,
|
||||
"skill": "bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-double-take-take",
|
||||
"description": "Double/prend : l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-match-double",
|
||||
"description": "Match en 7 points, 3-5 : le joueur au trait double.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.93459773,
|
||||
"double_take": 1.1651278,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9405141,
|
||||
"double_take": 1.1657741,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-match-pass",
|
||||
"description": "Match en 7 points, 3-5 : l'adversaire passe.",
|
||||
"position_id": "cHd3AAC4uzsAAA",
|
||||
"match_id": "cAngAFAAGAAE",
|
||||
"action": "pass",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.93459773,
|
||||
"double_take": 1.1651278,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.9405141,
|
||||
"double_take": 1.1657741,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 1.0,
|
||||
"decision": "DOUBLE_PASS",
|
||||
"decision_text": "Double, pass"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-no-double-double",
|
||||
"description": "Pas de double : le joueur au trait double trop tôt.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.1721948,
|
||||
"skill": "very_bad"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.17361593,
|
||||
"skill": "very_bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-no-double-no-double",
|
||||
"description": "Pas de double : le joueur au trait ne double pas.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "cube-opponent-owns-not-analysed",
|
||||
"description": "Videau à 2 possédé par l'adversaire : pas de double possible, pas d'analyse.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "EQEAAAAAAAAA",
|
||||
"action": "no_double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": false,
|
||||
"cube": null,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-post-crawford-double",
|
||||
"description": "Match en 5 points, après Crawford, mené 2-4 : le joueur au trait double.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAmgAEAAEAAE",
|
||||
"action": "double",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.26475775,
|
||||
"double_take": 0.2732438,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.2732438,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.22408937,
|
||||
"double_take": 0.22408937,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.22408937,
|
||||
"decision": "OPTIONAL_DOUBLE_TAKE",
|
||||
"decision_text": "Optional double, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "cube-redouble-take",
|
||||
"description": "Redouble : videau à 2 possédé par le joueur au trait, l'adversaire prend.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "UQkAAAAAAAAA",
|
||||
"action": "take",
|
||||
"type": "cube",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.840925,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "REDOUBLE_TAKE",
|
||||
"decision_text": "Redouble, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
},
|
||||
"2": {
|
||||
"analysed": true,
|
||||
"cube": {
|
||||
"no_double": 0.84627455,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "REDOUBLE_TAKE",
|
||||
"decision_text": "Redouble, take"
|
||||
},
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-blitz-doubles",
|
||||
"description": "Attaque, double 3.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "5/2(2) 4/1*(2)"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/10 6/3 4/1*(2)"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/10 6/3 4/1*(2)"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/10(2) 8/5 6/3"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/7 13/10 6/3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "level-cube-double-take",
|
||||
"description": "Décision de videau double/prend, à chaque niveau.",
|
||||
"position_id": "6O5uAAC4uzsAAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null,
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"no_double": 0.42431837,
|
||||
"double_take": 0.26415053,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.42431837,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"no_double": 0.49009216,
|
||||
"double_take": 0.36710066,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.49009216,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"no_double": 0.55586565,
|
||||
"double_take": 0.47005057,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.55586565,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"no_double": 0.7202995,
|
||||
"double_take": 0.7274252,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.7274252,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.8189599,
|
||||
"double_take": 0.88185024,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.88185024,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.79611415,
|
||||
"double_take": 0.8986737,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.8986737,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.83056116,
|
||||
"double_take": 0.9218164,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.9218164,
|
||||
"decision": "DOUBLE_TAKE",
|
||||
"decision_text": "Double, take"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "level-cube-no-double",
|
||||
"description": "Décision de videau sans double, à chaque niveau.",
|
||||
"position_id": "2J0dBgDYnRsGAA",
|
||||
"match_id": "cAkAAAAAAAAA",
|
||||
"dice": null,
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"no_double": -0.16227412,
|
||||
"double_take": -0.6363357,
|
||||
"double_pass": 1.0,
|
||||
"optimal": -0.16227412,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"no_double": -0.075523466,
|
||||
"double_take": -0.4993003,
|
||||
"double_pass": 1.0,
|
||||
"optimal": -0.075523466,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"no_double": 0.011226978,
|
||||
"double_take": -0.36226535,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.011226978,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"no_double": 0.22810358,
|
||||
"double_take": -0.019677103,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.22810358,
|
||||
"decision": "NODOUBLE_BEAVER",
|
||||
"decision_text": "No double, beaver"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.35839266,
|
||||
"double_take": 0.18619786,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.35839266,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.33552775,
|
||||
"double_take": 0.15480106,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.33552775,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"no_double": 0.28532878,
|
||||
"double_take": 0.11171284,
|
||||
"double_pass": 1.0,
|
||||
"optimal": 0.28532878,
|
||||
"decision": "NODOUBLE_TAKE",
|
||||
"decision_text": "No double, take"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-middlegame-1",
|
||||
"description": "Milieu de partie, 6-4 : le bruit change le coup choisi.",
|
||||
"position_id": "mM/gATCw5+ABMA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
4
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "24/20 24/18"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "24/14"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "24/14"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-middlegame-2",
|
||||
"description": "Milieu de partie avec contact, 5-3.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "13/5"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "13/5"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "13/5"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/5"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-opening-31",
|
||||
"description": "Ouverture 3-1.",
|
||||
"position_id": "4HPwATDgc/ABMA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "8/5 6/5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "level-prime-vs-prime",
|
||||
"description": "Prime contre prime, 4-2.",
|
||||
"position_id": "4LZtAAbAbZsBAw",
|
||||
"match_id": "cAkKAAAAAAAA",
|
||||
"dice": [
|
||||
4,
|
||||
2
|
||||
],
|
||||
"type": "level",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"levels": {
|
||||
"beginner": {
|
||||
"plies": 0,
|
||||
"noise": 0.06,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"casual": {
|
||||
"plies": 0,
|
||||
"noise": 0.05,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"intermediate": {
|
||||
"plies": 0,
|
||||
"noise": 0.04,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"advanced": {
|
||||
"plies": 0,
|
||||
"noise": 0.015,
|
||||
"move": "13/11 7/3"
|
||||
},
|
||||
"0ply": {
|
||||
"plies": 0,
|
||||
"noise": 0.0,
|
||||
"move": "13/9 7/5"
|
||||
},
|
||||
"1ply": {
|
||||
"plies": 1,
|
||||
"noise": 0.0,
|
||||
"move": "13/9 7/5"
|
||||
},
|
||||
"2ply": {
|
||||
"plies": 2,
|
||||
"noise": 0.0,
|
||||
"move": "13/11 13/9"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "move-bearoff-doubles",
|
||||
"description": "Sortie, double 6 : coup unique ; chance d'un double.",
|
||||
"position_id": "xgAA4MgAAAAAAA",
|
||||
"match_id": "cAkbAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
6
|
||||
],
|
||||
"played": "6/off 6/off 4/off 1/off",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "6/off(2) 4/off 1/off",
|
||||
"played_position_id": "xgAAYAAAAAAAAA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": 0.8888838,
|
||||
"best_move": "6/off(2) 4/off 1/off",
|
||||
"best_equity": 0.8888838,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "6/off(2) 4/off 1/off",
|
||||
"equity": 0.8888838
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "6/off(2) 4/off 1/off",
|
||||
"played_position_id": "xgAAYAAAAAAAAA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": 0.8888838,
|
||||
"best_move": "6/off(2) 4/off 1/off",
|
||||
"best_equity": 0.8888838,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "6/off(2) 4/off 1/off",
|
||||
"equity": 0.8888838
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 1.6398247,
|
||||
"rating": "very_lucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
{
|
||||
"id": "move-blitz-doubles",
|
||||
"description": "Attaque, double 3 : un coup parmi 110.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cIkNAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"played": "13/10 13/10 5/2 4/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "13/10(2) 5/2 4/1*",
|
||||
"played_position_id": "4HPwAWBVXTMGAA",
|
||||
"move_count": 110,
|
||||
"rank": 109,
|
||||
"played_equity": 0.084023334,
|
||||
"best_move": "13/10 6/3 4/1*(2)",
|
||||
"best_equity": 0.9465638,
|
||||
"equity_loss": -0.8625404,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/10 6/3 4/1*(2)",
|
||||
"equity": 0.9465638
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3 4/1*(2)",
|
||||
"equity": 0.9422797
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 6/3",
|
||||
"equity": 0.94128984
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 6/3",
|
||||
"equity": 0.9370698
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 6/3",
|
||||
"equity": 0.93615985
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 6/3",
|
||||
"equity": 0.9330082
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/4 6/3",
|
||||
"equity": 0.932737
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 6/3",
|
||||
"equity": 0.9313148
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/1* 6/3",
|
||||
"equity": 0.93129885
|
||||
},
|
||||
{
|
||||
"move": "13/4 6/3",
|
||||
"equity": 0.92980635
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 6/3",
|
||||
"equity": 0.9264537
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 6/3",
|
||||
"equity": 0.9262361
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 4/1*(2)",
|
||||
"equity": 0.92529553
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 7/4 6/3",
|
||||
"equity": 0.9252107
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3 4/1",
|
||||
"equity": 0.92395884
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 5/2(2)",
|
||||
"equity": 0.9204093
|
||||
},
|
||||
{
|
||||
"move": "13/7 4/1*(2)",
|
||||
"equity": 0.9190106
|
||||
},
|
||||
{
|
||||
"move": "13/7(2)",
|
||||
"equity": 0.9184398
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 4/1*(2)",
|
||||
"equity": 0.9182539
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3 5/2(2)",
|
||||
"equity": 0.91752934
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10(2)",
|
||||
"equity": 0.91483337
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 6/3",
|
||||
"equity": 0.912639
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 8/5",
|
||||
"equity": 0.91152656
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/1*",
|
||||
"equity": 0.9115156
|
||||
},
|
||||
{
|
||||
"move": "13/1*",
|
||||
"equity": 0.9111869
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3 4/1*",
|
||||
"equity": 0.91055506
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 6/3",
|
||||
"equity": 0.9091699
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/4 6/3",
|
||||
"equity": 0.90897554
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5(2)",
|
||||
"equity": 0.90817225
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3 4/1*",
|
||||
"equity": 0.90714073
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 7/4",
|
||||
"equity": 0.9070756
|
||||
},
|
||||
{
|
||||
"move": "8/2(2)",
|
||||
"equity": 0.9065211
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/1*",
|
||||
"equity": 0.90571
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 7/4",
|
||||
"equity": 0.9040942
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 6/3 4/1*",
|
||||
"equity": 0.90343916
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 4/1*(2)",
|
||||
"equity": 0.90325713
|
||||
},
|
||||
{
|
||||
"move": "5/2(2) 4/1*(2)",
|
||||
"equity": 0.90183336
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 4/1",
|
||||
"equity": 0.9013542
|
||||
},
|
||||
{
|
||||
"move": "13/4 7/4",
|
||||
"equity": 0.9010268
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/2(2)",
|
||||
"equity": 0.90037674
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 7/1*",
|
||||
"equity": 0.89956194
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 7/4",
|
||||
"equity": 0.8988826
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3 5/2",
|
||||
"equity": 0.8984738
|
||||
},
|
||||
{
|
||||
"move": "13/4 13/10",
|
||||
"equity": 0.89749956
|
||||
},
|
||||
{
|
||||
"move": "6/3(2) 4/1*(2)",
|
||||
"equity": 0.8951012
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5(2)",
|
||||
"equity": 0.89493525
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/2",
|
||||
"equity": 0.8946096
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3(2)",
|
||||
"equity": 0.89403474
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/2(2)",
|
||||
"equity": 0.8924208
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/1* 4/1",
|
||||
"equity": 0.891961
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3(2)",
|
||||
"equity": 0.89077055
|
||||
},
|
||||
{
|
||||
"move": "13/4 8/5",
|
||||
"equity": 0.8907301
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 6/3 4/1*",
|
||||
"equity": 0.8900244
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2(2)",
|
||||
"equity": 0.8890666
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3(3)",
|
||||
"equity": 0.8890004
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 5/2(2)",
|
||||
"equity": 0.8888954
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 7/4",
|
||||
"equity": 0.8876569
|
||||
},
|
||||
{
|
||||
"move": "13/10(4)",
|
||||
"equity": 0.88716996
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3(2)",
|
||||
"equity": 0.8856077
|
||||
},
|
||||
{
|
||||
"move": "8/2 4/1*(2)",
|
||||
"equity": 0.8853629
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(3)",
|
||||
"equity": 0.8853096
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/3 5/2",
|
||||
"equity": 0.8831558
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 7/4",
|
||||
"equity": 0.8819176
|
||||
},
|
||||
{
|
||||
"move": "7/1* 4/1(2)",
|
||||
"equity": 0.88113916
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 6/3(2)",
|
||||
"equity": 0.88064635
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 8/5",
|
||||
"equity": 0.88049895
|
||||
},
|
||||
{
|
||||
"move": "6/3 5/2(2) 4/1*",
|
||||
"equity": 0.8790827
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/2",
|
||||
"equity": 0.8782023
|
||||
},
|
||||
{
|
||||
"move": "6/3 5/2 4/1*(2)",
|
||||
"equity": 0.8774586
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3(3)",
|
||||
"equity": 0.8774268
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/1*",
|
||||
"equity": 0.8772669
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 4/1*",
|
||||
"equity": 0.87700987
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 6/3(2)",
|
||||
"equity": 0.8757389
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 8/5",
|
||||
"equity": 0.87530077
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 6/3 5/2",
|
||||
"equity": 0.87215745
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 4/1*",
|
||||
"equity": 0.87202764
|
||||
},
|
||||
{
|
||||
"move": "8/5 7/4 6/3(2)",
|
||||
"equity": 0.86896765
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/3 5/2",
|
||||
"equity": 0.8687051
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/4 6/3 5/2",
|
||||
"equity": 0.8679464
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3 4/1*",
|
||||
"equity": 0.86750233
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 4/1*",
|
||||
"equity": 0.86701244
|
||||
},
|
||||
{
|
||||
"move": "8/5(2) 6/3(2)",
|
||||
"equity": 0.86167765
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 4/1*",
|
||||
"equity": 0.8568015
|
||||
},
|
||||
{
|
||||
"move": "6/3(2) 5/2(2)",
|
||||
"equity": 0.85501754
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/4 5/2",
|
||||
"equity": 0.8537997
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/2(2)",
|
||||
"equity": 0.8524791
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 4/1*",
|
||||
"equity": 0.84969926
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 5/2",
|
||||
"equity": 0.8466199
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/3(2)",
|
||||
"equity": 0.80814815
|
||||
},
|
||||
{
|
||||
"move": "13/10 5/2(2) 4/1*",
|
||||
"equity": 0.8037353
|
||||
},
|
||||
{
|
||||
"move": "13/10 5/2 4/1*(2)",
|
||||
"equity": 0.77350885
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(2) 4/1*",
|
||||
"equity": 0.74067366
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 5/2",
|
||||
"equity": 0.72703636
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 5/2",
|
||||
"equity": 0.7053956
|
||||
},
|
||||
{
|
||||
"move": "6/3(3) 4/1*",
|
||||
"equity": 0.66575944
|
||||
},
|
||||
{
|
||||
"move": "13/10 7/1* 5/2",
|
||||
"equity": 0.6462204
|
||||
},
|
||||
{
|
||||
"move": "8/5 6/3(2) 4/1*",
|
||||
"equity": 0.6374039
|
||||
},
|
||||
{
|
||||
"move": "8/2 8/5 4/1*",
|
||||
"equity": 0.62353325
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/2 4/1*",
|
||||
"equity": 0.61257124
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 5/2 4/1*",
|
||||
"equity": 0.5349428
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/2 4/1",
|
||||
"equity": 0.45721298
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/2 4/1*",
|
||||
"equity": 0.44362232
|
||||
},
|
||||
{
|
||||
"move": "13/4 5/2",
|
||||
"equity": 0.43828273
|
||||
},
|
||||
{
|
||||
"move": "13/10(3) 5/2",
|
||||
"equity": 0.2798175
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3(2) 5/2",
|
||||
"equity": 0.24207813
|
||||
},
|
||||
{
|
||||
"move": "6/3(3) 5/2",
|
||||
"equity": 0.23385207
|
||||
},
|
||||
{
|
||||
"move": "7/4 6/3(2) 5/2",
|
||||
"equity": 0.22096781
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/2 4/1*",
|
||||
"equity": 0.18849733
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2 4/1*",
|
||||
"equity": 0.084023334
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "13/10(2) 5/2 4/1*",
|
||||
"played_position_id": "4HPwAWBVXTMGAA",
|
||||
"move_count": 110,
|
||||
"rank": 7,
|
||||
"played_equity": -0.022027964,
|
||||
"best_move": "13/10 7/1* 6/3",
|
||||
"best_equity": 1.0,
|
||||
"equity_loss": -1.022028,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/10 7/1* 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/7 13/10 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 8/5 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/5 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 7/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5 7/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10(2) 5/2 4/1*",
|
||||
"equity": -0.022027964
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/5(2) 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/4 6/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/3 4/1*(2)",
|
||||
"equity": 0.99692726
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.011263669,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-crawford",
|
||||
"description": "Match en 5 points, partie Crawford, 4-3.",
|
||||
"position_id": "4Dn4AGiynYMHAA",
|
||||
"match_id": "8AmuAEAAEAAE",
|
||||
"dice": [
|
||||
4,
|
||||
3
|
||||
],
|
||||
"played": "5/2 5/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "5/2 5/1*",
|
||||
"played_position_id": "4Dn4AHDNnIMHAA",
|
||||
"move_count": 20,
|
||||
"rank": 2,
|
||||
"played_equity": 0.7229391,
|
||||
"best_move": "8/5 6/2",
|
||||
"best_equity": 0.7345603,
|
||||
"equity_loss": -0.011621237,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/2",
|
||||
"equity": 0.7345603
|
||||
},
|
||||
{
|
||||
"move": "5/2 5/1*",
|
||||
"equity": 0.7229391
|
||||
},
|
||||
{
|
||||
"move": "8/1*",
|
||||
"equity": 0.71705425
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/2",
|
||||
"equity": 0.7142835
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/5",
|
||||
"equity": 0.6963019
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/4",
|
||||
"equity": 0.68610644
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/4",
|
||||
"equity": 0.6855368
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/9",
|
||||
"equity": 0.6818689
|
||||
},
|
||||
{
|
||||
"move": "13/6",
|
||||
"equity": 0.6633271
|
||||
},
|
||||
{
|
||||
"move": "13/9 5/2",
|
||||
"equity": 0.6538005
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "5/2 5/1*",
|
||||
"played_position_id": "4Dn4AHDNnIMHAA",
|
||||
"move_count": 20,
|
||||
"rank": 4,
|
||||
"played_equity": 0.6911188,
|
||||
"best_move": "8/5 6/2",
|
||||
"best_equity": 0.7697691,
|
||||
"equity_loss": -0.078650296,
|
||||
"skill": "bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/2",
|
||||
"equity": 0.7697691
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/2",
|
||||
"equity": 0.7615671
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/5",
|
||||
"equity": 0.69608325
|
||||
},
|
||||
{
|
||||
"move": "5/2 5/1*",
|
||||
"equity": 0.6911188
|
||||
},
|
||||
{
|
||||
"move": "8/1*",
|
||||
"equity": 0.6836242
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/9",
|
||||
"equity": 0.6807338
|
||||
},
|
||||
{
|
||||
"move": "13/10 8/4",
|
||||
"equity": 0.6733323
|
||||
},
|
||||
{
|
||||
"move": "13/6",
|
||||
"equity": 0.66055346
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/4",
|
||||
"equity": 0.6556259
|
||||
},
|
||||
{
|
||||
"move": "13/9 5/2",
|
||||
"equity": 0.645951
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.050364614,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"id": "move-forced",
|
||||
"description": "Rentrée forcée depuis la barre, 3-1.",
|
||||
"position_id": "tm3ABwBw5+ABYA",
|
||||
"match_id": "cIkFAAAAAAAA",
|
||||
"dice": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"played": "bar/24",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "bar/24",
|
||||
"played_position_id": "tm3ABwBw5+ABUA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": -1.0,
|
||||
"best_move": "bar/24",
|
||||
"best_equity": -1.0,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "bar/24",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "bar/24",
|
||||
"played_position_id": "tm3ABwBw5+ABUA",
|
||||
"move_count": 1,
|
||||
"rank": 1,
|
||||
"played_equity": -1.0,
|
||||
"best_move": "bar/24",
|
||||
"best_equity": -1.0,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "bar/24",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.0,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"id": "move-jacoby",
|
||||
"description": "Partie libre avec la règle Jacoby, 6-4.",
|
||||
"position_id": "4HPwAVC0XYMHAA",
|
||||
"match_id": "cAkTAAAAAAAA",
|
||||
"dice": [
|
||||
6,
|
||||
4
|
||||
],
|
||||
"played": "8/2 5/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "8/2 5/1*",
|
||||
"played_position_id": "4HPwAWDVuoIHAA",
|
||||
"move_count": 14,
|
||||
"rank": 14,
|
||||
"played_equity": 0.50889754,
|
||||
"best_move": "13/3",
|
||||
"best_equity": 0.9328213,
|
||||
"equity_loss": -0.42392373,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": 0.9328213
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.9175232
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/3",
|
||||
"equity": 0.91263336
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/4",
|
||||
"equity": 0.90736973
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/1*",
|
||||
"equity": 0.9053596
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.9017469
|
||||
},
|
||||
{
|
||||
"move": "8/4 7/1*",
|
||||
"equity": 0.89993495
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/2",
|
||||
"equity": 0.8962312
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/1",
|
||||
"equity": 0.8907822
|
||||
},
|
||||
{
|
||||
"move": "7/1* 6/2",
|
||||
"equity": 0.87481093
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/2",
|
||||
"equity": 0.87251335
|
||||
},
|
||||
{
|
||||
"move": "8/4 8/2",
|
||||
"equity": 0.8716274
|
||||
},
|
||||
{
|
||||
"move": "13/7 5/1*",
|
||||
"equity": 0.87005275
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/1*",
|
||||
"equity": 0.50889754
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "8/2 5/1*",
|
||||
"played_position_id": "4HPwAWDVuoIHAA",
|
||||
"move_count": 14,
|
||||
"rank": 7,
|
||||
"played_equity": 0.3304975,
|
||||
"best_move": "13/3",
|
||||
"best_equity": 1.0,
|
||||
"equity_loss": -0.6695025,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": 1.0
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.99894416
|
||||
},
|
||||
{
|
||||
"move": "8/2 7/3",
|
||||
"equity": 0.99641544
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/4",
|
||||
"equity": 0.98718643
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/2",
|
||||
"equity": 0.9808178
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/1*",
|
||||
"equity": 0.9452111
|
||||
},
|
||||
{
|
||||
"move": "8/2 5/1*",
|
||||
"equity": 0.3304975
|
||||
},
|
||||
{
|
||||
"move": "8/4 7/1*",
|
||||
"equity": 0.93114305
|
||||
},
|
||||
{
|
||||
"move": "7/1* 5/1",
|
||||
"equity": 0.91711015
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.9160911
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.002478838,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-luck-lucky",
|
||||
"description": "Course, double 4 : jet chanceux, joué au mieux.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [
|
||||
4,
|
||||
4
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "13/5 11/3",
|
||||
"played_position_id": "cLezAQDk3ZwEAA",
|
||||
"move_count": 106,
|
||||
"rank": 1,
|
||||
"played_equity": 0.16933289,
|
||||
"best_move": "13/5 11/3",
|
||||
"best_equity": 0.16933289,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5 11/3",
|
||||
"equity": 0.16933289
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/3",
|
||||
"equity": 0.16701066
|
||||
},
|
||||
{
|
||||
"move": "13/5 9/5 7/3",
|
||||
"equity": 0.16552198
|
||||
},
|
||||
{
|
||||
"move": "13/5(2)",
|
||||
"equity": 0.16473158
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 11/7",
|
||||
"equity": 0.16365689
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 7/3",
|
||||
"equity": 0.16245355
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7 9/5",
|
||||
"equity": 0.16167049
|
||||
},
|
||||
{
|
||||
"move": "11/3 9/5(2)",
|
||||
"equity": 0.16043647
|
||||
},
|
||||
{
|
||||
"move": "13/9 11/3 11/7",
|
||||
"equity": 0.1603657
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7(2)",
|
||||
"equity": 0.1602792
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "13/5 11/3",
|
||||
"played_position_id": "cLezAQDk3ZwEAA",
|
||||
"move_count": 106,
|
||||
"rank": 1,
|
||||
"played_equity": 0.15129912,
|
||||
"best_move": "13/5 11/3",
|
||||
"best_equity": 0.15129912,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5 11/3",
|
||||
"equity": 0.15129912
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 11/7",
|
||||
"equity": 0.15059008
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/3",
|
||||
"equity": 0.14950007
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7 9/5",
|
||||
"equity": 0.14858595
|
||||
},
|
||||
{
|
||||
"move": "11/3 11/7 9/5",
|
||||
"equity": 0.14829463
|
||||
},
|
||||
{
|
||||
"move": "13/9 11/3 11/7",
|
||||
"equity": 0.1463402
|
||||
},
|
||||
{
|
||||
"move": "13/5 9/5 7/3",
|
||||
"equity": 0.13938732
|
||||
},
|
||||
{
|
||||
"move": "13/5 11/7(2)",
|
||||
"equity": 0.13921705
|
||||
},
|
||||
{
|
||||
"move": "13/9(2) 11/7(2)",
|
||||
"equity": 0.13873637
|
||||
},
|
||||
{
|
||||
"move": "13/5 13/9 6/2",
|
||||
"equity": 0.13852078
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.43559298,
|
||||
"rating": "lucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "move-luck-unlucky-bearoff",
|
||||
"description": "Fin de sortie, 2-1 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "TQAAYAsAAAAAAA",
|
||||
"match_id": "cAkFAAAAAAAA",
|
||||
"dice": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "2/off 1/off",
|
||||
"played_position_id": "TQAAoAIAAAAAAA",
|
||||
"move_count": 4,
|
||||
"rank": 2,
|
||||
"played_equity": -0.8607614,
|
||||
"best_move": "3/off",
|
||||
"best_equity": -0.8607614,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "3/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/off 1/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/1 2/off",
|
||||
"equity": -0.95561147
|
||||
},
|
||||
{
|
||||
"move": "3/1 2/1",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "2/off 1/off",
|
||||
"played_position_id": "TQAAoAIAAAAAAA",
|
||||
"move_count": 4,
|
||||
"rank": 2,
|
||||
"played_equity": -0.8607614,
|
||||
"best_move": "3/off",
|
||||
"best_equity": -0.8607614,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "3/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/off 1/off",
|
||||
"equity": -0.8607614
|
||||
},
|
||||
{
|
||||
"move": "2/1 2/off",
|
||||
"equity": -0.95561147
|
||||
},
|
||||
{
|
||||
"move": "3/1 2/1",
|
||||
"equity": -1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.32329792,
|
||||
"rating": "unlucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-luck-unlucky",
|
||||
"description": "Milieu de partie, 5-4 : jet malchanceux, joué au mieux.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cIkOAAAAAAAA",
|
||||
"dice": [
|
||||
5,
|
||||
4
|
||||
],
|
||||
"played": null,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "18/9",
|
||||
"played_position_id": "xGfhATDQ28IDIA",
|
||||
"move_count": 24,
|
||||
"rank": 1,
|
||||
"played_equity": 0.2544027,
|
||||
"best_move": "18/9",
|
||||
"best_equity": 0.2544027,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "18/9",
|
||||
"equity": 0.2544027
|
||||
},
|
||||
{
|
||||
"move": "18/14 13/8",
|
||||
"equity": 0.22496489
|
||||
},
|
||||
{
|
||||
"move": "24/20 18/13",
|
||||
"equity": 0.20747662
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/8",
|
||||
"equity": 0.1158272
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": 0.09201278
|
||||
},
|
||||
{
|
||||
"move": "24/15",
|
||||
"equity": 0.061017293
|
||||
},
|
||||
{
|
||||
"move": "18/13 6/2",
|
||||
"equity": 0.03581623
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.028887823
|
||||
},
|
||||
{
|
||||
"move": "13/8 6/2",
|
||||
"equity": -0.033572298
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/4",
|
||||
"equity": -0.09662818
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "18/9",
|
||||
"played_position_id": "xGfhATDQ28IDIA",
|
||||
"move_count": 24,
|
||||
"rank": 1,
|
||||
"played_equity": 0.27687487,
|
||||
"best_move": "18/9",
|
||||
"best_equity": 0.27687487,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "18/9",
|
||||
"equity": 0.27687487
|
||||
},
|
||||
{
|
||||
"move": "18/14 13/8",
|
||||
"equity": 0.21801135
|
||||
},
|
||||
{
|
||||
"move": "24/20 18/13",
|
||||
"equity": 0.21000355
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/8",
|
||||
"equity": 0.1144446
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": 0.09412725
|
||||
},
|
||||
{
|
||||
"move": "24/15",
|
||||
"equity": 0.07892294
|
||||
},
|
||||
{
|
||||
"move": "18/13 6/2",
|
||||
"equity": -0.020656409
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.028887823
|
||||
},
|
||||
{
|
||||
"move": "13/8 6/2",
|
||||
"equity": -0.033572298
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/4",
|
||||
"equity": -0.09662818
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.3615805,
|
||||
"rating": "unlucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"id": "move-match",
|
||||
"description": "Match en 7 points à 3-3, 5-3 : frappe au lieu du meilleur coup.",
|
||||
"position_id": "xGfhATDQ2+BBIA",
|
||||
"match_id": "cImuACAAEAAE",
|
||||
"dice": [
|
||||
5,
|
||||
3
|
||||
],
|
||||
"played": "24/16",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "24/16*",
|
||||
"played_position_id": "xGfwAFjQ2+CRAA",
|
||||
"move_count": 28,
|
||||
"rank": 3,
|
||||
"played_equity": 0.4581785,
|
||||
"best_move": "13/5",
|
||||
"best_equity": 0.7553502,
|
||||
"equity_loss": -0.29717168,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5",
|
||||
"equity": 0.7553502
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/5",
|
||||
"equity": 0.5814649
|
||||
},
|
||||
{
|
||||
"move": "24/16*",
|
||||
"equity": 0.4581785
|
||||
},
|
||||
{
|
||||
"move": "18/10",
|
||||
"equity": 0.31719947
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/3",
|
||||
"equity": 0.31371918
|
||||
},
|
||||
{
|
||||
"move": "18/15 13/8",
|
||||
"equity": 0.26037523
|
||||
},
|
||||
{
|
||||
"move": "24/21 18/13",
|
||||
"equity": 0.23547156
|
||||
},
|
||||
{
|
||||
"move": "8/3 6/3",
|
||||
"equity": 0.15432969
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/8",
|
||||
"equity": 0.13837479
|
||||
},
|
||||
{
|
||||
"move": "18/13 5/2",
|
||||
"equity": 0.12815686
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "24/16*",
|
||||
"played_position_id": "xGfwAFjQ2+CRAA",
|
||||
"move_count": 28,
|
||||
"rank": 2,
|
||||
"played_equity": 0.46805125,
|
||||
"best_move": "13/5",
|
||||
"best_equity": 0.85497326,
|
||||
"equity_loss": -0.386922,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/5",
|
||||
"equity": 0.85497326
|
||||
},
|
||||
{
|
||||
"move": "24/16*",
|
||||
"equity": 0.46805125
|
||||
},
|
||||
{
|
||||
"move": "18/13 8/5",
|
||||
"equity": 0.6511238
|
||||
},
|
||||
{
|
||||
"move": "18/10",
|
||||
"equity": 0.31719947
|
||||
},
|
||||
{
|
||||
"move": "8/5 8/3",
|
||||
"equity": 0.31371918
|
||||
},
|
||||
{
|
||||
"move": "18/15 13/8",
|
||||
"equity": 0.26037523
|
||||
},
|
||||
{
|
||||
"move": "24/21 18/13",
|
||||
"equity": 0.23547156
|
||||
},
|
||||
{
|
||||
"move": "8/3 6/3",
|
||||
"equity": 0.15432969
|
||||
},
|
||||
{
|
||||
"move": "13/10 13/8",
|
||||
"equity": 0.13837479
|
||||
},
|
||||
{
|
||||
"move": "18/13 5/2",
|
||||
"equity": 0.12815686
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.026781201,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"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",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "8/4 8/2",
|
||||
"played_position_id": "mM/gATDSnuABMA",
|
||||
"move_count": 14,
|
||||
"rank": 14,
|
||||
"played_equity": -0.35321844,
|
||||
"best_move": "24/14",
|
||||
"best_equity": 0.15439394,
|
||||
"equity_loss": -0.50761235,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/14",
|
||||
"equity": 0.15439394
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.10821653
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/9",
|
||||
"equity": 0.10337136
|
||||
},
|
||||
{
|
||||
"move": "24/20 24/18",
|
||||
"equity": 0.072762795
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.0029048775
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/7",
|
||||
"equity": 9.1438415e-05
|
||||
},
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": -0.012697897
|
||||
},
|
||||
{
|
||||
"move": "24/18 6/2",
|
||||
"equity": -0.075399
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/4",
|
||||
"equity": -0.088734664
|
||||
},
|
||||
{
|
||||
"move": "24/20 8/2",
|
||||
"equity": -0.095189385
|
||||
},
|
||||
{
|
||||
"move": "13/9 8/2",
|
||||
"equity": -0.09528468
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/4",
|
||||
"equity": -0.12819159
|
||||
},
|
||||
{
|
||||
"move": "13/7 6/2",
|
||||
"equity": -0.14467186
|
||||
},
|
||||
{
|
||||
"move": "8/4 8/2",
|
||||
"equity": -0.35321844
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "8/4 8/2",
|
||||
"played_position_id": "mM/gATDSnuABMA",
|
||||
"move_count": 14,
|
||||
"rank": 6,
|
||||
"played_equity": -0.36824828,
|
||||
"best_move": "24/14",
|
||||
"best_equity": 0.13111727,
|
||||
"equity_loss": -0.49936557,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/14",
|
||||
"equity": 0.13111727
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.11303549
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/9",
|
||||
"equity": 0.105096616
|
||||
},
|
||||
{
|
||||
"move": "24/20 24/18",
|
||||
"equity": 0.049068402
|
||||
},
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": -0.0040049786
|
||||
},
|
||||
{
|
||||
"move": "8/4 8/2",
|
||||
"equity": -0.36824828
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": -0.014309813
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/7",
|
||||
"equity": -0.029367238
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/4",
|
||||
"equity": -0.081171095
|
||||
},
|
||||
{
|
||||
"move": "24/18 6/2",
|
||||
"equity": -0.09313507
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.07414439,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"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",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "8/2 6/2",
|
||||
"played_position_id": "mM/gATDGzuABMA",
|
||||
"move_count": 14,
|
||||
"rank": 2,
|
||||
"played_equity": 0.10821653,
|
||||
"best_move": "24/14",
|
||||
"best_equity": 0.15439394,
|
||||
"equity_loss": -0.04617741,
|
||||
"skill": "doubtful",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/14",
|
||||
"equity": 0.15439394
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.10821653
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/9",
|
||||
"equity": 0.10337136
|
||||
},
|
||||
{
|
||||
"move": "24/20 24/18",
|
||||
"equity": 0.072762795
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": 0.0029048775
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/7",
|
||||
"equity": 9.1438415e-05
|
||||
},
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": -0.012697897
|
||||
},
|
||||
{
|
||||
"move": "24/18 6/2",
|
||||
"equity": -0.075399
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/4",
|
||||
"equity": -0.088734664
|
||||
},
|
||||
{
|
||||
"move": "24/20 8/2",
|
||||
"equity": -0.095189385
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "8/2 6/2",
|
||||
"played_position_id": "mM/gATDGzuABMA",
|
||||
"move_count": 14,
|
||||
"rank": 2,
|
||||
"played_equity": 0.11303549,
|
||||
"best_move": "24/14",
|
||||
"best_equity": 0.13111727,
|
||||
"equity_loss": -0.018081777,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/14",
|
||||
"equity": 0.13111727
|
||||
},
|
||||
{
|
||||
"move": "8/2 6/2",
|
||||
"equity": 0.11303549
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/9",
|
||||
"equity": 0.105096616
|
||||
},
|
||||
{
|
||||
"move": "24/20 24/18",
|
||||
"equity": 0.049068402
|
||||
},
|
||||
{
|
||||
"move": "13/3",
|
||||
"equity": -0.0040049786
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/7",
|
||||
"equity": -0.014309813
|
||||
},
|
||||
{
|
||||
"move": "24/20 13/7",
|
||||
"equity": -0.029367238
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/4",
|
||||
"equity": -0.081171095
|
||||
},
|
||||
{
|
||||
"move": "24/18 6/2",
|
||||
"equity": -0.09313507
|
||||
},
|
||||
{
|
||||
"move": "24/20 8/2",
|
||||
"equity": -0.095189385
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": -0.07414439,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"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",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "8/5 6/5",
|
||||
"played_position_id": "4HPwATCwZ/ABMA",
|
||||
"move_count": 16,
|
||||
"rank": 1,
|
||||
"played_equity": 0.21258597,
|
||||
"best_move": "8/5 6/5",
|
||||
"best_equity": 0.21258597,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/5",
|
||||
"equity": 0.21258597
|
||||
},
|
||||
{
|
||||
"move": "24/23 13/10",
|
||||
"equity": -0.010789523
|
||||
},
|
||||
{
|
||||
"move": "24/20",
|
||||
"equity": -0.017392516
|
||||
},
|
||||
{
|
||||
"move": "24/23 24/21",
|
||||
"equity": -0.023609947
|
||||
},
|
||||
{
|
||||
"move": "13/9",
|
||||
"equity": -0.027675275
|
||||
},
|
||||
{
|
||||
"move": "24/21 6/5",
|
||||
"equity": -0.04369401
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/5",
|
||||
"equity": -0.04984435
|
||||
},
|
||||
{
|
||||
"move": "24/23 8/5",
|
||||
"equity": -0.104100645
|
||||
},
|
||||
{
|
||||
"move": "24/23 6/3",
|
||||
"equity": -0.10542354
|
||||
},
|
||||
{
|
||||
"move": "24/21 8/7",
|
||||
"equity": -0.107585564
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "8/5 6/5",
|
||||
"played_position_id": "4HPwATCwZ/ABMA",
|
||||
"move_count": 16,
|
||||
"rank": 1,
|
||||
"played_equity": 0.20373425,
|
||||
"best_move": "8/5 6/5",
|
||||
"best_equity": 0.20373425,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/5",
|
||||
"equity": 0.20373425
|
||||
},
|
||||
{
|
||||
"move": "24/23 13/10",
|
||||
"equity": -0.022293653
|
||||
},
|
||||
{
|
||||
"move": "24/20",
|
||||
"equity": -0.031027086
|
||||
},
|
||||
{
|
||||
"move": "24/23 24/21",
|
||||
"equity": -0.03213631
|
||||
},
|
||||
{
|
||||
"move": "13/9",
|
||||
"equity": -0.040205758
|
||||
},
|
||||
{
|
||||
"move": "24/21 6/5",
|
||||
"equity": -0.04369401
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/5",
|
||||
"equity": -0.04984435
|
||||
},
|
||||
{
|
||||
"move": "24/23 8/5",
|
||||
"equity": -0.104100645
|
||||
},
|
||||
{
|
||||
"move": "24/23 6/3",
|
||||
"equity": -0.10542354
|
||||
},
|
||||
{
|
||||
"move": "24/21 8/7",
|
||||
"equity": -0.107585564
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.21258597,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"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",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "24/23 13/10",
|
||||
"played_position_id": "4HPwATDgc+IBKA",
|
||||
"move_count": 16,
|
||||
"rank": 2,
|
||||
"played_equity": -0.010789523,
|
||||
"best_move": "8/5 6/5",
|
||||
"best_equity": 0.21258597,
|
||||
"equity_loss": -0.2233755,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/5",
|
||||
"equity": 0.21258597
|
||||
},
|
||||
{
|
||||
"move": "24/23 13/10",
|
||||
"equity": -0.010789523
|
||||
},
|
||||
{
|
||||
"move": "24/20",
|
||||
"equity": -0.017392516
|
||||
},
|
||||
{
|
||||
"move": "24/23 24/21",
|
||||
"equity": -0.023609947
|
||||
},
|
||||
{
|
||||
"move": "13/9",
|
||||
"equity": -0.027675275
|
||||
},
|
||||
{
|
||||
"move": "24/21 6/5",
|
||||
"equity": -0.04369401
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/5",
|
||||
"equity": -0.04984435
|
||||
},
|
||||
{
|
||||
"move": "24/23 8/5",
|
||||
"equity": -0.104100645
|
||||
},
|
||||
{
|
||||
"move": "24/23 6/3",
|
||||
"equity": -0.10542354
|
||||
},
|
||||
{
|
||||
"move": "24/21 8/7",
|
||||
"equity": -0.107585564
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "24/23 13/10",
|
||||
"played_position_id": "4HPwATDgc+IBKA",
|
||||
"move_count": 16,
|
||||
"rank": 2,
|
||||
"played_equity": -0.0118339015,
|
||||
"best_move": "8/5 6/5",
|
||||
"best_equity": 0.1998143,
|
||||
"equity_loss": -0.21164821,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "8/5 6/5",
|
||||
"equity": 0.1998143
|
||||
},
|
||||
{
|
||||
"move": "24/23 13/10",
|
||||
"equity": -0.0118339015
|
||||
},
|
||||
{
|
||||
"move": "24/20",
|
||||
"equity": -0.031027086
|
||||
},
|
||||
{
|
||||
"move": "24/23 24/21",
|
||||
"equity": -0.03213631
|
||||
},
|
||||
{
|
||||
"move": "13/9",
|
||||
"equity": -0.040205758
|
||||
},
|
||||
{
|
||||
"move": "24/21 6/5",
|
||||
"equity": -0.04369401
|
||||
},
|
||||
{
|
||||
"move": "13/10 6/5",
|
||||
"equity": -0.04984435
|
||||
},
|
||||
{
|
||||
"move": "24/23 8/5",
|
||||
"equity": -0.104100645
|
||||
},
|
||||
{
|
||||
"move": "24/23 6/3",
|
||||
"equity": -0.10542354
|
||||
},
|
||||
{
|
||||
"move": "24/21 8/7",
|
||||
"equity": -0.107585564
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.21258597,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"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",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "13/8 13/7",
|
||||
"played_position_id": "4HPwATDg68EBMA",
|
||||
"move_count": 7,
|
||||
"rank": 3,
|
||||
"played_equity": -0.05142035,
|
||||
"best_move": "24/13",
|
||||
"best_equity": 0.082985915,
|
||||
"equity_loss": -0.13440627,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/13",
|
||||
"equity": 0.082985915
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/8",
|
||||
"equity": 0.03631018
|
||||
},
|
||||
{
|
||||
"move": "13/8 13/7",
|
||||
"equity": -0.05142035
|
||||
},
|
||||
{
|
||||
"move": "13/2",
|
||||
"equity": -0.13522762
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/3",
|
||||
"equity": -0.14635906
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/3",
|
||||
"equity": -0.17384717
|
||||
},
|
||||
{
|
||||
"move": "8/3 8/2",
|
||||
"equity": -0.37779096
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "13/8 13/7",
|
||||
"played_position_id": "4HPwATDg68EBMA",
|
||||
"move_count": 7,
|
||||
"rank": 3,
|
||||
"played_equity": -0.061850794,
|
||||
"best_move": "24/13",
|
||||
"best_equity": 0.07996891,
|
||||
"equity_loss": -0.1418197,
|
||||
"skill": "very_bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/13",
|
||||
"equity": 0.07996891
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/8",
|
||||
"equity": 0.03882884
|
||||
},
|
||||
{
|
||||
"move": "13/8 13/7",
|
||||
"equity": -0.061850794
|
||||
},
|
||||
{
|
||||
"move": "13/2",
|
||||
"equity": -0.12083713
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/3",
|
||||
"equity": -0.13999933
|
||||
},
|
||||
{
|
||||
"move": "13/7 8/3",
|
||||
"equity": -0.17384717
|
||||
},
|
||||
{
|
||||
"move": "8/3 8/2",
|
||||
"equity": -0.37779096
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.082985915,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"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,
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "24/18(2) 13/7(2)",
|
||||
"played_position_id": "4HPwATDg28HBAA",
|
||||
"move_count": 11,
|
||||
"rank": 1,
|
||||
"played_equity": 0.539388,
|
||||
"best_move": "24/18(2) 13/7(2)",
|
||||
"best_equity": 0.539388,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/18(2) 13/7(2)",
|
||||
"equity": 0.539388
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7(3)",
|
||||
"equity": 0.33099693
|
||||
},
|
||||
{
|
||||
"move": "24/18(2) 8/2(2)",
|
||||
"equity": 0.27119082
|
||||
},
|
||||
{
|
||||
"move": "13/7(2) 8/2(2)",
|
||||
"equity": 0.1563462
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7(2) 8/2",
|
||||
"equity": 0.14750561
|
||||
},
|
||||
{
|
||||
"move": "13/7(4)",
|
||||
"equity": 0.14310652
|
||||
},
|
||||
{
|
||||
"move": "24/18(2) 13/7 8/2",
|
||||
"equity": 0.12353878
|
||||
},
|
||||
{
|
||||
"move": "13/7(3) 8/2",
|
||||
"equity": 0.101389736
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7 8/2(2)",
|
||||
"equity": -0.05435923
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/2(3)",
|
||||
"equity": -0.12482335
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "24/18(2) 13/7(2)",
|
||||
"played_position_id": "4HPwATDg28HBAA",
|
||||
"move_count": 11,
|
||||
"rank": 1,
|
||||
"played_equity": 0.5886327,
|
||||
"best_move": "24/18(2) 13/7(2)",
|
||||
"best_equity": 0.5886327,
|
||||
"equity_loss": -0.0,
|
||||
"skill": "none",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "24/18(2) 13/7(2)",
|
||||
"equity": 0.5886327
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7(3)",
|
||||
"equity": 0.33312234
|
||||
},
|
||||
{
|
||||
"move": "24/18(2) 8/2(2)",
|
||||
"equity": 0.27119082
|
||||
},
|
||||
{
|
||||
"move": "13/7(2) 8/2(2)",
|
||||
"equity": 0.1563462
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7(2) 8/2",
|
||||
"equity": 0.14750561
|
||||
},
|
||||
{
|
||||
"move": "13/7(4)",
|
||||
"equity": 0.14310652
|
||||
},
|
||||
{
|
||||
"move": "24/18(2) 13/7 8/2",
|
||||
"equity": 0.12353878
|
||||
},
|
||||
{
|
||||
"move": "13/7(3) 8/2",
|
||||
"equity": 0.101389736
|
||||
},
|
||||
{
|
||||
"move": "24/18 13/7 8/2(2)",
|
||||
"equity": -0.05435923
|
||||
},
|
||||
{
|
||||
"move": "24/18 8/2(3)",
|
||||
"equity": -0.12482335
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.43498537,
|
||||
"rating": "lucky"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"id": "move-race",
|
||||
"description": "Course, 5-4 : sortie maladroite.",
|
||||
"position_id": "cLezAQBwN2cGAA",
|
||||
"match_id": "cIkSAAAAAAAA",
|
||||
"dice": [
|
||||
5,
|
||||
4
|
||||
],
|
||||
"played": "6/1 5/1",
|
||||
"type": "move",
|
||||
"gnubg_commit": "14b67984737ded0f6de43d63dcb789f729d9b06b",
|
||||
"met": "Kazaross-XG2",
|
||||
"plies": {
|
||||
"0": {
|
||||
"played_move": "6/1 5/1",
|
||||
"played_position_id": "cLezAQDDNmcGAA",
|
||||
"move_count": 29,
|
||||
"rank": 29,
|
||||
"played_equity": -0.31466243,
|
||||
"best_move": "13/9 11/6",
|
||||
"best_equity": -0.22230339,
|
||||
"equity_loss": -0.092359036,
|
||||
"skill": "bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/9 11/6",
|
||||
"equity": -0.22230339
|
||||
},
|
||||
{
|
||||
"move": "11/6 9/5",
|
||||
"equity": -0.22993731
|
||||
},
|
||||
{
|
||||
"move": "13/8 9/5",
|
||||
"equity": -0.23005769
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": -0.23073325
|
||||
},
|
||||
{
|
||||
"move": "13/8 11/7",
|
||||
"equity": -0.23153114
|
||||
},
|
||||
{
|
||||
"move": "11/7 11/6",
|
||||
"equity": -0.2335976
|
||||
},
|
||||
{
|
||||
"move": "13/8 7/3",
|
||||
"equity": -0.23374501
|
||||
},
|
||||
{
|
||||
"move": "11/6 7/3",
|
||||
"equity": -0.23394687
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.23507257
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/2",
|
||||
"equity": -0.23529215
|
||||
},
|
||||
{
|
||||
"move": "7/3 7/2",
|
||||
"equity": -0.23544219
|
||||
},
|
||||
{
|
||||
"move": "11/2",
|
||||
"equity": -0.235758
|
||||
},
|
||||
{
|
||||
"move": "11/7 9/4",
|
||||
"equity": -0.23589191
|
||||
},
|
||||
{
|
||||
"move": "13/8 6/2",
|
||||
"equity": -0.24026787
|
||||
},
|
||||
{
|
||||
"move": "9/5 7/2",
|
||||
"equity": -0.24087214
|
||||
},
|
||||
{
|
||||
"move": "13/8 5/1",
|
||||
"equity": -0.24252185
|
||||
},
|
||||
{
|
||||
"move": "9/5 9/4",
|
||||
"equity": -0.2440444
|
||||
},
|
||||
{
|
||||
"move": "9/4 6/2",
|
||||
"equity": -0.24487257
|
||||
},
|
||||
{
|
||||
"move": "9/4 7/3",
|
||||
"equity": -0.24512678
|
||||
},
|
||||
{
|
||||
"move": "11/6 5/1",
|
||||
"equity": -0.25016445
|
||||
},
|
||||
{
|
||||
"move": "9/4 5/1",
|
||||
"equity": -0.25572774
|
||||
},
|
||||
{
|
||||
"move": "7/2 5/1",
|
||||
"equity": -0.258138
|
||||
},
|
||||
{
|
||||
"move": "7/3 6/1",
|
||||
"equity": -0.25878713
|
||||
},
|
||||
{
|
||||
"move": "7/2 6/2",
|
||||
"equity": -0.26088732
|
||||
},
|
||||
{
|
||||
"move": "13/9 6/1",
|
||||
"equity": -0.26104733
|
||||
},
|
||||
{
|
||||
"move": "9/5 6/1",
|
||||
"equity": -0.26680842
|
||||
},
|
||||
{
|
||||
"move": "11/7 6/1",
|
||||
"equity": -0.2670043
|
||||
},
|
||||
{
|
||||
"move": "6/2 6/1",
|
||||
"equity": -0.27360544
|
||||
},
|
||||
{
|
||||
"move": "6/1 5/1",
|
||||
"equity": -0.31466243
|
||||
}
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"played_move": "6/1 5/1",
|
||||
"played_position_id": "cLezAQDDNmcGAA",
|
||||
"move_count": 29,
|
||||
"rank": 7,
|
||||
"played_equity": -0.29347342,
|
||||
"best_move": "13/9 11/6",
|
||||
"best_equity": -0.21922025,
|
||||
"equity_loss": -0.07425317,
|
||||
"skill": "bad",
|
||||
"ranking": [
|
||||
{
|
||||
"move": "13/9 11/6",
|
||||
"equity": -0.21922025
|
||||
},
|
||||
{
|
||||
"move": "13/9 13/8",
|
||||
"equity": -0.2202243
|
||||
},
|
||||
{
|
||||
"move": "11/6 9/5",
|
||||
"equity": -0.22022447
|
||||
},
|
||||
{
|
||||
"move": "13/8 9/5",
|
||||
"equity": -0.22088149
|
||||
},
|
||||
{
|
||||
"move": "11/7 11/6",
|
||||
"equity": -0.22277355
|
||||
},
|
||||
{
|
||||
"move": "13/4",
|
||||
"equity": -0.2230124
|
||||
},
|
||||
{
|
||||
"move": "6/1 5/1",
|
||||
"equity": -0.29347342
|
||||
},
|
||||
{
|
||||
"move": "11/7 9/4",
|
||||
"equity": -0.22985312
|
||||
},
|
||||
{
|
||||
"move": "13/8 11/7",
|
||||
"equity": -0.23169218
|
||||
},
|
||||
{
|
||||
"move": "13/9 7/2",
|
||||
"equity": -0.23561361
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"luck": {
|
||||
"value": 0.043956697,
|
||||
"rating": "none"
|
||||
}
|
||||
}
|
||||
+122
-3
@@ -138,6 +138,123 @@ def compare_position(report, cli, path):
|
||||
return len(position["plies"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Analyses (analysis/<id>.json, voir README.md, section « Analyses »).
|
||||
|
||||
# Seuils de gnubg (arSkillLevel et arLuckLevel) : une appréciation ou une
|
||||
# chance n'est comparée strictement que loin de ces seuils.
|
||||
SKILL_THRESHOLDS = (0.03, 0.06, 0.12)
|
||||
LUCK_THRESHOLDS = (0.3, 0.6)
|
||||
|
||||
|
||||
def run_cli(cli, args):
|
||||
command = shlex.split(cli) + args
|
||||
output = subprocess.run(command, capture_output=True, text=True)
|
||||
if output.returncode != 0:
|
||||
raise SystemExit("%s a échoué : %s" % (" ".join(command), output.stdout + output.stderr))
|
||||
return json.loads(output.stdout)
|
||||
|
||||
|
||||
def near_threshold(value, thresholds, tolerance):
|
||||
return any(abs(abs(value) - t) <= tolerance for t in thresholds)
|
||||
|
||||
|
||||
def same_label(report, where, field, expected, actual, value, thresholds):
|
||||
"""Même appréciation ou chance, sauf si la valeur de gnubg est à moins de
|
||||
la tolérance d'un seuil : remarque sans échec."""
|
||||
if expected == actual:
|
||||
return
|
||||
if near_threshold(value, thresholds, report.tolerance):
|
||||
report.notes.append("%s : %s %s pour gnubg, %s pour le moteur ; valeur %.4f proche "
|
||||
"d'un seuil" % (where, field, expected, actual, value))
|
||||
else:
|
||||
report.same(where, field, expected, actual)
|
||||
|
||||
|
||||
def compare_move_analysis(report, cli, case):
|
||||
dice = "%d%d" % tuple(case["dice"])
|
||||
args = [case["position_id"], case["match_id"], dice]
|
||||
luck = run_cli(cli, ["luck"] + args)
|
||||
where = "%s chance" % case["id"]
|
||||
report.number(where, "luck", case["luck"]["value"], luck["luck"])
|
||||
same_label(report, where, "rating", case["luck"]["rating"], luck["rating"],
|
||||
case["luck"]["value"], LUCK_THRESHOLDS)
|
||||
for ply, expected in sorted(case["plies"].items()):
|
||||
actual = run_cli(cli, ["--ply", ply, "analysemove"] + args
|
||||
+ [expected["played_position_id"]])
|
||||
where = "%s à %s pli(s)" % (case["id"], ply)
|
||||
report.same(where, "move_count", expected["move_count"], actual["move_count"])
|
||||
report.same(where, "coup joué", expected["played_move"], actual["played"]["move"])
|
||||
# Rang : le même, sauf si les coups entre les deux rangs ont, pour
|
||||
# gnubg, la même équité que le coup joué à la tolérance près.
|
||||
if expected["rank"] != actual["rank"]:
|
||||
low, high = sorted((expected["rank"], actual["rank"]))
|
||||
between = expected["ranking"][low - 1:high]
|
||||
tied = len(between) == high - low + 1 and all(
|
||||
abs(m["equity"] - expected["played_equity"]) <= report.tolerance for m in between)
|
||||
if tied:
|
||||
report.notes.append("%s : rang %d pour gnubg, %d pour le moteur ; égalité"
|
||||
% (where, expected["rank"], actual["rank"]))
|
||||
else:
|
||||
report.same(where, "rank", expected["rank"], actual["rank"])
|
||||
if expected["best_move"] != actual["best"]["move"]:
|
||||
ranking = expected["ranking"]
|
||||
tied = len(ranking) > 1 and ranking[0]["equity"] - ranking[1]["equity"] <= report.tolerance
|
||||
if tied:
|
||||
report.notes.append("%s : meilleur coup %s pour gnubg, %s pour le moteur ; égalité"
|
||||
% (where, expected["best_move"], actual["best"]["move"]))
|
||||
else:
|
||||
report.same(where, "meilleur coup", expected["best_move"], actual["best"]["move"])
|
||||
report.number(where, "played_equity", expected["played_equity"], actual["played"]["equity"])
|
||||
report.number(where, "best_equity", expected["best_equity"], actual["best"]["equity"])
|
||||
report.number(where, "equity_loss", expected["equity_loss"], actual["equity_loss"])
|
||||
same_label(report, where, "skill", expected["skill"], actual["skill"],
|
||||
expected["equity_loss"], SKILL_THRESHOLDS)
|
||||
|
||||
|
||||
def compare_cube_analysis(report, cli, case):
|
||||
for ply, expected in sorted(case["plies"].items()):
|
||||
actual = run_cli(cli, ["--ply", ply, "analysecube", case["position_id"],
|
||||
case["match_id"], case["action"]])
|
||||
where = "%s à %s pli(s)" % (case["id"], ply)
|
||||
report.same(where, "analysed", expected["analysed"], actual["analysed"])
|
||||
if expected["analysed"] and actual["analysed"]:
|
||||
compare_cube(report, where, expected["cube"], actual["cube"])
|
||||
report.number(where, "equity_loss", expected["equity_loss"], actual["equity_loss"])
|
||||
same_label(report, where, "skill", expected["skill"], actual["skill"],
|
||||
expected["equity_loss"], SKILL_THRESHOLDS)
|
||||
|
||||
|
||||
def compare_levels(report, cli, case, settings):
|
||||
for name, expected in sorted(case["levels"].items()):
|
||||
where = "%s niveau %s" % (case["id"], name)
|
||||
report.same(where, "réglages", (expected["plies"], expected["noise"]),
|
||||
(settings[name]["plies"], round(settings[name]["noise"], 6)))
|
||||
if case["dice"]:
|
||||
actual = run_cli(cli, ["--level", name, "bestmove", case["position_id"],
|
||||
case["match_id"], "%d%d" % tuple(case["dice"])])
|
||||
report.same(where, "coup choisi", expected["move"], actual["best_move"]["move"])
|
||||
else:
|
||||
actual = run_cli(cli, ["--level", name, "cube", case["position_id"], case["match_id"]])
|
||||
compare_cube(report, where, expected, actual)
|
||||
|
||||
|
||||
def compare_analyses(report, cli, folder):
|
||||
"""Compare toutes les analyses ; renvoie leur nombre."""
|
||||
settings = {level["name"]: level for level in run_cli(cli, ["levels"])["levels"]}
|
||||
paths = sorted(glob.glob(os.path.join(folder, "*.json")))
|
||||
for path in paths:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
case = json.load(f)
|
||||
if case["type"] == "move":
|
||||
compare_move_analysis(report, cli, case)
|
||||
elif case["type"] == "cube":
|
||||
compare_cube_analysis(report, cli, case)
|
||||
else:
|
||||
compare_levels(report, cli, case, settings)
|
||||
return len(paths)
|
||||
|
||||
|
||||
def print_table(lines):
|
||||
header = ("position et niveau", "champ", "attendu", "obtenu", "écart")
|
||||
rows = [header] + lines
|
||||
@@ -152,6 +269,7 @@ def main():
|
||||
parser.add_argument("--cli", default="build/engine-cli",
|
||||
help="programme interrogé, éventuellement avec ses arguments")
|
||||
parser.add_argument("--results", default=os.path.join(here, "results"))
|
||||
parser.add_argument("--analysis", default=os.path.join(here, "analysis"))
|
||||
parser.add_argument("--tolerance", type=float, default=TOLERANCE)
|
||||
parser.add_argument("--details", action="store_true",
|
||||
help="affiche tous les nombres comparés")
|
||||
@@ -160,14 +278,15 @@ def main():
|
||||
report = Report(args.tolerance)
|
||||
paths = sorted(glob.glob(os.path.join(args.results, "*.json")))
|
||||
levels = sum(compare_position(report, args.cli, path) for path in paths)
|
||||
analyses = compare_analyses(report, args.cli, args.analysis)
|
||||
|
||||
if args.details:
|
||||
print_table(report.lines)
|
||||
for note in report.notes:
|
||||
print(" Remarque : " + note)
|
||||
print(" %d positions, %d niveaux, %d nombres comparés ; écart maximal %.2e (%s), "
|
||||
"tolérance %.0e." % (len(paths), levels, len(report.lines), report.max_gap,
|
||||
report.max_gap_where, args.tolerance))
|
||||
print(" %d positions, %d niveaux, %d analyses, %d nombres comparés ; écart maximal %.2e "
|
||||
"(%s), tolérance %.0e." % (len(paths), levels, analyses, len(report.lines),
|
||||
report.max_gap, report.max_gap_where, args.tolerance))
|
||||
if report.failures:
|
||||
print(" %d écart(s) hors critères :" % len(report.failures))
|
||||
print_table(report.failures)
|
||||
|
||||
@@ -161,6 +161,259 @@ def check_settings():
|
||||
raise SystemExit("table d'équité de match inattendue")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Analyses (analyses.json -> analysis/<id>.json) : coup joué, décision de
|
||||
# videau, chance d'un jet et niveaux de jeu. Les valeurs viennent de l'analyse
|
||||
# de partie de gnubg (« analyse game ») sur une courte partie rejouée ici :
|
||||
# la position, puis le coup ou la décision. Voir README.md.
|
||||
|
||||
# Appréciations et chances : noms de gnubg (skillString et luckString de
|
||||
# gnubgmodule.c ; absents du dictionnaire quand rien n'est à signaler) vers
|
||||
# ceux d'engine-cli.
|
||||
SKILLS = {None: "none", "doubtful": "doubtful", "bad": "bad", "very bad": "very_bad"}
|
||||
LUCKS = {None: "none", "verybad": "very_unlucky", "bad": "unlucky", "good": "lucky",
|
||||
"verygood": "very_lucky"}
|
||||
|
||||
# Niveaux de jeu : réglages prédéfinis de gnubg (aecSettings, eval.c
|
||||
# l. 318-328), en (plis, bruit) ; l'élagage est actif à partir d'1 pli, le
|
||||
# bruit est déterministe. Ceux qui sont vérifiés : 3 et 4 plis sont trop
|
||||
# lents pour la suite de tests.
|
||||
LEVEL_SETTINGS = {
|
||||
"beginner": (0, 0.06), "casual": (0, 0.05), "intermediate": (0, 0.04),
|
||||
"advanced": (0, 0.015), "0ply": (0, 0.0), "1ply": (1, 0.0), "2ply": (2, 0.0),
|
||||
"3ply": (3, 0.0), "4ply": (4, 0.0),
|
||||
}
|
||||
CHECKED_LEVELS = ("beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply")
|
||||
RANKING_KEPT = 10 # coups gardés dans le classement d'une analyse (plus le coup joué)
|
||||
|
||||
|
||||
def level_context(name):
|
||||
plies, noise = LEVEL_SETTINGS[name]
|
||||
return {"cubeful": 1, "plies": plies, "deterministic": 1,
|
||||
"prune": 1 if plies > 0 else 0, "noise": noise}
|
||||
|
||||
|
||||
def start_game(case):
|
||||
"""Nouvelle partie entre deux joueurs humains (gnubg ne joue rien de
|
||||
lui-même), placée sur la position du cas, cache vidé."""
|
||||
gnubg.command("set player 0 human")
|
||||
gnubg.command("set player 1 human")
|
||||
gnubg.command("new session")
|
||||
gnubg.command("clear cache")
|
||||
gnubg.command("set gnubgid %s:%s" % (case["position_id"], case["match_id"]))
|
||||
read_back = (gnubg.positionid(), gnubg.matchid())
|
||||
if read_back != (case["position_id"], case["match_id"]):
|
||||
raise SystemExit("%s : gnubg relit %s" % (case["id"], read_back))
|
||||
|
||||
|
||||
def set_analysis(ply):
|
||||
"""Réglages de l'analyse de partie : niveau ply sans bruit, comme le
|
||||
réglage prédéfini de gnubg ; filtre de coups et analyse de la chance par
|
||||
défaut (« Normal », ecLuck)."""
|
||||
for kind in ("chequerplay", "cubedecision"):
|
||||
prefix = "set analysis %s evaluation " % kind
|
||||
gnubg.command("set analysis %s type evaluation" % kind)
|
||||
gnubg.command(prefix + "plies %d" % ply)
|
||||
gnubg.command(prefix + "cubeful on")
|
||||
gnubg.command(prefix + "prune " + ("on" if ply > 0 else "off"))
|
||||
gnubg.command(prefix + "deterministic on")
|
||||
gnubg.command(prefix + "noise 0")
|
||||
|
||||
|
||||
def analyse_game():
|
||||
"""Analyse la partie en cours ; renvoie ses enregistrements et ses
|
||||
statistiques par joueur (« X », « O »). Le cache est vidé avant : les
|
||||
évaluations à 2 plis faites pour choisir un coup y laissent des
|
||||
résultats à 0 pli des réseaux d'élagage, que l'analyse à 0 pli
|
||||
reprendrait (voir analyse_move)."""
|
||||
gnubg.command("clear cache")
|
||||
gnubg.command("analyse game")
|
||||
game = gnubg.match(analysis=1, verbose=1, statistics=1, boards=1)["games"][-1]
|
||||
return game["game"], game["stats"]
|
||||
|
||||
|
||||
def last_record(records, action):
|
||||
found = [r for r in records if r.get("action") == action]
|
||||
if not found:
|
||||
raise SystemExit("aucun enregistrement « %s » dans la partie analysée" % action)
|
||||
return found[-1]
|
||||
|
||||
|
||||
def swapped(board):
|
||||
"""La même position vue de l'autre joueur (gnubg.board() donne le joueur
|
||||
au trait en second)."""
|
||||
return (board[1], board[0])
|
||||
|
||||
|
||||
def set_dice(case):
|
||||
"""Les dés du cas, qui priment sur ceux du Match ID."""
|
||||
gnubg.command("set dice %d %d" % tuple(case["dice"]))
|
||||
|
||||
|
||||
def best_move():
|
||||
"""Le meilleur coup selon gnubg (findbestmove, réglages d'évaluation
|
||||
courants), en notation de gnubg. Pas « hint » : gnubg garderait son
|
||||
analyse dans l'enregistrement du coup, et « analyse game » la
|
||||
reprendrait au lieu de calculer au niveau demandé."""
|
||||
board = gnubg.board()
|
||||
return gnubg.movetupletostring(gnubg.findbestmove(), board)
|
||||
|
||||
|
||||
def play_move(case):
|
||||
"""Joue le coup du cas, ou le meilleur selon gnubg s'il n'est pas donné
|
||||
(cas qui ne servent qu'à la chance du jet)."""
|
||||
gnubg.command("move " + (case.get("played") or best_move()))
|
||||
|
||||
|
||||
def analyse_move(case, ply):
|
||||
start_game(case)
|
||||
set_dice(case)
|
||||
before = gnubg.board()
|
||||
play_move(case)
|
||||
# Sans analyse du videau : gnubg la ferait juste avant la chance du jet,
|
||||
# et à 2 plis elle laisse dans son cache d'évaluation des résultats à
|
||||
# 0 pli calculés avec les réseaux d'élagage, que l'analyse de la chance
|
||||
# (0 pli, sans élagage) reprend ensuite : la chance dépendrait alors de ce
|
||||
# qui a été calculé avant (écart observé : 5e-3 sur move-middlegame-close).
|
||||
gnubg.command("set analysis cube off")
|
||||
# Après le coup, l'adversaire est au trait : la position vue de celui qui
|
||||
# vient de jouer est la position retournée.
|
||||
played_id = gnubg.positionid(swapped(gnubg.board()))
|
||||
set_analysis(ply)
|
||||
records, stats = analyse_game()
|
||||
record = last_record(records, "move")
|
||||
analysis = record["analysis"]
|
||||
moves, index = analysis["moves"], analysis["imove"]
|
||||
for m in moves:
|
||||
if m["type"] != "eval":
|
||||
raise SystemExit("%s : coup évalué autrement que prévu : %r" % (case["id"], m))
|
||||
|
||||
def notation(move):
|
||||
return gnubg.movetupletostring(move, before)
|
||||
|
||||
kept = moves[:max(RANKING_KEPT, index + 1)]
|
||||
level = {
|
||||
"played_move": notation(record["move"]),
|
||||
"played_position_id": played_id,
|
||||
"move_count": len(moves),
|
||||
"rank": index + 1,
|
||||
"played_equity": f32(moves[index]["score"]),
|
||||
"best_move": notation(moves[0]["move"]),
|
||||
"best_equity": f32(moves[0]["score"]),
|
||||
# Perte calculée par gnubg pour ses statistiques (rChequerSkill),
|
||||
# comptée en positif.
|
||||
"equity_loss": f32(-stats[record["player"]]["moves"]["error-skill"]),
|
||||
"skill": SKILLS[analysis.get("skill")],
|
||||
"ranking": [{"move": notation(m["move"]), "equity": f32(m["score"])} for m in kept],
|
||||
}
|
||||
luck = {"value": f32(analysis["luck-value"]), "rating": LUCKS[analysis.get("luck")]}
|
||||
return level, luck
|
||||
|
||||
|
||||
def analyse_cube(case, ply):
|
||||
start_game(case)
|
||||
gnubg.command("set analysis cube on")
|
||||
set_analysis(ply)
|
||||
# Décision de videau de gnubg au même niveau, avant toute action.
|
||||
decision = cube_decision(ply)
|
||||
action = case["action"]
|
||||
if action == "no_double":
|
||||
# Ne pas doubler, c'est jeter les dés : n'importe quel jet et le
|
||||
# meilleur coup, l'analyse du videau portant sur la position avant.
|
||||
gnubg.command("set dice 2 1")
|
||||
gnubg.command("move " + best_move())
|
||||
records, stats = analyse_game()
|
||||
record = last_record(records, "move")
|
||||
analysed = "nd-cubeful-eq" in record["analysis"]
|
||||
skill = record["analysis"].get("cube-skill")
|
||||
else:
|
||||
# gnubg n'analyse un double qu'une fois la réponse donnée : après
|
||||
# « double », l'adversaire prend, ce qui ne change rien à l'analyse
|
||||
# du double.
|
||||
gnubg.command("double")
|
||||
gnubg.command("drop" if action == "pass" else "take")
|
||||
records, stats = analyse_game()
|
||||
analysed = "nd-cubeful-eq" in last_record(records, "double").get("analysis", {})
|
||||
record = last_record(records, {"double": "double", "take": "take", "pass": "drop"}[action])
|
||||
skill = record.get("analysis", {}).get("skill")
|
||||
return {
|
||||
"analysed": analysed,
|
||||
"cube": decision if analysed else None,
|
||||
"equity_loss": f32(-stats[record["player"]]["cube"]["error-skill"]),
|
||||
"skill": SKILLS[skill],
|
||||
}
|
||||
|
||||
|
||||
def level_move(case, name):
|
||||
"""Le coup que joue l'ordinateur de gnubg réglé à ce niveau."""
|
||||
start_game(case)
|
||||
set_dice(case)
|
||||
before = gnubg.board()
|
||||
turn = gnubg.posinfo()["turn"]
|
||||
plies, noise = LEVEL_SETTINGS[name]
|
||||
prefix = "set player %d chequerplay evaluation " % turn
|
||||
gnubg.command("set player %d gnubg" % turn)
|
||||
gnubg.command(prefix + "plies %d" % plies)
|
||||
gnubg.command(prefix + "noise %g" % noise)
|
||||
gnubg.command(prefix + "deterministic on")
|
||||
gnubg.command(prefix + "cubeful on")
|
||||
gnubg.command(prefix + "prune " + ("on" if plies > 0 else "off"))
|
||||
gnubg.command("play")
|
||||
record = last_record(gnubg.match(analysis=0, boards=1)["games"][-1]["game"], "move")
|
||||
gnubg.command("set player %d human" % turn)
|
||||
return {"move": gnubg.movetupletostring(record["move"], before)}
|
||||
|
||||
|
||||
def level_cube(case, name):
|
||||
"""Décision de videau de gnubg avec les réglages de ce niveau."""
|
||||
start_game(case)
|
||||
r = gnubg.cfevaluate(gnubg.board(), cube_info(), level_context(name))
|
||||
return {
|
||||
"no_double": f32(r[1]),
|
||||
"double_take": f32(r[2]),
|
||||
"double_pass": f32(r[3]),
|
||||
"optimal": f32(r[0]),
|
||||
"decision": CUBE_DECISIONS[r[4]],
|
||||
"decision_text": r[5],
|
||||
}
|
||||
|
||||
|
||||
def write_analysis(out_dir, result):
|
||||
with open(os.path.join(out_dir, result["id"] + ".json"), "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
print("analyse écrite :", result["id"])
|
||||
|
||||
|
||||
def generate_analyses(commit):
|
||||
with open(os.path.join(FOLDER, "analyses.json"), encoding="utf-8") as f:
|
||||
cases = json.load(f)
|
||||
out_dir = os.path.join(FOLDER, "analysis")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
# Pas d'affichage du plateau ; pas de nouvelle partie ni de jet de dés
|
||||
# automatiques, qui ajouteraient des enregistrements après le dernier.
|
||||
gnubg.command("set display off")
|
||||
gnubg.command("set automatic game off")
|
||||
gnubg.command("set automatic roll off")
|
||||
for kind in ("moves", "cubes", "levels"):
|
||||
for case in cases[kind]:
|
||||
result = dict(case, type=kind[:-1], gnubg_commit=commit, met=MET)
|
||||
if kind == "moves":
|
||||
result["plies"] = {}
|
||||
for ply in PLIES:
|
||||
result["plies"][str(ply)], result["luck"] = analyse_move(case, ply)
|
||||
elif kind == "cubes":
|
||||
result["plies"] = {str(ply): analyse_cube(case, ply) for ply in PLIES}
|
||||
else:
|
||||
result["levels"] = {}
|
||||
for name in CHECKED_LEVELS:
|
||||
plies, noise = LEVEL_SETTINGS[name]
|
||||
level = {"plies": plies, "noise": noise}
|
||||
level.update(level_move(case, name) if case["dice"] else level_cube(case, name))
|
||||
result["levels"][name] = level
|
||||
write_analysis(out_dir, result)
|
||||
|
||||
|
||||
def main():
|
||||
with open(os.path.join(FOLDER, "positions.json"), encoding="utf-8") as f:
|
||||
positions = json.load(f)["positions"]
|
||||
@@ -195,6 +448,7 @@ def main():
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
print("référence écrite :", pos["id"])
|
||||
generate_analyses(commit)
|
||||
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Relit dans GNU Backgammon de référence les parties exportées en SGF par
|
||||
# Ustagammon (app/src/domain/game/sgf.ts) et vérifie qu'il y retrouve les
|
||||
# positions du journal.
|
||||
#
|
||||
# Ce script s'exécute DANS gnubg (scripting Python intégré), dans l'image
|
||||
# Docker ustagammon/gnubg-reference : voir tests/test_web.sh. Le dossier
|
||||
# SGF_DIR contient, pour chaque partie, <nom>.sgf et <nom>.json (écrits par
|
||||
# app/tests/tools/sgf-samples.ts) :
|
||||
# after : après chaque coup du journal, la position vue de celui qui a
|
||||
# alors la main ;
|
||||
# result : le résultat de la partie ({winner, points}) ou null.
|
||||
#
|
||||
# Pour chaque partie, gnubg charge le fichier (load game), puis avance
|
||||
# enregistrement par enregistrement (next) : après chaque coup, sa position
|
||||
# doit être celle du journal. Une position de départ mal posée, un coup mal
|
||||
# écrit ou refusé changent donc la suite des positions. Le résultat (RE)
|
||||
# doit aussi être celui du journal. Code de sortie : 0 si tout concorde.
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gnubg
|
||||
|
||||
FOLDER = os.environ.get("SGF_DIR", "/sgf")
|
||||
|
||||
|
||||
def check(path):
|
||||
"""Les écarts entre gnubg et le journal pour une partie (liste vide si
|
||||
tout concorde)."""
|
||||
with open(path[:-4] + ".json") as f:
|
||||
expected = json.load(f)
|
||||
gnubg.command("load game " + path)
|
||||
games = gnubg.match(analysis=0, boards=0, statistics=0, verbose=0)["games"]
|
||||
if len(games) != 1:
|
||||
return ["gnubg n'a pas chargé la partie"]
|
||||
game = games[0]
|
||||
records = list(game["game"])
|
||||
|
||||
# Le premier « next » applique le premier enregistrement.
|
||||
gnubg.command("first move")
|
||||
after = []
|
||||
for record in records:
|
||||
gnubg.command("next")
|
||||
if record["action"] == "move":
|
||||
after.append(gnubg.positionid())
|
||||
|
||||
errors = []
|
||||
if len(after) != len(expected["after"]):
|
||||
errors.append("%d coups lus, %d attendus" % (len(after), len(expected["after"])))
|
||||
for i, (got, want) in enumerate(zip(after, expected["after"])):
|
||||
if got != want:
|
||||
errors.append("après le coup %d : gnubg %s, journal %s" % (i + 1, got, want))
|
||||
break
|
||||
|
||||
# Dans gnubg, X est le joueur 0 (W, l'ordinateur) et O le joueur 1 (B).
|
||||
info = game["info"]
|
||||
result = expected["result"]
|
||||
if result is None:
|
||||
if info.get("winner") is not None:
|
||||
errors.append("gnubg voit une partie finie : %s" % info)
|
||||
else:
|
||||
winner = "O" if result["winner"] == "player" else "X"
|
||||
if (info.get("winner"), info.get("points-won")) != (winner, result["points"]):
|
||||
errors.append("résultat : gnubg %s, journal %s" % (info, result))
|
||||
return errors
|
||||
|
||||
|
||||
failures = 0
|
||||
paths = sorted(glob.glob(os.path.join(FOLDER, "*.sgf")))
|
||||
for path in paths:
|
||||
errors = check(path)
|
||||
name = os.path.basename(path)
|
||||
if errors:
|
||||
failures += 1
|
||||
print("ÉCHEC %s : %s" % (name, " ; ".join(errors)))
|
||||
else:
|
||||
print("OK %s" % name)
|
||||
|
||||
if not paths:
|
||||
print("aucun SGF dans %s" % FOLDER)
|
||||
failures += 1
|
||||
sys.stdout.flush()
|
||||
# Un script --python de gnubg ne transmet pas son code de sortie : la
|
||||
# dernière ligne le donne au script shell.
|
||||
print("SGF-RESULTAT %s" % ("OK" if failures == 0 else "ECHEC"))
|
||||
+46
-1
@@ -2,7 +2,8 @@
|
||||
# Tests du moteur embarqué, via build/engine-cli (étape 0a.3) :
|
||||
# meilleurs coups d'ouverture connus, nombre de coups légaux, bornes et
|
||||
# symétrie des probabilités, décision de videau évidente, identifiants
|
||||
# invalides. La comparaison systématique au gnubg de bureau est faite par
|
||||
# invalides, niveaux de jeu (bruit reproductible), analyses et chance. La
|
||||
# comparaison systématique au gnubg de bureau est faite par
|
||||
# tests/test_engine_reference.sh.
|
||||
#
|
||||
# Lance `make engine` (sans effet si tout est à jour).
|
||||
@@ -219,6 +220,50 @@ grep -qF '"available": true, "double": true, "take": false' <<<"$($cli cube "$co
|
||||
grep -qF '"available": false, "double": false' <<<"$($cli cube "$course" "$(valeur match_id "$($cli matchid 2 1)")")" \
|
||||
|| echouer "videau chez l'adversaire : double indisponible attendu"
|
||||
|
||||
# Niveaux de jeu : les neuf réglages de gnubg, seuils exposés.
|
||||
sortie="$($cli levels)"
|
||||
[ "$(grep -o '"name"' <<<"$sortie" | wc -l)" = 9 ] || echouer "levels : 9 niveaux attendus"
|
||||
grep -qF '"name": "beginner", "plies": 0, "noise": 0.0599999987' <<<"$sortie" \
|
||||
|| echouer "levels : réglage débutant inattendu"
|
||||
grep -qF '"skill_thresholds": {"doubtful": 0.0299999993, "bad": 0.0599999987, "very_bad": 0.119999997}' \
|
||||
<<<"$sortie" || echouer "levels : seuils d'appréciation inattendus"
|
||||
# Le bruit est déterministe : le même coup à chaque appel, et le bruit change
|
||||
# le coup de cette position (vérifié contre gnubg par analysis/level-*.json).
|
||||
milieu=mM/gATCw5+ABMA
|
||||
milieu_mid=cAkTAAAAAAAA
|
||||
for essai in 1 2 3; do
|
||||
coup="$(valeur move "$($cli --level beginner bestmove "$milieu" "$milieu_mid" 64)")"
|
||||
[ "$coup" = "24/20 24/18" ] || echouer "niveau débutant, essai $essai : « $coup »"
|
||||
done
|
||||
coup="$(valeur move "$($cli --level 0ply bestmove "$milieu" "$milieu_mid" 64)")"
|
||||
[ "$coup" = "24/14" ] || echouer "niveau 0 pli : « $coup » au lieu de 24/14"
|
||||
grep -qF '"level": "advanced"' <<<"$($cli --level advanced cube "$depart" "$libre_j0")" \
|
||||
|| echouer "cube au niveau avancé : niveau absent de la réponse"
|
||||
attendre_erreur usage --level champion bestmove "$depart" "$libre_j0" 31
|
||||
attendre_erreur usage --level beginner --ply 2 bestmove "$depart" "$libre_j0" 31
|
||||
attendre_erreur usage --level beginner eval "$depart" "$libre_j0"
|
||||
|
||||
# Analyse d'un coup : le meilleur coup n'est pas une erreur ; un coup illégal
|
||||
# est refusé ; sans coup possible, rien à analyser.
|
||||
sortie="$($cli analysemove "$depart" "$libre_j0" 31 4HPwATCwZ/ABMA)"
|
||||
[ "$(valeur rank "$sortie") $(valeur skill "$sortie")" = "1 none" ] \
|
||||
|| echouer "analysemove 8/5 6/5 : rang 1 sans erreur attendus ($sortie)"
|
||||
attendre_erreur invalid_move analysemove "$depart" "$libre_j0" 31 "$depart"
|
||||
grep -qF '"move_count": 0, "rank": null' <<<"$($cli analysemove 27YBBwAcz8EDQA cIkWAAAAAAAA 55 27YBBwAcz8EDQA)" \
|
||||
|| echouer "analysemove sans coup possible : move_count 0 attendu"
|
||||
|
||||
# Analyse du videau : décision inconnue refusée ; pas d'analyse quand le
|
||||
# joueur au trait ne peut pas doubler.
|
||||
attendre_erreur usage analysecube "$depart" "$libre_j0" redouble
|
||||
grep -qF '"analysed": false, "cube": null, "equity_loss": 0, "skill": "none"' \
|
||||
<<<"$($cli analysecube "$course" "$(valeur match_id "$($cli matchid 2 1)")" no_double)" \
|
||||
|| echouer "analysecube, videau chez l'adversaire : pas d'analyse attendue"
|
||||
|
||||
# Chance : même jet, même chance ; ordre des dés indifférent.
|
||||
[ "$($cli luck "$milieu" "$milieu_mid" 64 | sed 's/"dice": \[[0-9], [0-9]\]//')" \
|
||||
= "$($cli luck "$milieu" "$milieu_mid" 46 | sed 's/"dice": \[[0-9], [0-9]\]//')" ] \
|
||||
|| echouer "luck : l'ordre des dés change la chance"
|
||||
|
||||
if [ "$echecs" -gt 0 ]; then
|
||||
echo " $echecs vérification(s) du moteur en échec."
|
||||
exit 1
|
||||
|
||||
@@ -117,4 +117,51 @@ for f in results/*.json; do
|
||||
printf '%s\n' "${ids[@]}" | grep -qxF "$id" || signaler "$f : position absente de positions.json"
|
||||
done
|
||||
|
||||
# Analyses (README.md, section « Analyses ») : un résultat par cas, et les
|
||||
# champs du format.
|
||||
jq empty analyses.json || { echo "analyses.json : JSON invalide"; exit 1; }
|
||||
read -r -d '' controle_analyse <<'JQ' || true
|
||||
def nombre: type == "number";
|
||||
def skill: IN("none", "doubtful", "bad", "very_bad");
|
||||
if .type == "move" then
|
||||
(if (.plies | keys) == ["0", "2"] and (.luck.value | nombre)
|
||||
and (.luck.rating | IN("very_unlucky", "unlucky", "none", "lucky", "very_lucky"))
|
||||
and all(.plies[]; (.move_count | nombre) and (.rank | nombre) and (.played_move | type) == "string"
|
||||
and (.played_position_id | test("^[A-Za-z0-9+/]{14}$")) and (.played_equity | nombre)
|
||||
and (.best_move | type) == "string" and (.best_equity | nombre)
|
||||
and (.equity_loss | nombre) and .equity_loss <= 0 and (.skill | skill)
|
||||
and (.ranking | length) >= ([.rank, ([.move_count, 10] | min)] | max))
|
||||
then empty else "analyse de coup incomplète" end)
|
||||
elif .type == "cube" then
|
||||
(if (.plies | keys) == ["0", "2"] and (.action | IN("no_double", "double", "take", "pass"))
|
||||
and all(.plies[]; (.analysed | type) == "boolean" and (.equity_loss | nombre)
|
||||
and .equity_loss <= 0 and (.skill | skill)
|
||||
and (if .analysed then (.cube.decision | type) == "string" else .cube == null end))
|
||||
then empty else "analyse de videau incomplète" end)
|
||||
elif .type == "level" then
|
||||
(if (.levels | length) > 0 and all(.levels[]; (.plies | nombre) and (.noise | nombre)
|
||||
and ((.move | type) == "string" or (.decision | type) == "string"))
|
||||
then empty else "niveaux incomplets" end)
|
||||
else "type inconnu" end
|
||||
JQ
|
||||
|
||||
mapfile -t analyses < <(jq -r '(.moves + .cubes + .levels)[].id' analyses.json)
|
||||
doublons="$(printf '%s\n' "${analyses[@]}" "${ids[@]}" | sort | uniq -d | paste -sd' ' -)"
|
||||
[ -z "$doublons" ] || signaler "analyses.json : identifiants en double : $doublons"
|
||||
for id in "${analyses[@]}"; do
|
||||
f="analysis/$id.json"
|
||||
if [ ! -f "$f" ]; then
|
||||
signaler "$f absent : lancer make reference"
|
||||
continue
|
||||
fi
|
||||
while read -r defaut; do
|
||||
[ -n "$defaut" ] && signaler "$f : $defaut"
|
||||
done < <(jq -r "$controle_analyse" "$f")
|
||||
done
|
||||
for f in analysis/*.json; do
|
||||
[ -e "$f" ] || continue
|
||||
printf '%s\n' "${analyses[@]}" | grep -qxF "$(basename "$f" .json)" \
|
||||
|| signaler "$f : cas absent d'analyses.json"
|
||||
done
|
||||
|
||||
[ "$erreurs" -eq 0 ]
|
||||
|
||||
+22
-1
@@ -5,7 +5,10 @@
|
||||
# - scénarios de docs/scenarios/ exécutés par Cucumber sur le domaine branché
|
||||
# au vrai moteur (ADR 0008) ;
|
||||
# - règles d'architecture (dependency-cruiser, ADR 0007), et preuve qu'une
|
||||
# violation volontaire de chaque règle est bien détectée.
|
||||
# violation volontaire de chaque règle est bien détectée ;
|
||||
# - export SGF : des parties jouées sur le vrai moteur, exportées, relues par
|
||||
# le GNU Backgammon de référence, qui doit y retrouver les positions du
|
||||
# journal (tests/sgf/check.py).
|
||||
#
|
||||
# Lance `make engine-wasm` et `make web` (sans effet si tout est à jour).
|
||||
|
||||
@@ -39,6 +42,24 @@ web . node --test --test-reporter=dot 'build/web/tests/**/*.test.js' \
|
||||
web . cucumber-js --format summary --import 'build/web/tests/steps/*.js' docs/scenarios/*.feature \
|
||||
|| report "scénarios en échec (voir ci-dessus)"
|
||||
|
||||
# Export SGF relu par gnubg de bureau. Le script Python de gnubg ne
|
||||
# transmet pas de code de sortie : sa dernière ligne donne le verdict.
|
||||
samples=build/sgf-samples
|
||||
rm -rf "$samples"
|
||||
if ! docker image inspect "${REFERENCE_IMAGE:?lancer via make test}" >/dev/null 2>&1; then
|
||||
report "gnubg de référence absent : lancer d'abord make setup"
|
||||
elif ! web . node build/web/tests/tools/sgf-samples.js "$samples" >/dev/null; then
|
||||
report "parties d'exemple SGF non produites (voir ci-dessus)"
|
||||
else
|
||||
output="$(docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$samples:/sgf:ro" -v "$PWD/tests/sgf:/check:ro" "$REFERENCE_IMAGE" \
|
||||
gnubg --tty --quiet --no-rc --python=/check/check.py 2>&1)" || true
|
||||
if ! grep -q '^SGF-RESULTAT OK$' <<<"$output"; then
|
||||
grep -E '^(ÉCHEC|aucun|Traceback| File|[A-Za-z]*Error)' <<<"$output" || true
|
||||
report "export SGF : gnubg ne retrouve pas les positions du journal"
|
||||
fi
|
||||
fi
|
||||
|
||||
web app depcruise --config .dependency-cruiser.cjs src tests \
|
||||
|| report "règles d'architecture non respectées (voir ci-dessus)"
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export const commands = {
|
||||
["--ply", ply, "cube", positionId, matchId],
|
||||
evaluate: (positionId, matchId, ply) =>
|
||||
["--ply", ply, "eval", positionId, matchId],
|
||||
analyseMove: (positionId, matchId, dice, playedPositionId, ply) =>
|
||||
["--ply", ply, "analysemove", positionId, matchId, diceText(dice), playedPositionId],
|
||||
reference: (positionId, matchId, dice, ply) =>
|
||||
["--ply", ply, "reference", positionId, matchId, ...(dice ? [diceText(dice)] : [])],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user