Compare commits
38
Commits
@@ -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));
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
@@ -357,6 +365,83 @@ export class Game {
|
||||
return moves.filter((_, i) => checks[i]?.valid === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le coup évident qui commence par le pion de from (une case du joueur,
|
||||
* ou la barre : BAR), après les demi-coups already (par défaut, ceux
|
||||
* déjà saisis) : les demi-coups qui le complètent, ou null s'il n'y en
|
||||
* a pas
|
||||
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
|
||||
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
|
||||
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
|
||||
* positions différentes, comme dans la liste des coups de gnubg.
|
||||
*
|
||||
* Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au
|
||||
* moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le
|
||||
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
|
||||
* coups complets différents apparaissent.
|
||||
*/
|
||||
async obviousMove(
|
||||
from: number,
|
||||
already: readonly HalfMove[] = this.pendingHalfMoves,
|
||||
): Promise<HalfMove[] | null> {
|
||||
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
|
||||
const state = this.state;
|
||||
const pending = [...already];
|
||||
if (state.turn !== "player" || state.phase !== "moving") {
|
||||
return null;
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
|
||||
if (limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
let displayed = state.position;
|
||||
if (pending.length > 0) {
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, pending);
|
||||
if (!check.valid || check.position === null) {
|
||||
return null;
|
||||
}
|
||||
displayed = check.position;
|
||||
}
|
||||
const legal = await this.#engine.legalMoves(state.position, roll);
|
||||
// Un seul coup légal : il est évident, quel que soit le pion.
|
||||
const anyChecker = legal.length === 1;
|
||||
const found = new Map<PositionId, HalfMove[]>();
|
||||
const explore = async (sequence: HalfMove[], position: PositionId): Promise<void> => {
|
||||
if (found.size > 1 || sequence.length >= limit) {
|
||||
return;
|
||||
}
|
||||
const { player } = await this.#sideBoards(position, "player", state);
|
||||
const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player);
|
||||
const candidates = new Map<string, HalfMove>();
|
||||
for (const start of starts) {
|
||||
for (const die of roll) {
|
||||
const to = Math.max(start - die, OFF);
|
||||
candidates.set(`${start}/${to}`, { from: start, to });
|
||||
}
|
||||
}
|
||||
for (const halfMove of candidates.values()) {
|
||||
if (found.size > 1) {
|
||||
return;
|
||||
}
|
||||
const next = [...sequence, halfMove];
|
||||
const check = await this.#engine.checkHalfMoves(state.position, roll, [
|
||||
...pending,
|
||||
...next,
|
||||
]);
|
||||
if (!check.valid || check.position === null) {
|
||||
continue;
|
||||
}
|
||||
if (check.complete && !found.has(check.position)) {
|
||||
found.set(check.position, next);
|
||||
}
|
||||
await explore(next, check.position);
|
||||
}
|
||||
};
|
||||
await explore([], displayed);
|
||||
return found.size === 1 ? ([...found.values()][0] ?? null) : null;
|
||||
}
|
||||
|
||||
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
|
||||
* permet.) */
|
||||
async canDouble(): Promise<boolean> {
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+383
-19
@@ -36,9 +36,10 @@ 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";
|
||||
import { BAR, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
@@ -49,6 +50,7 @@ import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import { createAnalysisView } from "./analysis-view.js";
|
||||
import { createPromptView, type PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { attachDrag, createDoubleTap, nextPlace } from "./board-gestures.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -62,12 +64,16 @@ import {
|
||||
import {
|
||||
type BoardAdvice,
|
||||
type BoardDecor,
|
||||
type BoardDrag,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
moveDraggedChecker,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
returnDraggedChecker,
|
||||
type ShownDie,
|
||||
type Spot,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
@@ -84,6 +90,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 {
|
||||
@@ -224,6 +231,20 @@ type ActionName =
|
||||
| "fullscreen"
|
||||
| "close";
|
||||
|
||||
/** Le premier toucher d'un double toucher : la partie, le pion touché et
|
||||
* les demi-coups saisis à ce moment-là. */
|
||||
interface ObviousTap {
|
||||
readonly game: Game;
|
||||
readonly place: Place;
|
||||
readonly pending: readonly HalfMove[];
|
||||
}
|
||||
|
||||
/** Le numéro, pour le domaine, d'un endroit du joueur : la barre (BAR) ou
|
||||
* une case de 1 à 24. */
|
||||
function pointOf(place: Place): number {
|
||||
return place === "bar-player" ? BAR : Number(place.slice(1));
|
||||
}
|
||||
|
||||
/** Démarre l'écran de jeu dans la page (index.html). */
|
||||
export function startApp(options: AppOptions): void {
|
||||
const stripOf = (id: string) => {
|
||||
@@ -373,7 +394,57 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
const againLabel = document.createElement("span");
|
||||
againLabel.textContent = MATCH_TEXTS.replay;
|
||||
ui.again.replaceChildren(againLabel);
|
||||
// Les raccourcis du clavier (interaction.md), en petite touche sur
|
||||
// chaque bouton ; visibles seulement quand un clavier est détecté.
|
||||
const shortcuts: [HTMLElement, string, string][] = [
|
||||
[ui.roll, "Espace", "Space"],
|
||||
[ui.confirm, "Espace", "Space"],
|
||||
[ui.skip, "Espace", "Space"],
|
||||
[ui.again, "Espace", "Space"],
|
||||
[ui.double, "D", "D"],
|
||||
[ui.take, "P", "P"],
|
||||
[ui.drop, "N", "N"],
|
||||
[ui.cancel, "Z", "Z"],
|
||||
[ui.helpButton, "?", "?"],
|
||||
[ui.fullscreenButton, "F", "F"],
|
||||
[ui.menuButton, "M", "M"],
|
||||
];
|
||||
for (const [button, key, aria] of shortcuts) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "shortcut";
|
||||
kbd.textContent = key;
|
||||
kbd.setAttribute("aria-hidden", "true");
|
||||
button.append(kbd);
|
||||
button.setAttribute("aria-keyshortcuts", aria);
|
||||
}
|
||||
// Un clavier est détecté : sur un ordinateur (pointeur fin, écran
|
||||
// large), ou dès qu'une touche est frappée ; un toucher du doigt les
|
||||
// cache de nouveau.
|
||||
const desktop = window.matchMedia("(hover: hover) and (pointer: fine) and (min-width: 64rem)");
|
||||
const showShortcuts = (on: boolean) =>
|
||||
document.documentElement.classList.toggle("keyboard", on);
|
||||
showShortcuts(desktop.matches);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key !== "Unidentified") {
|
||||
showShortcuts(true);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch") {
|
||||
showShortcuts(false);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -438,11 +509,29 @@ export function startApp(options: AppOptions): void {
|
||||
let sides: SideBoards | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
let selected: Place | null = null;
|
||||
// Le pion qu'on fait glisser (charte, 1i), et celui qui vient d'être
|
||||
// lâché sur une case permise : il reste dessiné là où le doigt l'a
|
||||
// posé jusqu'à ce que le moteur ait accepté le demi-coup, puis glisse
|
||||
// à sa place (to).
|
||||
let drag: BoardDrag | null = null;
|
||||
let dropped: (BoardDrag & { readonly to: Place }) | null = null;
|
||||
// Le pion lâché hors d'une case permise rentre à sa place.
|
||||
let returning = false;
|
||||
// Clavier : la case d'arrivée choisie aux flèches haut et bas, pour le
|
||||
// pion choisi (selected) aux flèches gauche et droite.
|
||||
let keyTarget: Place | null = null;
|
||||
// Ceux qui attendent que l'écran soit libre (le double toucher qui suit
|
||||
// un pion parti dès le premier toucher).
|
||||
let idleWaiters: (() => void)[] = [];
|
||||
let canDouble = false;
|
||||
// Le joueur a lancé les dés mais ne peut pas jouer : il doit passer.
|
||||
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.
|
||||
@@ -630,6 +719,8 @@ export function startApp(options: AppOptions): void {
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
drag: drag ?? dropped,
|
||||
focus: selected !== null && keyTarget !== null ? keyTarget : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
@@ -646,7 +737,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
|
||||
? ""
|
||||
@@ -671,7 +766,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
againLabel.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -1037,7 +1132,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;
|
||||
}
|
||||
@@ -1111,15 +1207,40 @@ export function startApp(options: AppOptions): void {
|
||||
async function settle(animate: boolean): Promise<void> {
|
||||
try {
|
||||
await update();
|
||||
await settleDropped();
|
||||
if (animate && shownMove !== null) {
|
||||
await animateMove(shownMove);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
dropped = null;
|
||||
render();
|
||||
const waiters = idleWaiters;
|
||||
idleWaiters = [];
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Le pion lâché sur une case permise : une fois le demi-coup accepté,
|
||||
* il glisse de là où le doigt l'a posé jusqu'à sa place (duration-fast),
|
||||
* ou y apparaît, si le système demande moins de mouvement. */
|
||||
async function settleDropped(): Promise<void> {
|
||||
const landing = dropped;
|
||||
dropped = null;
|
||||
if (landing === null || reducedMotion.matches) {
|
||||
return;
|
||||
}
|
||||
render();
|
||||
await slideChecker(ui.board, landing.to, landing.at, SLIDE_MS);
|
||||
}
|
||||
|
||||
/** Résolue quand l'écran ne travaille plus (moteur, animation). */
|
||||
function whenIdle(): Promise<void> {
|
||||
return busy ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve();
|
||||
}
|
||||
|
||||
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
|
||||
* ignorées pendant ce temps. */
|
||||
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
|
||||
@@ -1128,6 +1249,8 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
busy = true;
|
||||
selected = null;
|
||||
keyTarget = null;
|
||||
drag = null;
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
bubbleSide = null;
|
||||
@@ -1529,25 +1652,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 }));
|
||||
}
|
||||
});
|
||||
@@ -1577,10 +1712,64 @@ export function startApp(options: AppOptions): void {
|
||||
if (await g.playHalfMove(halfMove)) {
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
} else {
|
||||
dropped = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Double toucher (charte, 1i) : joue le coup évident (le domaine le
|
||||
* cherche, le moteur le juge) qui commence par le pion touché, depuis
|
||||
* l'état du premier toucher. Ce premier toucher a pu faire partir le
|
||||
* pion tout de suite (une seule case d'arrivée) : il ne reste alors que
|
||||
* la suite du coup. Sans coup évident, le second toucher n'est qu'un
|
||||
* toucher : touchers rapides sur une même pile (un double, par exemple)
|
||||
* et pion repris (touché deux fois : plus rien de choisi).
|
||||
* L'appelant a pris l'écran (busy) : le moteur est seul à travailler.
|
||||
*/
|
||||
async function playObvious(tap: ObviousTap): Promise<void> {
|
||||
const g = game;
|
||||
const now = g?.pendingHalfMoves ?? [];
|
||||
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
|
||||
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
|
||||
let rest: HalfMove[] = [];
|
||||
if (
|
||||
g === tap.game &&
|
||||
g.state.phase === "moving" &&
|
||||
now.length >= tap.pending.length &&
|
||||
tap.pending.every((h, i) => same(h, now[i]))
|
||||
) {
|
||||
// Demandé seulement maintenant : un toucher simple ne coûte rien
|
||||
// au moteur.
|
||||
const obvious = await g.obviousMove(pointOf(tap.place), tap.pending).catch(() => null);
|
||||
const done = now.slice(tap.pending.length);
|
||||
if (obvious !== null && done.every((h, i) => same(h, obvious[i]))) {
|
||||
rest = obvious.slice(done.length);
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
if (game !== g || g === null) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
tapPlace(tap.place);
|
||||
return;
|
||||
}
|
||||
void act(async (g) => {
|
||||
info = "";
|
||||
const before = (await g.displayedBoard()).computer.bar;
|
||||
for (const halfMove of rest) {
|
||||
if (!(await g.playHalfMove(halfMove))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const after = (await g.displayedBoard()).computer.bar;
|
||||
haptic(after > before ? "checkerHit" : "checkerPlaced");
|
||||
});
|
||||
}
|
||||
|
||||
const canAct = (): Game | null => (busy || game === null ? null : game);
|
||||
|
||||
const actions: Record<ActionName, () => void> = {
|
||||
@@ -2015,8 +2204,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 +2216,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");
|
||||
@@ -2387,10 +2578,75 @@ export function startApp(options: AppOptions): void {
|
||||
ui.replay.onclick = actions.replay;
|
||||
ui.again.onclick = actions.again;
|
||||
|
||||
// Glisser un pion (charte, 1i) : il suit le doigt, agrandi et un peu
|
||||
// au-dessus ; un fantôme reste à son départ et ses cases d'arrivée (du
|
||||
// moteur) sont marquées. Lâché sur l'une d'elles, il s'y pose ; ailleurs,
|
||||
// il rentre à sa place, sans vibration ni message.
|
||||
const dragging = attachDrag(ui.board, {
|
||||
canStart: (place) =>
|
||||
!busy && !animating && !returning && game !== null && targets.has(place),
|
||||
start: (place, at) => {
|
||||
closeTransient();
|
||||
selected = place;
|
||||
keyTarget = null;
|
||||
shownMove = null;
|
||||
drag = { place, at };
|
||||
render();
|
||||
},
|
||||
move: (at) => {
|
||||
if (drag !== null) {
|
||||
drag = { place: drag.place, at };
|
||||
moveDraggedChecker(ui.board, at);
|
||||
}
|
||||
},
|
||||
drop: (over, at) => {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
const from = drag.place;
|
||||
const halfMove = over === null ? undefined : targets.get(from)?.get(over);
|
||||
if (halfMove === undefined || over === null || busy) {
|
||||
void dragBack(at);
|
||||
return;
|
||||
}
|
||||
dropped = { place: from, at, to: over };
|
||||
drag = null;
|
||||
playHalfMove(halfMove);
|
||||
},
|
||||
cancel: (at) => void dragBack(at),
|
||||
});
|
||||
|
||||
/** Le pion glissé rentre à sa place (duration-fast), puis le plateau
|
||||
* redevient celui d'avant le glissement. */
|
||||
async function dragBack(at: Spot): Promise<void> {
|
||||
if (drag === null) {
|
||||
return;
|
||||
}
|
||||
drag = { place: drag.place, at };
|
||||
returning = true;
|
||||
try {
|
||||
if (!reducedMotion.matches) {
|
||||
await returnDraggedChecker(ui.board, at, SLIDE_MS);
|
||||
}
|
||||
} finally {
|
||||
returning = false;
|
||||
drag = null;
|
||||
selected = null;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// Double toucher : le premier toucher fait ce que fait un toucher, et
|
||||
// demande déjà au domaine le coup évident de ce pion ; le second le joue.
|
||||
const doubleTap = createDoubleTap<ObviousTap | null>();
|
||||
|
||||
// Toucher le plateau : une commande (dés, videau, colonne de gauche), ou
|
||||
// un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a
|
||||
// qu'une : il y va tout de suite).
|
||||
ui.board.addEventListener("click", (event) => {
|
||||
if (dragging.takeClick()) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
if (!ui.bubble.hidden) {
|
||||
closeOverlays();
|
||||
@@ -2406,9 +2662,29 @@ export function startApp(options: AppOptions): void {
|
||||
return;
|
||||
}
|
||||
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
|
||||
if (busy || place == null) {
|
||||
if (place == null || game === null) {
|
||||
return;
|
||||
}
|
||||
const current = game;
|
||||
const second = doubleTap.tap(place, performance.now(), () =>
|
||||
busy || !targets.has(place)
|
||||
? null
|
||||
: { game: current, place, pending: [...current.pendingHalfMoves] },
|
||||
);
|
||||
if (second !== null) {
|
||||
doubleTapped(second);
|
||||
return;
|
||||
}
|
||||
if (!busy) {
|
||||
tapPlace(place);
|
||||
}
|
||||
});
|
||||
|
||||
/** Un toucher simple sur un endroit du plateau : le pion choisi va sur
|
||||
* cette case d'arrivée ; ou ce pion part tout de suite, s'il n'a qu'une
|
||||
* case d'arrivée ; ou il est choisi (touché de nouveau : plus rien). */
|
||||
function tapPlace(place: Place): void {
|
||||
keyTarget = null;
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
|
||||
if (halfMove !== undefined) {
|
||||
playHalfMove(halfMove);
|
||||
@@ -2424,7 +2700,83 @@ export function startApp(options: AppOptions): void {
|
||||
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
|
||||
}
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
/** Le second toucher d'un double toucher : l'écran le prend tout de
|
||||
* suite (réponse immédiate), ou dès qu'il a fini l'effet du premier. */
|
||||
function doubleTapped(tap: ObviousTap): void {
|
||||
const claim = () => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
render();
|
||||
void playObvious(tap);
|
||||
};
|
||||
if (busy) {
|
||||
void whenIdle().then(claim);
|
||||
} else {
|
||||
claim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plateau au clavier (interaction.md) : les flèches gauche et droite
|
||||
* choisissent un pion jouable (dans le sens de la course), haut et bas
|
||||
* une de ses cases d'arrivée (anneau focus), Entrée l'y pose. Les cases
|
||||
* sont celles que le moteur a données. Renvoie vrai si la touche a
|
||||
* servi.
|
||||
*/
|
||||
function boardKey(key: string, onButton: boolean): boolean {
|
||||
const g = game;
|
||||
const open =
|
||||
!ui.game.hidden &&
|
||||
ui.menuVeil.hidden &&
|
||||
ui.doubleVeil.hidden &&
|
||||
ui.explainVeil.hidden &&
|
||||
g !== null &&
|
||||
g.state.turn === "player" &&
|
||||
g.state.phase === "moving";
|
||||
if (!open || targets.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const landings = selected === null ? undefined : targets.get(selected);
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight": {
|
||||
if (busy) {
|
||||
return true;
|
||||
}
|
||||
selected = nextPlace(targets.keys(), selected, key === "ArrowRight" ? 1 : -1);
|
||||
const tos = selected === null ? undefined : targets.get(selected);
|
||||
keyTarget = tos === undefined ? null : nextPlace(tos.keys(), null, 1);
|
||||
shownMove = null;
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
if (busy || landings === undefined) {
|
||||
return landings !== undefined;
|
||||
}
|
||||
keyTarget = nextPlace(landings.keys(), keyTarget, key === "ArrowDown" ? 1 : -1);
|
||||
render();
|
||||
return true;
|
||||
case "Enter": {
|
||||
const halfMove =
|
||||
keyTarget === null || landings === undefined
|
||||
? undefined
|
||||
: landings.get(keyTarget);
|
||||
if (onButton || halfMove === undefined || busy) {
|
||||
return false;
|
||||
}
|
||||
keyTarget = null;
|
||||
playHalfMove(halfMove);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clavier (interaction.md) : les mêmes actions que les touchers.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
@@ -2451,6 +2803,10 @@ export function startApp(options: AppOptions): void {
|
||||
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
if (boardKey(event.key, onButton)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
let name: ActionName | null = null;
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
@@ -2661,22 +3017,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
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Les gestes sur le plateau : glisser-déposer et double toucher (charte,
|
||||
// docs/identite/charte/ecrans-etape-1.md, 1i), et l'ordre des pions et des
|
||||
// cases au clavier (interaction.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un seul code pour la souris, le doigt et le stylet : les Pointer Events.
|
||||
// Ce module ne connaît ni la partie ni le moteur : il dit où le pion est
|
||||
// pris, où il passe et où il est lâché ; l'écran décide (app.ts), avec les
|
||||
// cases d'arrivée que le moteur a données.
|
||||
|
||||
import type { Place } from "./board-model.js";
|
||||
import type { Spot } from "./board-view.js";
|
||||
|
||||
/** Au-delà de cette distance (px à l'écran), un appui devient un glissement :
|
||||
* en deçà, c'est un toucher. */
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
/** Le pion glissé est dessiné au-dessus du doigt, pour rester visible
|
||||
* (charte, 1i). */
|
||||
const LIFT_PX = 12;
|
||||
/** Deux touchers sur le même pion à moins de tant de ms : un double
|
||||
* toucher. */
|
||||
export const DOUBLE_TAP_MS = 350;
|
||||
/** Après un glissement, le « click » que le navigateur envoie au lâcher
|
||||
* n'est pas un toucher. */
|
||||
const CLICK_AFTER_DRAG_MS = 400;
|
||||
|
||||
/** Ce que l'écran fait d'un glissement. */
|
||||
export interface DragHandlers {
|
||||
/** Ce pion peut-il partir maintenant (pion jouable, moteur libre) ? */
|
||||
canStart(place: Place): boolean;
|
||||
/** Le pion de place commence à glisser ; at : son centre, dans le repère
|
||||
* du dessin. */
|
||||
start(place: Place, at: Spot): void;
|
||||
move(at: Spot): void;
|
||||
/** Le pion est lâché en at, au-dessus de over (null : hors de toute
|
||||
* zone du plateau). */
|
||||
drop(over: Place | null, at: Spot): void;
|
||||
/** Le glissement est interrompu par le navigateur (appel, geste du
|
||||
* système) : le pion rentre. */
|
||||
cancel(at: Spot): void;
|
||||
}
|
||||
|
||||
/** L'endroit du plateau sous un élément touché (sa zone data-place). */
|
||||
function placeOf(target: EventTarget | null): Place | null {
|
||||
if (!(target instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
return (target.closest("[data-place]")?.getAttribute("data-place") as Place | null) ?? null;
|
||||
}
|
||||
|
||||
/** Le point du dessin sous le pointeur, remonté de LIFT_PX vers le haut du
|
||||
* plateau (null si le dessin n'est pas affiché). */
|
||||
function liftedPoint(svg: SVGSVGElement, event: PointerEvent): Spot | null {
|
||||
const matrix = svg.getScreenCTM();
|
||||
if (matrix === null) {
|
||||
return null;
|
||||
}
|
||||
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
|
||||
const unit = 1 / (Math.hypot(matrix.a, matrix.b) || 1);
|
||||
return { x: point.x, y: point.y - LIFT_PX * unit };
|
||||
}
|
||||
|
||||
/** La zone du plateau (data-place) qui contient at, dans le repère du
|
||||
* dessin (null : aucune). */
|
||||
export function placeAtSpot(svg: SVGSVGElement, at: Spot): Place | null {
|
||||
for (const hit of svg.querySelectorAll(".hit[data-place]")) {
|
||||
const number = (name: string) => Number(hit.getAttribute(name));
|
||||
const x = number("x");
|
||||
const y = number("y");
|
||||
if (at.x >= x && at.x <= x + number("width") && at.y >= y && at.y <= y + number("height")) {
|
||||
return hit.getAttribute("data-place") as Place;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche le glisser-déposer sur le plateau svg. Renvoie takeClick : vrai
|
||||
* si le « click » reçu suit un glissement (il faut alors l'ignorer).
|
||||
*/
|
||||
export function attachDrag(svg: SVGSVGElement, handlers: DragHandlers): { takeClick(): boolean } {
|
||||
let press: { id: number; place: Place; x: number; y: number } | null = null;
|
||||
let dragging = false;
|
||||
let clickIgnoredUntil = 0;
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
if (!event.isPrimary || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const place = placeOf(event.target);
|
||||
press =
|
||||
place !== null && handlers.canStart(place)
|
||||
? { id: event.pointerId, place, x: event.clientX, y: event.clientY }
|
||||
: null;
|
||||
dragging = false;
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const at = liftedPoint(svg, event);
|
||||
if (at === null) {
|
||||
return;
|
||||
}
|
||||
if (dragging) {
|
||||
handlers.move(at);
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
||||
if (distance < DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
if (!handlers.canStart(press.place)) {
|
||||
press = null;
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
// Le plateau est redessiné pendant le glissement : c'est lui, et non
|
||||
// la zone touchée, qui reçoit la suite du geste.
|
||||
try {
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Sans capture, les événements remontent quand même au plateau.
|
||||
}
|
||||
handlers.start(press.place, at);
|
||||
});
|
||||
const end = (event: PointerEvent, cancelled: boolean) => {
|
||||
if (press === null || event.pointerId !== press.id) {
|
||||
return;
|
||||
}
|
||||
const wasDragging = dragging;
|
||||
press = null;
|
||||
dragging = false;
|
||||
if (!wasDragging) {
|
||||
return;
|
||||
}
|
||||
clickIgnoredUntil = performance.now() + CLICK_AFTER_DRAG_MS;
|
||||
const at = liftedPoint(svg, event) ?? { x: 0, y: 0 };
|
||||
if (cancelled) {
|
||||
handlers.cancel(at);
|
||||
} else {
|
||||
handlers.drop(placeAtSpot(svg, at), at);
|
||||
}
|
||||
};
|
||||
svg.addEventListener("pointerup", (event) => end(event, false));
|
||||
svg.addEventListener("pointercancel", (event) => end(event, true));
|
||||
return {
|
||||
takeClick: () => {
|
||||
const ignored = performance.now() < clickIgnoredUntil;
|
||||
clickIgnoredUntil = 0;
|
||||
return ignored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnaît le double toucher : tap(place, now, first) renvoie ce que first
|
||||
* a produit au premier toucher quand celui-ci est le second, sur le même
|
||||
* endroit, à moins de DOUBLE_TAP_MS ; sinon null, et ce toucher devient le
|
||||
* premier (first() n'est appelé que pour lui).
|
||||
*/
|
||||
export function createDoubleTap<T>(): {
|
||||
tap(place: Place, now: number, first: () => T): T | null;
|
||||
} {
|
||||
let last: { place: Place; time: number; data: T } | null = null;
|
||||
return {
|
||||
tap(place, now, first) {
|
||||
if (last !== null && last.place === place && now - last.time < DOUBLE_TAP_MS) {
|
||||
const data = last.data;
|
||||
last = null;
|
||||
return data;
|
||||
}
|
||||
last = { place, time: now, data: first() };
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Le numéro d'un endroit dans le sens de la course du joueur : la barre
|
||||
* (25), les cases de 24 à 1, la sortie (0). */
|
||||
function raceOrder(place: Place): number {
|
||||
if (place.startsWith("bar-")) {
|
||||
return 25;
|
||||
}
|
||||
if (place.startsWith("off-")) {
|
||||
return 0;
|
||||
}
|
||||
return Number(place.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clavier : l'endroit suivant (step 1) ou précédent (step -1) de places
|
||||
* après current, dans le sens de la course du joueur (de la barre vers la
|
||||
* sortie), en bouclant. Sans current (ou s'il n'en fait pas partie), le
|
||||
* premier dans ce sens. null si places est vide.
|
||||
*/
|
||||
export function nextPlace(
|
||||
places: Iterable<Place>,
|
||||
current: Place | null,
|
||||
step: 1 | -1,
|
||||
): Place | null {
|
||||
const sorted = [...places].sort((a, b) => raceOrder(b) - raceOrder(a));
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = current === null ? -1 : sorted.indexOf(current);
|
||||
if (index === -1) {
|
||||
return (step === 1 ? sorted[0] : sorted.at(-1)) ?? null;
|
||||
}
|
||||
return sorted[(index + step + sorted.length) % sorted.length] ?? null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+121
-20
@@ -86,8 +86,23 @@ export interface BoardDecor {
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
/** Le pion qu'on fait glisser (absent ou null : aucun). */
|
||||
readonly drag?: BoardDrag | null;
|
||||
/** Clavier : l'endroit qui a le focus (anneau focus ; absent ou null :
|
||||
* aucun). */
|
||||
readonly focus?: Place | null;
|
||||
}
|
||||
|
||||
/** Un pion qu'on fait glisser : il a quitté place (un fantôme en
|
||||
* pointillés y reste) et son centre est en at, dans le repère du dessin. */
|
||||
export interface BoardDrag {
|
||||
readonly place: Place;
|
||||
readonly at: Spot;
|
||||
}
|
||||
|
||||
/** Le pion qu'on fait glisser est agrandi (charte, 1i : 115 %). */
|
||||
export const DRAG_SCALE = 1.15;
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
@@ -203,6 +218,9 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
if (decor.focus != null) {
|
||||
drawFocus(svg, g, board, decor.focus, decor.targets.has(decor.focus));
|
||||
}
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
@@ -235,6 +253,9 @@ export function renderBoard(
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
if (decor.drag != null) {
|
||||
drawDrag(svg, g, decor.drag);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -303,23 +324,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),
|
||||
@@ -365,18 +380,13 @@ function drawStack(
|
||||
classes.push("movable");
|
||||
}
|
||||
const { x, y } = slotCenter(g, place, i);
|
||||
const checker = add(svg, "circle", {
|
||||
class: classes.join(" "),
|
||||
cx: x,
|
||||
cy: y,
|
||||
r: g.radius,
|
||||
});
|
||||
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
|
||||
if (last) {
|
||||
checker.setAttribute("data-checker", place);
|
||||
piece.setAttribute("data-checker", place);
|
||||
}
|
||||
if (last && count > limit) {
|
||||
add(
|
||||
svg,
|
||||
piece,
|
||||
"text",
|
||||
{
|
||||
class: `checker-count count-${side}`,
|
||||
@@ -487,6 +497,23 @@ function drawTray(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Un pion vu de dessus, centré en spot : son disque (classes cssClass) et
|
||||
* son filet intérieur, l'écho du logo (charte, board.js : rayon 12 pour un
|
||||
* pion de 20). Les deux sont groupés : c'est le groupe qui glisse.
|
||||
*/
|
||||
function addPiece(svg: Element, g: Geometry, side: Side, cssClass: string, spot: Spot): SVGElement {
|
||||
const piece = add(svg, "g", { class: "piece" });
|
||||
add(piece, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r: g.radius });
|
||||
add(piece, "circle", {
|
||||
class: `checker-ring ring-${side}`,
|
||||
cx: spot.x,
|
||||
cy: spot.y,
|
||||
r: g.radius * 0.6,
|
||||
});
|
||||
return piece;
|
||||
}
|
||||
|
||||
/** Une tranche de pion sorti, centrée en spot. */
|
||||
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
|
||||
return add(svg, "rect", {
|
||||
@@ -816,13 +843,87 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
|
||||
if (checker === null) {
|
||||
return null;
|
||||
}
|
||||
const number = (name: string) => Number(checker.getAttribute(name));
|
||||
if (checker.tagName === "circle") {
|
||||
return shapeCenter(checker.tagName === "g" ? (checker.firstElementChild ?? checker) : checker);
|
||||
}
|
||||
|
||||
/** Le centre d'un disque (cx, cy) ou d'un rectangle (x, y, largeur, hauteur). */
|
||||
function shapeCenter(shape: Element): Spot {
|
||||
const number = (name: string) => Number(shape.getAttribute(name));
|
||||
if (shape.tagName === "circle") {
|
||||
return { x: number("cx"), y: number("cy") };
|
||||
}
|
||||
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pion qu'on fait glisser : le pion de départ devient un fantôme en
|
||||
* pointillés, et un pion agrandi est dessiné en drag.at, devant les autres.
|
||||
* Il se déplace par son attribut transform (moveDraggedChecker), sans
|
||||
* redessiner le plateau.
|
||||
*/
|
||||
function drawDrag(svg: SVGSVGElement, g: Geometry, drag: BoardDrag): void {
|
||||
svg.querySelector(`[data-checker="${drag.place}"]`)?.classList.add("drag-origin");
|
||||
const group = add(svg, "g", {
|
||||
class: "drag-piece",
|
||||
id: "drag-piece",
|
||||
transform: dragTransform(drag.at, DRAG_SCALE),
|
||||
});
|
||||
addPiece(group, g, "player", "checker checker-player dragged", { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
function dragTransform(at: Spot, scale: number): string {
|
||||
return `translate(${at.x} ${at.y}) scale(${scale})`;
|
||||
}
|
||||
|
||||
/** Déplace le pion qu'on fait glisser en at (repère du dessin). */
|
||||
export function moveDraggedChecker(svg: SVGSVGElement, at: Spot): void {
|
||||
svg.querySelector("#drag-piece")?.setAttribute("transform", dragTransform(at, DRAG_SCALE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ramène le pion qu'on fait glisser à son départ (le centre du fantôme), en
|
||||
* ms millisecondes, et à sa taille ; résolue à la fin (animation du
|
||||
* navigateur, sans bibliothèque).
|
||||
*/
|
||||
export async function returnDraggedChecker(
|
||||
svg: SVGSVGElement,
|
||||
from: Spot,
|
||||
ms: number,
|
||||
): Promise<void> {
|
||||
const piece = svg.querySelector("#drag-piece");
|
||||
const origin = svg.querySelector(".drag-origin");
|
||||
if (piece === null || origin === null || typeof piece.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
const to = shapeCenter(origin.firstElementChild ?? origin);
|
||||
const animation = piece.animate(
|
||||
[
|
||||
{ transform: `translate(${from.x}px, ${from.y}px) scale(${DRAG_SCALE})` },
|
||||
{ transform: `translate(${to.x}px, ${to.y}px) scale(1)` },
|
||||
],
|
||||
{ duration: ms, easing: EASING_STANDARD, fill: "forwards" },
|
||||
);
|
||||
await animation.finished.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Clavier : l'anneau focus autour de l'endroit choisi, sur la case
|
||||
* d'arrivée (target) ou sur le pion du dessus. */
|
||||
function drawFocus(
|
||||
svg: SVGSVGElement,
|
||||
g: Geometry,
|
||||
board: BoardModel,
|
||||
place: Place,
|
||||
target: boolean,
|
||||
): void {
|
||||
const count = countAt(board, "player", place);
|
||||
const index = target
|
||||
? place.startsWith("off-")
|
||||
? count
|
||||
: Math.min(count, MAX_STACK - 1)
|
||||
: Math.max(topIndex(g, board, "player", place), 0);
|
||||
addRing(svg, g, "key-focus", place, slotCenter(g, place, index), g.radius + 5);
|
||||
}
|
||||
|
||||
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
|
||||
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
|
||||
|
||||
|
||||
+10
-5
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}.`,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Les gestes du plateau qui se vérifient sans navigateur : le double toucher
|
||||
// et l'ordre des pions et des cases au clavier. Le glisser-déposer lui-même
|
||||
// est joué dans un navigateur (tests/browser/plateau.mjs).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createDoubleTap, DOUBLE_TAP_MS, nextPlace } from "../src/ui/board-gestures.js";
|
||||
import type { Place } from "../src/ui/board-model.js";
|
||||
|
||||
test("double toucher : deux touchers rapprochés sur le même endroit", () => {
|
||||
const taps = createDoubleTap<string>();
|
||||
let firsts = 0;
|
||||
const first = () => {
|
||||
firsts++;
|
||||
return `premier ${firsts}`;
|
||||
};
|
||||
assert.equal(taps.tap("p8", 0, first), null);
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS - 1, first), "premier 1");
|
||||
// Le troisième toucher recommence un double toucher.
|
||||
assert.equal(taps.tap("p8", DOUBLE_TAP_MS, first), null);
|
||||
// Trop lent : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p8", 3 * DOUBLE_TAP_MS, first), null);
|
||||
// Un autre endroit : un nouveau premier toucher.
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 10, first), null);
|
||||
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 20, first), "premier 4");
|
||||
assert.equal(firsts, 4);
|
||||
});
|
||||
|
||||
test("clavier : les pions dans le sens de la course, en boucle", () => {
|
||||
const places: Place[] = ["p6", "bar-player", "p13", "p8"];
|
||||
assert.equal(nextPlace(places, null, 1), "bar-player");
|
||||
assert.equal(nextPlace(places, null, -1), "p6");
|
||||
assert.equal(nextPlace(places, "bar-player", 1), "p13");
|
||||
assert.equal(nextPlace(places, "p8", 1), "p6");
|
||||
assert.equal(nextPlace(places, "p6", 1), "bar-player");
|
||||
assert.equal(nextPlace(places, "bar-player", -1), "p6");
|
||||
// Un endroit qui n'est plus jouable : on repart du début.
|
||||
assert.equal(nextPlace(places, "p24", 1), "bar-player");
|
||||
assert.equal(nextPlace([], null, 1), null);
|
||||
// Les cases d'arrivée, sortie comprise.
|
||||
assert.equal(nextPlace(["off-player", "p2"], "p2", 1), "off-player");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
};
|
||||
},
|
||||
analyseMove: async () => null,
|
||||
rankMoves: async () => [],
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le
|
||||
// vrai moteur WebAssembly : un seul coup légal complet commence par le pion
|
||||
// touché, ou le moteur ne propose qu'un coup.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1.
|
||||
const SIX_AND_ONE = "BQAACAIAAAAAAA";
|
||||
// Le joueur, au trait : deux pions, sur ses cases 6 et 5.
|
||||
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
|
||||
|
||||
let engine: Engine | null = null;
|
||||
async function loaded(): Promise<Engine> {
|
||||
engine ??= await loadNodeEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Une partie à partir d'une position, joueur au trait, dés lancés. */
|
||||
async function from(position: string, dice: [number, number]): Promise<Game> {
|
||||
const game = Game.start(await loaded(), {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
await game.roll();
|
||||
return game;
|
||||
}
|
||||
|
||||
function text(halfMoves: readonly HalfMove[] | null): string | null {
|
||||
return halfMoves === null ? null : formatHalfMoves(halfMoves);
|
||||
}
|
||||
|
||||
test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) });
|
||||
for (const point of [24, 13, 8, 6]) {
|
||||
assert.equal(await game.obviousMove(point), null, `pion de la ${point}`);
|
||||
}
|
||||
// Rien n'a été saisi.
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
});
|
||||
|
||||
test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => {
|
||||
const game = await from(SIX_AND_ONE, [6, 1]);
|
||||
// 1/off, puis le 6 sort l'autre pion : un seul coup.
|
||||
assert.equal(text(await game.obviousMove(1)), "1/off 6/off");
|
||||
// Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste).
|
||||
assert.equal(await game.obviousMove(6), null);
|
||||
// Une case sans pion n'a pas de coup.
|
||||
assert.equal(await game.obviousMove(3), null);
|
||||
});
|
||||
|
||||
test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => {
|
||||
const game = await from(PLAYER_ALMOST_DONE, [6, 6]);
|
||||
assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1);
|
||||
const fromFive = await game.obviousMove(5);
|
||||
const fromSix = await game.obviousMove(6);
|
||||
assert.ok(fromFive !== null && fromSix !== null);
|
||||
assert.equal(fromFive.length, 2);
|
||||
assert.equal(fromSix.length, 2);
|
||||
// Une fois 6/off saisi, il ne reste que 5/off.
|
||||
assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true);
|
||||
assert.equal(text(await game.obviousMove(5)), "5/off");
|
||||
// Le coup évident se joue tel quel, demi-coup par demi-coup.
|
||||
for (const halfMove of (await game.obviousMove(5)) ?? []) {
|
||||
assert.equal(await game.playHalfMove(halfMove), true);
|
||||
}
|
||||
assert.equal(game.moveComplete, true);
|
||||
});
|
||||
|
||||
test("hors du tour de jeu du joueur, aucun coup évident", async () => {
|
||||
const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
assert.equal(await game.obviousMove(13), null);
|
||||
});
|
||||
@@ -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}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -511,3 +511,20 @@ Then("le résultat indique le gagnant et les points gagnés", () => {
|
||||
// règle Jacoby, le videau n'a pas bougé.
|
||||
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
|
||||
});
|
||||
|
||||
// ------------------------------------------------- Glisser, double toucher
|
||||
|
||||
When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => {
|
||||
positionBefore = game.displayedPosition;
|
||||
const obvious = await game.obviousMove(point);
|
||||
accepted = obvious !== null;
|
||||
for (const halfMove of obvious ?? []) {
|
||||
accepted &&= await game.playHalfMove(halfMove);
|
||||
}
|
||||
});
|
||||
|
||||
Then("aucun pion n'a bougé", () => {
|
||||
assert.equal(accepted, false);
|
||||
assert.deepEqual(game.pendingHalfMoves, []);
|
||||
assert.equal(game.displayedPosition, positionBefore);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1i. Plateau soigné (fait) | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -44,3 +47,6 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
|
||||
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
|
||||
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
|
||||
- [ ] Glisser-déposer : le pion suit le doigt, agrandi et au-dessus de lui, un fantôme en pointillés à son départ ; lâché hors d'une case permise, il revient à sa place sans vibration ni message (`tests/browser/plateau.mjs`, captures `plateau/glissement-*`).
|
||||
- [ ] Double toucher : le coup évident seulement (un seul coup commence par ce pion, ou un seul coup possible) ; sinon rien de plus qu'un toucher.
|
||||
- [ ] Clavier : `←` `→` choisissent un pion, `↑` `↓` sa case (anneau `focus`), `Entrée` le pose ; les raccourcis s'affichent en `kbd` sur les boutons dès qu'un clavier est détecté (capture `plateau/clavier-*`).
|
||||
|
||||
@@ -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,32 +0,0 @@
|
||||
# Plateau soigné : gestes et finitions (1i)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1i (`docs/etape-1.md`)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
|
||||
- les gestes naturels (principe d'expérience 2) ;
|
||||
- les écarts restants avec `docs/identite/charte/board.js`.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
|
||||
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
|
||||
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
|
||||
4. **Clavier.** Faire ce que la révision UX a laissé :
|
||||
- le choix des pions aux flèches ;
|
||||
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
|
||||
5. **Tests.**
|
||||
- Scénarios Gherkin si un nouveau comportement est visible.
|
||||
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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 » |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
|
||||
Alors seul votre 3 est marqué comme joué
|
||||
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
|
||||
|
||||
@position:BQAACAIAAAAAAA
|
||||
Scénario: Puis-je faire glisser un pion, ou jouer au clavier ?
|
||||
Vous pouvez aussi faire glisser un pion jusqu'à sa case d'arrivée : il
|
||||
suit votre doigt, juste au-dessus pour rester visible, et un pion en
|
||||
pointillés marque sa place de départ. Lâché ailleurs que sur une case
|
||||
permise, il revient à sa place. Touchez deux fois de suite un pion pour
|
||||
jouer le coup évident : quand un seul coup commence par ce pion, ou que
|
||||
vous n'avez qu'un coup possible. Sinon, rien de plus ne se passe. Votre
|
||||
coup attend ensuite votre validation, comme d'habitude. Au clavier, les
|
||||
flèches gauche et droite choisissent un pion, les flèches haut et bas sa
|
||||
case d'arrivée, et Entrée le pose.
|
||||
|
||||
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 6
|
||||
Alors aucun pion n'a bougé
|
||||
Quand vous demandez le coup évident de votre pion de la flèche 1
|
||||
Alors vos deux pions sont sortis
|
||||
|
||||
Scénario: Je me suis trompé de pion
|
||||
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
|
||||
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
|
||||
@@ -144,7 +162,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
+36
-4
@@ -12,10 +12,18 @@
|
||||
# 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/. Les gestes du plateau soigné
|
||||
# (tests/browser/plateau.mjs : glisser-déposer, pion lâché hors cible, double
|
||||
# toucher, clavier, filet des pions), captures dans build/browser-shots/plateau/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts) restreignent le test.
|
||||
# prompts, nouvelle-partie, retours-joueurs, plateau) 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 +36,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 plateau}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -66,7 +74,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 tests/browser/plateau.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 +122,28 @@ 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
|
||||
if [[ " $scripts " == *" plateau "* ]]; then
|
||||
# Gestes du plateau : glisser-déposer, lâcher hors cible, double
|
||||
# toucher, clavier ; captures dans build/browser-shots/plateau/.
|
||||
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 plateau.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
@@ -329,8 +329,8 @@ for (;;) {
|
||||
await page.click("#summary-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
check((await page.textContent("#again > span")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
|
||||
@@ -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)`);
|
||||
@@ -0,0 +1,328 @@
|
||||
// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte,
|
||||
// ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans
|
||||
// écran (Playwright), sur le site servi par l'image de production :
|
||||
//
|
||||
// - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion
|
||||
// glissé agrandi, cases d'arrivée du moteur) ;
|
||||
// - un pion lâché hors d'une case permise revient à sa place, sans rien
|
||||
// jouer ;
|
||||
// - le double toucher : rien quand plusieurs coups commencent par le pion,
|
||||
// le coup évident sinon ;
|
||||
// - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd
|
||||
// affichés une fois le clavier détecté ;
|
||||
// - le filet intérieur des pions ;
|
||||
// - aucune erreur, aucune violation de la politique de sécurité.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node plateau.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici
|
||||
// par une file de valeurs choisies (window.__dice), puis par un générateur à
|
||||
// graine. Captures de la recette design (Chromium) dans
|
||||
// <dossier des captures>/plateau/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node plateau.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/plateau`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un
|
||||
// sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident).
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
const SIX_AND_ONE = "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",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
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}`),
|
||||
);
|
||||
if (localStorage.getItem("ustagammon.settings") === null) {
|
||||
localStorage.setItem("ustagammon.settings", JSON.stringify({ 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()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** 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 = 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}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une nouvelle partie à partir d'une position, joueur au trait, puis les
|
||||
* dés imposés lancés. */
|
||||
async function startFrom(position, dice) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
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((d) => window.__dice.push(...d), dice);
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** L'état du plateau : pions par endroit (pion du dessus marqué), pion
|
||||
* choisi, cases d'arrivée, glissement en cours, dés joués. */
|
||||
function boardState() {
|
||||
return page.evaluate(() => {
|
||||
const board = document.getElementById("board");
|
||||
const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null;
|
||||
return {
|
||||
at: Object.fromEntries(
|
||||
["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]),
|
||||
),
|
||||
selected: board.querySelector(".checker.selected")?.closest("[data-checker]")
|
||||
?.getAttribute("data-checker") ?? null,
|
||||
landing: [...board.querySelectorAll(".hit.can-land")].map((e) =>
|
||||
e.getAttribute("data-place"),
|
||||
),
|
||||
dragging: board.querySelector("#drag-piece") !== null,
|
||||
ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null,
|
||||
used: board.querySelectorAll(".die-used").length,
|
||||
focus: board.querySelector(".key-focus") !== null,
|
||||
rings: board.querySelectorAll(".checker-ring").length,
|
||||
checkers: board.querySelectorAll("circle.checker").length,
|
||||
confirm: !document.getElementById("confirm").hidden,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, de la zone à toucher d'un endroit. */
|
||||
async function center(place) {
|
||||
const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox();
|
||||
check(box !== null, `zone ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Le centre, à l'écran, du pion du dessus d'un endroit. */
|
||||
async function checkerCenter(place) {
|
||||
const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox();
|
||||
check(box !== null, `pion ${place} introuvable`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/** Prend le pion de from et le fait glisser au-dessus de la zone de to,
|
||||
* sans le lâcher ; renvoie l'état du plateau pendant le glissement. */
|
||||
async function dragOver(from, to) {
|
||||
const start = await checkerCenter(from);
|
||||
const end = await center(to);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 });
|
||||
await page.mouse.move(end.x, end.y, { steps: 8 });
|
||||
await sleep(100);
|
||||
return boardState();
|
||||
}
|
||||
|
||||
/** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre
|
||||
* et sans glissement (le pion rentré ou posé). */
|
||||
async function release() {
|
||||
await page.mouse.up();
|
||||
await until("glissement fini", () => document.getElementById("drag-piece") === null);
|
||||
await idle();
|
||||
}
|
||||
|
||||
/** Une capture de la recette, à la taille du moment (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
const size = page.viewportSize();
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Le filet des pions
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
let state = await boardState();
|
||||
check(state.checkers >= 28 && state.rings === state.checkers,
|
||||
`filet : ${state.rings} filets pour ${state.checkers} pions`);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await capture("plateau-filet");
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
log(`filet intérieur sur chaque pion (${state.rings})`);
|
||||
|
||||
// ------------------------------------- Un coup entier au glisser-déposer
|
||||
|
||||
state = await dragOver("p8", "p5");
|
||||
check(state.dragging && state.ghost === "p8" && state.selected === "p8",
|
||||
`glissement de 8 : ${JSON.stringify(state)}`);
|
||||
check(state.landing.includes("p5") && state.landing.includes("p7"),
|
||||
`glissement de 8 : cases d'arrivée ${state.landing}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null,
|
||||
`8/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await dragOver("p6", "p5");
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`);
|
||||
await page.click("#confirm");
|
||||
await idle();
|
||||
log("un coup entier joué au glisser-déposer (8/5 6/5)");
|
||||
|
||||
// Le glissement en paysage, pour la recette.
|
||||
if (full) {
|
||||
await startFrom(START, [3, 1]);
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(300);
|
||||
state = await dragOver("p13", "p10");
|
||||
check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`);
|
||||
await capture("glissement");
|
||||
await release();
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Lâché hors d'une case permise
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
const before = await boardState();
|
||||
// La 12 est tenue par l'ordinateur : 13/12 n'est pas permis.
|
||||
state = await dragOver("p13", "p12");
|
||||
check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`);
|
||||
await release();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging &&
|
||||
state.checkers === before.checkers,
|
||||
`lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`);
|
||||
log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué");
|
||||
|
||||
// ----------------------------------------------------------- Double toucher
|
||||
|
||||
await startFrom(SIX_AND_ONE, [6, 1]);
|
||||
// Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien.
|
||||
await page.dblclick('#board .hit[data-place="p6"]');
|
||||
await sleep(500);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`);
|
||||
// Depuis la 1 : 1/off puis 6/off, le seul coup.
|
||||
await page.dblclick('#board .hit[data-place="p1"]');
|
||||
await until("coup évident joué", () => !document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"],
|
||||
`double toucher sur la 1 : ${JSON.stringify(state)}`);
|
||||
log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1");
|
||||
|
||||
// ------------------------------------------------------------------ Clavier
|
||||
|
||||
await startFrom(START, [3, 1]);
|
||||
await page.locator("body").focus().catch(() => {});
|
||||
// Les pions jouables dans le sens de la course : 24, 13, 8, 6.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
state = await boardState();
|
||||
check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`);
|
||||
// Les cases d'arrivée de la 8 : la 7, puis la 5.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`);
|
||||
// En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5.
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
state = await boardState();
|
||||
check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`);
|
||||
await page.keyboard.press("Enter");
|
||||
await idle();
|
||||
state = await boardState();
|
||||
check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`);
|
||||
const keys = await page.evaluate(() => {
|
||||
const kbd = document.querySelector("#confirm kbd.shortcut");
|
||||
return {
|
||||
keyboard: document.documentElement.classList.contains("keyboard"),
|
||||
shown: kbd !== null && kbd.getBoundingClientRect().width > 0,
|
||||
text: kbd?.textContent ?? null,
|
||||
};
|
||||
});
|
||||
check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`);
|
||||
await capture("clavier");
|
||||
await page.keyboard.press(" ");
|
||||
await until("coup validé", () => document.getElementById("confirm").hidden);
|
||||
await idle();
|
||||
log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés");
|
||||
|
||||
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(`plateau soigné : tout est conforme (${captures} captures)`);
|
||||
+102
-15
@@ -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
|
||||
@@ -314,7 +353,8 @@ async function checkStable(when) {
|
||||
}
|
||||
|
||||
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte), touche de son
|
||||
* raccourci comprise. Un bouton plus
|
||||
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
|
||||
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
|
||||
async function wrappedButtons(target) {
|
||||
@@ -329,7 +369,9 @@ async function wrappedButtons(target) {
|
||||
const tops = new Set();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
||||
if (node.textContent.trim() === "") {
|
||||
// La touche du raccourci (kbd, quand un clavier est détecté)
|
||||
// est un badge en coin, pas une ligne du libellé.
|
||||
if (node.textContent.trim() === "" || node.parentElement?.closest("kbd.shortcut")) {
|
||||
continue;
|
||||
}
|
||||
const range = document.createRange();
|
||||
@@ -587,17 +629,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 +1092,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 +1105,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.)
|
||||
@@ -1127,6 +1196,7 @@ let resumed = false;
|
||||
let landscapeTurn = false;
|
||||
let rotatedChecked = false;
|
||||
let vibrationsChecked = false;
|
||||
let lastTouch = 0;
|
||||
let tracesSeen = 0;
|
||||
let replays = 0;
|
||||
let skips = 0;
|
||||
@@ -1354,7 +1424,7 @@ for (let actions = 0; ; actions++) {
|
||||
const confirm = document.getElementById("confirm");
|
||||
const cancel = document.getElementById("cancel");
|
||||
return {
|
||||
text: confirm.textContent,
|
||||
text: confirm.firstChild.textContent,
|
||||
primary: !confirm.classList.contains("secondary"),
|
||||
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
|
||||
inBar: confirm.closest("#actions .slot.primary") !== null,
|
||||
@@ -1430,9 +1500,14 @@ for (let actions = 0; ; actions++) {
|
||||
check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu");
|
||||
}
|
||||
const vibrationsBefore = (await vibrations()).length;
|
||||
// Deux touchers rapprochés sur la même pile seraient un double
|
||||
// toucher (coup évident, tests/browser/plateau.mjs) : ici, un
|
||||
// toucher à la fois, un demi-coup à la fois.
|
||||
await sleep(Math.max(0, lastTouch + 500 - Date.now()));
|
||||
// Un pion à destination unique avance dès qu'on le touche ; sinon,
|
||||
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
|
||||
// l'écran répond dans le gestionnaire du toucher.
|
||||
lastTouch = Date.now();
|
||||
const ms = await page.evaluate(() => {
|
||||
const hit = document.querySelector(".hit.can-move");
|
||||
const t0 = performance.now();
|
||||
@@ -1607,7 +1682,7 @@ await shot("4-fin-de-partie");
|
||||
// reste dans la barre d'actions.
|
||||
await page.click("#summary-close");
|
||||
await page.waitForSelector("#summary-veil", { state: "hidden" });
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
@@ -1617,11 +1692,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 +1825,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");
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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` : ""}`,
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+138
-62
@@ -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;
|
||||
@@ -422,10 +412,42 @@ button:disabled {
|
||||
}
|
||||
|
||||
.icon-button.labelled {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
/* Raccourci clavier d'un bouton, en petite touche (interaction.md) :
|
||||
* seulement quand un clavier est détecté (classe keyboard, app.ts). */
|
||||
.shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.keyboard .shortcut {
|
||||
display: inline-block;
|
||||
min-width: 1.6em;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Barre haute : la touche en coin, sans changer la taille du bouton. */
|
||||
.keyboard .icon-button.labelled .shortcut {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 3px;
|
||||
line-height: 15px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.icon-button .label {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
@@ -586,6 +608,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);
|
||||
}
|
||||
@@ -963,6 +998,13 @@ button:disabled {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
/* Le plateau de la partie : le doigt y fait glisser les pions, il ne fait
|
||||
* ni défiler ni zoomer la page (glisser-déposer, double toucher ; charte,
|
||||
* 1i). Les autres plateaux (accueil, tutoriel, position) laissent défiler. */
|
||||
#board {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-frame { fill: var(--frame); }
|
||||
.board-bed { fill: var(--bed); }
|
||||
.board-bar { fill: var(--bar); }
|
||||
@@ -971,9 +1013,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;
|
||||
}
|
||||
@@ -992,8 +1035,8 @@ button:disabled {
|
||||
stroke: var(--checker-computer-edge);
|
||||
}
|
||||
|
||||
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
|
||||
* le dessine. */
|
||||
/* Filet intérieur des pions (bundle.css), l'écho du logo : un cercle aux
|
||||
* trois cinquièmes du rayon, dessiné avec chaque pion (board-view.ts). */
|
||||
.checker-ring {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
@@ -1012,6 +1055,34 @@ button:disabled {
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* Glisser-déposer (charte, 1i) : le pion parti laisse un fantôme en
|
||||
* pointillés ; le pion glissé, agrandi, passe devant tout. */
|
||||
.drag-origin .checker {
|
||||
fill: none;
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.drag-origin .checker-ring,
|
||||
.drag-origin .checker-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-piece { pointer-events: none; }
|
||||
|
||||
.checker.dragged {
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
/* Clavier : le pion ou la case d'arrivée choisis aux flèches. */
|
||||
.key-focus {
|
||||
fill: none;
|
||||
stroke: var(--focus);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Tailles des chiffres du plateau : attribut font-size, calculé par
|
||||
* geometry.ts (13 px au moins à l'écran). */
|
||||
.checker-count {
|
||||
@@ -1105,10 +1176,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 +2102,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 +2113,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
|
||||
|
||||
Reference in New Issue
Block a user