Compare commits
25
Commits
plan-0e5
...
revision-ux
@@ -163,8 +163,8 @@ reference-image: ## Construit l'image Docker du gnubg de référence (cache Dock
|
||||
docker build -t $(REFERENCE_IMAGE) docker/gnubg-reference
|
||||
@echo "Image $(REFERENCE_IMAGE) prête."
|
||||
|
||||
reference: reference-image ## Régénère tests/reference/results/ avec le gnubg de référence
|
||||
rm -rf tests/reference/results
|
||||
reference: reference-image ## Régénère tests/reference/results/ et analysis/ avec le gnubg de référence
|
||||
rm -rf tests/reference/results tests/reference/analysis
|
||||
docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp \
|
||||
-v "$(CURDIR)/tests/reference:/reference" $(REFERENCE_IMAGE) \
|
||||
gnubg --tty --quiet --no-rc --python=/reference/generate.py
|
||||
|
||||
@@ -38,6 +38,141 @@ export interface Engine {
|
||||
|
||||
/** Le plateau d'une position, et le résultat si la partie y est finie. */
|
||||
board(position: PositionId, cube: EngineCube): Promise<Board>;
|
||||
|
||||
/** Le coup que joue l'ordinateur de gnubg à ce niveau (bruit compris,
|
||||
* reproductible), ou null si aucun coup n'est possible. */
|
||||
levelMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
level: Level,
|
||||
): Promise<Move | null>;
|
||||
|
||||
/** La décision de videau de l'ordinateur à ce niveau, bruit compris. */
|
||||
levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise<CubeDecision>;
|
||||
|
||||
/** L'analyse du coup joué (played : position après le coup, vue du joueur
|
||||
* qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si
|
||||
* aucun coup n'était possible. */
|
||||
analyseMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
played: PositionId,
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** L'analyse d'une décision de videau, position vue du joueur au trait
|
||||
* (celui qui double ou non ; take et pass sont la réponse de son
|
||||
* adversaire). */
|
||||
analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
action: CubeAction,
|
||||
ply: Ply,
|
||||
): Promise<CubeAnalysis>;
|
||||
|
||||
/** La chance d'un jet pour le joueur au trait. */
|
||||
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Niveau de jeu de l'ordinateur : un réglage prédéfini de GNU Backgammon
|
||||
* (aecSettings dans eval.c ; voir engine/include/engine.h). Les quatre
|
||||
* premiers jouent à 0 pli avec un bruit qui leur fait commettre des erreurs ;
|
||||
* les suivants sont sans bruit, de 0 à 4 plis.
|
||||
*
|
||||
* Glossaire : docs/glossaire/niveau-de-jeu.md
|
||||
*/
|
||||
export type Level =
|
||||
| "beginner"
|
||||
| "casual"
|
||||
| "intermediate"
|
||||
| "advanced"
|
||||
| "0ply"
|
||||
| "1ply"
|
||||
| "2ply"
|
||||
| "3ply"
|
||||
| "4ply";
|
||||
|
||||
/**
|
||||
* Appréciation d'un coup ou d'une décision de videau par le moteur, selon
|
||||
* l'équité perdue : rien à signaler, douteux, mauvais, très mauvais.
|
||||
*
|
||||
* Glossaire : docs/glossaire/appreciation.md
|
||||
*/
|
||||
export type Skill = "none" | "doubtful" | "bad" | "veryBad";
|
||||
|
||||
/**
|
||||
* Seuils de GNU Backgammon (arSkillLevel dans gnubg.c) : une perte
|
||||
* d'équité strictement plus grande fait passer à l'appréciation. Le moteur
|
||||
* classe lui-même ; ces valeurs servent à l'expliquer.
|
||||
*/
|
||||
export const SKILL_THRESHOLDS = { doubtful: 0.03, bad: 0.06, veryBad: 0.12 } as const;
|
||||
|
||||
/** Catégorie de chance d'un jet, de très malchanceux à très chanceux. */
|
||||
export type LuckRating = "veryUnlucky" | "unlucky" | "none" | "lucky" | "veryLucky";
|
||||
|
||||
/**
|
||||
* Seuils de GNU Backgammon (arLuckLevel dans gnubg.c) sur la chance d'un
|
||||
* jet, dans un sens ou dans l'autre.
|
||||
*/
|
||||
export const LUCK_THRESHOLDS = { lucky: 0.3, veryLucky: 0.6 } as const;
|
||||
|
||||
/**
|
||||
* La chance d'un jet : l'équité qu'il fait gagner (positive) ou perdre au
|
||||
* joueur par rapport à la moyenne des jets.
|
||||
*
|
||||
* Glossaire : docs/glossaire/chance.md
|
||||
*/
|
||||
export interface Luck {
|
||||
readonly value: number;
|
||||
readonly rating: LuckRating;
|
||||
}
|
||||
|
||||
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
|
||||
export interface EvaluatedMove extends Move {
|
||||
readonly equity: number;
|
||||
/** Niveau auquel le coup a réellement été évalué. */
|
||||
readonly ply: Ply;
|
||||
}
|
||||
|
||||
/** Ce que le moteur dit d'un coup joué, comme l'analyse de partie de gnubg. */
|
||||
export interface MoveAnalysis {
|
||||
/** Nombre de coups légaux. */
|
||||
readonly moveCount: number;
|
||||
/** Place du coup joué dans le classement, à partir de 1. */
|
||||
readonly rank: number;
|
||||
readonly played: EvaluatedMove;
|
||||
readonly best: EvaluatedMove;
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
|
||||
* (son adversaire). */
|
||||
export type CubeAction = "noDouble" | "double" | "take" | "pass";
|
||||
|
||||
/** Ce que le moteur dit d'une décision de videau. */
|
||||
export interface CubeAnalysis {
|
||||
/** gnubg analyse-t-il cette décision ? Pas quand le joueur au trait ne
|
||||
* peut pas doubler, ni à la position de départ. */
|
||||
readonly analysed: boolean;
|
||||
/** Équités du joueur au trait, normalisées pour un videau de 1 (null si
|
||||
* non analysée). */
|
||||
readonly equities: CubeEquities | null;
|
||||
/** Équité perdue par celui qui décide : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
export interface CubeEquities {
|
||||
readonly noDouble: number;
|
||||
readonly doubleTake: number;
|
||||
readonly doublePass: number;
|
||||
/** Après les meilleures décisions des deux joueurs. */
|
||||
readonly optimal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,12 +12,20 @@ import type {
|
||||
Board,
|
||||
BoardResult,
|
||||
BoardSide,
|
||||
CubeAction,
|
||||
CubeAnalysis,
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
EvaluatedMove,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Luck,
|
||||
LuckRating,
|
||||
Move,
|
||||
MoveAnalysis,
|
||||
Ply,
|
||||
Skill,
|
||||
} from "../domain/game/engine.js";
|
||||
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
@@ -32,6 +40,16 @@ interface CliMove {
|
||||
position_id: string;
|
||||
half_moves: [number, number][];
|
||||
}
|
||||
interface CliEvaluatedMove extends CliMove {
|
||||
ply: number;
|
||||
equity: number;
|
||||
}
|
||||
type CliSkill = "none" | "doubtful" | "bad" | "very_bad";
|
||||
interface CliCubeDecision {
|
||||
available: boolean;
|
||||
double: boolean;
|
||||
take: boolean;
|
||||
}
|
||||
interface CliSide {
|
||||
points: number[];
|
||||
bar: number;
|
||||
@@ -86,8 +104,108 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
ply: Ply,
|
||||
): Promise<CubeDecision> {
|
||||
const args = ["--ply", String(ply), "cube", position, await matchId(cube)];
|
||||
const answer = (await run(args)) as CubeDecision;
|
||||
return { available: answer.available, double: answer.double, take: answer.take };
|
||||
return toCubeDecision((await run(args)) as CliCubeDecision);
|
||||
},
|
||||
|
||||
async levelMove(position: PositionId, cube: EngineCube, roll: Roll, level: Level) {
|
||||
const args = ["--level", level, "bestmove", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null };
|
||||
return answer.best_move === null ? null : toMove(answer.best_move);
|
||||
},
|
||||
|
||||
async levelCubeDecision(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
level: Level,
|
||||
): Promise<CubeDecision> {
|
||||
const args = ["--level", level, "cube", position, await matchId(cube)];
|
||||
return toCubeDecision((await run(args)) as CliCubeDecision);
|
||||
},
|
||||
|
||||
async analyseMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
played: PositionId,
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null> {
|
||||
const args = ["--ply", String(ply), "analysemove", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll), played])) as {
|
||||
move_count: number;
|
||||
rank: number | null;
|
||||
played: CliEvaluatedMove | null;
|
||||
best: CliEvaluatedMove | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
};
|
||||
if (answer.rank === null || answer.played === null || answer.best === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
moveCount: answer.move_count,
|
||||
rank: answer.rank,
|
||||
played: toEvaluatedMove(answer.played),
|
||||
best: toEvaluatedMove(answer.best),
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
};
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
action: CubeAction,
|
||||
ply: Ply,
|
||||
): Promise<CubeAnalysis> {
|
||||
const cliAction = {
|
||||
noDouble: "no_double",
|
||||
double: "double",
|
||||
take: "take",
|
||||
pass: "pass",
|
||||
}[action];
|
||||
const args = ["--ply", String(ply), "analysecube", position, await matchId(cube)];
|
||||
const answer = (await run([...args, cliAction])) as {
|
||||
analysed: boolean;
|
||||
cube: {
|
||||
no_double: number;
|
||||
double_take: number;
|
||||
double_pass: number;
|
||||
optimal: number;
|
||||
} | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
};
|
||||
return {
|
||||
analysed: answer.analysed,
|
||||
equities:
|
||||
answer.cube === null
|
||||
? null
|
||||
: {
|
||||
noDouble: answer.cube.no_double,
|
||||
doubleTake: answer.cube.double_take,
|
||||
doublePass: answer.cube.double_pass,
|
||||
optimal: answer.cube.optimal,
|
||||
},
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
};
|
||||
},
|
||||
|
||||
async luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck> {
|
||||
const args = ["luck", position, await matchId(cube), diceText(roll)];
|
||||
const answer = (await run(args)) as { luck: number; rating: string };
|
||||
const ratings: Record<string, LuckRating> = {
|
||||
very_unlucky: "veryUnlucky",
|
||||
unlucky: "unlucky",
|
||||
none: "none",
|
||||
lucky: "lucky",
|
||||
very_lucky: "veryLucky",
|
||||
};
|
||||
const rating = ratings[answer.rating];
|
||||
if (rating === undefined) {
|
||||
throw new Error(`chance inconnue : ${answer.rating}`);
|
||||
}
|
||||
return { value: answer.luck, rating };
|
||||
},
|
||||
|
||||
async checkHalfMoves(
|
||||
@@ -139,6 +257,18 @@ function toMove(move: CliMove): Move {
|
||||
};
|
||||
}
|
||||
|
||||
function toEvaluatedMove(move: CliEvaluatedMove): EvaluatedMove {
|
||||
return { ...toMove(move), equity: move.equity, ply: move.ply };
|
||||
}
|
||||
|
||||
function toCubeDecision(answer: CliCubeDecision): CubeDecision {
|
||||
return { available: answer.available, double: answer.double, take: answer.take };
|
||||
}
|
||||
|
||||
function toSkill(skill: CliSkill): Skill {
|
||||
return skill === "very_bad" ? "veryBad" : skill;
|
||||
}
|
||||
|
||||
function toSide(side: CliSide): BoardSide {
|
||||
return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count };
|
||||
}
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import { Game } from "./domain/game/game.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
import { startApp } from "./ui/app.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
@@ -16,6 +17,7 @@ import { startApp } from "./ui/app.js";
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
const settings = createSettingsStore(window.localStorage);
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
@@ -31,8 +33,12 @@ startApp({
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
},
|
||||
resumeGame: (journal) => Game.resume(engine, journal),
|
||||
saveGame: (journal) => store.save(journal),
|
||||
confirm: (question) => window.confirm(question),
|
||||
cachedBoard: () => settings.loadBoard(),
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
});
|
||||
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Ce que l'application garde en plus de la partie, dans le même stockage
|
||||
// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier
|
||||
// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que
|
||||
// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import type { KeyValueStorage } from "./game-store.js";
|
||||
|
||||
const SETTINGS_KEY = "ustagammon.settings";
|
||||
const BOARD_KEY = "ustagammon.last-board";
|
||||
|
||||
/** Le dernier plateau affiché, tel que le moteur l'a décodé. */
|
||||
export interface CachedBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
}
|
||||
|
||||
export interface SettingsStore {
|
||||
/** Le texte JSON des réglages (null : jamais enregistrés). */
|
||||
loadSettings(): string | null;
|
||||
saveSettings(json: string): void;
|
||||
loadBoard(): CachedBoard | null;
|
||||
saveBoard(board: CachedBoard): void;
|
||||
}
|
||||
|
||||
function isSide(value: unknown): boolean {
|
||||
const side = value as Record<string, unknown> | null;
|
||||
return (
|
||||
side !== null &&
|
||||
typeof side === "object" &&
|
||||
Array.isArray(side.points) &&
|
||||
side.points.length === 24 &&
|
||||
side.points.every((n) => Number.isInteger(n) && n >= 0) &&
|
||||
Number.isInteger(side.bar) &&
|
||||
Number.isInteger(side.off) &&
|
||||
Number.isInteger(side.pipCount)
|
||||
);
|
||||
}
|
||||
|
||||
export function createSettingsStore(storage: KeyValueStorage): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
// Stockage plein ou interdit : l'application marche sans.
|
||||
}
|
||||
};
|
||||
return {
|
||||
loadSettings() {
|
||||
try {
|
||||
return storage.getItem(SETTINGS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveSettings(json) {
|
||||
write(SETTINGS_KEY, json);
|
||||
},
|
||||
loadBoard() {
|
||||
try {
|
||||
const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null");
|
||||
const valid =
|
||||
board !== null &&
|
||||
isSide(board.sides?.player) &&
|
||||
isSide(board.sides?.computer) &&
|
||||
Number.isInteger(board.cube?.value) &&
|
||||
[null, "player", "computer"].includes(board.cube?.owner);
|
||||
return valid ? board : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveBoard(board) {
|
||||
write(BOARD_KEY, JSON.stringify(board));
|
||||
},
|
||||
};
|
||||
}
|
||||
+926
-189
File diff suppressed because it is too large
Load Diff
@@ -155,3 +155,26 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
}
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
* comme dans le dessin de référence de la charte (board.js), pas une règle. */
|
||||
export const OPENING_BOARDS: SideBoards = (() => {
|
||||
const points = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0);
|
||||
const side = { points, bar: 0, off: 0, pipCount: 167 };
|
||||
return { player: side, computer: side };
|
||||
})();
|
||||
|
||||
/** Qui mène la course, et de combien : le plus petit compte de points
|
||||
* (leader null : égalité). */
|
||||
export function pipLead(pipCount: Readonly<Record<Side, number>>): {
|
||||
leader: Side | null;
|
||||
by: number;
|
||||
} {
|
||||
const by = Math.abs(pipCount.player - pipCount.computer);
|
||||
if (by === 0) {
|
||||
return { leader: null, by };
|
||||
}
|
||||
return { leader: pipCount.player < pipCount.computer ? "player" : "computer", by };
|
||||
}
|
||||
|
||||
+420
-173
@@ -7,7 +7,14 @@
|
||||
// il n'y a aucun état caché dans le dessin. Les couleurs viennent des
|
||||
// variables CSS (app.css), par des classes : aucun style dans le SVG. Chaque
|
||||
// endroit qu'on peut toucher est un rectangle transparent, posé par-dessus
|
||||
// le reste, qui porte son nom dans data-place.
|
||||
// le reste, qui porte son nom dans data-place (une case, la barre, la
|
||||
// sortie) ou data-action (dés, videau, commandes).
|
||||
//
|
||||
// Les mesures viennent de la géométrie (geometry.ts), calculée d'après la
|
||||
// forme de l'écran. En paysage, le plateau porte aussi les commandes : la
|
||||
// colonne de gauche (menu, videau, annuler ou revoir), les comptes de
|
||||
// points en pastilles aux bouts de la barre et la consigne dans la bande
|
||||
// centrale.
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
@@ -18,6 +25,8 @@ import {
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "./board-model.js";
|
||||
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
|
||||
import { ICON_PATHS, type IconName } from "./icons.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
@@ -30,8 +39,17 @@ export interface ShownDie {
|
||||
readonly used: boolean;
|
||||
}
|
||||
|
||||
/** Ce que fait un toucher sur les dés : lancer, ou valider le coup saisi. */
|
||||
export type DiceAction = "roll" | "confirm";
|
||||
/** Ce que fait un toucher sur les dés : lancer, jouer le coup saisi, ou
|
||||
* passer son tour quand aucun coup n'est possible. */
|
||||
export type DiceAction = "roll" | "confirm" | "skip";
|
||||
|
||||
/** Les commandes de la colonne de gauche, en paysage. */
|
||||
export interface BoardControls {
|
||||
/** En bas : annuler le demi-coup, ou revoir le coup de l'ordinateur. */
|
||||
readonly back: "undo" | "replay";
|
||||
readonly backEnabled: boolean;
|
||||
readonly menuEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
@@ -44,12 +62,16 @@ export interface BoardDecor {
|
||||
readonly targets: ReadonlySet<Place>;
|
||||
/** Les traces du dernier coup de l'ordinateur, tant qu'on les montre. */
|
||||
readonly trace: MoveTrace | null;
|
||||
/** Ce que fait un toucher sur la zone des dés, d'un côté ou de l'autre
|
||||
* du plateau (null : rien). Pour « roll », les dés du joueur en attente
|
||||
* sont dessinés. */
|
||||
/** Ce que fait un toucher sur la bande centrale, d'un côté ou de
|
||||
* l'autre du plateau (null : rien). En portrait, pour « roll », les dés
|
||||
* du joueur en attente sont dessinés. */
|
||||
readonly diceAction: DiceAction | null;
|
||||
/** Le joueur peut doubler : le videau se touche. */
|
||||
readonly canDouble: boolean;
|
||||
/** Paysage : la consigne courte, dans la bande centrale. */
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -62,30 +84,7 @@ export const NO_MARKS = {
|
||||
canDouble: false,
|
||||
} as const;
|
||||
|
||||
/** Taille minimale d'une cible à toucher, en unités du dessin : au moins
|
||||
* 44 px sur un téléphone de 360 px de large, où le plateau en fait 344
|
||||
* (640 unités). */
|
||||
const MIN_TARGET = 88;
|
||||
|
||||
// Géométrie, en unités du viewBox (le SVG s'adapte à la largeur de l'écran).
|
||||
const WIDTH = 640;
|
||||
const HEIGHT = 520;
|
||||
const FRAME = 8;
|
||||
const POINT_WIDTH = 44;
|
||||
const HALF_WIDTH = 6 * POINT_WIDTH;
|
||||
const BAR_LEFT = FRAME + HALF_WIDTH;
|
||||
const BAR_WIDTH = 40;
|
||||
const RIGHT_HALF = BAR_LEFT + BAR_WIDTH;
|
||||
const TRAY_LEFT = RIGHT_HALF + HALF_WIDTH + FRAME;
|
||||
const TRAY_WIDTH = 48;
|
||||
const POINT_HEIGHT = 190;
|
||||
const MIDDLE = HEIGHT / 2;
|
||||
const RADIUS = 20;
|
||||
const STEP = 42; // d'un pion au suivant dans une pile
|
||||
const MAX_STACK = 5; // au-delà, un nombre sur le dernier pion
|
||||
const OFF_SLAB = 12; // épaisseur d'un pion sorti
|
||||
const DIE = 36;
|
||||
const CUBE = 34;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -103,11 +102,6 @@ function add(parent: Element, name: string, attributes: Attributes, text?: strin
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne (0 à 11). */
|
||||
function columnLeft(column: number): number {
|
||||
return column < 6 ? FRAME + column * POINT_WIDTH : RIGHT_HALF + (column - 6) * POINT_WIDTH;
|
||||
}
|
||||
|
||||
/** Un point du dessin. */
|
||||
export interface Spot {
|
||||
readonly x: number;
|
||||
@@ -120,27 +114,24 @@ export interface Spot {
|
||||
* joueur dans la moitié haute, où il rentre, l'ordinateur en bas) ; dans la
|
||||
* sortie, une tranche de pion vu de profil (l'ordinateur en haut).
|
||||
*/
|
||||
function slotCenter(place: Place, index: number): Spot {
|
||||
function slotCenter(g: Geometry, place: Place, index: number): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (place === "bar-player" || place === "bar-computer") {
|
||||
const offset = RADIUS + 4 + Math.min(index, MAX_STACK) * STEP;
|
||||
const offset = g.radius + 4 + Math.min(index, g.barStack - 1) * g.step;
|
||||
return {
|
||||
x: BAR_LEFT + BAR_WIDTH / 2,
|
||||
y: place === "bar-player" ? MIDDLE - offset : MIDDLE + offset,
|
||||
x: g.barLeft + g.bar / 2,
|
||||
y: place === "bar-player" ? middle - offset : middle + offset,
|
||||
};
|
||||
}
|
||||
if (place === "off-player" || place === "off-computer") {
|
||||
const y =
|
||||
place === "off-computer"
|
||||
? FRAME + index * OFF_SLAB
|
||||
: HEIGHT - FRAME - (index + 1) * OFF_SLAB;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: y + OFF_SLAB / 2 };
|
||||
? g.edge + index * OFF_SLAB
|
||||
: g.height - g.edge - (index + 1) * OFF_SLAB;
|
||||
return { x: g.trayLeft + g.tray / 2, y: y + OFF_SLAB / 2 };
|
||||
}
|
||||
const { top, column } = pointSlot(Number(place.slice(1)));
|
||||
const offset = RADIUS + 1 + Math.min(index, MAX_STACK) * STEP;
|
||||
return {
|
||||
x: columnLeft(column) + POINT_WIDTH / 2,
|
||||
y: top ? FRAME + offset : HEIGHT - FRAME - offset,
|
||||
};
|
||||
return { x: columnLeft(g, column) + g.pointWidth / 2, y: stackY(g, top, index) };
|
||||
}
|
||||
|
||||
/** Le nombre de pions de side à un endroit. */
|
||||
@@ -155,63 +146,110 @@ function countAt(board: BoardModel, side: Side, place: Place): number {
|
||||
return point?.owner === side ? point.count : 0;
|
||||
}
|
||||
|
||||
/** Le nombre de pions dessinés au plus à un endroit. */
|
||||
function stackLimit(g: Geometry, place: Place): number {
|
||||
return place.startsWith("bar-") ? g.barStack : MAX_STACK;
|
||||
}
|
||||
|
||||
/** Redessine tout le plateau dans svg. */
|
||||
export function renderBoard(svg: SVGSVGElement, board: BoardModel, decor: BoardDecor): void {
|
||||
export function renderBoard(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
svg.replaceChildren();
|
||||
svg.setAttribute("viewBox", `0 0 ${WIDTH} ${HEIGHT}`);
|
||||
drawFrame(svg);
|
||||
svg.setAttribute("viewBox", `0 0 ${g.width} ${g.height}`);
|
||||
svg.dataset.layout = g.layout;
|
||||
drawFrame(svg, g);
|
||||
const hits = add(svg, "g", {});
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, hits, board, point.number, decor);
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
drawBar(svg, hits, board, decor);
|
||||
drawTray(svg, hits, board, decor);
|
||||
drawDice(svg, decor.dice);
|
||||
const cube = drawCube(svg, decor.cube);
|
||||
drawBar(svg, pieces, g, hits, board, decor);
|
||||
drawTray(svg, pieces, g, hits, board, decor);
|
||||
if (decor.trace !== null) {
|
||||
drawTrace(svg, board, decor.trace);
|
||||
drawTraceArrows(svg, g, board, decor.trace);
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
if (decor.diceAction !== null) {
|
||||
if (decor.diceAction === "roll") {
|
||||
waitingDice(svg);
|
||||
if (decor.diceAction === "roll" && g.layout === "portrait") {
|
||||
waitingDice(svg, g);
|
||||
}
|
||||
// Une zone sur chaque moitié du plateau : les dés en attente (à
|
||||
// gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = decor.diceAction === "roll" ? TEXTS.roll : TEXTS.confirm;
|
||||
for (const x of [FRAME + HALF_WIDTH / 2, RIGHT_HALF + HALF_WIDTH / 2]) {
|
||||
addAction(hits, decor.diceAction, label, { x, y: MIDDLE }, HALF_WIDTH - 2 * FRAME);
|
||||
// Une zone sur chaque moitié de la bande centrale : les dés en
|
||||
// attente (à gauche) comme le dernier jet (à droite) se touchent.
|
||||
const label = TEXTS.diceActions[decor.diceAction];
|
||||
const width = 6 * g.pointWidth - 2 * g.frame;
|
||||
for (const x of [g.x0 + 3 * g.pointWidth, g.rightHalf + 3 * g.pointWidth]) {
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(hits, "double", TEXTS.double, cubeCenter(decor.cube), MIN_TARGET);
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
|
||||
function drawFrame(svg: SVGSVGElement): void {
|
||||
add(svg, "rect", { class: "board-frame", x: 0, y: 0, width: WIDTH, height: HEIGHT, rx: 6 });
|
||||
for (const left of [FRAME, RIGHT_HALF]) {
|
||||
function drawFrame(svg: SVGSVGElement, g: Geometry): void {
|
||||
const landscape = g.layout === "landscape";
|
||||
add(svg, "rect", {
|
||||
class: "board-frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: g.width,
|
||||
height: g.height,
|
||||
rx: landscape ? 0 : 6,
|
||||
});
|
||||
for (const left of [g.x0, g.rightHalf]) {
|
||||
add(svg, "rect", {
|
||||
class: "board-bed",
|
||||
x: left,
|
||||
y: FRAME,
|
||||
width: HALF_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
y: g.edge,
|
||||
width: 6 * g.pointWidth,
|
||||
height: g.height - 2 * g.edge,
|
||||
});
|
||||
}
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: TRAY_LEFT,
|
||||
y: FRAME,
|
||||
width: TRAY_WIDTH,
|
||||
height: HEIGHT - 2 * FRAME,
|
||||
x: g.trayLeft,
|
||||
y: g.edge,
|
||||
width: g.tray,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: landscape ? 4 : 0,
|
||||
});
|
||||
if (g.left > 0) {
|
||||
add(svg, "rect", {
|
||||
class: "board-tray",
|
||||
x: g.frame,
|
||||
y: g.edge,
|
||||
width: g.left - 4,
|
||||
height: g.height - 2 * g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawPoint(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
number: number,
|
||||
@@ -222,38 +260,54 @@ function drawPoint(
|
||||
return;
|
||||
}
|
||||
const { top, column } = pointSlot(number);
|
||||
const left = columnLeft(column);
|
||||
const center = left + POINT_WIDTH / 2;
|
||||
const base = top ? FRAME : HEIGHT - FRAME;
|
||||
const tip = top ? FRAME + POINT_HEIGHT : HEIGHT - FRAME - POINT_HEIGHT;
|
||||
const left = columnLeft(g, column);
|
||||
const center = left + g.pointWidth / 2;
|
||||
const middle = g.height / 2;
|
||||
const base = top ? g.edge : g.height - g.edge;
|
||||
const length = middle - g.edge - g.band / 2 + 6;
|
||||
const tip = top ? base + length : base - length;
|
||||
add(svg, "polygon", {
|
||||
class: pointColor(number),
|
||||
points: `${left},${base} ${left + POINT_WIDTH},${base} ${center},${tip}`,
|
||||
"data-point": number,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
// Numéros : sur le cadre en paysage, dans la bande centrale en
|
||||
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// du dessin : c'est une mesure du dessin, comme x et y.
|
||||
const size = g.numberSize;
|
||||
let y: number;
|
||||
if (g.layout === "landscape") {
|
||||
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
|
||||
} else {
|
||||
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
|
||||
}
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{ class: "point-number", x: center, y: top ? MIDDLE - 26 : MIDDLE + 34 },
|
||||
{
|
||||
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
|
||||
x: center,
|
||||
y,
|
||||
"font-size": size,
|
||||
},
|
||||
String(number),
|
||||
);
|
||||
// Case d'arrivée possible : la flèche seule est voilée (son triangle) ;
|
||||
// l'endroit où le pion se posera est marqué plus loin (drawLandings).
|
||||
if (decor.targets.has(point.place)) {
|
||||
add(svg, "rect", {
|
||||
add(svg, "polygon", {
|
||||
class: "target-zone",
|
||||
x: left + 2,
|
||||
y: top ? FRAME : MIDDLE,
|
||||
width: POINT_WIDTH - 4,
|
||||
height: HEIGHT / 2 - FRAME,
|
||||
rx: 4,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
}
|
||||
if (point.owner !== null) {
|
||||
drawStack(svg, point.owner, point.count, point.place, decor);
|
||||
drawStack(pieces, g, point.owner, point.count, point.place, decor);
|
||||
}
|
||||
addHit(hits, point.place, decor, {
|
||||
x: left,
|
||||
y: top ? 0 : MIDDLE,
|
||||
width: POINT_WIDTH,
|
||||
height: MIDDLE,
|
||||
y: top ? 0 : middle,
|
||||
width: g.pointWidth,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,13 +315,15 @@ function drawPoint(
|
||||
* pour les montrer tous. Le pion du dessus porte son endroit dans
|
||||
* data-checker : c'est lui qu'on fait glisser quand un pion arrive. */
|
||||
function drawStack(
|
||||
svg: SVGSVGElement,
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
side: Side,
|
||||
count: number,
|
||||
place: Place,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
const shown = Math.min(count, MAX_STACK);
|
||||
const limit = stackLimit(g, place);
|
||||
const shown = Math.min(count, limit);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const last = i === shown - 1;
|
||||
const classes = [`checker checker-${side}`];
|
||||
@@ -276,119 +332,270 @@ function drawStack(
|
||||
} else if (last && decor.movable.has(place)) {
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(place, i);
|
||||
const checker = add(svg, "circle", { class: classes.join(" "), cx: x, cy: y, r: RADIUS });
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > MAX_STACK) {
|
||||
add(svg, "text", { class: `checker-count count-${side}`, x, y: y + 6 }, String(count));
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
x,
|
||||
y: y + g.countFont * 0.35,
|
||||
"font-size": g.countFont,
|
||||
},
|
||||
String(count),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBar(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
add(svg, "rect", {
|
||||
class: "board-bar",
|
||||
x: BAR_LEFT,
|
||||
y: 0,
|
||||
width: BAR_WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
function drawBar(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
add(svg, "rect", { class: "board-bar", x: g.barLeft, y: 0, width: g.bar, height: g.height });
|
||||
// Les pions du joueur sur la barre entrent en haut à droite : on les met
|
||||
// dans la moitié haute, en partant du milieu ; ceux de l'ordinateur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
drawStack(svg, side, board.bar[side], `bar-${side}`, decor);
|
||||
drawStack(pieces, g, side, board.bar[side], `bar-${side}`, decor);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, { x: BAR_LEFT, y: 0, width: BAR_WIDTH, height: MIDDLE });
|
||||
if (g.layout === "landscape") {
|
||||
drawChips(svg, g, board);
|
||||
}
|
||||
addHit(hits, "bar-player", decor, {
|
||||
x: g.barLeft,
|
||||
y: 0,
|
||||
width: g.bar,
|
||||
height: g.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function drawTray(svg: SVGSVGElement, hits: Element, board: BoardModel, decor: BoardDecor): void {
|
||||
/** Paysage : les comptes de points en pastilles aux deux bouts de la barre,
|
||||
* aux couleurs du camp (l'ordinateur en haut, vous en bas). */
|
||||
function drawChips(svg: SVGSVGElement, g: Geometry, board: BoardModel): void {
|
||||
const width = g.bar - 6;
|
||||
const h = g.chipHeight;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const y = side === "computer" ? g.edge + 4 : g.height - g.edge - 4 - h;
|
||||
add(svg, "rect", {
|
||||
class: `pip-chip chip-${side}`,
|
||||
x: g.barLeft + 3,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
rx: h / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: `pip-chip-text chip-text-${side}`,
|
||||
"data-pips": side,
|
||||
x: g.barLeft + g.bar / 2,
|
||||
y: y + h / 2 + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
},
|
||||
String(board.pipCount[side]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawTray(
|
||||
svg: SVGSVGElement,
|
||||
pieces: Element,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
board: BoardModel,
|
||||
decor: BoardDecor,
|
||||
): void {
|
||||
// Pions sortis, vus de profil : l'ordinateur en haut, le joueur en bas.
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
const count = board.off[side];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slab = addSlab(svg, `checker checker-${side}`, slotCenter(`off-${side}`, i));
|
||||
const slab = addSlab(
|
||||
pieces,
|
||||
g,
|
||||
`checker checker-${side}`,
|
||||
slotCenter(g, `off-${side}`, i),
|
||||
);
|
||||
if (i === count - 1) {
|
||||
slab.setAttribute("data-checker", `off-${side}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const middle = g.height / 2;
|
||||
if (decor.targets.has("off-player")) {
|
||||
add(svg, "rect", {
|
||||
class: "target-zone",
|
||||
x: TRAY_LEFT + 2,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH - 4,
|
||||
height: MIDDLE - FRAME,
|
||||
x: g.trayLeft + 2,
|
||||
y: middle,
|
||||
width: g.tray - 4,
|
||||
height: middle - g.edge,
|
||||
rx: 4,
|
||||
});
|
||||
}
|
||||
addHit(hits, "off-player", decor, {
|
||||
x: TRAY_LEFT,
|
||||
y: MIDDLE,
|
||||
width: TRAY_WIDTH,
|
||||
height: MIDDLE,
|
||||
x: g.trayLeft,
|
||||
y: middle,
|
||||
width: g.width - g.trayLeft,
|
||||
height: middle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, cssClass: string, spot: Spot): SVGElement {
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
class: cssClass,
|
||||
x: TRAY_LEFT + 4,
|
||||
x: g.trayLeft + 4,
|
||||
y: spot.y - OFF_SLAB / 2 + 1,
|
||||
width: TRAY_WIDTH - 8,
|
||||
width: g.tray - 8,
|
||||
height: OFF_SLAB - 2,
|
||||
rx: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/** Un cercle de rayon r autour d'un pion (un cadre pour un pion sorti). */
|
||||
function addRing(svg: Element, cssClass: string, place: Place, spot: Spot, r: number): void {
|
||||
function addRing(
|
||||
svg: Element,
|
||||
g: Geometry,
|
||||
cssClass: string,
|
||||
place: Place,
|
||||
spot: Spot,
|
||||
r: number,
|
||||
): void {
|
||||
if (place.startsWith("off-")) {
|
||||
addSlab(svg, cssClass, spot);
|
||||
addSlab(svg, g, cssClass, spot);
|
||||
} else {
|
||||
add(svg, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r });
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la consigne courte, dans la moitié gauche de la bande
|
||||
* centrale, sur une pastille accent-soft. */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
controls: BoardControls,
|
||||
): void {
|
||||
const x = g.frame + (g.left - 4) / 2;
|
||||
const r = (g.left - 4) / 2 - 6;
|
||||
const top = g.edge + g.target / 2 + 2;
|
||||
const bottom = g.height - g.edge - g.target / 2 - 2;
|
||||
const control = (name: IconName, action: string, label: string, y: number, on: boolean) => {
|
||||
const group = add(svg, "g", { class: on ? "control" : "control disabled" });
|
||||
add(group, "circle", { class: "control-circle", cx: x, cy: y, r });
|
||||
const size = r * 1.1;
|
||||
const scale = size / 24;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
|
||||
});
|
||||
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
|
||||
if (on) {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
if (controls.back === "replay") {
|
||||
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
|
||||
} else {
|
||||
control("undo", "undo", TEXTS.undoTitle, bottom, controls.backEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Coup de l'ordinateur
|
||||
|
||||
/**
|
||||
* Les traces d'un coup : un pion fantôme (contour seul) là où chaque pion
|
||||
* Les traces d'un coup (sauf ses flèches, dessinées sous les pions) : un pion fantôme (contour seul) là où chaque pion
|
||||
* est parti, un cercle autour de chaque pion arrivé et de chaque pion frappé
|
||||
* (sur la barre), et une flèche par demi-coup.
|
||||
*/
|
||||
function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): void {
|
||||
function drawTraceRings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
const R = g.radius;
|
||||
for (const [place, n] of trace.left) {
|
||||
// Au-dessus des pions restés sur la case.
|
||||
const remaining = countAt(board, side, place);
|
||||
for (let k = 0; k < n; k++) {
|
||||
addRing(svg, "trace-ghost", place, slotCenter(place, remaining + k), RADIUS - 1);
|
||||
addRing(svg, g, "trace-ghost", place, slotCenter(g, place, remaining + k), R - 1);
|
||||
}
|
||||
}
|
||||
for (const [place, n] of trace.landed) {
|
||||
const top = topIndex(board, side, place);
|
||||
const top = topIndex(g, board, side, place);
|
||||
for (let k = 0; k < n && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-landed", place, slotCenter(place, top - k), RADIUS + 3);
|
||||
addRing(svg, g, "trace-landed", place, slotCenter(g, place, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
const opponent: Side = side === "player" ? "computer" : "player";
|
||||
const bar: Place = `bar-${opponent}`;
|
||||
const top = topIndex(board, opponent, bar);
|
||||
const top = topIndex(g, board, opponent, bar);
|
||||
for (let k = 0; k < trace.hits && top - k >= 0; k++) {
|
||||
addRing(svg, "trace-hit", bar, slotCenter(bar, top - k), RADIUS + 3);
|
||||
addRing(svg, g, "trace-hit", bar, slotCenter(g, bar, top - k), R + 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les flèches d'un coup, une par demi-coup : sous les pions. */
|
||||
function drawTraceArrows(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
trace: MoveTrace,
|
||||
): void {
|
||||
const side = trace.side;
|
||||
const defs = add(svg, "defs", {});
|
||||
const marker = add(defs, "marker", {
|
||||
id: "trace-head",
|
||||
viewBox: "0 0 10 10",
|
||||
refX: 8,
|
||||
refY: 5,
|
||||
markerWidth: 3.5,
|
||||
markerHeight: 3.5,
|
||||
markerWidth: 3,
|
||||
markerHeight: 3,
|
||||
orient: "auto",
|
||||
});
|
||||
add(marker, "path", { class: "trace-head", d: "M0,0 L10,5 L0,10 z" });
|
||||
@@ -398,20 +605,35 @@ function drawTrace(svg: SVGSVGElement, board: BoardModel, trace: MoveTrace): voi
|
||||
// d'arrivée.
|
||||
const k = departures.get(from) ?? 0;
|
||||
departures.set(from, k + 1);
|
||||
const start = slotCenter(from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(to, Math.max(topIndex(board, side, to), 0));
|
||||
drawArrow(svg, start, end, to.startsWith("off-") ? OFF_SLAB : RADIUS + 4);
|
||||
const start = slotCenter(g, from, countAt(board, side, from) + k);
|
||||
const end = slotCenter(g, to, Math.max(topIndex(g, board, side, to), 0));
|
||||
drawArrow(svg, g, start, end, to.startsWith("off-") ? OFF_SLAB : g.radius + 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Les cases d'arrivée du pion touché : un anneau pointillé là où il se
|
||||
* posera (sur la pile du joueur, ou au pied de la flèche). */
|
||||
function drawLandings(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
targets: ReadonlySet<Place>,
|
||||
): void {
|
||||
for (const place of targets) {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = place.startsWith("off-") ? count : Math.min(count, MAX_STACK - 1);
|
||||
addRing(svg, g, "landing", place, slotCenter(g, place, index), g.radius);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'indice du pion du dessus dessiné à un endroit (-1 s'il n'y en a pas). */
|
||||
function topIndex(board: BoardModel, side: Side, place: Place): number {
|
||||
function topIndex(g: Geometry, board: BoardModel, side: Side, place: Place): number {
|
||||
const count = countAt(board, side, place);
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, MAX_STACK) - 1;
|
||||
return place.startsWith("off-") ? count - 1 : Math.min(count, stackLimit(g, place)) - 1;
|
||||
}
|
||||
|
||||
/** Une flèche de start vers end, arrêtée à gap du centre d'arrivée. */
|
||||
function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): void {
|
||||
function drawArrow(svg: SVGSVGElement, g: Geometry, start: Spot, end: Spot, gap: number): void {
|
||||
const dx = end.x - start.x;
|
||||
const dy = end.y - start.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
@@ -422,6 +644,7 @@ function drawArrow(svg: SVGSVGElement, start: Spot, end: Spot, gap: number): voi
|
||||
y1: start.y,
|
||||
x2: start.x + dx * keep,
|
||||
y2: start.y + dy * keep,
|
||||
"stroke-width": g.arrowWidth,
|
||||
"marker-end": "url(#trace-head)",
|
||||
});
|
||||
}
|
||||
@@ -440,6 +663,9 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
/**
|
||||
* Fait glisser le pion du dessus de place depuis from jusqu'à sa position
|
||||
* dessinée, en ms millisecondes (animation du navigateur, sans bibliothèque).
|
||||
@@ -464,7 +690,7 @@ export async function slideChecker(
|
||||
{ transform: `translate(${from.x - to.x}px, ${from.y - to.y}px)` },
|
||||
{ transform: "translate(0px, 0px)" },
|
||||
],
|
||||
{ duration: ms, easing: "ease-in-out" },
|
||||
{ duration: ms, easing: EASING_STANDARD },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
@@ -505,70 +731,90 @@ const PIPS: Record<number, [number, number][]> = {
|
||||
};
|
||||
|
||||
/** Les dés, au milieu de la moitié droite. */
|
||||
function drawDice(svg: SVGSVGElement, dice: readonly ShownDie[]): void {
|
||||
const center = RIGHT_HALF + HALF_WIDTH / 2;
|
||||
function drawDice(svg: SVGSVGElement, g: Geometry, dice: readonly ShownDie[]): void {
|
||||
const center = g.rightHalf + 3 * g.pointWidth;
|
||||
const size = g.die;
|
||||
const gap = 8;
|
||||
const total = dice.length * DIE + (dice.length - 1) * gap;
|
||||
const total = dice.length * size + (dice.length - 1) * gap;
|
||||
dice.forEach((die, i) => {
|
||||
const x = center - total / 2 + i * (DIE + gap);
|
||||
const y = MIDDLE - DIE / 2;
|
||||
const x = center - total / 2 + i * (size + gap);
|
||||
const y = g.height / 2 - size / 2;
|
||||
const group = add(svg, "g", {
|
||||
class: ["die", die.blocked ? "die-blocked" : "", die.used ? "die-used" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
"data-value": die.value,
|
||||
});
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: DIE, height: DIE, rx: 6 });
|
||||
add(group, "rect", { class: `die-${die.side}`, x, y, width: size, height: size, rx: 6 });
|
||||
for (const [px, py] of PIPS[die.value] ?? []) {
|
||||
add(group, "circle", {
|
||||
class: `pip-${die.side}`,
|
||||
cx: x + px * DIE,
|
||||
cy: y + py * DIE,
|
||||
r: 3.5,
|
||||
cx: x + px * size,
|
||||
cy: y + py * size,
|
||||
r: size / 10,
|
||||
});
|
||||
}
|
||||
if (die.blocked) {
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + DIE, x2: x + DIE, y2: y });
|
||||
add(group, "line", { class: "die-strike", x1: x, y1: y + size, x2: x + size, y2: y });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Le videau, dans le plateau de sortie : au milieu s'il est au centre,
|
||||
* sinon du côté de son propriétaire. Au centre, il montre 64. */
|
||||
function drawCube(svg: SVGSVGElement, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(cube);
|
||||
const x = center.x - CUBE / 2;
|
||||
const y = center.y - CUBE / 2;
|
||||
/** Le videau : dans la colonne des sorties en portrait, dans la colonne de
|
||||
* commandes en paysage ; au milieu s'il est au centre (il montre alors 64),
|
||||
* sinon du côté de son propriétaire. */
|
||||
function drawCube(svg: SVGSVGElement, g: Geometry, cube: Cube): SVGElement {
|
||||
const center = cubeCenter(g, cube);
|
||||
const size = g.cube;
|
||||
const x = center.x - size / 2;
|
||||
const y = center.y - size / 2;
|
||||
const group = add(svg, "g", { class: "cube", "data-owner": cube.owner ?? "center" });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: CUBE, height: CUBE, rx: 4 });
|
||||
add(group, "rect", { class: "cube-face", x, y, width: size, height: size, rx: 4 });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{ class: "cube-value", x: center.x, y: center.y + 6 },
|
||||
{
|
||||
class: "cube-value",
|
||||
x: center.x,
|
||||
y: center.y + g.cubeFont * 0.35,
|
||||
"font-size": g.cubeFont,
|
||||
},
|
||||
String(cube.owner === null ? 64 : cube.value),
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Le centre du videau dans le plateau de sortie. */
|
||||
function cubeCenter(cube: Cube): Spot {
|
||||
/** Le centre du videau. */
|
||||
function cubeCenter(g: Geometry, cube: Cube): Spot {
|
||||
const middle = g.height / 2;
|
||||
if (g.layout === "landscape") {
|
||||
const shift = g.target + 6;
|
||||
const y =
|
||||
cube.owner === "computer"
|
||||
? middle - shift
|
||||
: cube.owner === "player"
|
||||
? middle + shift
|
||||
: middle;
|
||||
return { x: g.frame + (g.left - 4) / 2, y };
|
||||
}
|
||||
const shift = cube.owner === "computer" ? -48 : cube.owner === "player" ? 48 : 0;
|
||||
return { x: TRAY_LEFT + TRAY_WIDTH / 2, y: MIDDLE + shift };
|
||||
return { x: g.trayLeft + g.tray / 2, y: middle + shift };
|
||||
}
|
||||
|
||||
/** Les dés du joueur en attente d'être lancés, faces vides, au milieu de la
|
||||
* moitié gauche (la droite garde le dernier jet de l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement): void {
|
||||
const center = { x: FRAME + HALF_WIDTH / 2, y: MIDDLE };
|
||||
/** Portrait : les dés du joueur en attente d'être lancés, faces vides, au
|
||||
* milieu de la moitié gauche (la droite garde le dernier jet de
|
||||
* l'ordinateur). */
|
||||
function waitingDice(svg: SVGSVGElement, g: Geometry): void {
|
||||
const center = g.x0 + 3 * g.pointWidth;
|
||||
const gap = 8;
|
||||
const group = add(svg, "g", { class: "die die-waiting" });
|
||||
for (const x of [center.x - gap / 2 - DIE, center.x + gap / 2]) {
|
||||
for (const x of [center - gap / 2 - g.die, center + gap / 2]) {
|
||||
add(group, "rect", {
|
||||
class: "die-player",
|
||||
x,
|
||||
y: MIDDLE - DIE / 2,
|
||||
width: DIE,
|
||||
height: DIE,
|
||||
y: g.height / 2 - g.die / 2,
|
||||
width: g.die,
|
||||
height: g.die,
|
||||
rx: 6,
|
||||
});
|
||||
}
|
||||
@@ -576,27 +822,28 @@ function waitingDice(svg: SVGSVGElement): void {
|
||||
|
||||
/**
|
||||
* Une zone à toucher qui fait la même chose qu'un bouton (lancer les dés,
|
||||
* valider le coup, doubler) : au moins MIN_TARGET de côté, centrée sur ce
|
||||
* qu'on touche, avec un rôle et un nom accessibles. Les boutons restent
|
||||
* pour le clavier.
|
||||
* jouer le coup, doubler, menu…) : au moins g.target de côté (44 px à
|
||||
* l'écran), centrée sur ce qu'on touche, avec un rôle et un nom accessibles.
|
||||
*/
|
||||
function addAction(
|
||||
g: Geometry,
|
||||
hits: Element,
|
||||
action: string,
|
||||
label: string,
|
||||
center: Spot,
|
||||
width: number,
|
||||
): void {
|
||||
const w = Math.max(width, g.target);
|
||||
add(hits, "rect", {
|
||||
class: "hit action",
|
||||
"data-action": action,
|
||||
role: "button",
|
||||
"aria-label": label,
|
||||
// Sans déborder du dessin à droite (le videau est contre le bord).
|
||||
x: Math.min(center.x - width / 2, WIDTH - width),
|
||||
y: center.y - MIN_TARGET / 2,
|
||||
width,
|
||||
height: MIN_TARGET,
|
||||
// Sans déborder du dessin (le videau est contre le bord).
|
||||
x: Math.max(0, Math.min(center.x - w / 2, g.width - w)),
|
||||
y: center.y - g.target / 2,
|
||||
width: w,
|
||||
height: g.target,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// La géométrie du plateau, calculée d'après la forme de l'écran (fonction
|
||||
// pure, testée sous Node).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le dessin est en unités du viewBox, de hauteur fixe (520). En portrait, le
|
||||
// plateau garde ses proportions habituelles (640 × 520) et suit la largeur de
|
||||
// l'écran. En paysage, « le plateau est l'écran » (ADR 0012, amendements du
|
||||
// 2026-11-03) : sa largeur suit le rapport largeur/hauteur de l'écran, et les
|
||||
// flèches s'élargissent d'autant. Formules du composant Board des maquettes
|
||||
// « Révision UX » : largeur d'une flèche = ce qui reste une fois ôtés le
|
||||
// cadre, les colonnes et la barre, divisé par 12 ; rayon d'un pion = le plus
|
||||
// petit de la demi-largeur d'une flèche (moins 2) et du dixième de la
|
||||
// hauteur d'une demi-table (hors bande des numéros et bande centrale).
|
||||
|
||||
/** Portrait : barre haute, bandeaux et barre d'actions autour du plateau ;
|
||||
* paysage : le plateau seul, commandes comprises. */
|
||||
export type Layout = "portrait" | "landscape";
|
||||
|
||||
export interface Geometry {
|
||||
readonly layout: Layout;
|
||||
/** Taille du dessin, en unités du viewBox. */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
/** Épaisseur du cadre sur les côtés. */
|
||||
readonly frame: number;
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
|
||||
readonly edge: number;
|
||||
/** Colonne de commandes à gauche (0 en portrait). */
|
||||
readonly left: number;
|
||||
/** Colonne des sorties, à droite. */
|
||||
readonly tray: number;
|
||||
readonly bar: number;
|
||||
/** Bande centrale, entre les pointes des flèches (dés, consigne). */
|
||||
readonly band: number;
|
||||
readonly pointWidth: number;
|
||||
readonly radius: number;
|
||||
/** D'un pion au suivant dans une pile. */
|
||||
readonly step: number;
|
||||
/** Bord gauche de la première colonne de flèches. */
|
||||
readonly x0: number;
|
||||
readonly barLeft: number;
|
||||
/** Bord gauche de la moitié droite. */
|
||||
readonly rightHalf: number;
|
||||
readonly trayLeft: number;
|
||||
/** Côté minimal d'une zone à toucher : 44 px à l'écran. */
|
||||
readonly target: number;
|
||||
/** Tailles de texte, en unités : numéros des flèches (12 px au moins à
|
||||
* l'écran), consigne de la bande et pastilles des comptes (13 px). */
|
||||
readonly numberSize: number;
|
||||
readonly bandFont: number;
|
||||
readonly chipFont: number;
|
||||
/** Videau : côté et taille du chiffre ; nombre posé sur une pile trop
|
||||
* haute (13 px au moins à l'écran). */
|
||||
readonly cube: number;
|
||||
readonly cubeFont: number;
|
||||
readonly countFont: number;
|
||||
/** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */
|
||||
readonly arrowWidth: number;
|
||||
readonly die: number;
|
||||
/** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un
|
||||
* nombre sur le dernier) : en paysage, ils s'arrêtent avant les
|
||||
* pastilles des comptes. */
|
||||
readonly barStack: number;
|
||||
/** Hauteur d'une pastille de compte, au bout de la barre (paysage). */
|
||||
readonly chipHeight: number;
|
||||
}
|
||||
|
||||
export const BOARD_HEIGHT = 520;
|
||||
/** Rapport largeur / hauteur du plateau en portrait. */
|
||||
export const PORTRAIT_ASPECT = 640 / 520;
|
||||
/** Pions dessinés au plus sur une flèche ; au-delà, un nombre sur le dernier. */
|
||||
export const MAX_STACK = 5;
|
||||
|
||||
/** Une taille en unités qui fait au moins px pixels à l'écran. */
|
||||
function atLeast(px: number, pxPerUnit: number, min: number): number {
|
||||
return Math.max(min, Math.ceil(px / pxPerUnit));
|
||||
}
|
||||
|
||||
/**
|
||||
* La géométrie du plateau pour une mise en page. aspect : rapport largeur /
|
||||
* hauteur de la place offerte (ignoré en portrait) ; pxPerUnit : pixels à
|
||||
* l'écran par unité du dessin, pour les tailles minimales (textes, cibles).
|
||||
*/
|
||||
export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number): Geometry {
|
||||
const height = BOARD_HEIGHT;
|
||||
const target = atLeast(46, pxPerUnit, 40);
|
||||
const numberSize = atLeast(12.5, pxPerUnit, 13);
|
||||
const text = atLeast(13.5, pxPerUnit, 17);
|
||||
const sizes = {
|
||||
cubeFont: text,
|
||||
cube: Math.max(36, text + 20),
|
||||
countFont: Math.max(18, text),
|
||||
arrowWidth: 2.5 / pxPerUnit,
|
||||
};
|
||||
if (layout === "portrait") {
|
||||
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
|
||||
const frame = 8;
|
||||
const tray = 48;
|
||||
const bar = 40;
|
||||
const width = 640;
|
||||
const pointWidth = (width - 3 * frame - tray - bar) / 12;
|
||||
const band = 84;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
|
||||
const x0 = frame;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge: frame,
|
||||
left: 0,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step: 2 * radius + 2,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth + frame,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 17),
|
||||
chipFont: atLeast(13.5, pxPerUnit, 17),
|
||||
...sizes,
|
||||
die: 36,
|
||||
barStack: MAX_STACK,
|
||||
chipHeight: 0,
|
||||
};
|
||||
}
|
||||
const width = Math.round(height * aspect);
|
||||
const frame = 6;
|
||||
const edge = Math.max(24, numberSize + 8);
|
||||
const left = target + 4;
|
||||
const tray = 56;
|
||||
const bar = 64;
|
||||
const band = 80;
|
||||
const pointWidth = (width - 2 * frame - left - tray - bar) / 12;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2 - 4) / 10);
|
||||
const step = 2 * radius + 1;
|
||||
const chipFont = atLeast(13.5, pxPerUnit, 18);
|
||||
const chipHeight = chipFont + 12;
|
||||
// Pions de la barre, depuis le milieu (voir board-view) : le dernier
|
||||
// s'arrête à 2 unités de la pastille.
|
||||
const room = height / 2 - edge - 4 - chipHeight - 2 - (radius + 4) - radius;
|
||||
const barStack = Math.max(1, Math.min(MAX_STACK, Math.floor(room / step) + 1));
|
||||
const x0 = frame + left;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
layout,
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge,
|
||||
left,
|
||||
tray,
|
||||
bar,
|
||||
band,
|
||||
pointWidth,
|
||||
radius,
|
||||
step,
|
||||
x0,
|
||||
barLeft,
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth,
|
||||
target,
|
||||
numberSize,
|
||||
bandFont: atLeast(13.5, pxPerUnit, 22),
|
||||
chipFont,
|
||||
...sizes,
|
||||
die: Math.min(40, band - 12),
|
||||
barStack,
|
||||
chipHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Abscisse du bord gauche d'une colonne de flèches (0 à 11). */
|
||||
export function columnLeft(g: Geometry, column: number): number {
|
||||
return column < 6 ? g.x0 + column * g.pointWidth : g.rightHalf + (column - 6) * g.pointWidth;
|
||||
}
|
||||
|
||||
/** Ordonnée du centre du pion numéro index (0 : contre le bord) d'une pile
|
||||
* de la rangée du haut ou du bas. */
|
||||
export function stackY(g: Geometry, top: boolean, index: number): number {
|
||||
const offset = g.radius + 1 + Math.min(index, MAX_STACK) * g.step;
|
||||
return top ? g.edge + offset : g.height - g.edge - offset;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Les vibrations (docs/identite/charte/interaction.md) : elles doublent un
|
||||
// événement visible, jamais seules, et se coupent dans le menu. Rien ne vibre
|
||||
// pour un toucher de bouton ni pour le lancer des dés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export type HapticEvent =
|
||||
| "checkerPlaced"
|
||||
| "checkerHit"
|
||||
| "computerDoubles"
|
||||
| "gameWon"
|
||||
| "gameLost";
|
||||
|
||||
/** Motifs en millisecondes (vibration, pause, vibration…). */
|
||||
export const HAPTIC_PATTERNS: Readonly<Record<HapticEvent, readonly number[]>> = {
|
||||
checkerPlaced: [12],
|
||||
checkerHit: [25, 40, 25],
|
||||
computerDoubles: [40, 60, 40],
|
||||
gameWon: [30, 50, 30, 50, 60],
|
||||
gameLost: [60],
|
||||
};
|
||||
|
||||
/** navigator.vibrate, s'il existe. */
|
||||
export type Vibrate = (pattern: number[]) => boolean;
|
||||
|
||||
/**
|
||||
* La fonction qui fait vibrer pour un événement : rien si le navigateur ne
|
||||
* sait pas vibrer ou si le joueur a coupé les vibrations. Une seule vibration
|
||||
* à la fois : chaque appel remplace le précédent (comportement de
|
||||
* navigator.vibrate).
|
||||
*/
|
||||
export function createHaptics(
|
||||
vibrate: Vibrate | undefined,
|
||||
enabled: () => boolean,
|
||||
): (event: HapticEvent) => void {
|
||||
return (event) => {
|
||||
if (vibrate !== undefined && enabled()) {
|
||||
try {
|
||||
vibrate([...HAPTIC_PATTERNS[event]]);
|
||||
} catch {
|
||||
// Navigateur qui refuse (pas encore de geste du joueur) : la
|
||||
// marque visible suffit.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+10
-1
@@ -15,7 +15,10 @@ import {
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = ["docs/scenarios/jouer-une-partie.feature"];
|
||||
const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@@ -32,6 +35,12 @@ async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
|
||||
* copie du site : la bulle du compte de points en affiche le texte. */
|
||||
export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | null> {
|
||||
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Les pictogrammes de l'interface (composant Icons de la charte,
|
||||
// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en
|
||||
// currentColor, dessinés en SVG. Les tracés viennent des maquettes
|
||||
// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la
|
||||
// classe « icon » (app.css) : aucun style dans le SVG (CSP).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ICON_PATHS = {
|
||||
menu: "M4 7h16M4 12h16M4 17h16",
|
||||
help: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5",
|
||||
exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5",
|
||||
undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
|
||||
replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5",
|
||||
close: "M6 6l12 12M18 6L6 18",
|
||||
chevron: "M9 6l6 6-6 6",
|
||||
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||
newGame: "M12 5v14M5 12h14",
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
verdictBad: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICON_PATHS;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Un pictogramme, décoratif (le nom est porté par son bouton). */
|
||||
export function icon(name: IconName): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", `icon icon-${name}`);
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", ICON_PATHS[name]);
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Les réglages de l'application (menu) et ce qui en dépend, sans navigateur
|
||||
// (testé sous Node). Ils sont enregistrés à part de la partie, en JSON.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export type Theme = "auto" | "light" | "dark";
|
||||
|
||||
export interface Settings {
|
||||
/** Vibrations aux moments forts (activées par défaut). */
|
||||
readonly vibrations: boolean;
|
||||
/** Écran maintenu allumé pendant une partie (activé par défaut). */
|
||||
readonly keepScreenOn: boolean;
|
||||
readonly theme: Theme;
|
||||
/** Parties commencées : la consigne est longue pendant les trois
|
||||
* premières, courte ensuite. */
|
||||
readonly gamesStarted: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
vibrations: true,
|
||||
keepScreenOn: true,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
};
|
||||
|
||||
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
|
||||
export const LONG_PROMPT_GAMES = 3;
|
||||
|
||||
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
|
||||
* valeur illisible garde sa valeur par défaut. */
|
||||
export function parseSettings(text: string | null): Settings {
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text ?? "{}");
|
||||
if (parsed !== null && typeof parsed === "object") {
|
||||
raw = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (key: "vibrations" | "keepScreenOn") =>
|
||||
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
const games = raw.gamesStarted;
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted:
|
||||
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */
|
||||
export function nextTheme(theme: Theme): Theme {
|
||||
return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto";
|
||||
}
|
||||
|
||||
/** La consigne doit-elle encore être longue ? (La partie en cours compte.) */
|
||||
export function longPrompts(settings: Settings): boolean {
|
||||
return settings.gamesStarted <= LONG_PROMPT_GAMES;
|
||||
}
|
||||
+92
-29
@@ -22,9 +22,11 @@ function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
|
||||
function seconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
|
||||
/** Une consigne : sa forme longue (les trois premières parties) et sa forme
|
||||
* courte (ensuite, et toujours en paysage, dans la bande du plateau). */
|
||||
export interface PromptText {
|
||||
readonly long: string;
|
||||
readonly short: string;
|
||||
}
|
||||
|
||||
export const TEXTS = {
|
||||
@@ -36,57 +38,118 @@ export const TEXTS = {
|
||||
double: "Doubler",
|
||||
take: "Prendre",
|
||||
drop: "Passer",
|
||||
confirm: "Valider",
|
||||
confirm: "Jouer ce coup",
|
||||
cancel: "Annuler",
|
||||
skip: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayTitle: "Revoir le dernier coup de l'ordinateur",
|
||||
undoTitle: "Annuler le demi-coup",
|
||||
newGame: "Nouvelle partie",
|
||||
reviewGame: "Revoir la partie",
|
||||
help: "Aide",
|
||||
menu: "Menu",
|
||||
fullscreen: "Plein écran",
|
||||
exitFullscreen: "Quitter le plein écran",
|
||||
back: "Retour à la partie",
|
||||
close: "Fermer",
|
||||
/** Ce que fait un toucher sur les dés (nom accessible de la zone). */
|
||||
diceActions: {
|
||||
roll: "Lancer les dés",
|
||||
confirm: "Jouer ce coup",
|
||||
skip: "Passer votre tour",
|
||||
},
|
||||
moveHint: "Le coup fini, touchez les dés pour le jouer.",
|
||||
|
||||
// Menu
|
||||
helpAndGlossary: "Aide et glossaire",
|
||||
theme: "Thème",
|
||||
themes: { auto: "Automatique", light: "Clair", dark: "Sombre" },
|
||||
vibrations: "Vibrations",
|
||||
keepScreenOn: "Écran allumé pendant la partie",
|
||||
newGameStarted: "Nouvelle partie commencée.",
|
||||
resumeOld: "Reprendre l'ancienne",
|
||||
|
||||
// Bandeaux des joueurs et compte de points
|
||||
you: "Vous",
|
||||
computer: "Ordinateur",
|
||||
yourTurn: "à vous de jouer",
|
||||
computerTurn: "joue",
|
||||
lead: (n: number) => `mène de ${n}`,
|
||||
pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`,
|
||||
pipCountFile: "compte-de-points.md",
|
||||
seeGlossary: "Voir le glossaire",
|
||||
|
||||
// Chargement
|
||||
loading: "Chargement du moteur…",
|
||||
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
|
||||
actionFailed: (message: string) => `Erreur : ${message}`,
|
||||
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
|
||||
|
||||
// Déroulement
|
||||
// Déroulement (ligne d'information)
|
||||
opening: (player: number, computer: number) =>
|
||||
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
|
||||
player > computer ? "Vous commencez." : "L'ordinateur commence."
|
||||
}`,
|
||||
yourRoll: "À vous : doublez ou lancez les dés.",
|
||||
yourRollNoDouble: "À vous : lancez les dés.",
|
||||
yourMove: (dice: readonly number[]) =>
|
||||
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
confirmOrUndo:
|
||||
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
|
||||
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
|
||||
thinking: "L'ordinateur réfléchit…",
|
||||
computerMoved: (dice: readonly number[], move: string, ms: number) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
|
||||
computerMoved: (dice: readonly number[], move: string) =>
|
||||
`L'ordinateur a joué ${roll(dice)} : ${move}.`,
|
||||
computerCannotMove: (dice: readonly number[]) =>
|
||||
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
computerTakes: (value: number) =>
|
||||
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
|
||||
youTook: (value: number) =>
|
||||
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
|
||||
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
|
||||
|
||||
// Consignes
|
||||
prompts: {
|
||||
loading: { long: "", short: "Chargement…" },
|
||||
yourRoll: {
|
||||
long: "À vous : touchez les dés pour les lancer, ou le videau pour doubler.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourRollNoDouble: {
|
||||
long: "À vous : touchez les dés pour les lancer.",
|
||||
short: "À vous : lancez les dés.",
|
||||
},
|
||||
yourMove: (dice: readonly number[]): PromptText => ({
|
||||
long: `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
|
||||
short: `Jouez ${roll(dice)}.`,
|
||||
}),
|
||||
pickTarget: {
|
||||
long: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
|
||||
short: "Choisissez la case.",
|
||||
},
|
||||
confirmOrUndo: {
|
||||
long: "Touchez vos dés pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
|
||||
short: "Touchez les dés pour jouer.",
|
||||
},
|
||||
stuck: {
|
||||
long: "Aucun coup possible : touchez vos dés pour passer votre tour.",
|
||||
short: "Aucun coup : touchez les dés.",
|
||||
},
|
||||
thinking: { long: "L'ordinateur réfléchit", short: "L'ordinateur réfléchit" },
|
||||
computerDoubles: {
|
||||
long: "L'ordinateur vous propose de doubler : prenez ou passez.",
|
||||
short: "L'ordinateur double.",
|
||||
},
|
||||
} satisfies Record<string, PromptText | ((dice: readonly number[]) => PromptText)>,
|
||||
|
||||
// Fin de partie
|
||||
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
|
||||
youLose: (kind: ResultKind, n: number) =>
|
||||
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
|
||||
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
|
||||
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
|
||||
|
||||
// Sous le plateau
|
||||
pipCounts: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
gameOver: "Partie finie",
|
||||
endOverline: "Fin de partie",
|
||||
/** Titre du panneau de fin : qui gagne, et comment. */
|
||||
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
|
||||
if (dropped) {
|
||||
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
|
||||
}
|
||||
return winner === "player"
|
||||
? `Vous gagnez ${RESULT_KIND[kind]}`
|
||||
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
|
||||
},
|
||||
/** « 8 points pour lui, videau à 4. » */
|
||||
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
|
||||
cube > 1 ? `, videau à ${cube}` : ""
|
||||
}.`,
|
||||
|
||||
// Aide
|
||||
helpTitle: "Aide",
|
||||
|
||||
@@ -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,271 @@
|
||||
// Tests de la révision UX qui se vérifient sans navigateur : géométrie du
|
||||
// plateau selon l'écran, vibrations, réglages, compte de points, plateau
|
||||
// gardé pour le chargement, et règles de la charte vérifiables dans le code.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { createSettingsStore } from "../src/storage/settings-store.js";
|
||||
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
|
||||
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
|
||||
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
|
||||
import { ICON_PATHS } from "../src/ui/icons.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LONG_PROMPT_GAMES,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
} from "../src/ui/settings.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
|
||||
|
||||
// --------------------------------------------------------------- Géométrie
|
||||
|
||||
/** Les écrans en paysage de la recette : largeur et hauteur en pixels,
|
||||
* dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large
|
||||
* encore. */
|
||||
const LANDSCAPES: [number, number][] = [
|
||||
[915, 412],
|
||||
[844, 390],
|
||||
[844, 320],
|
||||
[740, 360],
|
||||
[800, 308],
|
||||
];
|
||||
|
||||
test("géométrie en portrait : les mesures du plateau d'origine", () => {
|
||||
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
|
||||
assert.equal(g.width, 640);
|
||||
assert.equal(g.pointWidth, 44);
|
||||
assert.equal(g.radius, 20);
|
||||
assert.equal(g.step, 42);
|
||||
assert.equal(g.trayLeft + g.tray + g.frame, 640);
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
|
||||
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
|
||||
});
|
||||
|
||||
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const px = height / BOARD_HEIGHT;
|
||||
const g = boardGeometry("landscape", width / height, px);
|
||||
const where = `${width}x${height}`;
|
||||
assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`);
|
||||
// Les colonnes, la barre et les flèches occupent toute la largeur.
|
||||
assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`);
|
||||
assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`);
|
||||
assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`);
|
||||
assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`);
|
||||
assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`);
|
||||
assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`);
|
||||
assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`);
|
||||
assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`);
|
||||
assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`);
|
||||
assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`);
|
||||
assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`);
|
||||
// Les numéros tiennent dans le cadre.
|
||||
assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`);
|
||||
}
|
||||
});
|
||||
|
||||
test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => {
|
||||
const cases = [
|
||||
boardGeometry("portrait", 640 / 520, 0.62),
|
||||
...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)),
|
||||
];
|
||||
for (const g of cases) {
|
||||
const band = g.height / 2 - g.die / 2;
|
||||
const last = stackY(g, true, MAX_STACK - 1) + g.radius;
|
||||
assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => {
|
||||
for (const [width, height] of LANDSCAPES) {
|
||||
const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT);
|
||||
const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius;
|
||||
const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight);
|
||||
assert.ok(g.barStack >= 1);
|
||||
assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Vibrations
|
||||
|
||||
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
|
||||
const calls: number[][] = [];
|
||||
let on = true;
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
calls.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => on,
|
||||
);
|
||||
haptic("checkerPlaced");
|
||||
haptic("checkerHit");
|
||||
haptic("computerDoubles");
|
||||
haptic("gameWon");
|
||||
haptic("gameLost");
|
||||
assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]);
|
||||
on = false;
|
||||
haptic("checkerHit");
|
||||
assert.equal(calls.length, 5);
|
||||
// Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur.
|
||||
assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon"));
|
||||
const refusing = createHaptics(
|
||||
() => {
|
||||
throw new Error("refusé");
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
assert.doesNotThrow(() => refusing("gameWon"));
|
||||
// Aucun motif ne dépasse 60 ms par impulsion (interaction.md).
|
||||
for (const pattern of Object.values(HAPTIC_PATTERNS)) {
|
||||
assert.ok(pattern.every((ms) => ms > 0 && ms <= 60));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Réglages
|
||||
|
||||
test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => {
|
||||
assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS);
|
||||
assert.equal(DEFAULT_SETTINGS.vibrations, true);
|
||||
assert.equal(DEFAULT_SETTINGS.keepScreenOn, true);
|
||||
assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS);
|
||||
assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
vibrations: false,
|
||||
theme: "dark",
|
||||
gamesStarted: 4,
|
||||
});
|
||||
assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), {
|
||||
...DEFAULT_SETTINGS,
|
||||
});
|
||||
assert.equal(nextTheme("auto"), "light");
|
||||
assert.equal(nextTheme("light"), "dark");
|
||||
assert.equal(nextTheme("dark"), "auto");
|
||||
});
|
||||
|
||||
test("consigne longue pendant les trois premières parties, courte ensuite", () => {
|
||||
assert.equal(LONG_PROMPT_GAMES, 3);
|
||||
for (const games of [0, 1, 2, 3]) {
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true);
|
||||
}
|
||||
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false);
|
||||
});
|
||||
|
||||
test("stockage des réglages et du dernier plateau affiché", () => {
|
||||
const data = new Map<string, string>();
|
||||
const store = createSettingsStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
assert.equal(store.loadSettings(), null);
|
||||
store.saveSettings('{"vibrations":false}');
|
||||
assert.equal(store.loadSettings(), '{"vibrations":false}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } };
|
||||
store.saveBoard(board);
|
||||
assert.deepEqual(store.loadBoard(), board);
|
||||
// Un plateau abîmé est ignoré : la position de départ sera dessinée.
|
||||
data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}');
|
||||
assert.equal(store.loadBoard(), null);
|
||||
const full = createSettingsStore({
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.saveBoard(board));
|
||||
assert.equal(full.loadBoard(), null);
|
||||
assert.equal(full.loadSettings(), null);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
test("compte de points : le plus petit mène, de l'écart", () => {
|
||||
assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 });
|
||||
assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 });
|
||||
assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 });
|
||||
// La position de départ : 15 pions, 167 points chacun.
|
||||
for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) {
|
||||
assert.equal(
|
||||
side.points.reduce((a, b) => a + b, 0),
|
||||
15,
|
||||
);
|
||||
assert.equal(
|
||||
side.points.reduce((sum, n, i) => sum + n * (i + 1), 0),
|
||||
side.pipCount,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Charte
|
||||
|
||||
/** Les fichiers .ts d'un dossier, récursivement. */
|
||||
function sources(dir: string): string[] {
|
||||
const url = new URL(dir, root);
|
||||
return readdirSync(url).flatMap((name) => {
|
||||
const path = `${dir}${name}`;
|
||||
return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les deux blocs du thème sombre de app.css sont identiques", () => {
|
||||
const css = read("web/app.css");
|
||||
const block = (start: string) => {
|
||||
const from = css.indexOf(start);
|
||||
assert.ok(from >= 0, `bloc ${start} absent`);
|
||||
const body = css.slice(css.indexOf("{", from + start.length - 1) + 1);
|
||||
return body
|
||||
.slice(0, body.indexOf("}"))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
assert.deepEqual(
|
||||
block(':root:not([data-theme="light"]) {'),
|
||||
block(':root[data-theme="dark"] {'),
|
||||
);
|
||||
});
|
||||
|
||||
test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => {
|
||||
assert.doesNotMatch(read("web/index.html"), /\sstyle=|<style/);
|
||||
for (const path of sources("app/src/")) {
|
||||
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
|
||||
for (const name of [
|
||||
"menu",
|
||||
"help",
|
||||
"fullscreen",
|
||||
"exitFullscreen",
|
||||
"undo",
|
||||
"replay",
|
||||
"close",
|
||||
]) {
|
||||
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
|
||||
}
|
||||
for (const d of Object.values(ICON_PATHS)) {
|
||||
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import type { GameState } from "../../src/domain/game/state.js";
|
||||
import { boardModel, pipLead } from "../../src/ui/board-model.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
|
||||
@@ -283,7 +284,7 @@ Then("c'est toujours à vous", () => {
|
||||
assert.equal(lastEvent().type, "DiceRolled");
|
||||
});
|
||||
|
||||
When("vous continuez", async () => {
|
||||
When("vous laissez passer votre tour", async () => {
|
||||
await game.skipTurn();
|
||||
const event = lastEvent();
|
||||
assert.ok(event.type === "MovePlayed" && event.side === "player");
|
||||
@@ -344,6 +345,27 @@ Then("vous pouvez revoir son coup", async () => {
|
||||
assert.deepEqual(await game.lastMoveSteps(), computerMove);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Compte de points
|
||||
|
||||
// Les bandeaux lisent le compte de points du plateau donné par le moteur ;
|
||||
// qui mène se calcule dans l'interface (board-model.ts).
|
||||
|
||||
async function pips() {
|
||||
return boardModel(await game.displayedBoard()).pipCount;
|
||||
}
|
||||
|
||||
Then("chaque camp a un compte de points de {int}", async (n: number) => {
|
||||
assert.deepEqual(await pips(), { player: n, computer: n });
|
||||
});
|
||||
|
||||
Then("personne ne mène la course", async () => {
|
||||
assert.equal(pipLead(await pips()).leader, null);
|
||||
});
|
||||
|
||||
Then("vous menez la course de {int}", async (n: number) => {
|
||||
assert.deepEqual(pipLead(await pips()), { leader: "player", by: n });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Videau
|
||||
|
||||
// La confirmation elle-même est une question de l'interface (une fenêtre
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
|
||||
// la partie reprise passe par le domaine et le vrai moteur, et par le même
|
||||
// enregistrement que l'application ; les réglages et les vibrations par les
|
||||
// modules de l'interface qui les portent (settings.ts, haptics.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameStore } from "../../src/storage/game-store.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let oldJournal: GameJournal;
|
||||
let oldBoard: unknown;
|
||||
let settings: Settings;
|
||||
let vibrations: number[][] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
// -------------------------------------------------- Nouvelle partie annulable
|
||||
|
||||
// Comme dans l'application : chaque partie est enregistrée (localStorage,
|
||||
// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal.
|
||||
const data = new Map<string, string>();
|
||||
const store = createGameStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) });
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
store.save(game.journal);
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = Game.start(engine);
|
||||
store.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence aussitôt", () => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
|
||||
});
|
||||
|
||||
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
|
||||
assert.equal(BANNER_MS, seconds * 1000);
|
||||
});
|
||||
|
||||
When("vous reprenez l'ancienne partie", () => {
|
||||
game = Game.resume(engine, oldJournal);
|
||||
store.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.deepEqual(store.load(), oldJournal);
|
||||
assert.deepEqual(game.journal, oldJournal);
|
||||
assert.deepEqual(await game.displayedBoard(), oldBoard);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- Réglages
|
||||
|
||||
Given("vous ouvrez l'application pour la première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
vibrations = [];
|
||||
});
|
||||
|
||||
const haptic = createHaptics(
|
||||
(pattern) => {
|
||||
vibrations.push(pattern);
|
||||
return true;
|
||||
},
|
||||
() => settings.vibrations,
|
||||
);
|
||||
|
||||
When("un pion est frappé", () => {
|
||||
vibrations = [];
|
||||
haptic("checkerHit");
|
||||
});
|
||||
|
||||
Then("le téléphone vibre deux fois, brièvement", () => {
|
||||
assert.deepEqual(vibrations, [[25, 40, 25]]);
|
||||
});
|
||||
|
||||
When("vous coupez les vibrations dans le menu", () => {
|
||||
settings = { ...settings, vibrations: false };
|
||||
});
|
||||
|
||||
Then("le téléphone ne vibre pas", () => {
|
||||
assert.deepEqual(vibrations, []);
|
||||
});
|
||||
|
||||
Then("l'écran reste allumé pendant une partie", () => {
|
||||
assert.equal(settings.keepScreenOn, true);
|
||||
});
|
||||
|
||||
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
|
||||
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
|
||||
});
|
||||
|
||||
Then("l'écran peut se mettre en veille comme d'habitude", () => {
|
||||
assert.equal(settings.keepScreenOn, false);
|
||||
});
|
||||
|
||||
Then("le thème suit celui du téléphone", () => {
|
||||
assert.equal(settings.theme, "auto");
|
||||
});
|
||||
|
||||
When("vous changez de thème dans le menu", () => {
|
||||
// Rien : l'étape suivante fait le tour des thèmes.
|
||||
});
|
||||
|
||||
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
const seen = [settings.theme];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
seen.push(nextTheme(seen.at(-1) ?? "auto"));
|
||||
}
|
||||
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
|
||||
});
|
||||
@@ -207,13 +207,13 @@ test("la partie enregistrée se relit ; un enregistrement illisible est ignoré"
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
|
||||
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
|
||||
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
|
||||
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
|
||||
assert.equal(
|
||||
TEXTS.youLose("single", 1),
|
||||
"L'ordinateur gagne la partie simple : 1 point pour lui.",
|
||||
);
|
||||
assert.equal(
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9"),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,3 +47,41 @@ moteur sait les lire et les écrire (`engine.h`).
|
||||
- Les liens restent valables tant que GNU Backgammon garde ces formats, stables
|
||||
depuis des années.
|
||||
- Les liens contiennent la position, rien d'autre : pas de donnée personnelle.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Une adresse pour chaque écran, des liens plutôt que des états
|
||||
|
||||
Demandé par Pierre : « il faudra vraiment que chaque page (l'aide, etc.) ait
|
||||
une URL. Utilise des liens accessibles, pas des états. » Aucun lien de
|
||||
position n'a encore circulé : le format ci-dessus est remplacé par un schéma
|
||||
unique pour tous les écrans, avant sa première mise en œuvre.
|
||||
|
||||
- Chaque écran a son adresse, dans le fragment (pour les mêmes raisons
|
||||
qu'au-dessus : hors ligne, service worker, aucune configuration serveur) :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Partie en cours | `#/` |
|
||||
| Accueil | `#/accueil` |
|
||||
| Aide (onglet Jouer) | `#/aide`, une question : `#/aide/<identifiant>` |
|
||||
| Les mots du jeu | `#/glossaire`, un terme : `#/glossaire/<fichier sans .md>` |
|
||||
| À propos | `#/a-propos` |
|
||||
| Tutoriel | `#/tutoriel`, une étape : `#/tutoriel/<n>` |
|
||||
| Mes parties | `#/parties`, une partie : `#/parties/<id>` |
|
||||
| Réglages / menu | `#/menu` |
|
||||
| Une position | `#/position/<Position ID>/<Match ID>` (Match ID facultatif) |
|
||||
|
||||
Les identifiants de gnubg sont encodés avec `encodeURIComponent`. Les
|
||||
écrans à venir (analyse, leçons, progression) suivront le même schéma.
|
||||
- On change d'écran par de vrais liens (`<a href="#/aide">`), pas par des
|
||||
boutons qui modifient un état caché : le lien se partage, s'ouvre dans un
|
||||
nouvel onglet, se lit aux lecteurs d'écran comme un lien, et le bouton
|
||||
« retour » du téléphone ramène à l'écran précédent. Les actions de jeu
|
||||
(lancer, jouer, doubler) restent des boutons : elles ne sont pas des écrans.
|
||||
- Les feuilles (menu, explication) et panneaux qui sont des écrans à part
|
||||
entière ont aussi leur adresse ; une confirmation ou un bandeau éphémère
|
||||
n'en a pas.
|
||||
- Une adresse inconnue mène à la partie en cours avec un message clair.
|
||||
- L'adresse de l'écran courant est la source de vérité de la navigation :
|
||||
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
|
||||
|
||||
+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
|
||||
|
||||
@@ -23,8 +23,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` |
|
||||
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (ADR 0013) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
@@ -35,10 +35,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
|
||||
| Terme | Anglais (code) |
|
||||
| --- | --- |
|
||||
| [Appréciation](appreciation.md) | Skill |
|
||||
| [Backgammon](backgammon.md) | Backgammon |
|
||||
| [Barre](barre.md) | Bar |
|
||||
| [Blot](blot.md) | Blot |
|
||||
| [Branche](branche.md) | Branch |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Coup](coup.md) | Move |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
@@ -52,6 +54,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Jet](jet.md) | Roll |
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
| [Pion](pion.md) | Checker |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Appréciation
|
||||
english: Skill
|
||||
code: Skill
|
||||
---
|
||||
# Appréciation
|
||||
|
||||
**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix.
|
||||
|
||||
GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus
|
||||
de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous,
|
||||
il n'y a rien à signaler.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Chance
|
||||
english: Luck
|
||||
code: Luck
|
||||
---
|
||||
# Chance
|
||||
|
||||
**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles.
|
||||
|
||||
Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la
|
||||
moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans
|
||||
l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si
|
||||
on a perdu à cause des dés ou de son jeu.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Jet](jet.md)
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
term: Niveau de jeu
|
||||
english: Level
|
||||
code: Level
|
||||
---
|
||||
# Niveau de jeu
|
||||
|
||||
**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance.
|
||||
|
||||
Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant,
|
||||
amateur, intermédiaire, avancé) jugent la position sans regarder les jets
|
||||
suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible :
|
||||
c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de
|
||||
la position seule : dans la même position, avec le même jet, l'ordinateur
|
||||
joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent
|
||||
de 0 à 4 plis.
|
||||
|
||||
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
|
||||
@@ -47,6 +47,8 @@ Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'ins
|
||||
- `beforeinstallprompt` simulé dans Chromium ;
|
||||
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
|
||||
|
||||
Chaque écran (accueil, étapes du tutoriel, onglets et questions de l'aide) a son adresse et s'ouvre par un vrai lien, selon l'amendement de l'ADR 0013 ; le routeur vient de 0e.4.
|
||||
|
||||
`make lint`, `make test` et `make test-browser` doivent passer. Aucune nouvelle dépendance.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Conseil du maître (1d)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1d (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1c)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
|
||||
après chaque coup et chaque décision de videau du joueur, jamais avant
|
||||
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
|
||||
du moteur.
|
||||
|
||||
## Références
|
||||
|
||||
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
|
||||
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
|
||||
de la révision UX.
|
||||
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
|
||||
(gnubg : 0,03 / 0,06 / 0,12).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
|
||||
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
|
||||
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
|
||||
elles ne citent que des faits du moteur (coup recommandé, rang, pions
|
||||
frappés, écart) : aucune explication inventée. Première version à faire
|
||||
valider par Pierre (ton).
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
|
||||
désactivé, décision de videau commentée) avec positions et dés imposés ;
|
||||
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
|
||||
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
|
||||
recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après une erreur volontaire dans une position connue, la ligne du maître dit
|
||||
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
|
||||
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Match, niveaux et réglages (1c)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1c (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après la révision UX et 1b)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Choisir sa partie : partie libre ou match en N points, avec les règles de
|
||||
Crawford et de Jacoby, contre l'ordinateur au niveau voulu.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg
|
||||
(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C.
|
||||
|
||||
1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford
|
||||
et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en
|
||||
partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le
|
||||
journal d'événements reste rétrocompatible (les parties existantes se
|
||||
rechargent).
|
||||
2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford)
|
||||
ou remplacé par le Match ID, sans casser les appels existants.
|
||||
3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau
|
||||
choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de
|
||||
déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double
|
||||
facultatif à 0 pli, avec citation des sources.
|
||||
4. **Écran de nouvelle partie** (maquettes de la session « identité » si
|
||||
disponibles, sinon sobre et conforme à la charte) : type de partie, niveau
|
||||
(noms montrés au joueur à proposer simplement : Débutant, Amateur,
|
||||
Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux
|
||||
de gnubg documentée), conseil du maître activé ou non. Par défaut : partie
|
||||
libre, Intermédiaire, conseil activé. Le réglage est mémorisé.
|
||||
5. Affichage du score de match et de la partie Crawford sur le plateau.
|
||||
6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés,
|
||||
Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si
|
||||
absentes). `make lint`, `make test`, `make test-browser` (un match court).
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford
|
||||
appliqué ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Moteur pour l'analyse et les niveaux (1a)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1a (`docs/etape-1.md`)
|
||||
- **Statut** : en cours
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Donner au domaine tout ce dont l'étape 1 a besoin du moteur : niveaux de jeu de
|
||||
l'ordinateur, analyse d'un coup joué et d'une décision de videau, chance d'un
|
||||
jet. Tout vient de GNU Backgammon, comparé à gnubg de bureau.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0005, 0007 et la méthode de comparaison de `tests/reference/` (README).
|
||||
Ne pas modifier `engine/gnubg/` (copie fidèle). Pas de nouvelle dépendance.
|
||||
|
||||
1. **Niveaux de jeu** : les niveaux de GNU Backgammon (débutant, amateur,
|
||||
intermédiaire, avancé, expert, champion du monde…), avec leurs plis, filtres
|
||||
et bruit, repris des réglages prédéfinis de gnubg (pas inventés). Le bruit
|
||||
doit être déterministe (option « deterministic » de gnubg) pour que les
|
||||
tests soient reproductibles. API C (`engine.h`), `engine-cli`, adaptateur
|
||||
WebAssembly et interface `Engine` du domaine (ajout seulement : ne pas
|
||||
toucher au reste de `app/src/domain/game/`, qu'un autre agent modifie).
|
||||
2. **Analyse d'un coup joué** : pour une position, un jet et un coup joué,
|
||||
l'équité du coup joué, celle du meilleur coup, l'écart, et le rang du coup
|
||||
joué, au niveau d'analyse demandé (comme l'analyse de partie de gnubg).
|
||||
3. **Analyse d'une décision de videau** : équités sans double, double/prend,
|
||||
double/passe, et l'écart de la décision prise (double, pas de double,
|
||||
prendre, passer), comme gnubg.
|
||||
4. **Chance d'un jet** : la chance (luck) d'un jet au sens de gnubg, et son
|
||||
classement (très chanceux… très malchanceux) avec les seuils de gnubg.
|
||||
5. **Seuils d'appréciation** : exposer les seuils de gnubg (douteux, mauvais,
|
||||
très mauvais) comme constantes documentées ; l'interface les traduira.
|
||||
6. **Références** : ajouter des positions de référence évaluées par gnubg de
|
||||
bureau (Docker existant, `make reference`) pour l'analyse d'un coup, d'une
|
||||
décision de videau et la chance, comparées par `make test` (natif et
|
||||
WebAssembly) avec la tolérance existante. Les niveaux avec bruit : vérifier
|
||||
au moins que le coup choisi est reproductible et identique à celui de gnubg
|
||||
de bureau sur quelques positions.
|
||||
7. Glossaire : fiches pour les nouveaux termes (niveau, chance, appréciation,
|
||||
équité si absente…), reliées au code.
|
||||
8. Mesurer sous Node (`make bench` ou équivalent) le temps d'une analyse de
|
||||
coup au niveau prévu pour le conseil du maître (2 plis), pour vérifier
|
||||
qu'elle tient en arrière-plan sur le téléphone (repère : ADR 0009).
|
||||
|
||||
Machine ancienne : `-j2`, ne recompiler le moteur que si nécessaire.
|
||||
`make lint` et `make test` doivent passer. Commits fréquents ; `git add` avec
|
||||
chemins explicites ; ne pas pousser, pas de MR.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
- `engine-cli` répond aux nouvelles commandes en JSON.
|
||||
- Les nouvelles références concordent avec gnubg de bureau, natif et WebAssembly.
|
||||
- `make lint` et `make test` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,20 +1,29 @@
|
||||
# Position dans l'adresse (0e.4)
|
||||
# Une adresse pour chaque écran, et pour chaque position (0e.4)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.4 (feuille de route)
|
||||
- **Statut** : prévu (après la fusion de la révision UX, qui touche les mêmes fichiers)
|
||||
- **Statut** : prévu (après la fusion de la révision UX et de 1b)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer l'ADR 0013 : chaque position du plateau a un lien direct.
|
||||
Appliquer l'ADR 0013 et son amendement du 2026-11-03 : chaque écran (aide,
|
||||
glossaire, menu, mes parties…) a son adresse et s'ouvre par un vrai lien ;
|
||||
chaque position du plateau a un lien direct.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
|
||||
|
||||
1. Lecture du fragment `#position=…&match=…` au chargement et sur
|
||||
`hashchange` ; validation par le moteur ; message clair si illisible.
|
||||
0. **Routeur** : un petit module pur (sans dépendance) qui traduit le
|
||||
fragment en écran et inversement, testé unitairement ; l'app dessine
|
||||
l'écran demandé par l'adresse, sur `hashchange`. Tous les écrans existants
|
||||
(aide et ses questions, glossaire et ses termes, à propos, menu, mes
|
||||
parties de 1b) passent par lui, avec de vrais liens `<a href>` ; le bouton
|
||||
retour du navigateur ramène à l'écran précédent. Les actions de jeu restent
|
||||
des boutons.
|
||||
1. Lecture de `#/position/<Position ID>/<Match ID>` ; validation par le
|
||||
moteur ; message clair si illisible.
|
||||
2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle
|
||||
partie depuis la position, la partie en cours restant reprenable) et
|
||||
« Retour à ma partie ».
|
||||
@@ -26,8 +35,10 @@ ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
|
||||
position (test aller-retour).
|
||||
5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la
|
||||
position »), fiches du glossaire (Position ID, Match ID si absentes).
|
||||
6. `make lint`, `make test`, `make test-browser` (ouvrir un lien, jouer à
|
||||
partir d'ici, revenir, lien invalide).
|
||||
6. `make lint`, `make test`, `make test-browser` : chaque adresse du tableau
|
||||
de l'ADR ouverte directement dans un nouvel onglet montre le bon écran ;
|
||||
« retour » du navigateur ; liens reconnus comme liens (rôle `link`) ;
|
||||
position : ouvrir un lien, jouer à partir d'ici, revenir, lien invalide.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Révision UX : portrait stable, compte de points, menu, paysage « plateau = écran »
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.3 (feuille de route)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer la revue UX (0e.2) telle qu'arbitrée par la session « identité » sur
|
||||
délégation de Pierre le 2026-11-03, et l'amendement de l'ADR 0012.
|
||||
|
||||
## Références
|
||||
|
||||
- Maquettes : https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
|
||||
« Révision UX » en bas du canevas.
|
||||
- Règles : section « Interaction » du design system, copiée dans
|
||||
`docs/identite/charte/interaction.md` (durées, courbes, motifs de vibration,
|
||||
touches) et composant Icons (10 pictogrammes).
|
||||
- Rapport du designer UI : conservé dans la description de la MR qui clôt ce
|
||||
plan.
|
||||
|
||||
## Décisions à appliquer
|
||||
|
||||
1. **Portrait stable** (maquette « Portrait révisé ») :
|
||||
- une barre haute avec le symbole et trois pictogrammes, libellés de 13 px dessous : Aide, Plein écran, Menu ;
|
||||
- le bandeau de l'ordinateur, puis le plateau, puis le bandeau du joueur ;
|
||||
- une ligne réservée de 44 px : le dernier coup de l'ordinateur avec ↻, ou la ligne du maître ;
|
||||
- la consigne ;
|
||||
- une barre d'actions collée en bas, l'action secondaire à gauche et la principale à droite (« Jouer ce coup »). Toucher les dés valide aussi ;
|
||||
- la durée de calcul affichée disparaît.
|
||||
2. **Compte de points** :
|
||||
- en portrait, des bandeaux. Les chiffres sont en 20 px gras, alignés. Le bandeau de celui qui a la main est en accent-soft (« Vous · à vous de jouer »). L'écart s'écrit « mène de 8 », en muted, du côté de celui qui mène. Toucher le chiffre ouvre une bulle avec le texte de la fiche du glossaire (source unique, ADR 0008) ;
|
||||
- en paysage, des pastilles aux bouts de la barre.
|
||||
3. **Menu en feuille** qui monte du bas :
|
||||
- il contient Nouvelle partie, Plein écran, Aide et glossaire, Thème, et les interrupteurs Vibrations et « Écran allumé pendant la partie » ;
|
||||
- « Nouvelle partie » agit sans confirmation. Un bandeau « Nouvelle partie commencée. Reprendre l'ancienne » reste 10 s avec une barre de temps, et l'ancienne partie se restaure depuis son journal ;
|
||||
- plus de `window.confirm`.
|
||||
4. **Pas de passage automatique du tour** quand aucun coup n'est possible : les dés sont barrés avec le message, et le joueur passe en touchant les dés (ou Espace). Ajouter `overscroll-behavior: none` et `user-select: none`. L'écran reste allumé pendant une partie (Wake Lock), activé par défaut, avec un interrupteur.
|
||||
5. **Paysage, « le plateau est l'écran »** (ADR 0012 amendé) :
|
||||
- la vue s'applique dès la rotation, sur `100dvh` ;
|
||||
- la géométrie suit l'écran, par une fonction pure testée unitairement, d'après les formules du composant Board des maquettes ;
|
||||
- une colonne de commandes à gauche dans le cadre : menu en haut, videau au milieu, ↶ en bas, remplacé par ↻ quand le coup de l'ordinateur est montré ;
|
||||
- les sorties restent libres ;
|
||||
- numéros des flèches à 12 px au moins ;
|
||||
- la fin de partie s'affiche dans un panneau centré sur un voile ;
|
||||
- le plein écran s'ouvre sur un geste (pictogramme ou menu), avec `requestFullscreen` puis `screen.orientation.lock` et un repli par rotation CSS. Ce n'est pas le réglage par défaut de l'app installée.
|
||||
6. **Vibrations** (`navigator.vibrate`, si disponible) aux quatre moments, avec les motifs d'`interaction.md` : pion posé, pion frappé, double proposé par l'ordinateur, fin de partie. Elles sont activées par défaut, rien au lancer des dés, et elles doublent toujours un signal visible.
|
||||
7. **Pictogrammes** du composant Icons, en SVG dans le site (CSP : pas de style en ligne).
|
||||
8. **Consigne** : forme longue pendant les trois premières parties, puis forme courte.
|
||||
9. **Mouvement** :
|
||||
- durées de 90, 160 et 240 ms, avec la courbe cubic-bezier(0.2, 0, 0, 1) ;
|
||||
- l'ordinateur joue pion par pion : 160 ms par pion, 120 ms de pause ;
|
||||
- `prefers-reduced-motion` est respecté.
|
||||
10. **Clavier** (priorité basse) : prévoir dans l'architecture une table d'actions commune au toucher et au clavier. Les touches d'`interaction.md` peuvent venir dans une MR ultérieure.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007 : ni le domaine ni le moteur ne changent pour la mise en page. ADR 0008 : scénarios Gherkin pour les comportements visibles nouveaux (nouvelle partie annulable, menu). Aucune nouvelle dépendance. `tests/browser/play.mjs` étendu aux deux orientations, avec des captures Playwright en 412×915, 915×412 et 844×320, en thème clair et sombre, rangées dans un dossier indiqué par la session principale pour la revue design. `make lint`, `make test` et `make test-browser` passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -47,3 +47,4 @@ Règles d'écriture :
|
||||
| Fichier | Tâche | Depuis |
|
||||
| --- | --- | --- |
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
|
||||
|
||||
@@ -14,7 +14,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Touchez un pion, puis sa case d'arrivée ; s'il n'en a qu'une, il y va dès
|
||||
que vous le touchez. Chaque dé joué s'estompe. Une fois tous vos dés
|
||||
joués, votre coup attend que vous le validiez : touchez vos dés, d'un côté
|
||||
ou de l'autre du plateau, ou « Valider ».
|
||||
ou de l'autre du plateau, ou « Jouer ce coup ».
|
||||
|
||||
Étant donné que c'est à vous de jouer avec le jet 3-1
|
||||
Quand vous jouez 8/5 6/5
|
||||
@@ -61,21 +61,22 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
|
||||
Scénario: Aucun coup possible avec votre jet
|
||||
Il arrive que les dés ne permettent de bouger aucun pion. Votre jet reste
|
||||
affiché avec un message, et le tour ne passe à l'ordinateur qu'une fois
|
||||
que vous avez touché « Continuer ».
|
||||
affiché, barré, avec un message. L'application ne joue jamais à votre
|
||||
place : le tour ne passe à l'ordinateur que quand vous touchez vos dés
|
||||
(ou « Continuer »).
|
||||
|
||||
Étant donné que votre pion sur la barre ne peut pas rentrer
|
||||
Quand vous lancez les dés
|
||||
Alors aucun coup n'est possible avec votre jet
|
||||
Et c'est toujours à vous
|
||||
Quand vous continuez
|
||||
Quand vous laissez passer votre tour
|
||||
Alors c'est ensuite au tour de l'ordinateur
|
||||
|
||||
Scénario: Voir le coup de l'ordinateur
|
||||
Après chaque coup de l'ordinateur, ses pions glissent un par un sur le
|
||||
plateau. Restent ensuite, jusqu'à votre prochaine action, une flèche par
|
||||
pion déplacé, ses pions arrivés entourés et un pion fantôme là où ils
|
||||
étaient. Le bouton « Revoir » rejoue son coup.
|
||||
étaient. « Revoir » (la flèche circulaire) rejoue son coup.
|
||||
|
||||
Étant donné qu'un de vos pions est seul à portée de l'ordinateur
|
||||
Quand l'ordinateur a joué
|
||||
@@ -83,6 +84,18 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Et votre pion frappé part sur la barre
|
||||
Et vous pouvez revoir son coup
|
||||
|
||||
Scénario: Voir le compte de points
|
||||
Au-dessus et au-dessous du plateau, le bandeau de chaque camp donne son
|
||||
compte de points, et « mène de 7 » s'affiche du côté de celui qui mène la
|
||||
course. Touchez un compte pour lire ce qu'il veut dire. En paysage, les
|
||||
comptes sont aux deux bouts de la barre.
|
||||
|
||||
Quand vous commencez une nouvelle partie
|
||||
Alors chaque camp a un compte de points de 167
|
||||
Et personne ne mène la course
|
||||
Quand vous jouez 13/8 13/11
|
||||
Alors vous menez la course de 7
|
||||
|
||||
Scénario: Doubler, après confirmation
|
||||
Avant de lancer les dés, vous pouvez doubler en touchant le videau ou
|
||||
« Doubler ». L'application vous demande de confirmer : une fois proposé,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Régler l'application
|
||||
|
||||
Le menu, qui monte du bas de l'écran, regroupe ce qui ne sert pas à chaque
|
||||
coup : commencer une nouvelle partie, passer en plein écran, ouvrir l'aide
|
||||
et le glossaire, choisir le thème, couper les vibrations, et laisser ou non
|
||||
l'écran allumé pendant la partie. Rien de ce qu'il contient ne vous pose de
|
||||
question : ce qui se fait se rattrape.
|
||||
|
||||
Scénario: Commencer une nouvelle partie, puis reprendre l'ancienne
|
||||
Une nouvelle partie commence tout de suite, sans question. Pendant 10
|
||||
secondes, le bandeau « Reprendre l'ancienne » vous rend la partie que
|
||||
vous venez de quitter, exactement là où vous l'aviez laissée.
|
||||
|
||||
Étant donné qu'une partie est en cours depuis quelques coups
|
||||
Quand vous commencez une nouvelle partie depuis le menu
|
||||
Alors une nouvelle partie commence aussitôt
|
||||
Et l'ancienne partie peut être reprise pendant 10 secondes
|
||||
Quand vous reprenez l'ancienne partie
|
||||
Alors vous retrouvez exactement la partie quittée
|
||||
|
||||
Scénario: Couper les vibrations
|
||||
Le téléphone vibre brièvement quand votre pion se pose, quand un pion est
|
||||
frappé, quand l'ordinateur vous propose de doubler et à la fin de la
|
||||
partie. Ces vibrations accompagnent toujours ce que montre l'écran ; rien
|
||||
ne vibre quand vous lancez les dés. Elles se coupent dans le menu.
|
||||
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Quand un pion est frappé
|
||||
Alors le téléphone vibre deux fois, brièvement
|
||||
Quand vous coupez les vibrations dans le menu
|
||||
Et qu'un pion est frappé
|
||||
Alors le téléphone ne vibre pas
|
||||
|
||||
Scénario: Garder l'écran allumé pendant la partie
|
||||
Pour que le téléphone ne se mette pas en veille pendant que vous
|
||||
réfléchissez, l'écran reste allumé tant qu'une partie est en cours.
|
||||
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Alors l'écran reste allumé pendant une partie
|
||||
Quand vous coupez « Écran allumé pendant la partie » dans le menu
|
||||
Alors l'écran peut se mettre en veille comme d'habitude
|
||||
|
||||
Scénario: Choisir le thème
|
||||
Étant donné que vous ouvrez l'application pour la première fois
|
||||
Alors le thème suit celui du téléphone
|
||||
Quand vous changez de thème dans le menu
|
||||
Alors il passe à clair, puis à sombre, puis revient à automatique
|
||||
+199
-10
@@ -5,7 +5,7 @@
|
||||
* Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
*
|
||||
* Usage :
|
||||
* engine-cli [--ply N] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
* engine-cli [--ply N | --level NIVEAU] [--data DOSSIER] COMMANDE ARGUMENTS
|
||||
*
|
||||
* Commandes :
|
||||
* bestmove POSITION_ID MATCH_ID DÉS meilleur coup (DÉS : ex. 31)
|
||||
@@ -27,8 +27,23 @@
|
||||
* référence (tests/reference/README.md) :
|
||||
* évaluation, puis classement des coups
|
||||
* avec DÉS, décision de videau sans
|
||||
* levels niveaux de jeu, seuils d'appréciation
|
||||
* et de chance de gnubg
|
||||
* analysemove POSITION_ID MATCH_ID DÉS POSITION_JOUÉE
|
||||
* analyse du coup joué (POSITION_JOUÉE :
|
||||
* position après le coup, vue du joueur
|
||||
* qui l'a joué, comme position_id dans
|
||||
* moves et legal)
|
||||
* analysecube POSITION_ID MATCH_ID DÉCISION
|
||||
* analyse d'une décision de videau :
|
||||
* no_double ou double (joueur au trait),
|
||||
* take ou pass (son adversaire)
|
||||
* luck POSITION_ID MATCH_ID DÉS chance du jet pour le joueur au trait
|
||||
*
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4.
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint »,
|
||||
* analyses comme l'analyse de partie de gnubg).
|
||||
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves
|
||||
* et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris.
|
||||
* --data : dossier des données ; par défaut « data » à côté du programme.
|
||||
*
|
||||
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
|
||||
@@ -55,20 +70,32 @@
|
||||
#define NUMBER "%.9g"
|
||||
|
||||
/* Liste des commandes, pour les messages d'erreur. */
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid ou reference"
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \
|
||||
"levels, analysemove, analysecube ou luck"
|
||||
|
||||
/* Coups gardés dans le classement de la commande reference (comme hint). */
|
||||
#define REFERENCE_MAX_MOVES 10
|
||||
|
||||
/* Arguments de la ligne de commande, une fois lus. */
|
||||
#define MAX_ARGS 4
|
||||
|
||||
typedef struct {
|
||||
int ply;
|
||||
int level; /* -1 : pas de --level */
|
||||
const char *data_dir;
|
||||
const char *command;
|
||||
const char *args[3];
|
||||
const char *args[MAX_ARGS];
|
||||
int arg_count;
|
||||
} options;
|
||||
|
||||
/* Noms JSON des appréciations (engine_skill), des chances
|
||||
* (engine_luck_rating) et des décisions de videau (engine_cube_action). */
|
||||
static const char *const skill_names[] = { "none", "doubtful", "bad", "very_bad" };
|
||||
static const char *const luck_names[] = {
|
||||
"very_unlucky", "unlucky", "none", "lucky", "very_lucky"
|
||||
};
|
||||
static const char *const cube_action_names[] = { "no_double", "double", "take", "pass" };
|
||||
|
||||
/* Code d'erreur JSON pour chaque engine_status. */
|
||||
static const char *
|
||||
status_code(engine_status status)
|
||||
@@ -92,6 +119,12 @@ status_code(engine_status status)
|
||||
return "too_many_moves";
|
||||
case ENGINE_ERROR_INTERNAL:
|
||||
return "internal_error";
|
||||
case ENGINE_ERROR_LEVEL:
|
||||
return "invalid_level";
|
||||
case ENGINE_ERROR_MOVE:
|
||||
return "invalid_move";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "invalid_cube_action";
|
||||
}
|
||||
return "unknown_error";
|
||||
}
|
||||
@@ -178,6 +211,18 @@ print_reference_evaluation(const engine_evaluation *e)
|
||||
printf(", \"equity\": " NUMBER "}", e->cubeless_equity);
|
||||
}
|
||||
|
||||
/* « , "ply": N » ou « , "level": "NOM" », selon les options. */
|
||||
static void
|
||||
print_level_or_ply(const options *opt)
|
||||
{
|
||||
engine_level_settings settings;
|
||||
|
||||
if (opt->level >= 0 && engine_level_settings_of(opt->level, &settings) == ENGINE_OK)
|
||||
printf(", \"level\": \"%s\"", settings.name);
|
||||
else
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
}
|
||||
|
||||
/* Lit des dés au format « 31 » ; renvoie 0 si le format est faux. */
|
||||
static int
|
||||
parse_dice(const char *text, int *die1, int *die2)
|
||||
@@ -208,7 +253,10 @@ run_moves_command(const options *opt)
|
||||
moves = malloc(sizeof *moves * ENGINE_MAX_MOVES);
|
||||
if (!moves)
|
||||
return print_error("out_of_memory", "mémoire insuffisante", EXIT_ENGINE_ERROR);
|
||||
if (evaluate)
|
||||
if (evaluate && opt->level >= 0)
|
||||
status = engine_level_best_moves(position_id, match_id, die1, die2, opt->level,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else if (evaluate)
|
||||
status = engine_best_moves(position_id, match_id, die1, die2, opt->ply,
|
||||
moves, ENGINE_MAX_MOVES, &count);
|
||||
else
|
||||
@@ -222,7 +270,7 @@ run_moves_command(const options *opt)
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d]",
|
||||
position_id, match_id, die1, die2);
|
||||
if (evaluate)
|
||||
printf(", \"ply\": %d", opt->ply);
|
||||
print_level_or_ply(opt);
|
||||
if (best_only) {
|
||||
printf(", \"best_move\": ");
|
||||
if (count > 0)
|
||||
@@ -270,11 +318,15 @@ run_position_command(const options *opt)
|
||||
e.cubeless_equity, e.cubeful_equity);
|
||||
} else {
|
||||
engine_cube c;
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (opt->level >= 0)
|
||||
status = engine_level_cube_decision(position_id, match_id, opt->level, &c);
|
||||
else
|
||||
status = engine_cube_decision(position_id, match_id, opt->ply, &c);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"match\": ",
|
||||
position_id, match_id, opt->ply);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
|
||||
print_level_or_ply(opt);
|
||||
printf(", \"match\": ");
|
||||
print_match(&match);
|
||||
printf(", \"probabilities\": ");
|
||||
print_probabilities(c.probabilities);
|
||||
@@ -507,6 +559,122 @@ run_reference_command(const options *opt)
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* levels : niveaux de jeu, seuils d'appréciation et de chance. */
|
||||
static int
|
||||
run_levels_command(const options *opt)
|
||||
{
|
||||
engine_level_settings s;
|
||||
int level;
|
||||
|
||||
if (opt->arg_count != 0)
|
||||
return usage_error("levels n'attend pas d'argument");
|
||||
printf("{\"levels\": [");
|
||||
for (level = 0; level < ENGINE_LEVEL_COUNT; level++) {
|
||||
engine_level_settings_of(level, &s);
|
||||
printf("%s{\"name\": \"%s\", \"plies\": %d, \"noise\": " NUMBER
|
||||
", \"deterministic\": %s, \"prune\": %s}", level ? ", " : "", s.name, s.plies,
|
||||
s.noise, s.deterministic ? "true" : "false", s.prune ? "true" : "false");
|
||||
}
|
||||
printf("], \"skill_thresholds\": {\"doubtful\": " NUMBER ", \"bad\": " NUMBER
|
||||
", \"very_bad\": " NUMBER "}, \"luck_thresholds\": {\"lucky\": " NUMBER
|
||||
", \"very_lucky\": " NUMBER "}}\n",
|
||||
ENGINE_SKILL_DOUBTFUL_THRESHOLD, ENGINE_SKILL_BAD_THRESHOLD,
|
||||
ENGINE_SKILL_VERY_BAD_THRESHOLD, ENGINE_LUCK_THRESHOLD, ENGINE_LUCK_VERY_THRESHOLD);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysemove : analyse du coup joué. */
|
||||
static int
|
||||
run_analyse_move_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
const char *played_id = opt->args[3];
|
||||
engine_move_analysis a;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 4)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_analyse_move(position_id, match_id, die1, die2, played_id, opt->ply, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"ply\": %d, "
|
||||
"\"played_position_id\": \"%s\", \"move_count\": %d",
|
||||
position_id, match_id, die1, die2, opt->ply, played_id, a.move_count);
|
||||
if (a.move_count == 0) {
|
||||
printf(", \"rank\": null, \"played\": null, \"best\": null");
|
||||
} else {
|
||||
printf(", \"rank\": %d, \"played\": ", a.rank);
|
||||
print_move(&a.played, 1);
|
||||
printf(", \"best\": ");
|
||||
print_move(&a.best, 1);
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* analysecube : analyse d'une décision de videau. */
|
||||
static int
|
||||
run_analyse_cube_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_cube_analysis a;
|
||||
engine_status status;
|
||||
int action;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉCISION");
|
||||
for (action = 0; action < 4 && strcmp(opt->args[2], cube_action_names[action]) != 0; action++)
|
||||
;
|
||||
if (action == 4)
|
||||
return usage_error("DÉCISION attend no_double, double, take ou pass");
|
||||
status = engine_analyse_cube(position_id, match_id, opt->ply, (engine_cube_action) action, &a);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"ply\": %d, \"action\": \"%s\", "
|
||||
"\"analysed\": %s, \"cube\": ", position_id, match_id, opt->ply,
|
||||
cube_action_names[action], a.analysed ? "true" : "false");
|
||||
if (a.analysed) {
|
||||
printf("{\"probabilities\": ");
|
||||
print_probabilities(a.cube.probabilities);
|
||||
printf(", ");
|
||||
print_cube_fields(&a.cube);
|
||||
printf("}");
|
||||
} else {
|
||||
printf("null");
|
||||
}
|
||||
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
|
||||
skill_names[a.skill]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* luck : chance du jet pour le joueur au trait. */
|
||||
static int
|
||||
run_luck_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_luck luck;
|
||||
engine_status status;
|
||||
int die1, die2;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS");
|
||||
if (!parse_dice(opt->args[2], &die1, &die2))
|
||||
return usage_error("dés attendus sous la forme 31");
|
||||
status = engine_luck_of(position_id, match_id, die1, die2, &luck);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\", \"dice\": [%d, %d], \"luck\": " NUMBER
|
||||
", \"rating\": \"%s\"}\n", position_id, match_id, die1, die2, luck.luck,
|
||||
luck_names[luck.rating]);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* Dossier « data » à côté du programme. */
|
||||
static char *
|
||||
default_data_dir(const char *program)
|
||||
@@ -531,20 +699,28 @@ parse_options(int argc, char **argv, options *opt)
|
||||
{
|
||||
int i;
|
||||
|
||||
int has_ply = 0;
|
||||
|
||||
memset(opt, 0, sizeof *opt);
|
||||
opt->level = -1;
|
||||
for (i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--ply") == 0 && i + 1 < argc) {
|
||||
char *end;
|
||||
opt->ply = (int) strtol(argv[++i], &end, 10);
|
||||
if (*end || end == argv[i])
|
||||
return "--ply attend un nombre";
|
||||
has_ply = 1;
|
||||
} else if (strcmp(argv[i], "--level") == 0 && i + 1 < argc) {
|
||||
if (engine_level_from_name(argv[++i], &opt->level) != ENGINE_OK)
|
||||
return "--level attend un niveau : beginner, casual, intermediate, advanced, "
|
||||
"0ply, 1ply, 2ply, 3ply ou 4ply";
|
||||
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
|
||||
opt->data_dir = argv[++i];
|
||||
} else if (argv[i][0] == '-' && argv[i][1] == '-') {
|
||||
return "option inconnue ou sans valeur";
|
||||
} else if (!opt->command) {
|
||||
opt->command = argv[i];
|
||||
} else if (opt->arg_count < 3) {
|
||||
} else if (opt->arg_count < MAX_ARGS) {
|
||||
opt->args[opt->arg_count++] = argv[i];
|
||||
} else {
|
||||
return "trop d'arguments";
|
||||
@@ -552,6 +728,11 @@ parse_options(int argc, char **argv, options *opt)
|
||||
}
|
||||
if (!opt->command)
|
||||
return "commande attendue : " COMMANDS;
|
||||
if (opt->level >= 0 && has_ply)
|
||||
return "--ply et --level ne vont pas ensemble";
|
||||
if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0
|
||||
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0)
|
||||
return "--level ne s'applique qu'à bestmove, moves et cube";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -591,6 +772,14 @@ main(int argc, char **argv)
|
||||
result = run_half_moves_command(&opt);
|
||||
else if (strcmp(opt.command, "matchid") == 0)
|
||||
result = run_match_id_command(&opt);
|
||||
else if (strcmp(opt.command, "levels") == 0)
|
||||
result = run_levels_command(&opt);
|
||||
else if (strcmp(opt.command, "analysemove") == 0)
|
||||
result = run_analyse_move_command(&opt);
|
||||
else if (strcmp(opt.command, "analysecube") == 0)
|
||||
result = run_analyse_cube_command(&opt);
|
||||
else if (strcmp(opt.command, "luck") == 0)
|
||||
result = run_luck_command(&opt);
|
||||
else
|
||||
result = usage_error("commande inconnue : " COMMANDS);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-5
@@ -18,11 +18,10 @@ browsers="${BROWSERS:-chromium firefox}"
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
|
||||
site_image="ustagammon/site-test:local"
|
||||
docker build -q -t "$site_image" build/deploy >/dev/null
|
||||
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
|
||||
trap 'docker stop "$container" >/dev/null' EXIT
|
||||
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
# Image de production lancée, étiquette propre à ce dépôt : site_image,
|
||||
# container, port.
|
||||
# shellcheck source=tests/site-image.sh
|
||||
. tests/site-image.sh
|
||||
|
||||
# Le paquet playwright, à la version des navigateurs de l'image.
|
||||
tools=build/browser-tools
|
||||
|
||||
+685
-83
File diff suppressed because it is too large
Load Diff
@@ -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,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Construit l'image de production (build/deploy, voir make package) sous une
|
||||
# étiquette propre à ce dépôt, et la lance. À sourcer depuis la racine du
|
||||
# dépôt ; définit site_image, container et port, et arrête le conteneur puis
|
||||
# supprime l'image à la sortie du script.
|
||||
#
|
||||
# Plusieurs copies de travail (worktrees) peuvent lancer leurs tests en même
|
||||
# temps : avec une étiquette commune, docker échouait (« parent snapshot ...
|
||||
# does not exist »). Le suffixe est un hachage court du chemin du dépôt.
|
||||
|
||||
site_image="ustagammon/site-test:$(pwd -P | sha1sum | cut -c1-12)"
|
||||
docker build -q -t "$site_image" build/deploy >/dev/null
|
||||
container="$(docker run -d --rm -p 127.0.0.1::80 "$site_image")"
|
||||
# docker rm -f, et non docker stop : avec --rm, stop rend la main avant que
|
||||
# le conteneur soit supprimé, et l'image serait encore utilisée.
|
||||
site_cleanup() {
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
docker rmi "$site_image" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap site_cleanup EXIT
|
||||
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
export site_image container port
|
||||
+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 ]
|
||||
|
||||
+4
-5
@@ -11,11 +11,10 @@ cd "$(dirname "$0")/.."
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
|
||||
image="ustagammon/site-test:local"
|
||||
docker build -q -t "$image" build/deploy >/dev/null
|
||||
container="$(docker run -d --rm -p 127.0.0.1::80 "$image")"
|
||||
trap 'docker stop "$container" >/dev/null' EXIT
|
||||
port="$(docker port "$container" 80/tcp | head -n1 | sed 's/.*://')"
|
||||
# Image de production lancée, étiquette propre à ce dépôt : site_image,
|
||||
# container, port.
|
||||
# shellcheck source=tests/site-image.sh
|
||||
. tests/site-image.sh
|
||||
base="http://127.0.0.1:$port"
|
||||
|
||||
# Caddy démarre en une fraction de seconde : on attend qu'il réponde.
|
||||
|
||||
+758
-164
File diff suppressed because it is too large
Load Diff
@@ -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)] : [])],
|
||||
};
|
||||
|
||||
+104
-34
@@ -2,7 +2,7 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#4A2E1F">
|
||||
<title>Ustagammon</title>
|
||||
@@ -16,47 +16,66 @@
|
||||
<script type="module" src="app/main.js"></script>
|
||||
</head>
|
||||
<body class="busy">
|
||||
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts. -->
|
||||
<!-- Les textes des boutons et des messages viennent de app/src/ui/texts.ts ;
|
||||
les pictogrammes, de app/src/ui/icons.ts. Tout l'écran est dans #screen :
|
||||
c'est lui qui tourne d'un quart de tour quand le plein écran ne peut pas
|
||||
verrouiller le paysage (ADR 0012). -->
|
||||
<div id="screen" class="screen">
|
||||
|
||||
<header class="topbar">
|
||||
<!-- Logo de la charte (docs/identite/logo/, copié dans icons/ par make
|
||||
site) : horizontal, ou symbole seul quand la place manque (téléphone
|
||||
étroit, colonne du mode paysage) ; version sombre en thème sombre. -->
|
||||
site), version sombre en thème sombre (choix par app.css). -->
|
||||
<h1 class="brand">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark) and (max-width: 24.5rem), (prefers-color-scheme: dark) and (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-sombre.svg">
|
||||
<source media="(max-width: 24.5rem), (orientation: landscape) and (max-height: 40rem)" srcset="icons/symbole-couleur.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="icons/logo-horizontal-sombre.svg">
|
||||
<img src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
</picture>
|
||||
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
|
||||
</h1>
|
||||
<button id="help-button" type="button" class="secondary"></button>
|
||||
<button id="new-game" type="button" class="secondary"></button>
|
||||
<button id="help-button" type="button" class="icon-button labelled"></button>
|
||||
<button id="fullscreen-button" type="button" class="icon-button labelled"></button>
|
||||
<button id="menu-button" type="button" class="icon-button labelled"></button>
|
||||
</header>
|
||||
|
||||
<main id="game">
|
||||
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<p id="pips" class="pips"></p>
|
||||
<p id="info" class="info" aria-live="polite"></p>
|
||||
<p id="prompt" class="prompt" aria-live="polite"></p>
|
||||
<div class="actions">
|
||||
<button id="roll" type="button" hidden></button>
|
||||
<button id="double" type="button" class="secondary" hidden></button>
|
||||
<button id="take" type="button" hidden></button>
|
||||
<button id="drop" type="button" class="secondary" hidden></button>
|
||||
<button id="confirm" type="button" hidden></button>
|
||||
<button id="cancel" type="button" class="secondary" hidden></button>
|
||||
<button id="skip" type="button" hidden></button>
|
||||
<button id="replay" type="button" class="secondary" hidden></button>
|
||||
<button id="again" type="button" hidden></button>
|
||||
<main id="game" class="game">
|
||||
<div id="strip-computer" class="strip">
|
||||
<span class="mini mini-computer"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<!-- Confirmation avant de doubler (ouverte par app/ui/app.js). -->
|
||||
<dialog id="double-dialog" class="confirm" aria-labelledby="double-question">
|
||||
<p id="double-question"></p>
|
||||
<div class="confirm-actions">
|
||||
<button id="double-confirm" type="button"></button>
|
||||
<button id="double-cancel" type="button" class="secondary"></button>
|
||||
<div class="board-box">
|
||||
<svg id="board" class="board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<div id="pip-bubble" class="bubble" role="dialog" aria-labelledby="pip-bubble-term" hidden>
|
||||
<p id="pip-bubble-term" class="bubble-term"></p>
|
||||
<p class="bubble-text"></p>
|
||||
<button type="button" class="link-button"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
<div id="strip-player" class="strip">
|
||||
<span class="mini mini-player"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="infoline">
|
||||
<p id="info" class="info" aria-live="polite"></p>
|
||||
<button id="replay" type="button" class="icon-button replay" hidden></button>
|
||||
</div>
|
||||
<p id="prompt" class="prompt" aria-live="polite"></p>
|
||||
<div class="spacer"></div>
|
||||
<div id="actions" class="actions">
|
||||
<div class="slot">
|
||||
<button id="double" type="button" class="secondary" hidden></button>
|
||||
<button id="cancel" type="button" class="secondary" hidden></button>
|
||||
<button id="drop" type="button" class="secondary" hidden></button>
|
||||
</div>
|
||||
<div class="slot primary">
|
||||
<button id="roll" type="button" hidden></button>
|
||||
<button id="confirm" type="button" hidden></button>
|
||||
<button id="skip" type="button" hidden></button>
|
||||
<button id="take" type="button" hidden></button>
|
||||
<button id="again" type="button" hidden></button>
|
||||
<p id="move-hint" class="move-hint" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
|
||||
</main>
|
||||
|
||||
@@ -64,5 +83,56 @@
|
||||
<button id="help-back" type="button" class="secondary"></button>
|
||||
<div id="help-content"></div>
|
||||
</section>
|
||||
|
||||
<!-- Menu : une feuille qui monte du bas. -->
|
||||
<div id="menu-veil" class="veil sheet-veil" hidden>
|
||||
<div id="menu" class="sheet" role="dialog" aria-modal="true" aria-labelledby="menu-title">
|
||||
<div class="sheet-head">
|
||||
<span class="grip" aria-hidden="true"></span>
|
||||
<h2 id="menu-title" class="sheet-title"></h2>
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<button id="menu-fullscreen" type="button" class="item"></button>
|
||||
<button id="menu-help" type="button" class="item"></button>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation avant de doubler : la seule question posée au joueur. -->
|
||||
<div id="double-veil" class="veil" hidden>
|
||||
<div id="double-dialog" class="panel" role="dialog" aria-modal="true" aria-labelledby="double-question">
|
||||
<p id="double-question"></p>
|
||||
<div class="panel-actions">
|
||||
<button id="double-cancel" type="button" class="secondary"></button>
|
||||
<button id="double-confirm" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fin de partie, et double de l'ordinateur en paysage : un panneau centré
|
||||
sur un voile. -->
|
||||
<div id="panel-veil" class="veil" hidden>
|
||||
<div id="panel" class="panel" role="dialog" aria-modal="true" aria-labelledby="panel-text">
|
||||
<p id="panel-overline" class="overline"></p>
|
||||
<p id="panel-text" class="panel-title"></p>
|
||||
<p id="panel-detail" class="panel-detail"></p>
|
||||
<div class="panel-actions">
|
||||
<button id="panel-secondary" type="button" class="secondary"></button>
|
||||
<button id="panel-primary" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- « Nouvelle partie commencée. Reprendre l'ancienne », 10 s. -->
|
||||
<div id="banner" class="banner" role="status" hidden>
|
||||
<span id="banner-text"></span>
|
||||
<button id="resume-old" type="button" class="secondary"></button>
|
||||
<span class="banner-timer" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user