Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 ac0e924e4c Test navigateur : fermer le bilan rouvert avant l'aide ; plan des corrections clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:14:13 +01:00
Pierre MARTIN ceed97e359 Fusion de main dans corrections-plis-firefox 2026-11-12 09:09:54 +01:00
Pierre MARTINandClaude Opus 5.5 109655537f Fusion de la MR !58 : retours des premiers joueurs, pions blancs et noirs, numéros sur le cadre, flèches du tutoriel, jans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:05:49 +01:00
Pierre MARTINandClaude Opus 5.5 a238da77ec Contour des pions blancs plus sombre en thème clair ; recette des pions du joueur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:05:45 +01:00
Pierre MARTINandClaude Opus 5.5 9f0f20896b Fusion de main (contour des pions blancs, règle de recette des pions du joueur) dans retours-joueurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:42:20 +01:00
pierre 32f5270215 Fusion de la MR !59 : contour des pions blancs 2026-11-12 02:41:51 +00:00
Pierre MARTINandClaude Opus 5.5 3fff880db2 Charte : contour des pions blancs plus sombre en thème clair (3:1 sur le chêne)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:41:49 +01:00
Pierre MARTINandClaude Opus 5.5 282a3d688b Test navigateur : zones du plateau relevées d'un coup, sans boîte nulle
touchTarget mesurait chaque zone par boundingBox ; une zone remplacée
entre-temps par un nouveau dessin du plateau n'avait plus de boîte (null,
vu sous Firefox). Les zones sont relevées dans la page en une lecture, et
le test attend qu'elles soient toutes là.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:38:35 +01:00
Pierre MARTINandClaude Opus 5.5 57eeb19630 Plan des retours des premiers joueurs clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:34:40 +01:00
Pierre MARTINandClaude Opus 5.5 5a13c40bbb Recette des retours : capture après le bandeau, capture si le bouton reste caché
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:54:27 +01:00
Pierre MARTINandClaude Opus 5.5 8c2043aca5 Recette : contraste du contour des pions, retours des joueurs dans le navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 966822084a Tutoriel : deux flèches circulaires ; les jans remplacent le camp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 09a2ddf566 Numéros des flèches sur le cadre en portrait aussi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 cfdbc1cedc Pions blancs et noirs, avec contour : valeurs de tokens.json dans app.css
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 cb42052c80 Test navigateur : attendre l'enregistrement de la nouvelle partie
Sous Firefox, sur disque lent, l'enregistrement de la nouvelle partie
attend dans la file derrière l'analyse de la partie finie ; le test lisait
IndexedDB trop tôt. Il attend désormais que la partie en cours change.
Test unitaire : rien n'est perdu avec un stockage lent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 99294900a0 Prompt d'un coup : le classement de l'analyse, au niveau calculé
Le classement du conseil vient de l'analyse du coup joué (conseils en
version 2) : mêmes équités que la feuille du maître et l'analyse. Le
titre dit le niveau du meilleur coup ; une ligne plus basse le dit aussi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 1d21ac15d2 Moteur : classement de l'analyse d'un coup, comme gnubg de bureau
engine_analyse_move rend les dix premiers coups de la liste de l'analyse,
chacun au niveau où gnubg l'a évalué, et refait d'abord l'analyse de la
chance comme AnalyzeMove : son cache change les coups restés à 0 pli.
Les références d'analyse gagnent le niveau de chaque coup du classement,
comparé par compare.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 c42316d9d8 Plan des corrections : plis du prompt d'un coup, test Firefox instable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:31:25 +01:00
Pierre MARTINandClaude Opus 5.5 0e17334f45 Plan : retours des premiers joueurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:12:22 +01:00
Pierre MARTINandClaude Opus 5.5 1f69635c49 Fusion de la MR !57 : nouvelle partie directe après un bilan, bouton Commencer, fiche Notation des coups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:02:48 +01:00
Pierre MARTINandClaude Opus 5.5 fb161789bf Liens de notation : pointillé discret, zone d'appui agrandie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 e20f215030 Fusion de main (1f, prompts à copier) dans nouvelle-partie-directe
Conflits résolus en gardant les deux apports : la feuille du prompt et la
correction de reviewFinishedGame dans app.ts ; prompts et nouvelle-partie
dans tests/browser.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 abff843519 Plan de la nouvelle partie directe clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:39:47 +01:00
Pierre MARTINandClaude Opus 5.5 05bd06c946 Fusion de la MR !56 : demander à une IA, prompts à copier (1f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:28:40 +01:00
Pierre MARTINandClaude Opus 5.5 4050517904 Glossaire : fiche « Notation des coups », touchable depuis la partie
La fiche explique 13/7, 24/22*, 6/off, bar/22 et 8/4(2), dans la notation
de gnubg. Le type MoveNotation la relie au code. Le coup de la ligne
« L'ordinateur a joué » et celui de la feuille du maître mènent à la fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 05d2479bd3 Écran « Nouvelle partie » : « Commencer la partie » ou « Commencer le match »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 5da3baecde Nouvelle partie : plus de retour au bilan de l'ancienne partie
Le passage différé au bilan (reviewFinishedGame) ne vérifiait pas que la
partie finie était encore à l'écran : « Commencer » redessinait d'abord
l'ancienne partie finie (adresse #/), puis lançait la nouvelle, et la
microtâche ouvrait le bilan de l'ancienne par-dessus. Le bilan ne s'ouvre
plus que pour la partie finie encore affichée, et « Commencer » lance la
partie avant de changer d'adresse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
pierre 65ee563794 Fusion de la MR !55 : pions blancs et noirs 2026-11-11 21:14:56 +00:00
Pierre MARTINandClaude Opus 5.5 145c19ac77 Identité : pions blancs et noirs avec contour (ADR 0010 amendé, choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:54 +01:00
Pierre MARTINandClaude Opus 5.5 0bb41524bf Test navigateur : nouvelle partie après une partie finie et son bilan
Reproduit le défaut signalé : après une partie finie, l'application
rouverte, « Commencer » de l'écran « Nouvelle partie » ramenait au bilan
de l'ancienne partie. Vérifie l'accueil, le menu et « Changer les réglages ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:46 +01:00
pierre 6a1ee6f65a Fusion de la MR !54 : retours des premiers joueurs 2026-11-11 21:00:44 +00:00
Pierre MARTINandClaude Opus 5.5 22e415bb81 Charte : retours des premiers joueurs (pions sombres lisibles, numéros sur le cadre, jans, flèches circulaires)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:00:42 +01:00
Pierre MARTINandClaude Opus 5.5 62ecff245f Plan : nouvelle partie directe, libellé « Commencer », notation des coups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:18 +01:00
77 changed files with 2103 additions and 329 deletions
+3 -4
View File
@@ -102,10 +102,9 @@ export async function analyseDecision(
if (analysis === null) {
return null;
}
const ranking =
options.ranking === false
? []
: await engine.rankMoves(before.position, cube, before.roll, ply);
// Le classement est celui de l'analyse même : mêmes coups, mêmes
// niveaux et mêmes équités que le coup joué et le meilleur coup.
const ranking = options.ranking === false ? [] : analysis.ranking;
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
+28 -9
View File
@@ -7,6 +7,7 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { AdvisedMove, MoveAdvice } from "../game/advice.js";
import type { Ply } from "../game/engine.js";
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
@@ -34,18 +35,21 @@ export interface RankingRow {
/**
* Le classement d'un conseil tel que l'affiche la feuille du maître : les
* coups du classement du moteur, chacun avec son écart par rapport au
* premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de
* l'analyse du coup joué.
* coups du classement de l'analyse, chacun avec son écart par rapport au
* premier ; puis le coup joué, s'il n'y figure pas. Le coup joué a toujours
* l'écart de l'analyse (equityLoss), celui que dit le maître.
*/
export function rankingRows(advice: MoveAdvice): RankingRow[] {
const first = advice.ranking[0]?.equity ?? advice.best.equity;
const rows = advice.ranking.map((move, i) => ({
move,
rank: i + 1,
loss: move.equity - first,
played: move.notation === advice.played.notation,
}));
const rows = advice.ranking.map((move, i) => {
const played = move.notation === advice.played.notation;
return {
move,
rank: i + 1,
loss: played ? advice.equityLoss : move.equity - first,
played,
};
});
if (!rows.some((row) => row.played)) {
rows.push({
move: advice.played,
@@ -56,3 +60,18 @@ export function rankingRows(advice: MoveAdvice): RankingRow[] {
}
return rows;
}
/**
* Le niveau du classement d'un conseil : celui auquel le moteur a évalué le
* meilleur coup. C'est le niveau demandé (advice.ply), sauf quand gnubg
* s'arrête avant, parce qu'un seul coup a passé ses filtres : tout le
* classement reste alors au niveau inférieur.
*/
export function rankingPly(advice: MoveAdvice): Ply {
return advice.best.ply;
}
/** « 1 pli », « 2 plis » : gnubg compte les plis à partir de 0. */
export function pliesText(ply: Ply): string {
return `${ply} ${ply > 1 ? "plis" : "pli"}`;
}
+8 -6
View File
@@ -10,13 +10,15 @@
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/** Version du format d'un conseil enregistré. Un conseil d'une autre
* version est ignoré à la reprise (il peut être recalculé). */
export const ADVICE_VERSION = 1;
* version est ignoré à la reprise (il peut être recalculé). Version 2 : le
* classement des coups vient de l'analyse du coup joué, et non plus d'un
* second classement comme la commande « hint » de gnubg. */
export const ADVICE_VERSION = 2;
/**
* Le conseil du maître sur une décision du joueur : un coup ou une décision
@@ -29,7 +31,7 @@ export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
@@ -66,8 +68,8 @@ export interface MoveAdvice {
readonly best: AdvisedMove;
readonly playedFacts: MoveFacts;
readonly bestFacts: MoveFacts;
/** Les meilleurs coups du classement du moteur, du meilleur au moins
* bon (cinq au plus). */
/** Les meilleurs coups du classement de l'analyse, du meilleur au
* moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */
readonly ranking: readonly AdvisedMove[];
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
+16 -12
View File
@@ -80,17 +80,6 @@ export interface Engine {
ply: Ply,
): Promise<MoveAnalysis | null>;
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
* ply). */
rankMoves(
position: PositionId,
cube: EngineCube,
roll: Roll,
ply: Ply,
): Promise<EvaluatedMove[]>;
/** 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). */
@@ -222,6 +211,12 @@ export interface MoveAnalysis {
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
/** Le classement de l'analyse, du meilleur au moins bon (dix coups au
* plus) : la liste de l'analyse de partie de gnubg, chaque coup au
* niveau où gnubg l'a évalué (ply). Les coups écartés par ses filtres
* gardent l'évaluation d'un niveau inférieur ; le meilleur coup et le
* coup joué y ont les évaluations de best et played. */
readonly ranking: readonly EvaluatedMove[];
}
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
@@ -281,6 +276,15 @@ export interface EngineMatchAfter {
readonly crawford: boolean;
}
/**
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
* fait que l'afficher.
*
* Glossaire : docs/glossaire/notation-des-coups.md
*/
export type MoveNotation = string;
/**
* Un coup légal complet.
*
@@ -288,7 +292,7 @@ export interface EngineMatchAfter {
*/
export interface Move {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
/** Ses demi-coups, dans un ordre jouable. */
readonly halfMoves: readonly HalfMove[];
/** La position après le coup, vue du joueur qui vient de jouer. */
+10 -2
View File
@@ -12,7 +12,15 @@
import type { Advice } from "./advice.js";
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
import type {
BoardSide,
Engine,
EngineCube,
Level,
MoveNotation,
Ply,
ResultKind,
} from "./engine.js";
import {
appendEvent,
currentLine,
@@ -290,7 +298,7 @@ export class Game {
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
*/
async moveNotation(eventId: string): Promise<string> {
async moveNotation(eventId: string): Promise<MoveNotation> {
const line = currentLine({ ...this.#journal, head: eventId });
const event = line.at(-1);
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
+19 -4
View File
@@ -20,7 +20,13 @@ import {
type ReviewedDecision,
summarizeAnalysis,
} from "../analysis/game-analysis.js";
import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js";
import {
formatEquity,
formatPercent,
pliesText,
rankingPly,
rankingRows,
} from "../analysis/numbers.js";
import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js";
import {
type Advice,
@@ -83,7 +89,7 @@ const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many :
/** « analyse à 2 plis ». */
export function plyText(ply: Ply): string {
return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`;
return `analyse à ${pliesText(ply)}`;
}
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
@@ -146,8 +152,14 @@ export function decisionPrompt(
niveau_eval: plyText(data.advice.ply),
appreciation: masterWords(phrases, data.advice).word,
};
// Le niveau d'un classement de coups : celui de son meilleur coup, que
// le moteur a pu évaluer moins profondément que demandé.
return data.advice.kind === "move"
? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) })
? fillPrompt(templates.move, {
...common,
niveau_eval: plyText(rankingPly(data.advice)),
...moveValues(data.advice),
})
: fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) });
}
@@ -163,7 +175,10 @@ function moveValues(advice: MoveAdvice): Record<string, string> {
// de « Voir les chiffres du moteur », avec les mêmes chiffres.
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
const ranking = rows.map((row) => {
const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`;
// Un coup écarté par les filtres du moteur garde l'évaluation d'un
// niveau inférieur à celui du classement : la ligne le dit.
const ply =
row.move.ply === rankingPly(advice) ? "" : `, évalué à ${pliesText(row.move.ply)}`;
const mine = row.played ? " (mon coup)" : "";
return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`;
});
+2 -8
View File
@@ -182,6 +182,7 @@ export function createWasmEngine(run: RunCommand): Engine {
best: CliEvaluatedMove | null;
equity_loss: number;
skill: CliSkill;
ranking: CliEvaluatedMove[];
};
if (answer.rank === null || answer.played === null || answer.best === null) {
return null;
@@ -193,17 +194,10 @@ export function createWasmEngine(run: RunCommand): Engine {
best: toEvaluatedMove(answer.best),
equityLoss: answer.equity_loss,
skill: toSkill(answer.skill),
ranking: answer.ranking.map(toEvaluatedMove),
};
},
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as {
moves: CliEvaluatedMove[];
};
return answer.moves.map(toEvaluatedMove);
},
async analyseCube(
position: PositionId,
cube: EngineCube,
+2 -2
View File
@@ -26,8 +26,8 @@ export const ADVICE_TEXTS = {
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (ply: number) =>
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
moveNote: (plies: string) =>
`Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
+7 -5
View File
@@ -7,7 +7,7 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { rankingRows } from "../domain/analysis/numbers.js";
import { pliesText, rankingPly, rankingRows } from "../domain/analysis/numbers.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
@@ -21,6 +21,7 @@ import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { showWithNotation } from "./notation.js";
import { routeHash } from "./router.js";
/** Préfixe des identifiants du plateau de la feuille. */
@@ -203,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const svg = sheetBoard(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
svg.setAttribute("aria-label", text);
showWithNotation(legend, text, advice.played.notation);
void player?.play(mine);
return;
}
@@ -233,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
showWithNotation(legend, text, notation);
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
void player?.play(shown === "mine" ? mine : best);
@@ -302,7 +304,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(advice.ply),
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
+47 -14
View File
@@ -36,6 +36,7 @@ import {
} from "../domain/analysis/phrases.js";
import type { Advice } from "../domain/game/advice.js";
import type { Cube } from "../domain/game/cube.js";
import type { MoveNotation } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
@@ -84,6 +85,7 @@ import { installPlatform, startInstaller } from "./install.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { replacingLink, startNavigation } from "./navigation.js";
import { createNewGameView } from "./new-game-view.js";
import { showWithNotation } from "./notation.js";
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
import {
@@ -443,6 +445,10 @@ export function startApp(options: AppOptions): void {
let stuck = false;
let busy = true;
let info: string = TEXTS.loading;
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
// « Notation des coups ».
let computerLine: ComputerTurnLine | null = null;
let engineFailed = false;
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
// la prochaine action du joueur ; null : rien à montrer.
@@ -646,7 +652,11 @@ export function startApp(options: AppOptions): void {
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
} else {
ui.info.textContent = info;
showWithNotation(
ui.info,
info,
computerLine?.text === info ? computerLine.notation : null,
);
}
ui.prompt.textContent = panelShown
? ""
@@ -1037,7 +1047,8 @@ export function startApp(options: AppOptions): void {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
await game.playComputerTurn();
info = await describeComputerTurn(game);
computerLine = await describeComputerTurn(game);
info = computerLine.text;
save();
played = true;
}
@@ -1529,25 +1540,37 @@ export function startApp(options: AppOptions): void {
* La partie à l'écran vient de finir : son analyse part en
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
* qu'il est prêt (charte 1e), si le panneau est toujours là.
*
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
* signalé le 2026-11-11).
*/
function reviewFinishedGame(): void {
const id = options.games.shownId();
if (game === null || id === null || busy || game.state.phase !== "over") {
const finished = game;
if (finished === null || id === null || busy || finished.state.phase !== "over") {
return;
}
// L'analyse d'une autre partie, regardée, passe d'abord.
const running = reviewRunner.running;
if (running === null || running === id) {
review(id, game.journal);
review(id, finished.journal);
}
if (
navigation.route.screen === "game" &&
!panelDismissed &&
phrases !== null &&
readyForSummary(game.journal)
readyForSummary(finished.journal)
) {
queueMicrotask(() => {
if (navigation.route.screen === "game" && !panelDismissed) {
if (
game === finished &&
options.games.shownId() === id &&
navigation.route.screen === "game" &&
!panelDismissed
) {
navigation.go(routeHash({ screen: "summary", game: id }));
}
});
@@ -2015,8 +2038,10 @@ export function startApp(options: AppOptions): void {
welcomed: () => saveSettings(welcomed()),
});
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
// celle de la partie (le retour du téléphone ne le rouvre pas).
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
// remplace son adresse par celle de la partie (le retour du téléphone
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
// jamais avec l'ancienne partie, finie, et son panneau de fin.
const newGameView = createNewGameView({
section: ui.newGameSection,
settings: () => settings,
@@ -2025,8 +2050,8 @@ export function startApp(options: AppOptions): void {
if (busy || game === null) {
return;
}
navigation.replace("#/");
newGame(chosenGame(settings));
navigation.replace("#/");
},
});
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
@@ -2661,22 +2686,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
return game.lastMoveSteps();
}
/** La ligne d'information après le tour de l'ordinateur, et le coup
* qu'elle cite (null s'il n'a pas joué de coup). */
interface ComputerTurnLine {
readonly text: string;
readonly notation: MoveNotation | null;
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
async function describeComputerTurn(game: Game): Promise<string> {
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
return { text: "", notation: null };
}
const played = game.events.findLast((e) => e.type === "MovePlayed");
if (played?.type !== "MovePlayed") {
return "";
return { text: "", notation: null };
}
const dice = rollBefore(game);
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
return { text: TEXTS.computerCannotMove(dice), notation: null };
}
return TEXTS.computerMoved(dice, await game.moveNotation(played.id));
const notation = await game.moveNotation(played.id);
return { text: TEXTS.computerMoved(dice, notation), notation };
}
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
+37
View File
@@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } {
return { top: true, column: number - 13 };
}
/**
* Le jan intérieur d'un camp : ses six dernières flèches avant la sortie,
* numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour
* l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur
* reste seul juge des règles qui en dépendent (sortie, rentrée).
* Glossaire : docs/glossaire/jan-interieur.md
*/
export interface HomeBoard {
readonly side: Side;
readonly points: readonly number[];
}
/**
* Le jan extérieur d'un camp : les six flèches qui précèdent son jan
* intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens
* de marche du camp.
* Glossaire : docs/glossaire/jan-exterieur.md
*/
export interface OuterBoard {
readonly side: Side;
readonly points: readonly number[];
}
/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre
* numérotation, renumérotées pour le joueur. */
function sixPoints(side: Side, first: number): number[] {
return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i)));
}
export function homeBoard(side: Side): HomeBoard {
return { side, points: sixPoints(side, 6) };
}
export function outerBoard(side: Side): OuterBoard {
return { side, points: sixPoints(side, 12) };
}
/** 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,
+4 -10
View File
@@ -303,23 +303,17 @@ function drawPoint(
"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
// Numéros : sur le cadre, bandes haute et basse, en portrait comme en
// paysage. 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: g.layout === "landscape" ? "point-number on-frame" : "point-number",
class: "point-number",
x: center,
y,
y: top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36,
"font-size": size,
},
String(number),
+10 -5
View File
@@ -25,7 +25,7 @@ export interface Geometry {
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. */
/** Hauteur du cadre en haut et en bas : la bande des numéros des flèches. */
readonly edge: number;
/** Colonne de commandes à gauche (0 en portrait). */
readonly left: number;
@@ -95,14 +95,19 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
arrowWidth: 2.5 / pxPerUnit,
};
if (layout === "portrait") {
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
// Les mesures du plateau d'origine : flèches de 44. Les bandes haute
// et basse du cadre portent les numéros des flèches, comme en
// paysage (12 px au moins à l'écran) : pions de 19 au plus. La bande ne
// dépasse pas 36 unités : sur une miniature (moins de 275 px de
// large), les numéros rapetissent plutôt que d'écraser les flèches.
const frame = 8;
const edge = Math.min(36, Math.max(24, numberSize + 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 radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2) / 10);
const x0 = frame;
const barLeft = x0 + 6 * pointWidth;
return {
@@ -110,7 +115,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
width,
height,
frame,
edge: frame,
edge,
left: 0,
tray,
bar,
@@ -123,7 +128,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
rightHalf: barLeft + bar,
trayLeft: barLeft + bar + 6 * pointWidth + frame,
target,
numberSize,
numberSize: Math.min(numberSize, edge - 8),
bandFont: atLeast(13.5, pxPerUnit, 17),
chipFont: atLeast(13.5, pxPerUnit, 17),
...sizes,
+3 -2
View File
@@ -94,14 +94,15 @@ export const HOME_TEXTS = {
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
pathLabel:
"Le sens du jeu : la flèche claire pour vos pions, la sombre pour ceux de l'ordinateur",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
: "Partie finie : il reste un pion de l'ordinateur dans votre jan intérieur, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
+1 -1
View File
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
advice: "Conseil du maître",
adviceDetail: "Une ligne après chacun de vos coups",
startGame: "Commencer la partie",
startMatch: (n: number) => `Commencer le match en ${n} points`,
startMatch: "Commencer le match",
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
+1 -3
View File
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch
? MATCH_TEXTS.startMatch(s.matchLength)
: MATCH_TEXTS.startGame;
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
+34
View File
@@ -0,0 +1,34 @@
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
// veulent dire l'étoile, « off », « bar » et « (2) ».
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveNotation } from "../domain/game/engine.js";
import { routeHash } from "./router.js";
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
export const NOTATION_TERM = "notation-des-coups";
/**
* Remplace le contenu de parent par text, où la première occurrence de
* notation devient un lien vers la fiche « Notation des coups ». Sans
* notation dans le texte, le texte seul.
*/
export function showWithNotation(
parent: HTMLElement,
text: string,
notation: MoveNotation | null,
): void {
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
if (notation === null || at < 0) {
parent.textContent = text;
return;
}
const link = document.createElement("a");
link.className = "notation";
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
link.textContent = notation;
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
}
+1 -1
View File
@@ -188,7 +188,7 @@ export const TEXTS = {
) =>
[
winner === null
? "Vous avez la main : vos pions ivoire, en bas."
? "Vous avez la main : vos pions blancs, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
+1 -1
View File
@@ -24,7 +24,7 @@ export interface TutorialScreen {
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu dessiné en pointillés. */
/** Explication : le sens du jeu, deux flèches circulaires. */
readonly path: boolean;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
+62 -45
View File
@@ -18,11 +18,14 @@ import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
homeBoard,
moveTargets,
moveTrace,
onlyHalfMove,
outerBoard,
type Place,
placeOf as placeOfChecker,
pointSlot,
} from "./board-model.js";
import {
type BoardDecor,
@@ -149,57 +152,71 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
/** Le sens du jeu : deux flèches circulaires, une claire pour les pions du
* joueur (du haut à droite vers la gauche, puis en bas jusqu'à son jan
* intérieur), une sombre pour ceux de l'ordinateur, en sens inverse et à
* l'intérieur de la première. Chacune a un liseré de la couleur opposée et
* finit par une pointe ; jamais en brique (la brique veut dire « à faire »).
* Dessinées sous les pions, qu'elles ne cachent jamais. pxPerUnit : pixels
* à l'écran par unité du dessin (trait de 5 px sur un liseré de 9 px,
* quelle que soit la taille du plateau). */
function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const px = (n: number) => n / pxPerUnit;
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
// Chaque flèche part du jan intérieur adverse (sa flèche 24), longe les
// deux jans extérieurs, tourne au bout du sien et finit dans son jan
// intérieur. Celle de l'ordinateur passe à l'intérieur de la claire.
const column = (point: number) => pointSlot(point).column;
const loops = [
{
side: "player",
from: g.height * 0.29,
to: g.height * 0.71,
left: center(column(outerBoard("player").points[0] ?? 12)) - g.pointWidth * 0.1,
},
{
side: "computer",
from: g.height * 0.62,
to: g.height * 0.38,
left: center(column(outerBoard("computer").points[0] ?? 13) + 1) + g.pointWidth * 0.5,
},
] as const;
const head = px(18);
const half = px(10);
for (const { side, from, to, left } of loops) {
const other = side === "player" ? "computer" : "player";
const start = center(column(homeBoard(other).points[5] ?? 1));
const end = center(column(homeBoard(side).points[4] ?? 2)) + g.pointWidth * 0.3;
const turn = Math.min(Math.abs(to - from) / 2, g.pointWidth * 0.9);
const down = to > from ? 1 : -1;
const d =
`M ${start} ${from} L ${left + turn} ${from} Q ${left} ${from} ${left} ${from + down * turn} ` +
`L ${left} ${to - down * turn} Q ${left} ${to} ${left + turn} ${to} L ${end - head / 2} ${to}`;
for (const [part, stroke] of [
["edge", px(9)],
["line", px(5)],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", `direction-${part} direction-${side}`);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
group.append(path);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
const tip = document.createElementNS(SVG_NS, "path");
tip.setAttribute("class", `direction-head direction-${side}`);
tip.setAttribute(
"d",
`M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`,
);
tip.setAttribute("stroke-width", String(px(2)));
group.append(tip);
}
// Sous les pions ; les zones à toucher restent devant tout.
svg.insertBefore(group, svg.querySelector(":scope > g.pieces"));
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
@@ -359,7 +376,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
+29
View File
@@ -35,6 +35,35 @@ test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meil
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
);
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
// Le classement de l'analyse, comme gnubg de bureau
// (tests/reference/analysis/move-opening-31-error.json) : le meilleur
// coup et le coup joué, une erreur, réévalués à 2 plis ; les suivants
// restés à 1 pli, puis à 0, écartés par les filtres.
assert.deepEqual(analysis.ranking[0], analysis.best);
assert.deepEqual(analysis.ranking[1], analysis.played);
assert.deepEqual(
analysis.ranking.map((move) => move.ply),
[2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
);
});
test("ouverture 3-1 jouée 8/5 6/5 : un seul coup passe le filtre, tout reste à 1 pli", async () => {
const engine = await loadNodeEngine();
const analysis = await engine.analyseMove(
START_POSITION,
centered,
[3, 1],
"4HPwATCwZ/ABMA",
2,
);
assert.ok(analysis !== null);
assert.equal(analysis.rank, 1);
assert.deepEqual(analysis.ranking[0], analysis.best);
assert.deepEqual(analysis.best, analysis.played);
assert.deepEqual(
analysis.ranking.slice(0, 5).map((move) => move.ply),
[1, 1, 1, 1, 1],
);
});
test("sans coup possible, il n'y a rien à analyser", async () => {
-1
View File
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
};
},
analyseMove: async () => null,
rankMoves: async () => [],
analyseCube: async () => ({
analysed: false,
equities: null,
+29 -5
View File
@@ -9,7 +9,7 @@ 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 { homeBoard, OPENING_BOARDS, outerBoard, 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";
@@ -37,15 +37,32 @@ const LANDSCAPES: [number, number][] = [
[800, 308],
];
test("géométrie en portrait : les mesures du plateau d'origine", () => {
test("géométrie en portrait : les mesures du plateau d'origine, numéros sur le cadre", () => {
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);
// Pions d'un peu moins de 19 unités (20 avant les numéros sur le cadre).
assert.ok(g.radius >= 18.5 && g.radius <= 19, `pions de ${g.radius}`);
assert.equal(g.step, 2 * g.radius + 2);
assert.equal(g.trayLeft + g.tray + g.frame, 640);
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
// Numéros d'au moins 12 px à l'écran, à 412 px de large, logés dans les
// bandes haute et basse du cadre (retours des premiers joueurs).
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
assert.ok(g.numberSize + 8 <= g.edge, `bande de ${g.edge} pour des numéros de ${g.numberSize}`);
});
test("géométrie en portrait : numéros d'au moins 12 px sur le cadre dès 275 px de large", () => {
for (const width of [275, 320, 358, 380, 396, 600]) {
const px = width / 640;
const g = boardGeometry("portrait", 640 / 520, px);
assert.ok(g.numberSize * px >= 12, `${width} px : numéros de ${g.numberSize * px} px`);
assert.ok(g.numberSize < g.edge, `${width} px : numéros hors du cadre`);
assert.ok(g.edge <= 36, `${width} px : bande de ${g.edge}`);
}
// Une miniature : la bande reste de 36 unités, les numéros y tiennent.
const thumbnail = boardGeometry("portrait", 640 / 520, 200 / 640);
assert.equal(thumbnail.edge, 36);
assert.ok(thumbnail.numberSize < thumbnail.edge);
});
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
@@ -93,6 +110,13 @@ test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comp
}
});
test("les jans de chaque camp, numérotés pour le joueur, dans le sens de marche", () => {
assert.deepEqual(homeBoard("player").points, [6, 5, 4, 3, 2, 1]);
assert.deepEqual(outerBoard("player").points, [12, 11, 10, 9, 8, 7]);
assert.deepEqual(homeBoard("computer").points, [19, 20, 21, 22, 23, 24]);
assert.deepEqual(outerBoard("computer").points, [13, 14, 15, 16, 17, 18]);
});
// -------------------------------------------------------------- Vibrations
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
+91 -1
View File
@@ -239,7 +239,97 @@ test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, m
assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`));
}
assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt);
assert.ok(prompt.includes("analyse à 2 plis"), prompt);
assert.match(prompt, /\(analyse à [12] plis?\)/);
}
});
/** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau :
* à l'arrondi près, plus la tolérance des positions de référence (0,002 ;
* tests/reference/README.md), le moteur WebAssembly ne calculant pas au
* bit près comme gnubg de bureau (ADR 0005). */
function assertNear(text: string, value: number, message: string): void {
const written = Number(text.replace("−", "-").replace("+", "").replace(",", "."));
assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`);
}
/** Le niveau du titre du classement, et chaque ligne du classement : coup,
* équité, écart, niveau propre (null : celui du titre). */
function rankingOfPrompt(prompt: string) {
const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt);
assert.ok(title, prompt);
const lines = [
...prompt.matchAll(
/^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm,
),
].map((m) => ({
rank: Number(m[1]),
notation: m[2],
mine: m[3] !== undefined,
equity: m[4],
loss: m[5],
ply: m[6] === undefined ? null : Number(m[6]),
}));
return { ply: Number(title[1]), lines };
}
test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => {
const engine = await loadNodeEngine();
// gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/).
const reference = (id: string) =>
JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8"))
.plies["2"];
for (const [dice, move, id] of [
// Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ;
// les autres restent au niveau où ses filtres les ont laissés.
[[3, 1], "24/23 13/10", "move-opening-31-error"],
// Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg
// s'arrête là, tout le classement est à 1 pli.
[[3, 1], "8/5 6/5", "move-opening-31-best"],
[[6, 5], "13/8 13/7", "move-opening-65-error"],
] as const) {
const played = await playOpening(engine, dice, move);
const advice = await adviceOn(engine, played);
assert.ok(advice.kind === "move");
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
const prompt = decisionPrompt(templates, phrases, {
journal: played.game.journal,
advice,
position,
});
const { ply, lines } = rankingOfPrompt(prompt);
const expected = reference(id);
// Le titre dit le niveau du meilleur coup, celui que gnubg de bureau
// donne ; une ligne sans niveau propre est à ce niveau, une ligne
// avec un niveau propre, à un niveau inférieur.
assert.equal(ply, expected.ranking[0].ply, prompt);
assert.equal(ply, advice.best.ply);
assert.ok(lines.length >= 3, prompt);
for (const line of lines) {
const reference = expected.ranking.find(
(m: { move: string }) => m.move === line.notation,
);
assert.ok(reference, `${line.notation} absent du classement de gnubg`);
assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`);
assert.ok(line.ply === null || line.ply < ply, prompt);
assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`);
}
// Les équités et l'écart du meilleur coup et du vôtre sont ceux de
// l'analyse, que disent la feuille du maître et l'écran d'analyse.
const best = lines[0];
const mine = lines.find((line) => line.mine);
assert.ok(best && mine, prompt);
assert.equal(best.notation, advice.best.notation);
assert.equal(best.equity, formatEquity(advice.best.equity));
assert.equal(best.loss, formatEquity(0));
assert.equal(mine.notation, advice.played.notation);
assert.equal(mine.rank, advice.rank);
assert.equal(mine.equity, formatEquity(advice.played.equity));
assert.equal(mine.loss, formatEquity(advice.equityLoss));
assertNear(mine.loss, expected.equity_loss, prompt);
assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`));
}
});
+51
View File
@@ -222,6 +222,57 @@ test("les identifiants de partie sont distincts", () => {
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
});
// Régression du test navigateur « mes parties » sous Firefox (disque lent) :
// la nouvelle partie n'est pas perdue quand son enregistrement attend
// derrière ceux, lents, de l'analyse de la partie finie ; seule une lecture
// directe du stockage, hors de la file, peut la précéder.
test("stockage lent : la nouvelle partie est enregistrée après l'analyse de l'ancienne", async () => {
const storage = memoryStorage();
const raw = createKeyValueBackend(storage);
const pause = () => new Promise((resolve) => setTimeout(resolve, 30));
const slow = {
...raw,
write: async (...args: Parameters<typeof raw.write>) => {
await pause();
await raw.write(...args);
},
setCurrentId: async (id: string) => {
await pause();
await raw.setCurrentId(id);
},
};
const games = createGameLibrary(slow, { now: clock() });
const engine = {} as Engine;
const session = createGameSession(games, {
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
});
const finished = session.newGame();
const finishedId = session.shownId as string;
session.save(finished.journal);
assert.equal((await games.current())?.summary.id, finishedId);
// L'analyse de la partie finie s'enregistre en arrière-plan, puis
// « Rejouer » : nouvelle partie, enregistrée sans attendre.
for (let i = 0; i < 5; i++) {
void games.saveAnalyses(finishedId, finished.journal, { level: "2ply" });
}
const next = session.newGame();
session.save(next.journal);
const nextId = session.shownId as string;
assert.notEqual(nextId, finishedId);
// Lue directement, tout de suite : encore l'ancienne partie.
assert.equal(await raw.currentId(), finishedId);
// Lue par la bibliothèque, après les écritures en attente : la nouvelle,
// avec son journal ; l'ancienne reste là.
const current = await games.current();
assert.equal(current?.summary.id, nextId);
assert.deepEqual(current?.journal, next.journal);
assert.equal(await raw.currentId(), nextId);
assert.deepEqual((await games.load(finishedId))?.journal, finished.journal);
});
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
const games = library();
// Game.start et Game.resume n'interrogent pas le moteur.
+2 -2
View File
@@ -101,8 +101,8 @@ test("chaque position du tutoriel est valide pour le moteur", async () => {
const s = board.sides[side];
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
};
assert.equal(count("player"), 15, `${name} : pions ivoire`);
assert.equal(count("computer"), 15, `${name} : pions noyer`);
assert.equal(count("player"), 15, `${name} : pions blancs`);
assert.equal(count("computer"), 15, `${name} : pions noirs`);
}
});
+3 -3
View File
@@ -209,14 +209,14 @@ test("aide contextuelle : chaque question ouverte depuis la partie existe dans l
});
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md"));
assert.ok(entry !== null);
assert.equal(entry.english, "Home board");
assert.deepEqual(
entry.seeAlso.map((s) => s.name),
["sortie", "barre", "point"],
["jan-exterieur", "sortie", "barre", "point"],
);
assert.ok(matchesSearch(entry, "camp"));
assert.ok(matchesSearch(entry, "jan interieur"));
assert.ok(matchesSearch(entry, "HOME"));
assert.ok(matchesSearch(entry, "fleches sortir"));
assert.ok(!matchesSearch(entry, "videau"));
@@ -184,3 +184,21 @@ a plus besoin (`app/src/ui/review-runner.ts`).
La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé
d'une partie sans en faire la partie en cours ni changer la date de sa
dernière action (l'ordre de « Mes parties »).
### 2026-11-11 — Version 2 des conseils : le classement de l'analyse
Le classement des coups d'un conseil (`ranking`) venait d'un second appel au
moteur, comme la commande « hint » de gnubg, alors que le coup joué et le
meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent :
quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le
coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup
passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à
2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient
désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme
le panneau d'analyse de gnubg de bureau.
- `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la
règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié
à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur
(`review.computer`), du même format, aussi.
- Ni les événements (version 2) ni le schéma IndexedDB ne changent.
+19
View File
@@ -60,3 +60,22 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
en dessous de 32 px.
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
## Amendements
### 2026-11-11 — Pions blancs et noirs, avec un contour
Retour des premiers joueurs, relayé par Pierre : les pions sombres se voyaient
mal sur la barre, et les joueurs préféraient le blanc et le noir à l'ivoire et
au noyer. Après comparaison sur le canevas (rangée « Retours des premiers
joueurs »), Pierre retient le blanc et le noir, avec un contour.
- Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé
(`#55554F` en thème clair, pour 3:1 sur les flèches en chêne ; `#6E6E69` en
sombre) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris
clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions.
- Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1
en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1).
- Le logo ne change pas : ses pions restent ivoire et noyer, qui sont la
matière de la marque, distincte des pièces du jeu.
- Valeurs dans `docs/identite/charte/tokens.json`.
+3 -1
View File
@@ -40,7 +40,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Barre](barre.md) | Bar |
| [Blot](blot.md) | Blot |
| [Branche](branche.md) | Branch |
| [Camp](camp.md) | Home board |
| [Chance](chance.md) | Luck |
| [Compte de points](compte-de-points.md) | Pip count |
| [Conseil du maître](conseil-du-maitre.md) | Advice |
@@ -54,11 +53,14 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Gammon](gammon.md) | Gammon |
| [Identifiant de match](identifiant-de-match.md) | Match ID |
| [Identifiant de position](identifiant-de-position.md) | Position ID |
| [Jan extérieur](jan-exterieur.md) | Outer board |
| [Jan intérieur](jan-interieur.md) | Home board |
| [Jet](jet.md) | Roll |
| [Lien de position](lien-de-position.md) | Position link |
| [Match](match.md) | Match |
| [Moteur](moteur.md) | Engine |
| [Niveau de jeu](niveau-de-jeu.md) | Level |
| [Notation des coups](notation-des-coups.md) | Move notation |
| [Partie](partie.md) | Game |
| [Partie enregistrée](partie-enregistree.md) | Saved game |
| [Passer](passer.md) | Drop |
-12
View File
@@ -1,12 +0,0 @@
---
term: Camp
english: Home board
---
# Camp
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Jan extérieur
english: Outer board
code: OuterBoard
---
# Jan extérieur
**En bref** : les six flèches qui précèdent le jan intérieur d'un joueur : ses pions y passent avant d'y entrer.
Votre jan extérieur est en bas à gauche (vos flèches 7 à 12) ; celui de
l'ordinateur, en haut à gauche (vos flèches 13 à 18). Vos pions partent du jan
intérieur de l'ordinateur, traversent les deux jans extérieurs, puis entrent
dans votre jan intérieur.
Voir aussi : [Jan intérieur](jan-interieur.md), [Point](point.md)
+16
View File
@@ -0,0 +1,16 @@
---
term: Jan intérieur
english: Home board
code: HomeBoard
---
# Jan intérieur
**En bref** : les six dernières flèches d'un joueur avant la sortie : il y rassemble ses quinze pions pour les sortir, et les pions adverses frappés y rentrent.
Le plateau se partage en quatre jans de six flèches. Votre jan intérieur est
en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, en haut à droite
(vos flèches 19 à 24) : c'est là que rentrent vos pions frappés. Un pion
adverse resté dans votre jan intérieur à la fin de la partie en fait un
backgammon.
Voir aussi : [Jan extérieur](jan-exterieur.md), [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+27
View File
@@ -0,0 +1,27 @@
---
term: Notation des coups
english: Move notation
code: MoveNotation
---
# Notation des coups
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
plus proche de sa sortie) à 24 :
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
dés compte en général deux déplacements, séparés par une espace :
`13/7 8/7`.
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
seul de l'adversaire, qui part sur la barre.
- `6/off` : le pion de la case 6 sort du plateau.
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
6 à 2.
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
de sa case de départ à sa case d'arrivée.
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
+34 -15
View File
@@ -76,9 +76,11 @@ interrompre.
demande au moteur si le coup du joueur fait partie des coups permis attendus.
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
veut dire « à faire »).
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
circulaires** : une claire pour vos pions, une sombre pour ceux de
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
courte, « Continuer » devient l'action principale. Un geste faux : le pion
revient, et une phrase dit pourquoi, sans le mot « erreur ».
@@ -87,14 +89,14 @@ interrompre.
### Texte, première version
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
1. **Le but du jeu.** « Vous avez quinze pions blancs, l'ordinateur quinze
pions noirs. Chacun fait faire le tour du plateau à ses pions, puis les
sort. Le premier qui a sorti tous les siens gagne la partie. »
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
l'ordinateur font le même chemin, en sens inverse. »
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
@@ -106,16 +108,16 @@ interrompre.
point 7 avec 6-1.
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée :
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
passe. »
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
(flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
## Vocabulaire : les jans
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
explications (retour des premiers joueurs, 2026-11-11) :
| Flèches | Nom |
| --- | --- |
| 1 à 6 | votre jan intérieur |
| 7 à 12 | votre jan extérieur |
| 13 à 18 | le jan extérieur de l'ordinateur |
| 19 à 24 | le jan intérieur de l'ordinateur |
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
vers leur fiche du glossaire à la première apparition. Fiches à créer :
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
outer board). « Camp » n'est plus employé dans les textes.
+2 -2
View File
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
+1 -1
View File
@@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions blancs (`checker-player`), l'ordinateur les pions noirs (`checker-computer`), chacun avec un contour et un filet (`checker-player-edge`, `checker-computer-edge`) qui le détachent de tous les fonds ; les pions du logo restent ivoire et noyer (ADR 0010, amendement du 2026-11-11).
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
+3
View File
@@ -8,6 +8,9 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
## Toutes tailles
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. (Vérifié par `tests/browser/retours-joueurs.mjs`.)
- [ ] Pions du joueur lisibles partout : en thème clair, contour `checker-player-edge` à 3:1 au moins sur les flèches en chêne et sur le fond. Règle automatique : `checkerContrastProblems` de `tests/browser/recette.mjs`.
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. Règle automatique : là où sa couleur ne se détache pas de son fond (moins de 3:1), son contour s'en détache à 3:1 au moins (`checkerContrasts` de `tests/browser/recette.mjs`).
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
+32 -32
View File
@@ -216,96 +216,96 @@
{
"name": "checker-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Pions du joueur : ivoire, l'apprenti."
"usage": "Pions du joueur : blancs."
},
{
"name": "checker-player-edge",
"value": {
"light": "#8C7A62",
"dark": "#8E7C64"
"light": "#55554F",
"dark": "#6E6E69"
},
"usage": "Contour et filet des pions du joueur."
"usage": "Contour des pions blancs. Thème clair : #55554F, pour 3:1 au moins sur les flèches en chêne (3,5:1) et le fond (5,5:1). Thème sombre : le pion blanc se détache déjà par sa couleur (plus de 5:1)."
},
{
"name": "checker-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
"usage": "Pions de l'ordinateur : noirs."
},
{
"name": "checker-computer-edge",
"value": {
"light": "#120A05",
"dark": "#A8957D"
"light": "#B5B5B0",
"dark": "#B5B5B0"
},
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
"usage": "Contour et filet des pions noirs : gris clair dans les deux thèmes, pour qu'ils se détachent de la barre, des flèches sombres et du fond sombre."
},
{
"name": "count-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Nombre écrit sur une pile de pions du joueur."
"usage": "Nombre écrit sur une pile de pions blancs."
},
{
"name": "count-computer",
"value": {
"light": "#F3EAD8",
"dark": "#F3EAD8"
"light": "#FAFAF7",
"dark": "#FAFAF7"
},
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
"usage": "Nombre écrit sur une pile de pions noirs."
},
{
"name": "die-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Dés du joueur."
"usage": "Dés du joueur, blancs comme ses pions."
},
{
"name": "pip-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Points des dés du joueur."
},
{
"name": "die-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Dés de l'ordinateur."
"usage": "Dés de l'ordinateur, noirs comme ses pions."
},
{
"name": "pip-computer",
"value": {
"light": "#F3EAD8",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Points des dés de l'ordinateur."
},
{
"name": "cube",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Face du videau."
},
{
"name": "cube-text",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Valeur du videau (2, 4, 8…)."
},
+1 -1
View File
@@ -54,5 +54,5 @@ Repères communs à plusieurs gabarits :
| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation |
| `{score}` | « partie libre » ou le score du match |
| `{videau}` | sa valeur et qui le possède |
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » |
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » ; pour un classement de coups, celui auquel le moteur a évalué le meilleur coup (voir [coup.md](coup.md)) |
| `{langue}` | la langue de la réponse : « français » |
+9 -1
View File
@@ -12,7 +12,15 @@ Repères propres à ce gabarit :
| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon |
| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » |
| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » |
| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
| `{classement}` | le classement de l'analyse de votre coup : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
Le niveau du titre (`{niveau_eval}`) est celui auquel GNU Backgammon a
évalué le meilleur coup. C'est d'ordinaire le niveau de l'analyse (2 plis),
mais le moteur s'arrête plus tôt quand un seul coup passe ses filtres : à
l'ouverture 3-1 jouée 8/5 6/5, tout le classement est à 1 pli. Les coups que
ses filtres ont écartés gardent l'évaluation d'un niveau inférieur, et leur
ligne le dit (« évalué à 1 pli »). Les équités et l'écart de votre coup sont
ceux de l'analyse, que disent la feuille du maître et l'écran d'analyse.
## Gabarit
+3 -1
View File
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
coups. Vos choix sont gardés pour la prochaine fois.
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
plateau, même juste après le bilan d'une partie finie.
Scénario: Comment choisir le niveau de l'ordinateur ?
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
+1 -1
View File
@@ -144,7 +144,7 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
@position:BQAACAIAAAAAAA
Scénario: Sortir ses pions dans l'ordre de son choix
Une fois tous vos pions dans les six dernières flèches, vous les
Une fois tous vos pions dans votre jan intérieur (flèches 1 à 6), vous les
sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6
et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous
ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce
+2 -1
View File
@@ -51,7 +51,8 @@ Le texte de la fin du tutoriel.
position (par défaut, vous) ;
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
joue tout seul (notation de gnubg) ;
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
- `chemin: oui` : le sens du jeu, deux flèches circulaires sur le plateau
(claire pour vos pions, sombre pour ceux de l'ordinateur) ;
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
coups justes, en notation de gnubg, tels que le moteur les écrit ;
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
+11 -9
View File
@@ -1,13 +1,15 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
validé par Pierre le 9 novembre 2026. Format : voir README.md.
validé par Pierre le 9 novembre 2026 ; pions blancs et noirs, flèches
circulaires et jans repris de la charte le 11 novembre 2026
(docs/identite/charte/accueil-tutoriel-aide.md). Format : voir README.md.
## 1. Le but du jeu
- position: 4HPwATDgc/ABMA
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Vous avez quinze [pions](pion.md) blancs, l'ordinateur quinze pions noirs. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
@@ -16,9 +18,9 @@ Le premier qui a sorti tous les siens gagne la [partie](partie.md).
- position: 4HPwATDgc/ABMA
- chemin: oui
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
Vos pions suivent la **flèche claire** : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à votre [jan intérieur](jan-interieur.md), les flèches 1 à 6. C'est là que vous les rassemblez avant de les sortir.
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
Les pions de l'ordinateur suivent la **flèche sombre**, en sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24.
## 3. Les dés et les doubles
@@ -60,10 +62,10 @@ Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez p
- attendu: 24/23 13/7*
- attendu: 24/18 8/7*
- attendu: 8/7* 8/2
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
- juste: Touché : le pion noir part sur la barre, il devra refaire tout le chemin.
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
Vous avez 6 et 1. Le pion noir de la flèche 7 est seul : frappez-le.
## 5. Rentrer depuis la barre
@@ -71,7 +73,7 @@ Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
- jet: 4-3
- coup: bar/22 13/9
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le jan intérieur de l'ordinateur, en haut à droite.
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
@@ -81,7 +83,7 @@ Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les d
- jet: 5-2
- coup: 5/off 2/off
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Quand vos quinze pions sont dans votre jan intérieur (flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Touchez le pion, puis la sortie à droite.
@@ -101,7 +103,7 @@ Vous avez 6 et 3 : sortez deux pions.
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
+10 -2
View File
@@ -43,7 +43,8 @@
* 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)
* moves et legal), avec le classement
* de l'analyse (dix coups au plus)
* analysecube POSITION_ID MATCH_ID DÉCISION
* analyse d'une décision de videau :
* no_double ou double (joueur au trait),
@@ -684,7 +685,7 @@ run_analyse_move_command(const options *opt)
const char *played_id = opt->args[3];
engine_move_analysis a;
engine_status status;
int die1, die2;
int die1, die2, i;
if (opt->arg_count != 4)
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
@@ -705,6 +706,13 @@ run_analyse_move_command(const options *opt)
printf(", \"best\": ");
print_move(&a.best, 1);
}
printf(", \"ranking\": [");
for (i = 0; i < a.ranking_count; i++) {
if (i > 0)
printf(", ");
print_move(&a.ranking[i], 1);
}
printf("]");
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
skill_names[a.skill]);
return EXIT_SUCCESS;
+31 -9
View File
@@ -512,6 +512,8 @@ engine_level_best_moves(const char *position_id, const char *match_id, int die1,
/* ------------------------------------------------- Analyse d'un coup joué */
static float luck_analysis(const char *position_id, position *pos, int die1, int die2);
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)
@@ -546,6 +548,17 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
* premier.
*/
memset(analysis, 0, sizeof *analysis);
/*
* gnubg analyse la chance du jet juste avant le coup (AnalyzeMove,
* l. 679-682), à 0 pli sans élagage, et garde ces évaluations dans son
* cache, qui ne distingue pas une évaluation à 0 pli avec ou sans
* réseaux d'élagage : le tri à 0 pli de FindnSaveBestMoves les reprend
* ensuite, ce qui change l'équité des coups restés à 0 pli, et parfois
* ceux que les filtres retiennent. Faire de même, sans garder le
* résultat, donne le classement de gnubg de bureau.
*/
if (luck_analysis(position_id, &pos, die1, die2) == ERR_VAL)
return ENGINE_ERROR_INTERNAL;
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE,
DOUBTFUL_THRESHOLD, &pos.cube, &context,
aaamfMoveFilterSettings[0]) < 0)
@@ -565,6 +578,10 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
status = ENGINE_OK;
break;
}
/* Le classement : le début de la même liste, dans l'ordre de gnubg. */
for (i = 0; i < list.cMoves && i < ENGINE_ANALYSIS_RANKING; i++)
fill_move(&analysis->ranking[i], &list.amMoves[i], &pos, 1);
analysis->ranking_count = (int) i;
g_free(list.amMoves);
return status;
}
@@ -1005,13 +1022,25 @@ luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
return equities[i0][j0] - mean / 30.0f;
}
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
* faible ; LuckFirst à la position de départ hors doubles. */
static float
luck_analysis(const char *position_id, position *pos, int die1, int die2)
{
int high = (die1 > die2 ? die1 : die2) - 1;
int low = (die1 > die2 ? die2 : die1) - 1;
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
return luck_first((ConstTanBoard) pos->board, high, low, &pos->cube);
return luck_normal((ConstTanBoard) pos->board, high, low, &pos->cube);
}
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);
@@ -1021,14 +1050,7 @@ engine_luck_of(const char *position_id, const char *match_id, int die1, int die2
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);
value = luck_analysis(position_id, &pos, die1, die2);
if (value == ERR_VAL)
return ENGINE_ERROR_INTERNAL;
luck->luck = value;
+21 -1
View File
@@ -207,6 +207,10 @@ typedef struct {
* (double_take <= double_pass) */
} engine_cube;
/* Coups gardés dans le classement d'une analyse : les dix premiers, comme
* les positions de référence (tests/reference/README.md). */
#define ENGINE_ANALYSIS_RANKING 10
/* 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
@@ -218,6 +222,14 @@ typedef struct {
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 */
/* Le classement de l'analyse, du meilleur au moins bon : les premiers
* coups de la liste que gnubg garde pour l'analyse de partie (ce que
* montre son panneau d'analyse), chacun au niveau où gnubg l'a
* évalué. Le meilleur coup et le coup joué y ont les mêmes évaluations
* que best et played ; le coup joué n'y figure que s'il est parmi les
* premiers. */
engine_move ranking[ENGINE_ANALYSIS_RANKING];
int ranking_count;
} engine_move_analysis;
/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */
@@ -452,7 +464,15 @@ 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
* filtre « Normal », sans RefreshMoveList), classement compris.
*
* Niveaux : gnubg n'évalue au niveau ply que les coups retenus par ses
* filtres ; les autres gardent l'évaluation d'un niveau inférieur (champ
* ply de chaque coup, même convention que gnubg de bureau : 0 = statique).
* Si un seul coup passe le filtre du niveau ply - 1, gnubg s'arrête là, et
* tout le classement reste à ce niveau, sauf quand le coup joué est une
* erreur (écart au meilleur supérieur au seuil « douteux ») : le meilleur
* coup et le coup joué sont alors réévalués au niveau ply. 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.
+27 -4
View File
@@ -12,10 +12,16 @@
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
# d'analyse (tests/browser/bilan.mjs), captures dans
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
# captures dans build/browser-shots/prompts/.
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
# partie finie et son bilan, par chaque entrée
# (tests/browser/nouvelle-partie.mjs), captures dans
# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs
# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
# build/browser-shots/retours-joueurs/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs) restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -28,7 +34,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan prompts}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -66,7 +72,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
tests/browser/retours-joueurs.mjs "$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -113,5 +120,21 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
# « Nouvelle partie » après une partie finie et son bilan : la
# nouvelle partie tout de suite ; captures dans
# build/browser-shots/nouvelle-partie/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" retours-joueurs "* ]]; then
# Retours des premiers joueurs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel ; captures dans
# build/browser-shots/retours-joueurs/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+6 -7
View File
@@ -274,17 +274,16 @@ async function playTouches(page, touches) {
await tutorialReady(page, hash);
}
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
check(await page.locator("#tutorial-board .tutorial-path .direction-line").count() === 2,
"étape 2 : pas les deux flèches du sens du jeu");
// Sous les pions (détail : retours-joueurs.mjs).
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
return { path: at(".tutorial-path"), pieces: at("g.pieces") };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
check(order.path >= 0 && order.path < order.pieces,
`étape 2 : les flèches passent au-dessus des pions ${JSON.stringify(order)}`);
}
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
+9
View File
@@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine")
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
// Le coup y est un lien vers la fiche « Notation des coups ».
const notationLink = await page.evaluate(() => {
const a = document.querySelector("#explain-legend a.notation");
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
+2 -2
View File
@@ -194,7 +194,7 @@ await page.click("#new-game-match");
await page.click("#new-game-length-3");
await page.click("#new-game-level-beginner");
s = await screen();
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match",
`match en 3 points : ${JSON.stringify(s)}`);
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
@@ -254,7 +254,7 @@ await page.click("#menu-new-game");
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden);
s = await screen();
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match",
`choix retrouvés : ${JSON.stringify(s)}`);
await page.click("#new-game-start");
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
+321
View File
@@ -0,0 +1,321 @@
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
// l'image de production.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
// une fin de course courte, partie d'un lien de position avec des dés
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
// (« Commencer la partie », « Commencer le match ») et que le coup de
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
// en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/nouvelle-partie`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
const BEAROFF = "BQAACAIAAAAAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 7;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(what, fn, arg, ms = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture en portrait et en paysage (Chromium seulement), après la
* règle de la recette : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
* l'identifiant de la partie. */
async function finishedGameWithSummary() {
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
await until("position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play");
await until(
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
await page.evaluate(() => window.__dice.push(6, 1));
await page.click("#roll");
await idle();
for (const [from, to] of [
["p6", "p5"],
["p5", "off-player"],
]) {
await page.click(`#board .hit[data-place="${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
await page.click("#confirm");
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
await until(
"bilan de la partie finie",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
return page.evaluate(() => location.hash.split("/")[2]);
}
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
* du bouton, puis commence. */
async function chooseAndStart(choice, shot) {
await until(
"écran « Nouvelle partie »",
() =>
location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden &&
!document.getElementById("new-game-start").disabled,
);
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
if (choice.match) {
await page.click(`#new-game-length-${choice.length}`);
}
await page.click(`#new-game-level-${choice.level}`);
const label = await page.textContent("#new-game-start");
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
if (shot) {
await capture("1-nouvelle-partie");
}
await page.click("#new-game-start");
}
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
* peu plus tard. */
async function checkNewGame(entry, oldId, choice) {
await until(
`${entry} : partie commencée`,
() => location.hash === "#/" && !document.body.classList.contains("busy"),
);
// Le défaut apparaissait juste après : l'adresse passait au bilan de
// l'ancienne partie. On regarde pendant deux secondes.
for (let waited = 0; waited < 2000; waited += 100) {
const s = await page.evaluate(() => ({
hash: location.hash,
summary: !document.getElementById("summary-veil").hidden,
panel: !document.getElementById("panel-veil").hidden,
board: !document.getElementById("game").hidden,
newGame: !document.getElementById("new-game").hidden,
}));
check(
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
);
await sleep(100);
}
await idle();
const shown = await page.evaluate(() => ({
line: document.getElementById("match-line").textContent,
lineShown: !document.getElementById("match-line").hidden,
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
over: document.getElementById("again").hidden === false,
}));
check(!shown.over, `${entry} : la partie à l'écran est finie`);
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
if (choice.match) {
check(
shown.lineShown && shown.line === `Match en ${choice.length} points`,
`${entry} : ligne du match « ${shown.line} »`,
);
} else {
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
}
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
}
// --------------------------- 1. L'application rouverte, puis l'accueil
let oldId = await finishedGameWithSummary();
// L'application rouverte : sans adresse, elle ouvre l'accueil.
await page.goto(base);
await until(
"accueil",
() =>
location.hash === "#/accueil" &&
!document.getElementById("home-new").hidden &&
!document.getElementById("home-new").disabled,
);
await page.click("#home-new");
let choice = { match: true, length: 3, level: "beginner" };
await chooseAndStart(choice, true);
await checkNewGame("accueil", oldId, choice);
await capture("2-partie-demarree");
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
for (let actions = 0; ; actions++) {
check(actions < 200, "l'ordinateur ne joue pas de coup");
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
if (link !== null) {
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
break;
}
const what = await page.evaluate(() => {
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return `#${id}`;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "#board .hit.can-land";
}
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
});
if (what === null) {
await sleep(100);
continue;
}
await page.locator(what).first().click();
await idle();
}
await page.click("#info a.notation");
await until(
"fiche « Notation des coups »",
() =>
location.hash === "#/glossaire/notation-des-coups" &&
document.getElementById("glossaire-notation-des-coups") !== null,
);
await page.goBack();
await until("retour à la partie", () => location.hash === "#/");
log("ligne de l'ordinateur : la notation mène à sa fiche");
// --------------------------------- 2. L'application rouverte sur le menu
oldId = await finishedGameWithSummary();
await page.goto(`${base}#/menu`);
await until("menu", () => !document.getElementById("menu-veil").hidden);
await page.click("#menu-new-game");
choice = { match: false, level: "amateur" };
await chooseAndStart(choice, false);
await checkNewGame("menu", oldId, choice);
// ------------------------------- 3. « Changer les réglages » du bilan
oldId = await finishedGameWithSummary();
await page.click("#summary-settings");
choice = { match: true, length: 5, level: "intermediate" };
await chooseAndStart(choice, false);
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
}
await browser.close();
if (problems.length > 0) {
for (const problem of problems) {
console.error(`[${browserName}] ${problem}`);
}
process.exit(1);
}
log(`nouvelle partie : tout est conforme (${captures} captures)`);
+89 -11
View File
@@ -164,6 +164,45 @@ async function readJournal() {
);
}
/** L'identifiant de la partie en cours enregistrée dans IndexedDB
* (magasin settings, clé « current »), ou null. */
async function savedGameId() {
return page.evaluate(
() =>
new Promise((resolve, reject) => {
const open = indexedDB.open("ustagammon");
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const db = open.result;
const current = db.transaction("settings").objectStore("settings").get("current");
current.onsuccess = () => {
db.close();
resolve(current.result?.value ?? null);
};
};
}),
);
}
/**
* Attend que la partie en cours enregistrée ne soit plus oldId, et renvoie
* le temps d'attente en ms (null au bout de 30 s). L'application enregistre
* sans attendre l'écriture, dans une file (app/src/storage/game-library.ts) :
* sous Firefox, sur un disque lent et chargé, l'enregistrement de la
* nouvelle partie peut attendre derrière ceux de l'analyse de la partie
* finie, et une lecture directe d'IndexedDB trouver encore l'ancienne.
*/
async function waitForSavedGameOtherThan(oldId) {
const start = Date.now();
while (Date.now() - start < 30_000) {
if ((await savedGameId()) !== oldId) {
return Date.now() - start;
}
await sleep(100);
}
return null;
}
/** Le nombre de parties enregistrées dans IndexedDB. */
async function savedGameCount() {
await savedJournal(); // les enregistrements en cours terminés
@@ -587,17 +626,41 @@ async function checkTraces() {
* (les dés en ont une de chaque côté du plateau), chacune un bouton
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
async function touchTarget(action, label, count = 1, index = 0) {
const zones = page.locator(`[data-action="${action}"]`);
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
// Les zones sont relevées d'un coup dans la page, où le plateau ne peut
// pas se redessiner entre deux lectures : mesurées une à une depuis
// Playwright (boundingBox), une zone remplacée entre-temps par un
// nouveau dessin du plateau n'avait plus de boîte (null, vu sous
// Firefox). On attend l'état observable : le nombre de zones attendu,
// toutes dessinées (5 s au plus).
const read = () =>
page.evaluate(
(name) =>
[...document.querySelectorAll(`[data-action="${name}"]`)].map((zone) => {
const box = zone.getBoundingClientRect();
return {
role: zone.getAttribute("role"),
label: zone.getAttribute("aria-label"),
width: box.width,
height: box.height,
};
}),
action,
);
let zones = await read();
const start = Date.now();
while ((zones.length !== count || zones.some((z) => z.width === 0 && z.height === 0)) &&
Date.now() - start < 5000) {
await sleep(100);
zones = await read();
}
return zones.nth(index);
check(zones.length === count, `zone ${action} : ${zones.length} sur le plateau, ${count} attendues`);
for (const zone of zones) {
check(zone.role === (action === "menu" ? "link" : "button") && zone.label === label,
`zone ${action} sans rôle accessible`);
check(zone.width >= 44 && zone.height >= 44,
`zone ${action} trop petite : ${Math.round(zone.width)} x ${Math.round(zone.height)} px`);
}
return page.locator(`[data-action="${action}"]`).nth(index);
}
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
@@ -1026,6 +1089,7 @@ async function rematch() {
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
async function newGameFromMenu() {
const before = await savedJournal();
const beforeId = await savedGameId();
const started = () =>
page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").gamesStarted);
const startedBefore = await started();
@@ -1038,6 +1102,8 @@ async function newGameFromMenu() {
`bandeau : « ${await page.textContent("#banner")} »`);
check(!(await page.isVisible("#menu")), "le menu reste ouvert après « Nouvelle partie »");
await waitForPlayer();
check((await waitForSavedGameOtherThan(beforeId)) !== null,
"« Nouvelle partie » : la partie n'a pas été enregistrée (30 s)");
const after = await savedJournal();
// (Les identifiants d'événements se suivent : une partie neuve peut avoir
// la même tête que la précédente ; on compte les parties commencées.)
@@ -1617,11 +1683,17 @@ step("mes parties");
// recharger la page.
{
const finished = await savedJournal();
const finishedId = await savedGameId();
await rematch();
await waitForPlayer();
// L'état observable, et non un délai : la nouvelle partie devient la
// partie en cours enregistrée.
const waited = await waitForSavedGameOtherThan(finishedId);
check(waited !== null, "la nouvelle partie n'a pas été enregistrée (30 s)");
log(`nouvelle partie enregistrée ${waited} ms après la main au joueur`);
const second = await savedJournal();
check(second.head !== finished.head || second.events.length !== finished.events.length,
"la nouvelle partie n'a pas été enregistrée");
"la nouvelle partie enregistrée a le journal de la partie finie");
await openMenu();
await page.click("#menu-games");
await page.waitForSelector("#games-veil:not([hidden]) .games-item");
@@ -1744,6 +1816,12 @@ if (offline === "roll") {
await page.click("#roll");
await waitForPlayer();
}
// Si la partie reprise est finie, son bilan se rouvre au rechargement et
// couvre l'écran (c'est voulu) : on le ferme avant d'ouvrir l'aide.
if (await page.locator("#summary-veil").isVisible()) {
await page.click("#summary-close");
await page.waitForSelector("#summary-veil", { state: "hidden" });
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt", { state: "attached" });
await page.click("#help-back");
+103
View File
@@ -167,3 +167,106 @@ export function tutorialWidthProblems(views) {
}
return problems;
}
/**
* Les contrastes des pions dessinés sur les plateaux visibles de la page
* (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche,
* le fond du plateau ou la sortie, trouvé au centre du pion (le dernier
* dessiné qui le contient). Pour chaque pion : son camp, ce fond, le
* contraste de son contour (stroke) et celui de sa couleur (fill) avec ce
* fond. Les pions entourés de brique (jouable, sélectionné) sont laissés :
* leur contour est celui du pion jouable, pas celui de son camp.
*/
export async function checkerContrasts(target) {
return target.evaluate(() => {
const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color) => {
const [r, g, b] = rgb(color).map((v) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y);
return (light + 0.05) / (dark + 0.05);
};
const BACKGROUNDS = [
["board-bar", "barre"],
["board-tray", "sortie"],
["point-a", "flèche impaire"],
["point-b", "flèche paire"],
["board-bed", "fond"],
];
const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", ");
const found = [];
for (const svg of document.querySelectorAll("svg.board")) {
if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) {
continue;
}
const shapes = [...svg.querySelectorAll(selector)].reverse();
for (const checker of svg.querySelectorAll("circle.checker")) {
if (checker.matches(".movable, .selected")) {
continue;
}
const point = svg.createSVGPoint();
point.x = Number(checker.getAttribute("cx"));
point.y = Number(checker.getAttribute("cy"));
const under = shapes.find((s) => s.isPointInFill(point));
if (under === undefined) {
continue;
}
const ground = getComputedStyle(under).fill;
const style = getComputedStyle(checker);
found.push({
board: svg.id || "plateau",
side: checker.matches(".checker-computer") ? "computer" : "player",
on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1],
edge: ratio(style.stroke, ground),
fill: ratio(style.fill, ground),
});
}
}
return found;
});
}
/**
* Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles
* partout ») : là où un pion de l'ordinateur ne se détache pas de son fond
* par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond
* sombre), son contour s'en détache à 3:1 au moins (guide de marque :
* contours porteurs de sens à 3:1). Et (« Pions du joueur lisibles
* partout ») : en thème clair, le contour d'un pion du joueur atteint 3:1 sur
* les flèches en chêne et sur le fond. contrasts : le résultat de
* checkerContrasts ; theme : « light » ou « dark ». Renvoie la liste des
* écarts, décrits (vide si tout va bien).
*/
export function checkerContrastProblems(contrasts, theme) {
const problems = new Set();
for (const c of contrasts) {
if (c.side === "computer" && c.fill < 3 && c.edge < 3) {
problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
}
if (
c.side === "player" &&
theme === "light" &&
(c.on === "flèche paire" || c.on === "fond") &&
c.edge < 3
) {
problems.add(`pion du joueur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
}
}
return [...problems];
}
/** Le plus faible contraste du contour, par camp et par fond, pour le
* journal du test (« ordinateur sur barre 6.79:1, … »). */
export function checkerContrastSummary(contrasts) {
const lowest = new Map();
for (const c of contrasts) {
const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`;
lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge));
}
return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", ");
}
+263
View File
@@ -0,0 +1,263 @@
// retours-joueurs.mjs -- les règles tirées des retours des premiers joueurs
// (ADR 0010, amendement du 2026-11-11 ; recette-ux.md), dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production, en thème clair et en thème sombre :
//
// - pions de l'ordinateur lisibles sur la barre : un pion de chaque camp sur
// la barre, contraste du contour de chaque pion sur son fond
// (recette.mjs, checkerContrasts) ;
// - numéros des flèches sur le cadre, en portrait comme en paysage, de
// 12 px au moins ;
// - étape 2 du tutoriel : deux flèches circulaires, claire et sombre, sous
// les pions, jamais en brique.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node retours-joueurs.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Captures (Chromium) dans
// <dossier des captures>/retours-joueurs/, en 412×915 et 915×412.
import { mkdirSync, writeFileSync } from "node:fs";
import { chromium, firefox } from "playwright";
import {
checkerContrastProblems,
checkerContrastSummary,
checkerContrasts,
hiddenUnderActionBar,
} from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node retours-joueurs.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/retours-joueurs`;
mkdirSync(dir, { recursive: true });
const SIZES = [
{ width: 412, height: 915 },
{ width: 915, height: 412 },
];
// Positions de gnubg, chacun vu de lui-même : la position de départ, et la
// même avec un pion de chaque camp de sa flèche 24 sur la barre.
const OPENING = "4HPwATDgc/ABMA";
const BOTH_ON_BAR = "4HPwAVDgc/ABUA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const contrasts = { light: [], dark: [] };
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
/** Un contexte neuf dans un thème, tutoriel déjà vu (l'accueil ne propose
* plus le premier lancement). */
async function newPage(theme) {
const context = await browser.newContext({
viewport: SIZES[0],
deviceScaleFactor: 1,
locale: "fr-FR",
reducedMotion: "reduce",
});
await context.addInitScript((saved) => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", saved);
}
}, JSON.stringify({ theme, tutorialSeen: 1 }));
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
return { context, page };
}
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(page, what, fn, arg, ms = 60_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg).catch(() => false)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
/** Une partie à partir d'une position, joueur au trait, dés à lancer. */
async function startFrom(page, position) {
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
await until(page, "position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play", { timeout: 15_000 }).catch(async (error) => {
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw error;
});
await until(
page,
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
}
/** Les numéros des flèches du plateau visible : tous sur le cadre (hors du
* fond du plateau) et de 12 px au moins à l'écran. Renvoie les écarts. */
async function numberProblems(page, selector) {
return page.evaluate((selector) => {
const svg = document.querySelector(selector);
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const bed = svg.querySelector(".board-bed").getBBox();
const found = [];
const numbers = [...svg.querySelectorAll(".point-number")];
if (numbers.length !== 24) {
found.push(`${numbers.length} numéros`);
}
for (const text of numbers) {
const b = text.getBBox();
const px = Number(text.getAttribute("font-size")) * scale;
if (b.y + b.height > bed.y + 0.5 && b.y < bed.y + bed.height - 0.5) {
found.push(`flèche ${text.textContent} dans le fond du plateau`);
}
if (px < 12) {
found.push(`flèche ${text.textContent} en ${px.toFixed(1)} px`);
}
}
return found;
}, selector);
}
/** Les règles sur l'écran courant, dans chaque taille, et sa capture
* (Chromium). */
async function checkScreen(page, theme, name, selector, settle = async () => {}) {
// Le bandeau « Reprendre l'ancienne » (10 s) n'est pas le sujet.
await until(page, "bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
await settle();
const where = `${name} ${theme} ${size.width}x${size.height}`;
const numbers = await numberProblems(page, selector);
check(numbers.length === 0, `${where} : numéros : ${numbers.join(", ")}`);
const measured = await checkerContrasts(page);
check(measured.length > 0, `${where} : aucun pion mesuré`);
const low = checkerContrastProblems(measured, theme);
check(low.length === 0, `${where} : ${low.join(", ")}`);
contrasts[theme].push(...measured);
if (full) {
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${theme}-${size.width}x${size.height}.png` });
captures++;
}
}
await page.setViewportSize(SIZES[0]);
await sleep(200);
}
for (const theme of ["light", "dark"]) {
const { context, page } = await newPage(theme);
// Un pion de chaque camp sur la barre.
await startFrom(page, BOTH_ON_BAR);
check(
(await page.evaluate(() => document.documentElement.dataset.theme)) === theme,
`thème ${theme} non appliqué`,
);
const onBar = await page.evaluate(() =>
["player", "computer"].map(
(side) => document.querySelectorAll(`#board [data-checker="bar-${side}"]`).length,
),
);
check(onBar[0] === 1 && onBar[1] === 1, `pions sur la barre : ${onBar}`);
await checkScreen(page, theme, "barre", "#board");
const bar = contrasts[theme].filter((c) => c.on === "barre" && c.side === "computer");
check(bar.length > 0, `${theme} : pion de l'ordinateur sur la barre non mesuré`);
// La position de départ : les numéros sur le cadre, en portrait aussi.
await startFrom(page, OPENING);
await checkScreen(page, theme, "numeros-sur-le-cadre", "#board");
// L'étape 2 du tutoriel : deux flèches circulaires sous les pions.
await page.goto(`${base}#/tutoriel/2`);
const ready = () =>
until(page, "tutoriel, étape 2", () => {
const section = document.getElementById("tutorial");
return (
location.hash === "#/tutoriel/2" &&
section.dataset.ready !== undefined &&
document.querySelectorAll("#tutorial-board .tutorial-path").length === 1
);
});
await ready();
const arrows = async () => {
const drawn = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const children = [...svg.children];
const path = svg.querySelector(".tutorial-path");
const colors = (selector) =>
[...path.querySelectorAll(selector)].map((el) => getComputedStyle(el).stroke);
return {
path: children.indexOf(path),
pieces: children.findIndex((c) => c.matches("g.pieces")),
player: colors(".direction-line.direction-player"),
computer: colors(".direction-line.direction-computer"),
heads: path.querySelectorAll(".direction-head").length,
pieceColors: ["player", "computer"].map(
(side) => getComputedStyle(svg.querySelector(`.checker-${side}`)).fill,
),
};
});
check(drawn.path >= 0 && drawn.path < drawn.pieces, `étape 2 : flèches au-dessus des pions ${JSON.stringify(drawn)}`);
check(drawn.player.length === 1 && drawn.computer.length === 1 && drawn.heads === 2,
`étape 2 : flèches ${JSON.stringify(drawn)}`);
// Aux couleurs des pions de chaque camp, donc jamais en brique.
check(drawn.player[0] === drawn.pieceColors[0] && drawn.computer[0] === drawn.pieceColors[1],
`étape 2 : flèches pas aux couleurs des pions ${JSON.stringify(drawn)}`);
};
await checkScreen(page, theme, "tutoriel-2", "#tutorial-board", async () => {
await ready();
await arrows();
});
const csp = await page.evaluate(() => window.__csp);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
await context.close();
log(`thème ${theme} : contours ${checkerContrastSummary(contrasts[theme])}`);
}
await browser.close();
if (full) {
// Les mesures, à côté des captures.
writeFileSync(
`${dir}/contrastes.txt`,
["light", "dark"].map((theme) => `${theme}\t${checkerContrastSummary(contrasts[theme])}`).join("\n") + "\n",
);
}
check(problems.length === 0, `problèmes relevés :\n ${problems.join("\n ")}`);
log(
"retours des joueurs : pions des deux camps sur la barre, numéros sur le cadre, " +
`flèches circulaires du tutoriel, thèmes clair et sombre${full ? ` ; ${captures} captures` : ""}`,
);
+9 -1
View File
@@ -256,7 +256,11 @@ Un fichier de résultat reprend le cas, avec `type` (`move`, `cube` ou
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
`ranking` (les dix premiers coups, et jusqu'au coup joué, chacun avec
`move`, `ply` et `equity` : comme pour `hint`, `ply` est le niveau auquel
gnubg a réellement évalué le coup ; à la différence de `hint`, l'analyse
réévalue au niveau demandé le meilleur coup et le coup joué quand celui-ci
est une erreur, au-delà du seuil « douteux ») ; 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
@@ -337,6 +341,10 @@ Pour chaque analyse (`engine-cli analysemove`, `analysecube`, `luck`, et
é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 ;
- classement de l'analyse (`ranking`, dix coups au plus) : chaque coup de
gnubg présent (sauf égalité avec le dixième), au même `ply` et à la même
équité à la tolérance près ; son premier coup et le coup joué y sont
exactement `best` et `played` ;
- 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 ;
@@ -25,6 +25,7 @@
"ranking": [
{
"move": "6/off(2) 4/off 1/off",
"ply": 0,
"equity": 0.8888838
}
]
@@ -42,6 +43,7 @@
"ranking": [
{
"move": "6/off(2) 4/off 1/off",
"ply": 0,
"equity": 0.8888838
}
]
@@ -25,438 +25,547 @@
"ranking": [
{
"move": "13/10 6/3 4/1*(2)",
"ply": 0,
"equity": 0.9465638
},
{
"move": "8/5 6/3 4/1*(2)",
"ply": 0,
"equity": 0.9422797
},
{
"move": "13/7 13/10 6/3",
"ply": 0,
"equity": 0.94128984
},
{
"move": "13/7 8/5 6/3",
"ply": 0,
"equity": 0.9370698
},
{
"move": "13/10(2) 7/4 6/3",
"ply": 0,
"equity": 0.93615985
},
{
"move": "13/10(2) 8/5 6/3",
"ply": 0,
"equity": 0.9330082
},
{
"move": "13/10 8/5 7/4 6/3",
"ply": 0,
"equity": 0.932737
},
{
"move": "13/10 7/1* 6/3",
"ply": 0,
"equity": 0.9313148
},
{
"move": "8/5 7/1* 6/3",
"ply": 0,
"equity": 0.93129885
},
{
"move": "13/4 6/3",
"ply": 0,
"equity": 0.92980635
},
{
"move": "13/10 8/5(2) 6/3",
"ply": 0,
"equity": 0.9264537
},
{
"move": "13/10(3) 6/3",
"ply": 0,
"equity": 0.9262361
},
{
"move": "13/10(2) 4/1*(2)",
"ply": 0,
"equity": 0.92529553
},
{
"move": "8/5(2) 7/4 6/3",
"ply": 0,
"equity": 0.9252107
},
{
"move": "7/1* 6/3 4/1",
"ply": 0,
"equity": 0.92395884
},
{
"move": "13/10 6/3 5/2(2)",
"ply": 0,
"equity": 0.9204093
},
{
"move": "13/7 4/1*(2)",
"ply": 0,
"equity": 0.9190106
},
{
"move": "13/7(2)",
"ply": 0,
"equity": 0.9184398
},
{
"move": "13/10 8/5 4/1*(2)",
"ply": 0,
"equity": 0.9182539
},
{
"move": "7/4 6/3 5/2(2)",
"ply": 0,
"equity": 0.91752934
},
{
"move": "13/7 13/10(2)",
"ply": 0,
"equity": 0.91483337
},
{
"move": "13/10 8/2 6/3",
"ply": 0,
"equity": 0.912639
},
{
"move": "13/7 13/10 8/5",
"ply": 0,
"equity": 0.91152656
},
{
"move": "13/10(2) 7/1*",
"ply": 0,
"equity": 0.9115156
},
{
"move": "13/1*",
"ply": 0,
"equity": 0.9111869
},
{
"move": "13/10(2) 6/3 4/1*",
"ply": 0,
"equity": 0.91055506
},
{
"move": "8/2 8/5 6/3",
"ply": 0,
"equity": 0.9091699
},
{
"move": "8/2 7/4 6/3",
"ply": 0,
"equity": 0.90897554
},
{
"move": "13/7 8/5(2)",
"ply": 0,
"equity": 0.90817225
},
{
"move": "13/7 6/3 4/1*",
"ply": 0,
"equity": 0.90714073
},
{
"move": "13/10(2) 8/5 7/4",
"ply": 0,
"equity": 0.9070756
},
{
"move": "8/2(2)",
"ply": 0,
"equity": 0.9065211
},
{
"move": "13/10 8/5 7/1*",
"ply": 0,
"equity": 0.90571
},
{
"move": "13/10(3) 7/4",
"ply": 0,
"equity": 0.9040942
},
{
"move": "13/10 8/5 6/3 4/1*",
"ply": 0,
"equity": 0.90343916
},
{
"move": "8/5(2) 4/1*(2)",
"ply": 0,
"equity": 0.90325713
},
{
"move": "5/2(2) 4/1*(2)",
"ply": 0,
"equity": 0.90183336
},
{
"move": "13/10 7/1* 4/1",
"ply": 0,
"equity": 0.9013542
},
{
"move": "13/4 7/4",
"ply": 0,
"equity": 0.9010268
},
{
"move": "13/7 5/2(2)",
"ply": 0,
"equity": 0.90037674
},
{
"move": "8/5(2) 7/1*",
"ply": 0,
"equity": 0.89956194
},
{
"move": "13/10 8/5(2) 7/4",
"ply": 0,
"equity": 0.8988826
},
{
"move": "8/2 6/3 5/2",
"ply": 0,
"equity": 0.8984738
},
{
"move": "13/4 13/10",
"ply": 0,
"equity": 0.89749956
},
{
"move": "6/3(2) 4/1*(2)",
"ply": 0,
"equity": 0.8951012
},
{
"move": "13/10(2) 8/5(2)",
"ply": 0,
"equity": 0.89493525
},
{
"move": "13/7 8/2",
"ply": 0,
"equity": 0.8946096
},
{
"move": "13/7 6/3(2)",
"ply": 0,
"equity": 0.89403474
},
{
"move": "7/1* 5/2(2)",
"ply": 0,
"equity": 0.8924208
},
{
"move": "8/5 7/1* 4/1",
"ply": 0,
"equity": 0.891961
},
{
"move": "13/10(2) 6/3(2)",
"ply": 0,
"equity": 0.89077055
},
{
"move": "13/4 8/5",
"ply": 0,
"equity": 0.8907301
},
{
"move": "8/5(2) 6/3 4/1*",
"ply": 0,
"equity": 0.8900244
},
{
"move": "13/10(2) 5/2(2)",
"ply": 0,
"equity": 0.8890666
},
{
"move": "7/4 6/3(3)",
"ply": 0,
"equity": 0.8890004
},
{
"move": "13/10 7/4 5/2(2)",
"ply": 0,
"equity": 0.8888954
},
{
"move": "8/2 8/5 7/4",
"ply": 0,
"equity": 0.8876569
},
{
"move": "13/10(4)",
"ply": 0,
"equity": 0.88716996
},
{
"move": "7/1* 6/3(2)",
"ply": 0,
"equity": 0.8856077
},
{
"move": "8/2 4/1*(2)",
"ply": 0,
"equity": 0.8853629
},
{
"move": "13/10 6/3(3)",
"ply": 0,
"equity": 0.8853096
},
{
"move": "13/7 6/3 5/2",
"ply": 0,
"equity": 0.8831558
},
{
"move": "13/10 8/2 7/4",
"ply": 0,
"equity": 0.8819176
},
{
"move": "7/1* 4/1(2)",
"ply": 0,
"equity": 0.88113916
},
{
"move": "13/10 7/4 6/3(2)",
"ply": 0,
"equity": 0.88064635
},
{
"move": "13/10(3) 8/5",
"ply": 0,
"equity": 0.88049895
},
{
"move": "6/3 5/2(2) 4/1*",
"ply": 0,
"equity": 0.8790827
},
{
"move": "13/10(2) 8/2",
"ply": 0,
"equity": 0.8782023
},
{
"move": "6/3 5/2 4/1*(2)",
"ply": 0,
"equity": 0.8774586
},
{
"move": "8/5 6/3(3)",
"ply": 0,
"equity": 0.8774268
},
{
"move": "8/2 7/1*",
"ply": 0,
"equity": 0.8772669
},
{
"move": "13/7 13/10 4/1*",
"ply": 0,
"equity": 0.87700987
},
{
"move": "13/10 8/5 6/3(2)",
"ply": 0,
"equity": 0.8757389
},
{
"move": "13/10 8/2 8/5",
"ply": 0,
"equity": 0.87530077
},
{
"move": "13/10(2) 6/3 5/2",
"ply": 0,
"equity": 0.87215745
},
{
"move": "13/7 8/5 4/1*",
"ply": 0,
"equity": 0.87202764
},
{
"move": "8/5 7/4 6/3(2)",
"ply": 0,
"equity": 0.86896765
},
{
"move": "7/1* 6/3 5/2",
"ply": 0,
"equity": 0.8687051
},
{
"move": "13/10 7/4 6/3 5/2",
"ply": 0,
"equity": 0.8679464
},
{
"move": "8/2 6/3 4/1*",
"ply": 0,
"equity": 0.86750233
},
{
"move": "13/10(2) 8/5 4/1*",
"ply": 0,
"equity": 0.86701244
},
{
"move": "8/5(2) 6/3(2)",
"ply": 0,
"equity": 0.86167765
},
{
"move": "13/10(3) 4/1*",
"ply": 0,
"equity": 0.8568015
},
{
"move": "6/3(2) 5/2(2)",
"ply": 0,
"equity": 0.85501754
},
{
"move": "8/2 7/4 5/2",
"ply": 0,
"equity": 0.8537997
},
{
"move": "8/2 5/2(2)",
"ply": 0,
"equity": 0.8524791
},
{
"move": "13/10 8/5(2) 4/1*",
"ply": 0,
"equity": 0.84969926
},
{
"move": "13/10 8/2 5/2",
"ply": 0,
"equity": 0.8466199
},
{
"move": "8/2 6/3(2)",
"ply": 0,
"equity": 0.80814815
},
{
"move": "13/10 5/2(2) 4/1*",
"ply": 0,
"equity": 0.8037353
},
{
"move": "13/10 5/2 4/1*(2)",
"ply": 0,
"equity": 0.77350885
},
{
"move": "13/10 6/3(2) 4/1*",
"ply": 0,
"equity": 0.74067366
},
{
"move": "13/7 13/10 5/2",
"ply": 0,
"equity": 0.72703636
},
{
"move": "13/10(2) 7/4 5/2",
"ply": 0,
"equity": 0.7053956
},
{
"move": "6/3(3) 4/1*",
"ply": 0,
"equity": 0.66575944
},
{
"move": "13/10 7/1* 5/2",
"ply": 0,
"equity": 0.6462204
},
{
"move": "8/5 6/3(2) 4/1*",
"ply": 0,
"equity": 0.6374039
},
{
"move": "8/2 8/5 4/1*",
"ply": 0,
"equity": 0.62353325
},
{
"move": "13/10 8/2 4/1*",
"ply": 0,
"equity": 0.61257124
},
{
"move": "13/10 6/3 5/2 4/1*",
"ply": 0,
"equity": 0.5349428
},
{
"move": "7/1* 5/2 4/1",
"ply": 0,
"equity": 0.45721298
},
{
"move": "8/2 5/2 4/1*",
"ply": 0,
"equity": 0.44362232
},
{
"move": "13/4 5/2",
"ply": 0,
"equity": 0.43828273
},
{
"move": "13/10(3) 5/2",
"ply": 0,
"equity": 0.2798175
},
{
"move": "13/10 6/3(2) 5/2",
"ply": 0,
"equity": 0.24207813
},
{
"move": "6/3(3) 5/2",
"ply": 0,
"equity": 0.23385207
},
{
"move": "7/4 6/3(2) 5/2",
"ply": 0,
"equity": 0.22096781
},
{
"move": "13/7 5/2 4/1*",
"ply": 0,
"equity": 0.18849733
},
{
"move": "13/10(2) 5/2 4/1*",
"ply": 0,
"equity": 0.084023334
}
]
@@ -474,42 +583,52 @@
"ranking": [
{
"move": "13/10 7/1* 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/7 13/10 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 8/5 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/7 8/5 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 7/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10 8/5 7/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 5/2 4/1*",
"ply": 2,
"equity": -0.022027964
},
{
"move": "13/10 8/5(2) 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10 6/3 4/1*(2)",
"ply": 2,
"equity": 0.99692726
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "8/5 6/2",
"ply": 0,
"equity": 0.7345603
},
{
"move": "5/2 5/1*",
"ply": 0,
"equity": 0.7229391
},
{
"move": "8/1*",
"ply": 0,
"equity": 0.71705425
},
{
"move": "13/10 6/2",
"ply": 0,
"equity": 0.7142835
},
{
"move": "13/9 8/5",
"ply": 0,
"equity": 0.6963019
},
{
"move": "8/5 8/4",
"ply": 0,
"equity": 0.68610644
},
{
"move": "13/10 8/4",
"ply": 0,
"equity": 0.6855368
},
{
"move": "13/10 13/9",
"ply": 0,
"equity": 0.6818689
},
{
"move": "13/6",
"ply": 0,
"equity": 0.6633271
},
{
"move": "13/9 5/2",
"ply": 0,
"equity": 0.6538005
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "8/5 6/2",
"ply": 2,
"equity": 0.7697691
},
{
"move": "13/10 6/2",
"ply": 2,
"equity": 0.7615671
},
{
"move": "13/9 8/5",
"ply": 2,
"equity": 0.69608325
},
{
"move": "5/2 5/1*",
"ply": 2,
"equity": 0.6911188
},
{
"move": "8/1*",
"ply": 2,
"equity": 0.6836242
},
{
"move": "13/10 13/9",
"ply": 2,
"equity": 0.6807338
},
{
"move": "13/10 8/4",
"ply": 1,
"equity": 0.6733323
},
{
"move": "13/6",
"ply": 1,
"equity": 0.66055346
},
{
"move": "8/5 8/4",
"ply": 1,
"equity": 0.6556259
},
{
"move": "13/9 5/2",
"ply": 1,
"equity": 0.645951
}
]
@@ -25,6 +25,7 @@
"ranking": [
{
"move": "bar/24",
"ply": 0,
"equity": -1.0
}
]
@@ -42,6 +43,7 @@
"ranking": [
{
"move": "bar/24",
"ply": 0,
"equity": -1.0
}
]
+24
View File
@@ -25,58 +25,72 @@
"ranking": [
{
"move": "13/3",
"ply": 0,
"equity": 0.9328213
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.9175232
},
{
"move": "8/2 7/3",
"ply": 0,
"equity": 0.91263336
},
{
"move": "13/7 8/4",
"ply": 0,
"equity": 0.90736973
},
{
"move": "13/9 7/1*",
"ply": 0,
"equity": 0.9053596
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.9017469
},
{
"move": "8/4 7/1*",
"ply": 0,
"equity": 0.89993495
},
{
"move": "13/7 6/2",
"ply": 0,
"equity": 0.8962312
},
{
"move": "7/1* 5/1",
"ply": 0,
"equity": 0.8907822
},
{
"move": "7/1* 6/2",
"ply": 0,
"equity": 0.87481093
},
{
"move": "13/9 8/2",
"ply": 0,
"equity": 0.87251335
},
{
"move": "8/4 8/2",
"ply": 0,
"equity": 0.8716274
},
{
"move": "13/7 5/1*",
"ply": 0,
"equity": 0.87005275
},
{
"move": "8/2 5/1*",
"ply": 0,
"equity": 0.50889754
}
]
@@ -94,42 +108,52 @@
"ranking": [
{
"move": "13/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": 0.99894416
},
{
"move": "8/2 7/3",
"ply": 2,
"equity": 0.99641544
},
{
"move": "13/7 8/4",
"ply": 2,
"equity": 0.98718643
},
{
"move": "13/7 6/2",
"ply": 2,
"equity": 0.9808178
},
{
"move": "13/9 7/1*",
"ply": 2,
"equity": 0.9452111
},
{
"move": "8/2 5/1*",
"ply": 2,
"equity": 0.3304975
},
{
"move": "8/4 7/1*",
"ply": 1,
"equity": 0.93114305
},
{
"move": "7/1* 5/1",
"ply": 1,
"equity": 0.91711015
},
{
"move": "8/2 6/2",
"ply": 1,
"equity": 0.9160911
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "13/5 11/3",
"ply": 0,
"equity": 0.16933289
},
{
"move": "13/9(2) 11/3",
"ply": 0,
"equity": 0.16701066
},
{
"move": "13/5 9/5 7/3",
"ply": 0,
"equity": 0.16552198
},
{
"move": "13/5(2)",
"ply": 0,
"equity": 0.16473158
},
{
"move": "13/5 13/9 11/7",
"ply": 0,
"equity": 0.16365689
},
{
"move": "13/5 13/9 7/3",
"ply": 0,
"equity": 0.16245355
},
{
"move": "13/5 11/7 9/5",
"ply": 0,
"equity": 0.16167049
},
{
"move": "11/3 9/5(2)",
"ply": 0,
"equity": 0.16043647
},
{
"move": "13/9 11/3 11/7",
"ply": 0,
"equity": 0.1603657
},
{
"move": "13/5 11/7(2)",
"ply": 0,
"equity": 0.1602792
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "13/5 11/3",
"ply": 2,
"equity": 0.15129912
},
{
"move": "13/5 13/9 11/7",
"ply": 2,
"equity": 0.15059008
},
{
"move": "13/9(2) 11/3",
"ply": 2,
"equity": 0.14950007
},
{
"move": "13/5 11/7 9/5",
"ply": 2,
"equity": 0.14858595
},
{
"move": "11/3 11/7 9/5",
"ply": 2,
"equity": 0.14829463
},
{
"move": "13/9 11/3 11/7",
"ply": 2,
"equity": 0.1463402
},
{
"move": "13/5 9/5 7/3",
"ply": 1,
"equity": 0.13938732
},
{
"move": "13/5 11/7(2)",
"ply": 1,
"equity": 0.13921705
},
{
"move": "13/9(2) 11/7(2)",
"ply": 1,
"equity": 0.13873637
},
{
"move": "13/5 13/9 6/2",
"ply": 1,
"equity": 0.13852078
}
]
@@ -25,18 +25,22 @@
"ranking": [
{
"move": "3/off",
"ply": 0,
"equity": -0.8607614
},
{
"move": "2/off 1/off",
"ply": 0,
"equity": -0.8607614
},
{
"move": "2/1 2/off",
"ply": 0,
"equity": -0.95561147
},
{
"move": "3/1 2/1",
"ply": 0,
"equity": -1.0
}
]
@@ -54,18 +58,22 @@
"ranking": [
{
"move": "3/off",
"ply": 2,
"equity": -0.8607614
},
{
"move": "2/off 1/off",
"ply": 2,
"equity": -0.8607614
},
{
"move": "2/1 2/off",
"ply": 2,
"equity": -0.95561147
},
{
"move": "3/1 2/1",
"ply": 1,
"equity": -1.0
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "18/9",
"ply": 0,
"equity": 0.2544027
},
{
"move": "18/14 13/8",
"ply": 0,
"equity": 0.22496489
},
{
"move": "24/20 18/13",
"ply": 0,
"equity": 0.20747662
},
{
"move": "24/20 13/8",
"ply": 0,
"equity": 0.1158272
},
{
"move": "13/9 13/8",
"ply": 0,
"equity": 0.09201278
},
{
"move": "24/15",
"ply": 0,
"equity": 0.061017293
},
{
"move": "18/13 6/2",
"ply": 0,
"equity": 0.03581623
},
{
"move": "13/4",
"ply": 0,
"equity": -0.028887823
},
{
"move": "13/8 6/2",
"ply": 0,
"equity": -0.033572298
},
{
"move": "18/13 8/4",
"ply": 0,
"equity": -0.09662818
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "18/9",
"ply": 2,
"equity": 0.27687487
},
{
"move": "18/14 13/8",
"ply": 2,
"equity": 0.21801135
},
{
"move": "24/20 18/13",
"ply": 2,
"equity": 0.21000355
},
{
"move": "24/20 13/8",
"ply": 2,
"equity": 0.1144446
},
{
"move": "13/9 13/8",
"ply": 2,
"equity": 0.09412725
},
{
"move": "24/15",
"ply": 2,
"equity": 0.07892294
},
{
"move": "18/13 6/2",
"ply": 1,
"equity": -0.020656409
},
{
"move": "13/4",
"ply": 0,
"equity": -0.028887823
},
{
"move": "13/8 6/2",
"ply": 0,
"equity": -0.033572298
},
{
"move": "18/13 8/4",
"ply": 0,
"equity": -0.09662818
}
]
+20
View File
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "13/5",
"ply": 0,
"equity": 0.7553502
},
{
"move": "18/13 8/5",
"ply": 0,
"equity": 0.5814649
},
{
"move": "24/16*",
"ply": 0,
"equity": 0.4581785
},
{
"move": "18/10",
"ply": 0,
"equity": 0.31719947
},
{
"move": "8/5 8/3",
"ply": 0,
"equity": 0.31371918
},
{
"move": "18/15 13/8",
"ply": 0,
"equity": 0.26037523
},
{
"move": "24/21 18/13",
"ply": 0,
"equity": 0.23547156
},
{
"move": "8/3 6/3",
"ply": 0,
"equity": 0.15432969
},
{
"move": "13/10 13/8",
"ply": 0,
"equity": 0.13837479
},
{
"move": "18/13 5/2",
"ply": 0,
"equity": 0.12815686
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "13/5",
"ply": 2,
"equity": 0.85497326
},
{
"move": "24/16*",
"ply": 2,
"equity": 0.46805125
},
{
"move": "18/13 8/5",
"ply": 1,
"equity": 0.6511238
},
{
"move": "18/10",
"ply": 0,
"equity": 0.31719947
},
{
"move": "8/5 8/3",
"ply": 0,
"equity": 0.31371918
},
{
"move": "18/15 13/8",
"ply": 0,
"equity": 0.26037523
},
{
"move": "24/21 18/13",
"ply": 0,
"equity": 0.23547156
},
{
"move": "8/3 6/3",
"ply": 0,
"equity": 0.15432969
},
{
"move": "13/10 13/8",
"ply": 0,
"equity": 0.13837479
},
{
"move": "18/13 5/2",
"ply": 0,
"equity": 0.12815686
}
]
@@ -25,58 +25,72 @@
"ranking": [
{
"move": "24/14",
"ply": 0,
"equity": 0.15439394
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.10821653
},
{
"move": "24/18 13/9",
"ply": 0,
"equity": 0.10337136
},
{
"move": "24/20 24/18",
"ply": 0,
"equity": 0.072762795
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.0029048775
},
{
"move": "24/20 13/7",
"ply": 0,
"equity": 9.1438415e-05
},
{
"move": "13/3",
"ply": 0,
"equity": -0.012697897
},
{
"move": "24/18 6/2",
"ply": 0,
"equity": -0.075399
},
{
"move": "24/18 8/4",
"ply": 0,
"equity": -0.088734664
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
},
{
"move": "13/9 8/2",
"ply": 0,
"equity": -0.09528468
},
{
"move": "13/7 8/4",
"ply": 0,
"equity": -0.12819159
},
{
"move": "13/7 6/2",
"ply": 0,
"equity": -0.14467186
},
{
"move": "8/4 8/2",
"ply": 0,
"equity": -0.35321844
}
]
@@ -94,42 +108,52 @@
"ranking": [
{
"move": "24/14",
"ply": 2,
"equity": 0.13111727
},
{
"move": "8/2 6/2",
"ply": 2,
"equity": 0.11303549
},
{
"move": "24/18 13/9",
"ply": 2,
"equity": 0.105096616
},
{
"move": "24/20 24/18",
"ply": 2,
"equity": 0.049068402
},
{
"move": "13/3",
"ply": 2,
"equity": -0.0040049786
},
{
"move": "8/4 8/2",
"ply": 2,
"equity": -0.36824828
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": -0.014309813
},
{
"move": "24/20 13/7",
"ply": 1,
"equity": -0.029367238
},
{
"move": "24/18 8/4",
"ply": 1,
"equity": -0.081171095
},
{
"move": "24/18 6/2",
"ply": 1,
"equity": -0.09313507
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "24/14",
"ply": 0,
"equity": 0.15439394
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.10821653
},
{
"move": "24/18 13/9",
"ply": 0,
"equity": 0.10337136
},
{
"move": "24/20 24/18",
"ply": 0,
"equity": 0.072762795
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.0029048775
},
{
"move": "24/20 13/7",
"ply": 0,
"equity": 9.1438415e-05
},
{
"move": "13/3",
"ply": 0,
"equity": -0.012697897
},
{
"move": "24/18 6/2",
"ply": 0,
"equity": -0.075399
},
{
"move": "24/18 8/4",
"ply": 0,
"equity": -0.088734664
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "24/14",
"ply": 2,
"equity": 0.13111727
},
{
"move": "8/2 6/2",
"ply": 2,
"equity": 0.11303549
},
{
"move": "24/18 13/9",
"ply": 2,
"equity": 0.105096616
},
{
"move": "24/20 24/18",
"ply": 2,
"equity": 0.049068402
},
{
"move": "13/3",
"ply": 2,
"equity": -0.0040049786
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": -0.014309813
},
{
"move": "24/20 13/7",
"ply": 1,
"equity": -0.029367238
},
{
"move": "24/18 8/4",
"ply": 1,
"equity": -0.081171095
},
{
"move": "24/18 6/2",
"ply": 1,
"equity": -0.09313507
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "8/5 6/5",
"ply": 0,
"equity": 0.21258597
},
{
"move": "24/23 13/10",
"ply": 0,
"equity": -0.010789523
},
{
"move": "24/20",
"ply": 0,
"equity": -0.017392516
},
{
"move": "24/23 24/21",
"ply": 0,
"equity": -0.023609947
},
{
"move": "13/9",
"ply": 0,
"equity": -0.027675275
},
{
"move": "24/21 6/5",
"ply": 0,
"equity": -0.04369401
},
{
"move": "13/10 6/5",
"ply": 0,
"equity": -0.04984435
},
{
"move": "24/23 8/5",
"ply": 0,
"equity": -0.104100645
},
{
"move": "24/23 6/3",
"ply": 0,
"equity": -0.10542354
},
{
"move": "24/21 8/7",
"ply": 0,
"equity": -0.107585564
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "8/5 6/5",
"ply": 1,
"equity": 0.20373425
},
{
"move": "24/23 13/10",
"ply": 1,
"equity": -0.022293653
},
{
"move": "24/20",
"ply": 1,
"equity": -0.031027086
},
{
"move": "24/23 24/21",
"ply": 1,
"equity": -0.03213631
},
{
"move": "13/9",
"ply": 1,
"equity": -0.040205758
},
{
"move": "24/21 6/5",
"ply": 0,
"equity": -0.04369401
},
{
"move": "13/10 6/5",
"ply": 0,
"equity": -0.04984435
},
{
"move": "24/23 8/5",
"ply": 0,
"equity": -0.104100645
},
{
"move": "24/23 6/3",
"ply": 0,
"equity": -0.10542354
},
{
"move": "24/21 8/7",
"ply": 0,
"equity": -0.107585564
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "8/5 6/5",
"ply": 0,
"equity": 0.21258597
},
{
"move": "24/23 13/10",
"ply": 0,
"equity": -0.010789523
},
{
"move": "24/20",
"ply": 0,
"equity": -0.017392516
},
{
"move": "24/23 24/21",
"ply": 0,
"equity": -0.023609947
},
{
"move": "13/9",
"ply": 0,
"equity": -0.027675275
},
{
"move": "24/21 6/5",
"ply": 0,
"equity": -0.04369401
},
{
"move": "13/10 6/5",
"ply": 0,
"equity": -0.04984435
},
{
"move": "24/23 8/5",
"ply": 0,
"equity": -0.104100645
},
{
"move": "24/23 6/3",
"ply": 0,
"equity": -0.10542354
},
{
"move": "24/21 8/7",
"ply": 0,
"equity": -0.107585564
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "8/5 6/5",
"ply": 2,
"equity": 0.1998143
},
{
"move": "24/23 13/10",
"ply": 2,
"equity": -0.0118339015
},
{
"move": "24/20",
"ply": 1,
"equity": -0.031027086
},
{
"move": "24/23 24/21",
"ply": 1,
"equity": -0.03213631
},
{
"move": "13/9",
"ply": 1,
"equity": -0.040205758
},
{
"move": "24/21 6/5",
"ply": 0,
"equity": -0.04369401
},
{
"move": "13/10 6/5",
"ply": 0,
"equity": -0.04984435
},
{
"move": "24/23 8/5",
"ply": 0,
"equity": -0.104100645
},
{
"move": "24/23 6/3",
"ply": 0,
"equity": -0.10542354
},
{
"move": "24/21 8/7",
"ply": 0,
"equity": -0.107585564
}
]
@@ -25,30 +25,37 @@
"ranking": [
{
"move": "24/13",
"ply": 0,
"equity": 0.082985915
},
{
"move": "24/18 13/8",
"ply": 0,
"equity": 0.03631018
},
{
"move": "13/8 13/7",
"ply": 0,
"equity": -0.05142035
},
{
"move": "13/2",
"ply": 0,
"equity": -0.13522762
},
{
"move": "24/18 8/3",
"ply": 0,
"equity": -0.14635906
},
{
"move": "13/7 8/3",
"ply": 0,
"equity": -0.17384717
},
{
"move": "8/3 8/2",
"ply": 0,
"equity": -0.37779096
}
]
@@ -66,30 +73,37 @@
"ranking": [
{
"move": "24/13",
"ply": 2,
"equity": 0.07996891
},
{
"move": "24/18 13/8",
"ply": 2,
"equity": 0.03882884
},
{
"move": "13/8 13/7",
"ply": 2,
"equity": -0.061850794
},
{
"move": "13/2",
"ply": 1,
"equity": -0.12083713
},
{
"move": "24/18 8/3",
"ply": 1,
"equity": -0.13999933
},
{
"move": "13/7 8/3",
"ply": 0,
"equity": -0.17384717
},
{
"move": "8/3 8/2",
"ply": 0,
"equity": -0.37779096
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "24/18(2) 13/7(2)",
"ply": 0,
"equity": 0.539388
},
{
"move": "24/18 13/7(3)",
"ply": 0,
"equity": 0.33099693
},
{
"move": "24/18(2) 8/2(2)",
"ply": 0,
"equity": 0.27119082
},
{
"move": "13/7(2) 8/2(2)",
"ply": 0,
"equity": 0.1563462
},
{
"move": "24/18 13/7(2) 8/2",
"ply": 0,
"equity": 0.14750561
},
{
"move": "13/7(4)",
"ply": 0,
"equity": 0.14310652
},
{
"move": "24/18(2) 13/7 8/2",
"ply": 0,
"equity": 0.12353878
},
{
"move": "13/7(3) 8/2",
"ply": 0,
"equity": 0.101389736
},
{
"move": "24/18 13/7 8/2(2)",
"ply": 0,
"equity": -0.05435923
},
{
"move": "24/18 8/2(3)",
"ply": 0,
"equity": -0.12482335
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "24/18(2) 13/7(2)",
"ply": 1,
"equity": 0.5886327
},
{
"move": "24/18 13/7(3)",
"ply": 1,
"equity": 0.33312234
},
{
"move": "24/18(2) 8/2(2)",
"ply": 0,
"equity": 0.27119082
},
{
"move": "13/7(2) 8/2(2)",
"ply": 0,
"equity": 0.1563462
},
{
"move": "24/18 13/7(2) 8/2",
"ply": 0,
"equity": 0.14750561
},
{
"move": "13/7(4)",
"ply": 0,
"equity": 0.14310652
},
{
"move": "24/18(2) 13/7 8/2",
"ply": 0,
"equity": 0.12353878
},
{
"move": "13/7(3) 8/2",
"ply": 0,
"equity": 0.101389736
},
{
"move": "24/18 13/7 8/2(2)",
"ply": 0,
"equity": -0.05435923
},
{
"move": "24/18 8/2(3)",
"ply": 0,
"equity": -0.12482335
}
]
+39
View File
@@ -25,118 +25,147 @@
"ranking": [
{
"move": "13/9 11/6",
"ply": 0,
"equity": -0.22230339
},
{
"move": "11/6 9/5",
"ply": 0,
"equity": -0.22993731
},
{
"move": "13/8 9/5",
"ply": 0,
"equity": -0.23005769
},
{
"move": "13/9 13/8",
"ply": 0,
"equity": -0.23073325
},
{
"move": "13/8 11/7",
"ply": 0,
"equity": -0.23153114
},
{
"move": "11/7 11/6",
"ply": 0,
"equity": -0.2335976
},
{
"move": "13/8 7/3",
"ply": 0,
"equity": -0.23374501
},
{
"move": "11/6 7/3",
"ply": 0,
"equity": -0.23394687
},
{
"move": "13/4",
"ply": 0,
"equity": -0.23507257
},
{
"move": "13/9 7/2",
"ply": 0,
"equity": -0.23529215
},
{
"move": "7/3 7/2",
"ply": 0,
"equity": -0.23544219
},
{
"move": "11/2",
"ply": 0,
"equity": -0.235758
},
{
"move": "11/7 9/4",
"ply": 0,
"equity": -0.23589191
},
{
"move": "13/8 6/2",
"ply": 0,
"equity": -0.24026787
},
{
"move": "9/5 7/2",
"ply": 0,
"equity": -0.24087214
},
{
"move": "13/8 5/1",
"ply": 0,
"equity": -0.24252185
},
{
"move": "9/5 9/4",
"ply": 0,
"equity": -0.2440444
},
{
"move": "9/4 6/2",
"ply": 0,
"equity": -0.24487257
},
{
"move": "9/4 7/3",
"ply": 0,
"equity": -0.24512678
},
{
"move": "11/6 5/1",
"ply": 0,
"equity": -0.25016445
},
{
"move": "9/4 5/1",
"ply": 0,
"equity": -0.25572774
},
{
"move": "7/2 5/1",
"ply": 0,
"equity": -0.258138
},
{
"move": "7/3 6/1",
"ply": 0,
"equity": -0.25878713
},
{
"move": "7/2 6/2",
"ply": 0,
"equity": -0.26088732
},
{
"move": "13/9 6/1",
"ply": 0,
"equity": -0.26104733
},
{
"move": "9/5 6/1",
"ply": 0,
"equity": -0.26680842
},
{
"move": "11/7 6/1",
"ply": 0,
"equity": -0.2670043
},
{
"move": "6/2 6/1",
"ply": 0,
"equity": -0.27360544
},
{
"move": "6/1 5/1",
"ply": 0,
"equity": -0.31466243
}
]
@@ -154,42 +183,52 @@
"ranking": [
{
"move": "13/9 11/6",
"ply": 2,
"equity": -0.21922025
},
{
"move": "13/9 13/8",
"ply": 2,
"equity": -0.2202243
},
{
"move": "11/6 9/5",
"ply": 2,
"equity": -0.22022447
},
{
"move": "13/8 9/5",
"ply": 2,
"equity": -0.22088149
},
{
"move": "11/7 11/6",
"ply": 2,
"equity": -0.22277355
},
{
"move": "13/4",
"ply": 2,
"equity": -0.2230124
},
{
"move": "6/1 5/1",
"ply": 2,
"equity": -0.29347342
},
{
"move": "11/7 9/4",
"ply": 1,
"equity": -0.22985312
},
{
"move": "13/8 11/7",
"ply": 1,
"equity": -0.23169218
},
{
"move": "13/9 7/2",
"ply": 1,
"equity": -0.23561361
}
]
+30
View File
@@ -210,6 +210,36 @@ def compare_move_analysis(report, cli, case):
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)
compare_analysis_ranking(report, where, expected, actual)
def compare_analysis_ranking(report, where, expected, actual):
"""Classement de l'analyse : les dix premiers coups de gnubg (le moteur
n'en rend pas plus), au même niveau et à la même équité, à la tolérance
près ; le meilleur coup et le coup joué y ont exactement les évaluations
de best et played."""
engine = actual["ranking"]
reference = expected["ranking"][:10]
report.same(where, "taille du classement", len(reference), len(engine))
by_move = {m["move"]: m for m in engine}
for m in reference:
other = by_move.get(m["move"])
here = "%s classement %s" % (where, m["move"])
if other is None:
# Un coup à égalité avec le dixième peut changer de côté.
tied = abs(m["equity"] - reference[-1]["equity"]) <= report.tolerance
if not tied:
report.same(here, "présent", True, False)
continue
report.same(here, "ply", m["ply"], other["ply"])
report.number(here, "equity", m["equity"], other["equity"])
if engine:
report.same(where, "classement, meilleur coup", actual["best"], engine[0])
for m in engine:
if m["move"] == actual["played"]["move"]:
report.same(where, "classement, coup joué", actual["played"], m)
report.same(where, "classement, rang du coup joué", actual["rank"],
engine.index(m) + 1)
def compare_cube_analysis(report, cli, case):
+21 -8
View File
@@ -221,15 +221,25 @@ def set_analysis(ply):
def analyse_game():
"""Analyse la partie en cours ; renvoie ses enregistrements et ses
statistiques par joueur (« X », « O »). Le cache est vidé avant : les
"""Analyse la partie en cours ; renvoie ses enregistrements, ses
statistiques par joueur (« X », « O ») et les informations du match. 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"]
match = gnubg.match(analysis=1, verbose=1, statistics=1, boards=1)
game = match["games"][-1]
return game["game"], game["stats"], match["match-info"]
def move_plies(match_info, move):
"""Niveau auquel gnubg a évalué un coup de l'analyse : gnubg.match ne
donne le réglage d'un coup (« evalcontext ») que s'il diffère du réglage
par défaut de la partie (« default-eval-context », celui du premier coup
évalué rencontré)."""
context = move.get("evalcontext", {})
return context.get("plies", match_info["default-eval-context"]["plies"])
def last_record(records, action):
@@ -280,7 +290,7 @@ def analyse_move(case, ply):
# vient de jouer est la position retournée.
played_id = gnubg.positionid(swapped(gnubg.board()))
set_analysis(ply)
records, stats = analyse_game()
records, stats, match_info = analyse_game()
record = last_record(records, "move")
analysis = record["analysis"]
moves, index = analysis["moves"], analysis["imove"]
@@ -304,7 +314,10 @@ def analyse_move(case, ply):
# 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],
# Avec le niveau de chaque coup : les coups écartés par les filtres
# gardent celui d'un niveau inférieur (voir README.md).
"ranking": [{"move": notation(m["move"]), "ply": move_plies(match_info, m),
"equity": f32(m["score"])} for m in kept],
}
luck = {"value": f32(analysis["luck-value"]), "rating": LUCKS[analysis.get("luck")]}
return level, luck
@@ -322,7 +335,7 @@ def analyse_cube(case, ply):
# 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()
records, stats, _ = analyse_game()
record = last_record(records, "move")
analysed = "nd-cubeful-eq" in record["analysis"]
skill = record["analysis"].get("cube-skill")
@@ -332,7 +345,7 @@ def analyse_cube(case, ply):
# du double.
gnubg.command("double")
gnubg.command("drop" if action == "pass" else "take")
records, stats = analyse_game()
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")
+2 -1
View File
@@ -130,7 +130,8 @@ if .type == "move" then
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))
and (.ranking | length) >= ([.rank, ([.move_count, 10] | min)] | max)
and all(.ranking[]; (.move | type) == "string" and (.ply | nombre) and (.equity | nombre)))
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"))
+69 -60
View File
@@ -81,29 +81,23 @@
--point-a: #6b4430;
--point-b: #c9ae88;
--point-number: #f3ead8;
--checker-player: #f7f0e2;
--checker-player-edge: #8c7a62;
--checker-computer: #2e1c12;
--checker-computer-edge: #120a05;
--count-player: #2b1d15;
--count-computer: #f3ead8;
--die-player: #f7f0e2;
--pip-player: #2b1d15;
--die-computer: #2e1c12;
--pip-computer: #f3ead8;
--cube: #f7f0e2;
--cube-text: #2b1d15;
--checker-player: #fafaf7;
--checker-player-edge: #55554f;
--checker-computer: #1c1c1c;
--checker-computer-edge: #b5b5b0;
--count-player: #1c1c1c;
--count-computer: #fafaf7;
--die-player: #fafaf7;
--pip-player: #1c1c1c;
--die-computer: #1c1c1c;
--pip-computer: #fafaf7;
--cube: #fafaf7;
--cube-text: #1c1c1c;
--highlight: #a8432b;
--target: rgba(168, 67, 43, 0.32);
/* Dernier coup joué (marques et traces) : le laiton, rien d'autre. */
--last-move: var(--brand-laiton);
/* Numéros des flèches. La charte les pose sur le cadre (point-number,
* ivoire) ; notre plateau les pose sur le fond, où l'ivoire ne se lirait
* pas : texte secondaire en thème clair (4,7:1 sur bed), point-number en
* thème sombre (6,3:1). */
--point-label: var(--muted);
--shadow-raised: 0 1px 2px rgba(43, 29, 21, 0.1);
--font-sans: "Alegreya Sans", "Gill Sans", "Segoe UI", system-ui, sans-serif;
@@ -167,23 +161,21 @@
--point-a: #3a2519;
--point-b: #7e6142;
--point-number: #cdbfa8;
--checker-player: #ede3cf;
--checker-player-edge: #8e7c64;
--checker-computer: #140d09;
--checker-computer-edge: #a8957d;
--count-player: #2b1d15;
--count-computer: #f3ead8;
--die-player: #ede3cf;
--pip-player: #2b1d15;
--die-computer: #140d09;
--pip-computer: #ede3cf;
--cube: #ede3cf;
--cube-text: #2b1d15;
--checker-player: #f2f2ef;
--checker-player-edge: #6e6e69;
--checker-computer: #141414;
--checker-computer-edge: #b5b5b0;
--count-player: #1c1c1c;
--count-computer: #fafaf7;
--die-player: #f2f2ef;
--pip-player: #1c1c1c;
--die-computer: #141414;
--pip-computer: #f2f2ef;
--cube: #f2f2ef;
--cube-text: #1c1c1c;
--highlight: #e07a5a;
--target: rgba(224, 122, 90, 0.38);
--point-label: var(--point-number);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
--scrim: rgba(0, 0, 0, 0.6);
color-scheme: dark;
@@ -215,23 +207,21 @@
--point-a: #3a2519;
--point-b: #7e6142;
--point-number: #cdbfa8;
--checker-player: #ede3cf;
--checker-player-edge: #8e7c64;
--checker-computer: #140d09;
--checker-computer-edge: #a8957d;
--count-player: #2b1d15;
--count-computer: #f3ead8;
--die-player: #ede3cf;
--pip-player: #2b1d15;
--die-computer: #140d09;
--pip-computer: #ede3cf;
--cube: #ede3cf;
--cube-text: #2b1d15;
--checker-player: #f2f2ef;
--checker-player-edge: #6e6e69;
--checker-computer: #141414;
--checker-computer-edge: #b5b5b0;
--count-player: #1c1c1c;
--count-computer: #fafaf7;
--die-player: #f2f2ef;
--pip-player: #1c1c1c;
--die-computer: #141414;
--pip-computer: #f2f2ef;
--cube: #f2f2ef;
--cube-text: #1c1c1c;
--highlight: #e07a5a;
--target: rgba(224, 122, 90, 0.38);
--point-label: var(--point-number);
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
--scrim: rgba(0, 0, 0, 0.6);
color-scheme: dark;
@@ -586,6 +576,19 @@ button:disabled {
line-clamp: 2;
}
/* Un coup en notation du moteur, lien vers sa fiche du glossaire (ligne de
* l'ordinateur, feuille du maître) : couleur du texte, soulignement en
* pointillé discret. Le remplissage vertical d'un élément en ligne agrandit
* la zone d'appui sans changer la hauteur des lignes. */
a.notation {
padding-block: 8px;
color: inherit;
text-decoration-line: underline;
text-decoration-style: dotted;
text-decoration-color: var(--muted);
text-decoration-thickness: 1px;
}
.infoline .replay {
color: var(--accent);
}
@@ -971,9 +974,10 @@ button:disabled {
.point-b { fill: var(--point-b); }
/* Numéros des flèches : leur taille est une mesure du dessin (attribut
* font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts). */
* font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts).
* Posés sur le cadre, en portrait comme en paysage. */
.point-number {
fill: var(--point-label);
fill: var(--point-number);
font-weight: 500;
text-anchor: middle;
}
@@ -1105,10 +1109,8 @@ button:disabled {
cursor: pointer;
}
/* Paysage : numéros sur le cadre (ivoire), comptes de points en pastilles
* aux bouts de la barre, consigne dans la bande, commandes à gauche. */
.point-number.on-frame { fill: var(--point-number); }
/* Paysage : comptes de points en pastilles aux bouts de la barre, consigne
* dans la bande, commandes à gauche. */
.pip-chip { stroke-width: 1.5; }
.chip-player { fill: var(--checker-player); stroke: var(--checker-player-edge); }
.chip-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); }
@@ -2033,9 +2035,10 @@ html.bare .topbar {
min-width: 0;
}
/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique
* (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les
* deux thèmes : le liseré reste lisible sur le bois. */
/* Le sens du jeu : deux flèches circulaires, claire pour le joueur, sombre
* pour l'ordinateur, aux couleurs de leurs pions, chacune avec un liseré de
* la couleur opposée pour rester lisible sur le bois ; jamais en brique (la
* brique veut dire « à faire »). */
.tutorial-path path {
fill: none;
stroke-linecap: round;
@@ -2043,13 +2046,19 @@ html.bare .topbar {
pointer-events: none;
}
.tutorial-path .path-edge {
stroke: var(--brand-ivoire);
.tutorial-path .direction-player.direction-line { stroke: var(--checker-player); }
.tutorial-path .direction-player.direction-edge { stroke: var(--checker-computer); }
.tutorial-path .direction-computer.direction-line { stroke: var(--checker-computer); }
.tutorial-path .direction-computer.direction-edge { stroke: var(--checker-player); }
.tutorial-path .direction-head.direction-player {
fill: var(--checker-player);
stroke: var(--checker-computer);
}
.tutorial-path .path-dots,
.tutorial-path .path-head {
stroke: var(--brand-noyer);
.tutorial-path .direction-head.direction-computer {
fill: var(--checker-computer);
stroke: var(--checker-player);
}
/* En paysage, comme la position partagée : le plateau à gauche sur toute