Fusion de la MR !52 : bilan de fin de partie et écran d'analyse (1e)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #52.
This commit is contained in:
Pierre MARTIN
2026-11-10 16:03:43 +01:00
co-authored by Claude Opus 5.5
45 changed files with 4587 additions and 199 deletions
+38 -14
View File
@@ -14,6 +14,7 @@ import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from ".
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
import { engineCube, STANDARD_PLY } from "../game/game.js";
import { otherSide, type Side } from "../game/side.js";
import { replay } from "../game/state.js";
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
@@ -30,35 +31,55 @@ export const RANKING_SIZE = 5;
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
*/
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
return decisionsOf(events, "player");
}
/** Les décisions de side parmi des événements, comme playerDecisions. */
export function decisionsOf(events: readonly GameEvent[], side: Side): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === "player" && event.halfMoves.length > 0;
return event.side === side && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === "player";
return event.side === side;
default:
return false;
}
});
}
/** Ce qu'on demande à l'analyse d'une décision. */
export interface DecisionOptions {
/** Niveau de l'analyse ; ANALYSIS_PLY par défaut. */
readonly ply?: Ply;
/** Le camp qui décide ; le joueur par défaut. Le conseil est alors vu
* de ce camp (sa position, ses coups). */
readonly side?: Side;
/** Garder le classement des coups (une seconde analyse) ; oui par
* défaut. L'analyse des coups de l'ordinateur s'en passe. */
readonly ranking?: boolean;
}
/**
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
* possible, videau inaccessible ou position de départ pour gnubg).
* Le conseil du moteur sur la décision eventId du joueur (ou du camp
* options.side) ; null s'il n'y a rien à analyser (pas une décision de ce
* camp, aucun coup possible, videau inaccessible ou position de départ pour
* gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
ply: Ply = ANALYSIS_PLY,
options: DecisionOptions = {},
): Promise<Advice | null> {
const ply = options.ply ?? ANALYSIS_PLY;
const side = options.side ?? "player";
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || playerDecisions([event]).length === 0) {
if (event === undefined || decisionsOf([event], side).length === 0) {
return null;
}
const earlier = line.slice(0, -1);
@@ -67,10 +88,10 @@ export async function analyseDecision(
if (before.roll === null) {
return null;
}
const cube = engineCube(before, "player");
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
// même position vue du joueur.
const after = await engine.board(event.position, engineCube(before, "computer"));
const cube = engineCube(before, side);
// Le coup joué est enregistré vu de l'adversaire : le moteur donne
// la même position vue de celui qui l'a joué.
const after = await engine.board(event.position, engineCube(before, otherSide(side)));
const analysis = await engine.analyseMove(
before.position,
cube,
@@ -81,7 +102,10 @@ export async function analyseDecision(
if (analysis === null) {
return null;
}
const ranking = await engine.rankMoves(before.position, cube, before.roll, ply);
const ranking =
options.ranking === false
? []
: await engine.rankMoves(before.position, cube, before.roll, ply);
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
@@ -105,7 +129,7 @@ export async function analyseDecision(
};
}
let action: CubeAction;
let decider: "player" | "computer" = "player";
let decider: Side = side;
if (event.type === "DiceRolled") {
if (before.phase !== "rolling") {
return null;
@@ -115,7 +139,7 @@ export async function analyseDecision(
action = "double";
} else {
action = event.type === "CubeTaken" ? "take" : "pass";
decider = "computer";
decider = otherSide(side);
}
const analysis = await engine.analyseCube(
before.position,
+579
View File
@@ -0,0 +1,579 @@
// L'analyse d'une partie (étape 1e) : ce qui manque à analyser, le calcul
// de chaque analyse par le moteur, et les chiffres du bilan à la manière de
// GNU Backgammon (taux d'erreur, chance, résultat corrigé de la chance).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qui juge le jeu vient du moteur : l'analyse de chaque décision
// (équité perdue, appréciation), la chance de chaque jet, l'évaluation des
// positions. Ce module ne fait que les ranger et les additionner, comme le
// fait gnubg dans ses statistiques de partie. Sources, dans gnubg 1.08.003
// (commit 14b67984, ADR 0005), fichiers analysis.c et formatgs.c :
// - updateStatcontext (analysis.c, l. 304-596) : les erreurs de chaque
// décision et leur nombre ;
// - getMWCFromError (analysis.c, l. 1432-1472) : le taux d'erreur ;
// - GetRating et arThrsRating (analysis.c, l. 83-101) : le classement ;
// - formatgs.c, l. 348-374 et getLuckRating (analysis.c, l. 1757) : la
// chance par coup et son appréciation ;
// - arLuckAdj (analysis.c, l. 304-330 et 405-414) : le résultat corrigé de
// la chance.
// Les tâches d'analyse sont indépendantes : l'application les lance une à
// une sur le second moteur, en arrière-plan, et peut s'arrêter entre deux.
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { Engine, LuckRating } from "../game/engine.js";
import { LUCK_THRESHOLDS } from "../game/engine.js";
import {
currentLine,
type GameEnded,
type GameEvent,
type GameJournal,
type MovePlayed,
withAdvice,
withReview,
} from "../game/events.js";
import { engineCube } from "../game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel } from "../game/level.js";
import { emptyReview, type GameReview } from "../game/review.js";
import type { Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
import { applyEvent, replay } from "../game/state.js";
import { ANALYSIS_PLY, analyseDecision, decisionsOf } from "./analyse.js";
// ------------------------------------------------------------ Les tours
/**
* Un tour de la partie : ce qu'un camp a fait quand il avait la main
* (doubler ou non, lancer, jouer), avec la réponse de l'adversaire à son
* double. Les tours sont numérotés à partir de 1 ; c'est le « coup » de
* l'écran d'analyse (#/parties/<id>/analyse/<n>).
*/
export interface Turn {
readonly number: number;
/** Le camp qui avait la main. */
readonly side: Side;
/** Le jet joué (null : le tour s'est fini sur un double passé). */
readonly roll: Roll | null;
/** Le coup joué (null : double passé). */
readonly move: MovePlayed | null;
/** Les événements du tour, dans l'ordre (sans la fin de partie). */
readonly events: readonly GameEvent[];
/** La fin de partie, si elle suit ce tour. */
readonly end: GameEnded | null;
}
/** Les tours d'une ligne de jeu (événements du début à la fin). */
export function turnsOf(line: readonly GameEvent[]): Turn[] {
const turns: Turn[] = [];
let bucket: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "GameStarted") {
continue;
}
if (event.type === "GameEnded") {
const last = turns.at(-1);
if (bucket.length === 0 && last !== undefined) {
turns[turns.length - 1] = { ...last, end: event };
} else if (bucket.length > 0) {
const offer = bucket.find((e) => e.type === "CubeOffered");
const side = offer?.type === "CubeOffered" ? offer.side : (before.turn ?? "player");
turns.push(makeTurn(turns.length + 1, side, bucket, null, event));
}
bucket = [];
continue;
}
bucket.push(event);
if (event.type === "MovePlayed") {
turns.push(makeTurn(turns.length + 1, event.side, bucket, event, null));
bucket = [];
}
}
return turns;
}
function makeTurn(
number: number,
side: Side,
events: readonly GameEvent[],
move: MovePlayed | null,
end: GameEnded | null,
): Turn {
const rolled = move === null ? undefined : events.findLast((e) => e.type === "DiceRolled");
return {
number,
side,
roll: rolled?.type === "DiceRolled" ? rolled.roll : null,
move,
events,
end,
};
}
/** Les tours de la ligne courante d'un journal. */
export function gameTurns(journal: GameJournal): Turn[] {
return turnsOf(currentLine(journal));
}
// --------------------------------------------- Ce qui reste à analyser
/** Une analyse à demander au moteur. */
export type AnalysisTask =
/** Une décision (coup ou videau) d'un camp. */
| { readonly kind: "decision"; readonly side: Side; readonly eventId: string }
/** La chance d'un jet. */
| { readonly kind: "luck"; readonly eventId: string }
/** Les chances de gagner du joueur après un coup. */
| { readonly kind: "chance"; readonly eventId: string };
/** L'analyse de partie du journal, au niveau d'analyse ; vide s'il n'y en
* a pas encore (ou à un autre niveau). */
export function reviewOf(journal: GameJournal): GameReview {
const review = journal.review;
return review !== undefined && review.ply === ANALYSIS_PLY ? review : emptyReview(ANALYSIS_PLY);
}
/**
* Ce qui reste à analyser sur la ligne courante, dans l'ordre où le bilan
* en a besoin : les décisions du joueur (celles d'avant l'étape 1d, ou
* dont l'analyse est arrivée trop tard), la chance de chaque jet, puis,
* pour l'écran d'analyse, les décisions de l'ordinateur et les chances de
* gagner après chaque coup.
*/
export function missingAnalyses(journal: GameJournal): AnalysisTask[] {
const line = currentLine(journal);
const review = reviewOf(journal);
const skipped = new Set(review.unanalysed);
const advice = journal.advice ?? {};
const tasks: AnalysisTask[] = [];
for (const event of decisionsOf(line, "player")) {
if (advice[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "player", eventId: event.id });
}
}
for (const event of luckyRolls(line)) {
if (review.luck[event.id] === undefined) {
tasks.push({ kind: "luck", eventId: event.id });
}
}
for (const event of decisionsOf(line, "computer")) {
if (review.computer[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "computer", eventId: event.id });
}
}
for (const event of line) {
if (event.type === "MovePlayed" && review.winChances[event.id] === undefined) {
tasks.push({ kind: "chance", eventId: event.id });
}
}
return tasks;
}
/** Ce qu'il faut au bilan : toutes les décisions du joueur et la chance
* de tous les jets. */
export function readyForSummary(journal: GameJournal): boolean {
return missingAnalyses(journal).every(
(task) => task.kind === "chance" || (task.kind === "decision" && task.side === "computer"),
);
}
/** Les jets qui ont une chance : tous ceux d'un camp, et le jet
* d'ouverture qui désigne le premier joueur (pas une égalité, qui se
* relance). */
function luckyRolls(line: readonly GameEvent[]): GameEvent[] {
const rolls: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type !== "DiceRolled") {
continue;
}
if (event.side !== null || (before.phase === "opening" && state.phase === "moving")) {
rolls.push(event);
}
}
return rolls;
}
/**
* Fait une analyse avec le moteur et la range dans le journal, qui est
* renvoyé (le journal reçu ne change pas). Une décision que le moteur
* n'analyse pas est notée comme telle, pour ne plus la demander.
*/
export async function runAnalysis(
engine: Engine,
journal: GameJournal,
task: AnalysisTask,
): Promise<GameJournal> {
const review = reviewOf(journal);
switch (task.kind) {
case "decision": {
const advice = await analyseDecision(engine, journal, task.eventId, {
side: task.side,
ranking: task.side === "player",
});
if (advice === null) {
return withReview(journal, {
...review,
unanalysed: [...review.unanalysed, task.eventId],
});
}
return task.side === "player"
? withAdvice(journal, advice)
: withReview(journal, {
...review,
computer: { ...review.computer, [task.eventId]: advice },
});
}
case "luck": {
const line = currentLine({ ...journal, head: task.eventId });
const event = line.at(-1);
if (event?.type !== "DiceRolled") {
throw new Error(`pas un jet : ${task.eventId}`);
}
const before = replay(line.slice(0, -1));
// Le camp qui joue le jet ; au jet d'ouverture, celui qu'il
// désigne. La position de départ est la même pour les deux.
const roller = event.side ?? replay(line).turn ?? "player";
const luck = await engine.luck(before.position, engineCube(before, roller), event.roll);
return withReview(journal, { ...review, luck: { ...review.luck, [event.id]: luck } });
}
case "chance": {
const chance = await chanceAfter(engine, journal, task.eventId);
return withReview(journal, {
...review,
winChances: { ...review.winChances, [task.eventId]: chance },
});
}
}
}
/** Les chances de gagner du joueur après le coup moveId : le résultat si
* la partie finit là, sinon l'évaluation du moteur, vue de celui qui a
* alors la main. */
async function chanceAfter(engine: Engine, journal: GameJournal, moveId: string): Promise<number> {
const line = currentLine(journal);
const index = line.findIndex((e) => e.id === moveId);
const upTo = index >= 0 ? line.slice(0, index + 1) : currentLine({ ...journal, head: moveId });
const next = index >= 0 ? line[index + 1] : undefined;
if (next?.type === "GameEnded") {
return next.winner === "player" ? 1 : 0;
}
const after = replay(upTo);
const onRoll = after.turn ?? "player";
const evaluation = await engine.evaluate(
after.position,
engineCube(after, onRoll),
ANALYSIS_PLY,
);
return onRoll === "player" ? evaluation.win : 1 - evaluation.win;
}
// ------------------------------------------------ Les chiffres du bilan
/**
* Classement de gnubg selon le taux d'erreur par décision (GetRating et
* arThrsRating, analysis.c l. 83-101) : en dessous de 0,005, « Super
* Grandmaster » ; puis 0,008 « Grandmaster », 0,013 « Master », 0,020
* « Advanced », 0,032 « Intermediate », au-delà « Beginner ». Les six
* classements portent les noms des six niveaux de l'ordinateur, dans le
* même ordre (correspondance dans docs/glossaire/taux-d-erreur.md).
*/
export const RATING_THRESHOLDS: readonly number[] = [0.032, 0.02, 0.013, 0.008, 0.005];
/**
* Le taux d'erreur d'un camp, à la manière de gnubg (getMWCFromError,
* analysis.c) : l'équité perdue sur toutes ses décisions, divisée par le
* nombre de coups non forcés et de décisions de videau « serrées ».
*
* Glossaire : docs/glossaire/taux-d-erreur.md
*/
export interface ErrorRate {
/** Équité perdue au total, pour un videau de 1 (positive ou nulle). */
readonly total: number;
/** Nombre de décisions comptées. */
readonly decisions: number;
/** Équité perdue par décision (0 sans décision). */
readonly perDecision: number;
/** Le niveau correspondant, du nom des niveaux de l'ordinateur. */
readonly level: ComputerLevel;
}
/** La chance d'un camp sur la partie, à la manière de gnubg (formatgs.c,
* « Luck total », « Luck rate », « Luck rating »). */
export interface LuckSummary {
/** Somme de la chance de ses jets, pour un videau de 1. */
readonly total: number;
/** Nombre de ses tours avec un jet (anTotalMoves de gnubg). */
readonly rolls: number;
/** Chance par tour (0 sans tour). */
readonly perRoll: number;
/** getLuckRating : la chance par tour, multipliée par 10, comparée aux
* seuils d'un jet (LUCK_THRESHOLDS). */
readonly rating: LuckRating;
/** La chance comptée en points : chaque jet multiplié par la valeur
* du videau à ce moment (arLuck[…][1] de gnubg en partie libre). */
readonly points: number;
}
/** Ce que le bilan dit d'un camp. */
export interface SideSummary {
readonly errorRate: ErrorRate;
readonly luck: LuckSummary;
}
/** Une décision du joueur et son conseil, dans son tour. */
export interface ReviewedDecision {
readonly turn: number;
readonly advice: Advice;
}
/** Le bilan d'une partie (ligne courante). */
export interface GameSummary {
readonly player: SideSummary;
readonly computer: SideSummary;
/** Les décisions du joueur comptées, par appréciation : ses coups, ses
* doubles et ses réponses aux doubles, et ses jets sans doubler
* seulement quand doubler était mieux. */
readonly counts: Readonly<Record<Verdict, number>>;
/** La plus grosse erreur du joueur (null : aucune imprécision ni
* erreur). */
readonly toReview: ReviewedDecision | null;
/** Le résultat de la partie en points pour le joueur (0 si elle n'est
* pas finie), et ce résultat corrigé de la chance des deux camps
* (arLuckAdj de gnubg) : ce qu'il serait sans les dés. */
readonly result: number;
readonly luckAdjustedResult: number;
}
/** Le niveau de gnubg pour un taux d'erreur par décision. */
export function levelOfErrorRate(perDecision: number): ComputerLevel {
let index = 0;
while (index < RATING_THRESHOLDS.length && perDecision < (RATING_THRESHOLDS[index] ?? 0)) {
index++;
}
return COMPUTER_LEVELS[index] ?? "beginner";
}
/** getLuckRating de gnubg : Luck(chance par tour × 10). */
export function luckRatingOf(perRoll: number): LuckRating {
const r = perRoll * 10;
if (r > LUCK_THRESHOLDS.veryLucky) {
return "veryLucky";
}
if (r > LUCK_THRESHOLDS.lucky) {
return "lucky";
}
if (r < -LUCK_THRESHOLDS.veryLucky) {
return "veryUnlucky";
}
if (r < -LUCK_THRESHOLDS.lucky) {
return "unlucky";
}
return "none";
}
/**
* Une décision de videau « serrée » pour gnubg (isCloseCubedecision,
* eval.c l. 5107) : doubler coûte moins de 0,16 par rapport à l'optimum.
* Un jet sans doubler n'est compté dans le taux d'erreur que dans ce cas ;
* un double, une prise ou un refus le sont toujours.
*/
function closeCube(advice: CubeAdvice): boolean {
const e = advice.equities;
return e.optimal - Math.min(e.doubleTake, 1) < 0.16;
}
/** L'erreur d'une décision (équité perdue, positive) et si elle compte
* dans le nombre de décisions (updateStatcontext). */
function errorOf(advice: Advice): { error: number; counted: boolean } {
const error = -Math.min(0, advice.equityLoss);
if (advice.kind === "move") {
// Un coup forcé ne compte pas (anUnforcedMoves).
return { error: advice.moveCount > 1 ? error : 0, counted: advice.moveCount > 1 };
}
return { error, counted: advice.action !== "noDouble" || closeCube(advice) };
}
function errorRateOf(advices: readonly Advice[]): ErrorRate {
let total = 0;
let decisions = 0;
for (const advice of advices) {
const { error, counted } = errorOf(advice);
total += error;
decisions += counted ? 1 : 0;
}
const perDecision = decisions > 0 ? total / decisions : 0;
return { total, decisions, perDecision, level: levelOfErrorRate(perDecision) };
}
/** Le bilan de la ligne courante, avec les analyses déjà faites (les
* manquantes ne comptent pas : voir readyForSummary). */
export function summarizeAnalysis(journal: GameJournal): GameSummary {
const line = currentLine(journal);
const review = reviewOf(journal);
const advice = journal.advice ?? {};
const turns = turnsOf(line);
const turnOf = new Map<string, number>();
for (const turn of turns) {
for (const event of turn.events) {
turnOf.set(event.id, turn.number);
}
}
const decided = (side: Side): Advice[] =>
decisionsOf(line, side)
.map((e) => (side === "player" ? advice[e.id] : review.computer[e.id]))
.filter((a): a is Advice => a !== undefined);
const mine = decided("player");
// La chance de chaque camp, et la valeur du videau à chaque jet.
const luck: Record<Side, { total: number; points: number; rolls: number }> = {
player: { total: 0, points: 0, rolls: 0 },
computer: { total: 0, points: 0, rolls: 0 },
};
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "MovePlayed") {
luck[event.side].rolls++;
}
const value = event.type === "DiceRolled" ? review.luck[event.id] : undefined;
if (value !== undefined && event.type === "DiceRolled") {
const roller = event.side ?? state.turn ?? "player";
luck[roller].total += value.value;
luck[roller].points += value.value * before.cube.value;
}
}
const luckSummary = (side: Side): LuckSummary => {
const { total, points, rolls } = luck[side];
const perRoll = rolls > 0 ? total / rolls : 0;
return { total, rolls, perRoll, rating: luckRatingOf(perRoll), points };
};
const counts: Record<Verdict, number> = { good: 0, doubtful: 0, bad: 0, veryBad: 0 };
let toReview: ReviewedDecision | null = null;
for (const a of mine) {
const verdict = verdictOf(a.skill);
// Un jet sans doubler n'est une décision visible que s'il était
// une faute.
if (!(a.kind === "cube" && a.action === "noDouble" && verdict === "good")) {
counts[verdict]++;
}
if (
verdict !== "good" &&
(toReview === null || a.equityLoss < toReview.advice.equityLoss)
) {
toReview = { turn: turnOf.get(a.eventId) ?? 0, advice: a };
}
}
const ended = line.findLast((e) => e.type === "GameEnded");
const result =
ended?.type === "GameEnded" ? (ended.winner === "player" ? 1 : -1) * ended.points : 0;
const player = luckSummary("player");
const computer = luckSummary("computer");
return {
player: { errorRate: errorRateOf(mine), luck: player },
computer: { errorRate: errorRateOf(decided("computer")), luck: computer },
counts,
toReview,
result,
luckAdjustedResult: result - player.points + computer.points,
};
}
// ------------------------------------------------ Coup par coup
/** Un point de la courbe des chances de gagner : un tour. */
export interface ChancePoint {
readonly turn: number;
readonly side: Side;
/** Chances de gagner du joueur après le tour, de 0 à 1 (null : pas
* encore analysé). */
readonly chance: number | null;
/** L'appréciation de la décision du joueur la plus coûteuse du tour
* (null : aucune décision du joueur analysée). */
readonly verdict: Verdict | null;
}
/**
* La courbe des chances de gagner du joueur, tour après tour : la position
* après chaque coup évaluée par le moteur, et le résultat après le dernier.
*
* Glossaire : docs/glossaire/courbe-des-chances.md
*/
export type WinChanceCurve = readonly ChancePoint[];
/** La décision du joueur à revoir dans un tour : la plus coûteuse, le coup
* à égalité (null : aucune décision du joueur analysée dans ce tour). */
export function mainAdvice(journal: GameJournal, turn: Turn): Advice | null {
const advice = journal.advice ?? {};
let main: Advice | null = null;
for (const event of decisionsOf(turn.events, "player")) {
const a = advice[event.id];
if (a === undefined) {
continue;
}
if (
main === null ||
a.equityLoss < main.equityLoss ||
(a.equityLoss === main.equityLoss && a.kind === "move")
) {
main = a;
}
}
return main;
}
/** Le conseil sur le coup de l'ordinateur d'un tour (null sinon). */
export function computerMoveAdvice(journal: GameJournal, turn: Turn): MoveAdvice | null {
if (turn.side !== "computer" || turn.move === null) {
return null;
}
const advice = reviewOf(journal).computer[turn.move.id];
return advice?.kind === "move" ? advice : null;
}
export function chanceCurve(journal: GameJournal): WinChanceCurve {
const review = reviewOf(journal);
return gameTurns(journal).map((turn) => {
const main = mainAdvice(journal, turn);
let chance: number | null = null;
if (turn.move !== null) {
chance = review.winChances[turn.move.id] ?? null;
}
if (turn.end !== null && (turn.move === null || chance === null)) {
chance = turn.end.winner === "player" ? 1 : 0;
}
return {
turn: turn.number,
side: turn.side,
chance,
verdict: main === null ? null : verdictOf(main.skill),
};
});
}
/**
* « Rejouer d'ici » sur un tour : l'événement où revenir pour rejouer la
* décision à revoir du joueur (juste avant elle : même jet pour un coup,
* même moment pour un videau). null pour un tour sans décision du joueur.
*/
export function replayPoint(journal: GameJournal, turn: Turn): string | null {
const main = mainAdvice(journal, turn);
const decisions = decisionsOf(turn.events, "player");
const event = main === null ? decisions.at(-1) : decisions.find((e) => e.id === main.eventId);
return event?.parent ?? null;
}
+46 -1
View File
@@ -15,7 +15,8 @@ import {
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { CubeAction } from "../game/engine.js";
import type { CubeAction, LuckRating } from "../game/engine.js";
import type { GameSummary } from "./game-analysis.js";
/** Les gabarits, par clé de section (« coup.meilleur »…). */
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
@@ -93,8 +94,52 @@ export const PHRASE_KEYS: readonly string[] = [
"contexte.videau.double",
"contexte.videau.pas-de-double",
"contexte.videau.reponse",
"bilan.jeu.gagne",
"bilan.jeu.perdu",
"bilan.des",
"bilan.des.tres-pour",
"bilan.des.pour",
"bilan.des.neutres",
"bilan.des.contre",
"bilan.des.tres-contre",
"bilan.aucun-coup",
];
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
veryLucky: "bilan.des.tres-pour",
lucky: "bilan.des.pour",
none: "bilan.des.neutres",
unlucky: "bilan.des.contre",
veryUnlucky: "bilan.des.tres-contre",
};
/** La chance de vos dés sur la partie, en mots : « un peu contre vous »… */
export function diceWords(phrases: MasterPhrases, rating: LuckRating): string {
return pick(phrases, DICE_KEYS[rating], "0");
}
/**
* La lecture du bilan d'une partie finie : ce qui a fait la différence,
* selon le résultat corrigé de la chance (gnubg) ; et vos dés, en mots.
* Partie gagnée : votre jeu, si le résultat corrigé de la chance reste
* positif, sinon les dés ; partie perdue : le jeu de l'ordinateur, si le
* résultat corrigé reste négatif, sinon les dés.
*/
export function summaryReading(phrases: MasterPhrases, summary: GameSummary): string {
const dice = diceWords(phrases, summary.player.luck.rating);
const won = summary.result > 0;
const playDecided = won ? summary.luckAdjustedResult > 0 : summary.luckAdjustedResult < 0;
if (!playDecided) {
return pick(phrases, "bilan.des", "0");
}
return fillIn(pick(phrases, won ? "bilan.jeu.gagne" : "bilan.jeu.perdu", "0"), { dice });
}
/** « Aucun coup à revoir… » : la carte du coup à revoir sans erreur. */
export function nothingToReview(phrases: MasterPhrases): string {
return pick(phrases, "bilan.aucun-coup", "0");
}
/** Ce que le maître dit d'un conseil du moteur. */
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
+15
View File
@@ -104,6 +104,11 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** L'évaluation d'une position, joueur au trait avant de lancer les
* dés, au niveau ply : ses chances de gain (sans videau) et son équité
* avec videau. */
evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation>;
/** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui
* (partie libre sans match), sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
@@ -189,6 +194,16 @@ export interface Luck {
readonly rating: LuckRating;
}
/** Une position évaluée par le moteur, du point de vue du joueur au
* trait. */
export interface Evaluation {
/** Chances de gagner la partie, de 0 à 1, sans tenir compte du videau
* (première probabilité de gnubg). */
readonly win: number;
/** Équité avec videau, normalisée pour un videau de 1. */
readonly equity: number;
}
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
export interface EvaluatedMove extends Move {
readonly equity: number;
+75 -2
View File
@@ -17,6 +17,7 @@ import type { Cube } from "./cube.js";
import type { Level, ResultKind } from "./engine.js";
import type { Match, MatchAfterGame } from "./match.js";
import type { HalfMove, PositionId } from "./position.js";
import { type GameReview, REVIEW_VERSION } from "./review.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
@@ -40,7 +41,7 @@ const VERSION_1_LEVEL: Level = "2ply";
* version inconnue est laissé tel quel (Game.resume le refuse).
*/
export function migrateJournal(saved: GameJournal): GameJournal {
const journal = keepKnownAdvice(saved);
const journal = keepKnownReview(keepKnownAdvice(saved));
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
@@ -87,6 +88,56 @@ function keepKnownAdvice(journal: GameJournal): GameJournal {
return { ...journal, advice: Object.fromEntries(kept) };
}
/**
* L'analyse de partie d'un journal enregistré (étape 1e) : oubliée si elle
* est d'une autre version (REVIEW_VERSION) ou mal formée ; sinon, seules
* restent ses entrées rattachées à un événement du journal. Ce n'est
* qu'une analyse du moteur, qui peut être refaite. Un journal sans analyse
* (enregistré avant l'étape 1e) reste tel quel.
*/
function keepKnownReview(journal: GameJournal): GameJournal {
const review = journal.review;
if (review === undefined) {
return journal;
}
const tables = [review.luck, review.winChances, review.computer];
const wellFormed =
typeof review === "object" &&
review !== null &&
review.version === REVIEW_VERSION &&
typeof review.ply === "number" &&
tables.every((table) => typeof table === "object" && table !== null) &&
Array.isArray(review.unanalysed);
if (!wellFormed) {
const { review: _, ...rest } = journal;
return rest;
}
const ids = new Set(journal.events.map((event) => event.id));
const known = <T>(table: Readonly<Record<string, T>>) =>
Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(id)));
const computer = Object.fromEntries(
Object.entries(review.computer).filter(
([id, a]) =>
ids.has(id) &&
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id,
),
);
const kept: GameReview = {
...review,
luck: known(review.luck),
winChances: known(review.winChances),
computer,
unanalysed: review.unanalysed.filter((id) => ids.has(id)),
};
// Le nombre d'entrées de chaque table : rien d'oublié, rien ne change.
const sizes = (r: GameReview) =>
[r.luck, r.winChances, r.computer, r.unanalysed].map((t) => Object.keys(t).length).join();
return sizes(kept) === sizes(review) ? journal : { ...journal, review: kept };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
readonly id: string;
@@ -182,11 +233,14 @@ type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
* conseil du maître sur les décisions du joueur, par identifiant de
* l'événement de la décision. Ce n'est pas un événement : il arrive après
* coup, quand l'analyse du moteur est prête, et ne change pas la partie. */
* coup, quand l'analyse du moteur est prête, et ne change pas la partie.
* review (étape 1e, facultatif) : le reste de l'analyse de la partie (chance
* des jets, chances de gagner, décisions de l'ordinateur), pour le bilan. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
readonly review?: GameReview;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -211,6 +265,25 @@ export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
}
/** Remplace l'analyse de partie du journal (elle ne change pas la
* partie). */
export function withReview(journal: GameJournal, review: GameReview): GameJournal {
return { ...journal, review };
}
/** Le journal avec les analyses d'une copie de lui-même, faite en
* arrière-plan : ses conseils s'ajoutent, son analyse de partie remplace
* celle du journal. Les événements ne changent pas. */
export function withAnalysesOf(journal: GameJournal, analysed: GameJournal): GameJournal {
const ids = new Set(journal.events.map((event) => event.id));
const advice = Object.entries(analysed.advice ?? {}).filter(([id]) => ids.has(id));
return {
...journal,
advice: { ...Object.fromEntries(advice), ...journal.advice },
...(analysed.review === undefined ? {} : { review: analysed.review }),
};
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
export function currentLine(journal: GameJournal): GameEvent[] {
const byId = new Map(journal.events.map((event) => [event.id, event]));
+7
View File
@@ -24,6 +24,7 @@ import {
migrateJournal,
type NewGameEvent,
withAdvice,
withAnalysesOf,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
@@ -284,6 +285,12 @@ export class Game {
this.#journal = withAdvice(this.#journal, advice);
}
/** Range dans le journal les analyses faites en arrière-plan sur une
* copie de celui-ci (étape 1e : conseils, analyse de partie). */
recordAnalyses(analysed: GameJournal): void {
this.#journal = withAnalysesOf(this.#journal, analysed);
}
/**
* Les demi-coups que le joueur peut saisir maintenant, à la suite de ceux
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
+55
View File
@@ -0,0 +1,55 @@
// L'analyse de la partie par le moteur, rangée dans le journal à côté du
// conseil du maître (étape 1e) : la chance de chaque jet, les chances de
// gagner après chaque coup, et les décisions de l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur. Le calcul est dans domain/analysis/game-analysis.ts. Comme le
// conseil (advice.ts), ce n'est pas un événement : l'analyse arrive après
// coup et ne change pas la partie. Le format est durable (ADR 0007) : il
// porte sa version (REVIEW_VERSION), et une analyse d'une autre version
// est oubliée à la reprise (elle peut être refaite).
import type { Advice } from "./advice.js";
import type { Luck, Ply } from "./engine.js";
/** Version du format de l'analyse de partie enregistrée. */
export const REVIEW_VERSION = 1;
/**
* L'analyse de la partie, en plus des conseils du maître : de quoi
* calculer le bilan (taux d'erreur, chance) et tracer la courbe des chances
* de gagner. Chaque table est indexée par l'identifiant d'un événement du
* journal ; une table incomplète est complétée en arrière-plan.
*/
export interface GameReview {
readonly version: number;
/** Niveau des analyses et des évaluations, en plis. */
readonly ply: Ply;
/** Pour chaque jet (DiceRolled, ouverture comprise), sa chance pour le
* camp qui le joue, selon le moteur. */
readonly luck: Readonly<Record<string, Luck>>;
/** Pour chaque coup (MovePlayed), les chances de gagner du joueur dans
* la position d'après, de 0 à 1, selon le moteur. */
readonly winChances: Readonly<Record<string, number>>;
/** Les décisions de l'ordinateur, analysées comme celles du joueur (le
* conseil d'un coup de l'ordinateur est vu de lui). */
readonly computer: Readonly<Record<string, Advice>>;
/** Les décisions (des deux camps) que le moteur n'analyse pas : videau
* inaccessible, position de départ. Elles ne sont plus demandées. */
readonly unanalysed: readonly string[];
}
/** Une analyse vide, au niveau ply. */
export function emptyReview(ply: Ply): GameReview {
return {
version: REVIEW_VERSION,
ply,
luck: {},
winChances: {},
computer: {},
unanalysed: [],
};
}
+10
View File
@@ -19,6 +19,7 @@ import type {
EngineCube,
EngineMatch,
EvaluatedMove,
Evaluation,
HalfMovesCheck,
Level,
Luck,
@@ -260,6 +261,15 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
async evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation> {
const args = ["--ply", String(ply), "eval", position, await matchId(cube)];
const answer = (await run(args)) as {
probabilities: { win: number };
cubeful_equity: number;
};
return { win: answer.probabilities.win, equity: answer.cubeful_equity };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {
+21
View File
@@ -6,8 +6,10 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { analyseDecision } from "./domain/analysis/analyse.js";
import { runAnalysis } from "./domain/analysis/game-analysis.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { levelOf } from "./domain/game/saved-game.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
@@ -103,6 +105,22 @@ startApp({
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
shownId: () => storage().session.shownId,
open: (id, branchHead) => storage().session.open(id, branchHead),
view: async (id) => {
const saved = await storage().library.load(id);
if (saved === null) {
return null;
}
try {
return Game.resume(engine, saved.journal);
} catch {
return null;
}
},
saveAnalyses: (id, journal) => {
storage()
.library.saveAnalyses(id, journal, { level: levelOf(journal) })
.catch(() => {});
},
remove: (id) => storage().library.remove(id),
exportSgf: async (id) => {
const game = await storage().library.load(id);
@@ -143,6 +161,9 @@ startApp({
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
// manquent, une à une, sur le même moteur à part.
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
+13
View File
@@ -33,6 +33,10 @@ export interface GameLibrary {
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Enregistre le journal d'une partie analysée en arrière-plan (étape
* 1e) : ni la partie en cours ni la date de la dernière action ne
* changent. Rien si la partie n'existe plus. */
saveAnalyses(id: string, journal: GameJournal, details: SavedGameDetails): Promise<void>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
@@ -118,6 +122,15 @@ export function createGameLibrary(
await backend.setCurrentId(id);
return summary;
}),
saveAnalyses: (id, journal, details) =>
enqueue(async () => {
const known = await backend.summary(id);
if (known === undefined) {
return;
}
const times = { startedAt: known.startedAt, updatedAt: known.updatedAt };
await backend.write(summarizeGame(id, journal, details, times), journal);
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
+17 -83
View File
@@ -15,16 +15,14 @@ import {
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import type { Game } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { routeHash } from "./router.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Préfixe des identifiants du plateau de la feuille. */
const PREFIX = "explain-";
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
@@ -85,8 +83,8 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
// fermeture, arrête l'animation en cours.
let opened = 0;
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
let playing = 0;
// Le plateau de la feuille joue un coup à la fois.
let player: MovePlayer | null = null;
async function show(eventId: string): Promise<void> {
const id = ++opened;
@@ -165,75 +163,14 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre. */
function boardSvg(box: HTMLElement): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "explain-board";
svg.setAttribute("class", "board explain-board");
svg.setAttribute("role", "img");
box.append(svg);
/** Le plateau de la feuille, dans son cadre, et ce qui y joue les
* coups. */
function sheetBoard(box: HTMLElement): SVGSVGElement {
const svg = boardSvg(box, "explain-board", "explain-board");
player = createMovePlayer(svg, PREFIX, options.reducedMotion);
return svg;
}
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(
svg,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
boardModel(boards),
{
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
},
);
// Les flèches des traces renvoient à leur pointe par identifiant :
// celui du plateau de jeu serait pris à sa place.
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `explain-${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
}
}
/** Joue un coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
const id = ++playing;
if (!options.reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(svg, steps, i, false);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(svg, steps, i + 1, false);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
if (id === playing) {
draw(svg, steps, steps.halfMoves.length, true);
}
}
async function showMoves(
id: number,
game: Game,
@@ -250,13 +187,12 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
return;
}
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
const svg = boardSvg(boardBox);
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);
draw(svg, mine, 0, false);
void animate(svg, mine);
void player?.play(mine);
return;
}
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
@@ -287,9 +223,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
legend.textContent = text;
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
const steps = shown === "mine" ? mine : best;
draw(svg, steps, 0, false);
void animate(svg, steps);
void player?.play(shown === "mine" ? mine : best);
};
for (const key of ["mine", "best"] as const) {
const button = buttons[key];
@@ -320,9 +254,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox);
const svg = boardSvg(boardBox, "explain-board", "explain-board");
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
draw(svg, steps, 0, false);
drawSteps(svg, steps, 0, false, PREFIX);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
@@ -399,7 +333,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
show,
hide() {
opened++;
playing++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
+682
View File
@@ -0,0 +1,682 @@
// L'écran d'analyse d'une partie (#/parties/<id>/analyse/<coup>, étape
// 1e ; charte, ecrans-etape-1.md, « Analyse ») : une page qui défile, avec
// la courbe de vos chances de gagner coup après coup, parcourue du doigt ;
// le visualiseur du coup choisi (votre coup contre le coup du maître,
// « Rejouer d'ici ») ; la liste des coups à revoir, filtrable ; et les
// chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La courbe est dessinée à la main en SVG (aucune bibliothèque). Chaque
// coup y a une bande verticale sur toute la hauteur, cible du doigt : on
// touche ou on glisse, et le coup sous le doigt est choisi. Le coup choisi
// est dans l'adresse ; en changer remplace l'adresse (le retour du
// téléphone quitte l'écran, il ne repasse pas par chaque coup).
import {
chanceCurve,
computerMoveAdvice,
type GameSummary,
gameTurns,
mainAdvice,
replayPoint,
summarizeAnalysis,
type Turn,
type WinChanceCurve,
} from "../domain/analysis/game-analysis.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import { type Advice, type Verdict, verdictOf } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { formatHalfMoves } from "../domain/game/position.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { percent, REVIEW_TEXTS, signed } from "./review-texts.js";
import { routeHash } from "./router.js";
/** Ce que montre l'écran. */
export interface AnalysisData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour rejouer les coups sur le plateau. */
readonly game: Game;
/** Le coup choisi (null : le coup à revoir, ou le premier). */
readonly turn: number | null;
readonly phrases: MasterPhrases | null;
}
export interface AnalysisViewOptions {
/** La page (index.html) ; son contenu est construit ici. */
readonly section: HTMLElement;
readonly reducedMotion: MediaQueryList;
/** Choisir un autre coup : remplacer l'adresse. */
readonly select: (turn: number) => void;
/** « Rejouer d'ici » : revenir à cet événement de la partie. */
readonly replay: (id: string, eventId: string) => void;
/** Le lien « Retour » (quitter l'écran sans empiler d'adresse). */
readonly back: (link: HTMLAnchorElement) => void;
}
export interface AnalysisView {
show(data: AnalysisData | null): void;
hide(): void;
/** ← et → : le coup d'avant ou d'après. */
step(delta: number): void;
}
type Filter = "all" | "errors" | "doubtful";
type Shown = "mine" | "best";
const SVG_NS = "http://www.w3.org/2000/svg";
/** Hauteur de la courbe, et marges du tracé (repère en pixels). */
const CURVE_HEIGHT = 168;
const PAD = { left: 40, right: 10, top: 10, bottom: 10 };
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function svgElement<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attributes: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const child = document.createElementNS(SVG_NS, tag);
for (const [name, value] of Object.entries(attributes)) {
child.setAttribute(name, String(value));
}
parent.append(child);
return child;
}
/** Les verdicts que montre chaque filtre. */
const FILTERS: Readonly<Record<Filter, readonly Verdict[]>> = {
all: ["good", "doubtful", "bad", "veryBad"],
errors: ["bad", "veryBad"],
doubtful: ["doubtful"],
};
export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
const { section } = options;
let data: AnalysisData | null = null;
let turns: Turn[] = [];
let curve: WinChanceCurve = [];
let selected = 1;
let filter: Filter = "all";
// Ce qui est dessiné dans le visualiseur, pour ne pas rejouer le coup à
// chaque analyse rangée.
let drawnTurn = "";
let player: MovePlayer | null = null;
// Chaque changement de coup a son numéro : un plateau qui arrive trop
// tard n'est pas dessiné.
let request = 0;
// La page : en-tête, courbe, visualiseur, liste, chiffres.
const head = element(section, "div", { className: "analysis-head" });
const back = element(head, "a", {
id: "analysis-back",
className: "button secondary",
text: REVIEW_TEXTS.back,
});
back.href = "#/";
options.back(back);
element(head, "h2", {
id: "analysis-title",
className: "analysis-title",
text: REVIEW_TEXTS.analysisTitle,
});
const message = element(section, "p", {
id: "analysis-message",
className: "analysis-message",
});
message.setAttribute("role", "status");
const curveCard = element(section, "section", { className: "analysis-card curve-card" });
element(curveCard, "h3", { className: "overline", text: REVIEW_TEXTS.curveTitle });
const curveSvg = document.createElementNS(SVG_NS, "svg");
curveSvg.id = "analysis-curve";
curveSvg.setAttribute("class", "analysis-curve");
curveSvg.setAttribute("role", "slider");
curveSvg.setAttribute("tabindex", "0");
curveCard.append(curveSvg);
const viewer = element(section, "section", {
id: "analysis-viewer",
className: "analysis-card viewer",
});
const nav = element(viewer, "div", { className: "viewer-nav" });
const prev = element(nav, "a", { id: "analysis-prev", className: "icon-button viewer-step" });
prev.append(icon("chevron"));
prev.setAttribute("aria-label", REVIEW_TEXTS.previous);
const turnTitle = element(nav, "p", { id: "analysis-turn", className: "viewer-turn" });
turnTitle.setAttribute("aria-live", "polite");
const next = element(nav, "a", { id: "analysis-next", className: "icon-button viewer-step" });
next.append(icon("chevron"));
next.setAttribute("aria-label", REVIEW_TEXTS.next);
for (const [link, delta] of [
[prev, -1],
[next, 1],
] as const) {
link.addEventListener("click", (event) => {
event.preventDefault();
step(delta);
});
}
const viewerBody = element(viewer, "div", { className: "viewer-body" });
const boardBox = element(viewerBody, "div", { className: "viewer-board-box" });
const board = boardSvg(boardBox, "analysis-board", "analysis-board");
player = createMovePlayer(board, "analysis-", options.reducedMotion);
const viewerText = element(viewerBody, "div", { className: "viewer-text" });
const phrase = element(viewerText, "p", { id: "analysis-phrase", className: "viewer-phrase" });
const choice = element(viewerText, "div", { id: "analysis-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", REVIEW_TEXTS.choiceLabel);
const segments: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", { id: "analysis-mine", className: "segment" }),
best: element(choice, "button", {
id: "analysis-best",
className: "segment",
text: REVIEW_TEXTS.masterMove,
}),
};
for (const key of ["mine", "best"] as const) {
segments[key].type = "button";
segments[key].setAttribute("role", "radio");
}
const legend = element(viewerText, "p", { id: "analysis-legend", className: "viewer-legend" });
const chanceLine = element(viewerText, "p", {
id: "analysis-chance",
className: "viewer-chance",
});
const viewerActions = element(viewerText, "div", { className: "viewer-actions" });
const replayButton = element(viewerActions, "button", {
id: "analysis-replay",
text: REVIEW_TEXTS.replayFromHere,
});
replayButton.type = "button";
// L'emplacement de « Demander à une IA » (étape 1f).
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
const replayHint = element(viewerText, "p", {
className: "viewer-legend",
text: REVIEW_TEXTS.replayHint,
});
const listCard = element(section, "section", { className: "analysis-card list-card" });
element(listCard, "h3", { className: "overline", text: REVIEW_TEXTS.listTitle });
const filters = element(listCard, "div", { id: "analysis-filters", className: "filters" });
filters.setAttribute("role", "group");
filters.setAttribute("aria-label", REVIEW_TEXTS.filterLabel);
const filterButtons: Record<Filter, HTMLButtonElement> = {
all: element(filters, "button", { className: "filter" }),
errors: element(filters, "button", { className: "filter" }),
doubtful: element(filters, "button", { className: "filter" }),
};
for (const key of ["all", "errors", "doubtful"] as const) {
const button = filterButtons[key];
button.type = "button";
button.dataset.filter = key;
button.onclick = () => {
filter = key;
renderList();
};
}
const list = element(listCard, "ul", { id: "analysis-list", className: "analysis-list" });
const numbers = element(section, "details", {
id: "analysis-numbers",
className: "analysis-card explain-numbers",
});
element(numbers, "summary", { text: REVIEW_TEXTS.numbers });
const table = element(numbers, "div", { className: "explain-table analysis-table" });
element(numbers, "p", { className: "explain-note", text: REVIEW_TEXTS.numbersNote });
// ------------------------------------------------------------ Courbe
/** Le coup sous l'abscisse x (pixels du tracé). */
function turnAt(x: number): number {
const width = curveSvg.getBoundingClientRect().width;
const inner = Math.max(1, width - PAD.left - PAD.right);
const index = Math.floor(((x - PAD.left) / inner) * turns.length);
return Math.min(turns.length, Math.max(1, index + 1));
}
let dragging = false;
curveSvg.addEventListener("pointerdown", (event) => {
dragging = true;
curveSvg.setPointerCapture?.(event.pointerId);
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
});
curveSvg.addEventListener("pointermove", (event) => {
if (dragging) {
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
}
});
for (const end of ["pointerup", "pointercancel"] as const) {
curveSvg.addEventListener(end, () => {
dragging = false;
});
}
curveSvg.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
step(event.key === "ArrowLeft" ? -1 : 1);
}
});
function renderCurve(): void {
const width = Math.round(curveSvg.getBoundingClientRect().width || 360);
const height = CURVE_HEIGHT;
curveSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
curveSvg.replaceChildren();
const inner = width - PAD.left - PAD.right;
const band = turns.length > 0 ? inner / turns.length : inner;
const x = (turn: number) => PAD.left + (turn - 0.5) * band;
const y = (chance: number) => PAD.top + (1 - chance) * (height - PAD.top - PAD.bottom);
// Axe : 100 %, 50 % (pointillé), 0 %.
for (const level of [1, 0.5, 0]) {
svgElement(curveSvg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(level),
y2: y(level),
class: level === 0.5 ? "curve-half" : "curve-axis",
});
const label = svgElement(curveSvg, "text", {
x: PAD.left - 6,
y: y(level) + 4,
class: "curve-label",
"text-anchor": "end",
});
label.textContent = percent(level);
}
const known = curve.filter((p) => p.chance !== null) as {
turn: number;
chance: number;
verdict: Verdict | null;
side: string;
}[];
if (known.length > 0) {
const line = known.map((p) => `${x(p.turn)},${y(p.chance)}`).join(" ");
const first = known[0] as { turn: number };
const last = known.at(-1) as { turn: number };
svgElement(curveSvg, "polygon", {
points: `${x(first.turn)},${y(0)} ${line} ${x(last.turn)},${y(0)}`,
class: "curve-area",
});
svgElement(curveSvg, "polyline", { points: line, class: "curve-line" });
}
// Le coup choisi : un trait vertical brique.
svgElement(curveSvg, "line", {
x1: x(selected),
x2: x(selected),
y1: PAD.top,
y2: height - PAD.bottom,
class: "curve-cursor",
});
for (const point of known) {
const commented = point.verdict !== null;
svgElement(curveSvg, "circle", {
cx: x(point.turn),
cy: y(point.chance),
r: commented ? 4 : 2,
class: commented ? `curve-dot verdict-${point.verdict}` : "curve-dot other",
});
}
const chosen = curve[selected - 1];
if (chosen?.chance != null) {
svgElement(curveSvg, "circle", {
cx: x(selected),
cy: y(chosen.chance),
r: 7,
class: "curve-ring",
});
}
// Les bandes, sur toute la hauteur : cibles du doigt.
for (const turn of turns) {
const rect = svgElement(curveSvg, "rect", {
x: PAD.left + (turn.number - 1) * band,
y: 0,
width: band,
height,
class: turn.number === selected ? "curve-band selected" : "curve-band",
});
rect.dataset.turn = String(turn.number);
}
const chance = chosen?.chance;
curveSvg.setAttribute("aria-valuemin", "1");
curveSvg.setAttribute("aria-valuemax", String(turns.length));
curveSvg.setAttribute("aria-valuenow", String(selected));
const spoken =
chance === null || chance === undefined ? REVIEW_TEXTS.curveUnknown : percent(chance);
curveSvg.setAttribute("aria-valuetext", spoken);
curveSvg.setAttribute(
"aria-label",
REVIEW_TEXTS.curveLabel(selected, turns.length, spoken),
);
}
// -------------------------------------------------------- Visualiseur
function renderViewer(): void {
const current = data;
const turn = turns[selected - 1];
if (current === null || turn === undefined) {
return;
}
prev.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.max(1, selected - 1),
});
next.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.min(turns.length, selected + 1),
});
prev.setAttribute("aria-disabled", String(selected <= 1));
next.setAttribute("aria-disabled", String(selected >= turns.length));
const roll = turn.roll === null ? "" : ` · ${REVIEW_TEXTS.roll(turn.roll)}`;
turnTitle.textContent = `${REVIEW_TEXTS.turnOf(selected, turns.length)}${roll}`;
const chance = curve[selected - 1]?.chance;
chanceLine.textContent =
chance === null || chance === undefined
? ""
: REVIEW_TEXTS.chanceAfter(percent(chance));
const main = mainAdvice(current.journal, turn);
const theirs = computerMoveAdvice(current.journal, turn);
const key = [
current.id,
selected,
main?.eventId ?? "",
theirs?.eventId ?? "",
current.phrases !== null,
].join("|");
replayButton.hidden = replayPoint(current.journal, turn) === null;
replayHint.hidden = replayButton.hidden;
replayButton.onclick = () => {
const point = replayPoint(current.journal, turn);
if (point !== null) {
options.replay(current.id, point);
}
};
if (key === drawnTurn) {
return;
}
drawnTurn = key;
player?.stop();
phrase.replaceChildren();
delete phrase.dataset.verdict;
legend.textContent = "";
const id = ++request;
// Ce qu'en dit le maître : la décision du joueur à revoir, ou ce
// qu'a fait l'ordinateur.
if (main !== null && current.phrases !== null) {
const words = masterWords(current.phrases, main);
phrase.dataset.verdict = words.verdict;
const word = document.createElement("strong");
word.append(icon(VERDICT_ICONS[words.verdict]), `${words.word}.`);
const said = document.createElement("em");
said.textContent = words.phrase;
phrase.append(word, " ", said);
} else if (turn.side === "computer") {
phrase.textContent =
turn.move === null
? REVIEW_TEXTS.computerCube
: turn.move.halfMoves.length === 0
? REVIEW_TEXTS.computerCannotMove
: REVIEW_TEXTS.computerPlayed(
theirs?.played.notation ?? formatHalfMoves(turn.move.halfMoves),
);
} else if (turn.move !== null && turn.move.halfMoves.length === 0) {
phrase.textContent = REVIEW_TEXTS.playerCannotMove;
}
// Le plateau : le coup joué et, s'il diffère, le meilleur coup.
void (async () => {
const side = turn.side;
let mine: MoveSteps | null = null;
let best: MoveSteps | null = null;
// Les coups s'écrivent dans la notation du moteur, comme dans le
// bilan et la feuille du maître (« 6/off », pas « 6/5 5/off ») ;
// à défaut d'analyse, à partir des demi-coups.
let names = { mine: "", best: "" };
const moveAdvice: Advice | null = side === "player" ? main : theirs;
try {
if (moveAdvice?.kind === "move") {
mine = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.played.halfMoves,
);
names = {
mine: moveAdvice.played.notation,
best: moveAdvice.best.notation,
};
if (moveAdvice.rank > 1) {
best = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.best.halfMoves,
);
}
} else if (moveAdvice?.kind === "cube") {
mine = await current.game.stepsBefore(moveAdvice.eventId, "player", []);
} else if (turn.move !== null) {
mine = await current.game.stepsBefore(turn.move.id, side, turn.move.halfMoves);
names = { mine: formatHalfMoves(turn.move.halfMoves), best: "" };
} else {
const first = turn.events[0];
mine =
first === undefined
? null
: await current.game.stepsBefore(first.id, side, []);
}
} catch {
mine = null;
}
if (id !== request || mine === null) {
return;
}
showMoves(mine, best, side, names);
})();
}
function showMoves(
mine: MoveSteps,
best: MoveSteps | null,
side: "player" | "computer",
names: { readonly mine: string; readonly best: string },
): void {
const who = side === "player" ? REVIEW_TEXTS.yourMoveSegment : REVIEW_TEXTS.theirMove;
segments.mine.textContent = who;
choice.hidden = best === null;
const select = (shown: Shown) => {
const steps = shown === "best" && best !== null ? best : mine;
for (const key of ["mine", "best"] as const) {
segments[key].setAttribute("aria-checked", String(key === shown));
}
board.dataset.move = shown;
if (steps.halfMoves.length === 0) {
legend.textContent = REVIEW_TEXTS.legendPosition;
board.setAttribute("aria-label", REVIEW_TEXTS.legendPosition);
drawSteps(board, steps, 0, false, "analysis-");
return;
}
const text = REVIEW_TEXTS.legendMove(
shown === "best" ? REVIEW_TEXTS.masterMove : who,
shown === "best" ? names.best : names.mine,
);
legend.textContent = text;
board.setAttribute("aria-label", text);
void player?.play(steps);
};
segments.mine.onclick = () => select("mine");
segments.best.onclick = () => select("best");
choice.onkeydown = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
const nextShown =
segments.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(nextShown);
segments[nextShown].focus();
}
};
select("mine");
}
// -------------------------------------------------------------- Liste
function renderList(): void {
const current = data;
if (current === null) {
return;
}
const rows = turns
.map((turn) => ({ turn, advice: mainAdvice(current.journal, turn) }))
.filter((row): row is { turn: Turn; advice: Advice } => row.advice !== null);
const count = (f: Filter) =>
rows.filter((r) => FILTERS[f].includes(verdictOf(r.advice.skill))).length;
for (const key of ["all", "errors", "doubtful"] as const) {
filterButtons[key].textContent = `${REVIEW_TEXTS.filters[key]} · ${count(key)}`;
filterButtons[key].setAttribute("aria-pressed", String(key === filter));
}
list.replaceChildren();
const shown = rows.filter((r) => FILTERS[filter].includes(verdictOf(r.advice.skill)));
if (shown.length === 0) {
element(list, "li", { className: "analysis-empty", text: REVIEW_TEXTS.emptyFilter });
return;
}
for (const { turn, advice } of shown) {
const item = element(list, "li");
const link = element(item, "a", { className: "analysis-row" });
link.href = routeHash({ screen: "analysis", game: current.id, turn: turn.number });
link.dataset.turn = String(turn.number);
if (turn.number === selected) {
link.setAttribute("aria-current", "true");
}
const verdict = verdictOf(advice.skill);
const word = current.phrases === null ? "" : masterWords(current.phrases, advice).word;
const roll = turn.roll === null ? "" : REVIEW_TEXTS.roll(turn.roll);
const move =
advice.kind === "move"
? advice.played.notation
: REVIEW_TEXTS.cubeActions[advice.action];
link.setAttribute("aria-label", REVIEW_TEXTS.rowLabel(turn.number, roll, move, word));
element(link, "span", { className: "row-number", text: String(turn.number) });
element(link, "span", { className: "row-roll", text: roll });
element(link, "span", { className: "row-move", text: move });
const mark = element(link, "span", { className: "row-word" });
mark.dataset.verdict = verdict;
mark.append(icon(VERDICT_ICONS[verdict]), word);
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
choose(turn.number);
});
}
}
// ------------------------------------------------- Chiffres du moteur
function renderNumbers(summary: GameSummary): void {
table.replaceChildren();
const cell = (text: string, className = "") => element(table, "span", { className, text });
cell("");
cell(REVIEW_TEXTS.numbersHead.you, "strong");
cell(REVIEW_TEXTS.numbersHead.computer, "strong");
const rows: [string, (s: GameSummary["player"]) => string][] = [
[REVIEW_TEXTS.numbersRows.errorRate, (s) => signed(-s.errorRate.perDecision)],
[REVIEW_TEXTS.numbersRows.decisions, (s) => String(s.errorRate.decisions)],
[REVIEW_TEXTS.numbersRows.level, (s) => MATCH_TEXTS.levels[s.errorRate.level].name],
[REVIEW_TEXTS.numbersRows.luck, (s) => signed(s.luck.perRoll)],
];
for (const [name, value] of rows) {
cell(name);
cell(value(summary.player));
cell(value(summary.computer));
}
}
// ---------------------------------------------------------- Ensemble
function choose(turn: number): void {
if (turn === selected || turn < 1 || turn > turns.length) {
return;
}
selected = turn;
renderCurve();
renderViewer();
renderList();
options.select(turn);
}
function step(delta: number): void {
choose(selected + delta);
}
window.addEventListener("resize", () => {
if (!section.hidden && data !== null) {
renderCurve();
}
});
return {
show(next) {
data = next;
if (next === null) {
message.textContent = REVIEW_TEXTS.missingGame;
curveCard.hidden = true;
viewer.hidden = true;
listCard.hidden = true;
numbers.hidden = true;
return;
}
turns = gameTurns(next.journal);
curve = chanceCurve(next.journal);
const summary = summarizeAnalysis(next.journal);
const fallback = summary.toReview?.turn ?? 1;
selected = Math.min(Math.max(1, next.turn ?? fallback), Math.max(1, turns.length));
message.textContent = turns.length === 0 ? REVIEW_TEXTS.emptyFilter : "";
curveCard.hidden = turns.length === 0;
viewer.hidden = turns.length === 0;
listCard.hidden = turns.length === 0;
numbers.hidden = turns.length === 0;
renderCurve();
renderViewer();
renderList();
renderNumbers(summary);
},
hide() {
data = null;
drawnTurn = "";
player?.stop();
request++;
},
step,
};
}
+245 -7
View File
@@ -21,6 +21,7 @@
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
// changement. Les actions de jeu restent des boutons.
import { missingAnalyses, readyForSummary } from "../domain/analysis/game-analysis.js";
import {
type MasterPhrases,
masterLine,
@@ -39,6 +40,7 @@ import type { GameState } from "../domain/game/state.js";
import { ADVICE_TEXTS } from "./advice-texts.js";
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
import { createAnalysisView } from "./analysis-view.js";
import {
boardModel,
moveTargets,
@@ -74,6 +76,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 { createReviewRunner, type RunAnalysis } from "./review-runner.js";
import { isHelpScreen, type Route, routeHash } from "./router.js";
import {
chosenGame,
@@ -83,6 +86,7 @@ import {
parseSettings,
type Settings,
} from "./settings.js";
import { createSummaryView } from "./summary-view.js";
import { type PromptText, TEXTS } from "./texts.js";
import { seenAfter } from "./tutorial-nav.js";
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
@@ -133,6 +137,9 @@ export interface AppOptions {
/** Le conseil du maître : l'analyse d'une décision du joueur, sur un
* moteur à part (elle ne retarde jamais la partie). */
readonly analyse: Analyse;
/** L'analyse d'une partie pour son bilan (étape 1e), une analyse à la
* fois, sur le même moteur à part. */
readonly analyseGame: RunAnalysis;
}
/** La source des phrases du maître, copiée dans le site (make site). */
@@ -148,6 +155,12 @@ export interface SavedGamesAccess {
/** Une partie reprise, au bout de la branche branchHead si elle est
* donnée ; elle devient la partie à l'écran. */
readonly open: (id: string, branchHead?: string) => Promise<Game>;
/** Une partie enregistrée, reprise pour la regarder (bilan, analyse)
* sans qu'elle devienne la partie à l'écran ; null si elle n'existe
* pas ou est illisible. */
readonly view: (id: string) => Promise<Game | null>;
/** Enregistre les analyses d'une partie qui n'est pas à l'écran. */
readonly saveAnalyses: (id: string, journal: GameJournal) => void;
readonly remove: (id: string) => Promise<void>;
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
}
@@ -213,6 +226,8 @@ export function startApp(options: AppOptions): void {
info: byId("info"),
adviceLine: byId<HTMLAnchorElement>("advice-line"),
explainVeil: byId("explain-veil"),
summaryVeil: byId("summary-veil"),
analysis: byId("analysis"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
@@ -665,6 +680,7 @@ export function startApp(options: AppOptions): void {
document.body.classList.toggle("busy", busy);
announceEnd(game);
void keepScreenOn(isPlaying(game) || state.phase === "doubled");
reviewFinishedGame();
}
/** Le conseil du maître à montrer : le réglage le permet, ses phrases
@@ -841,7 +857,13 @@ export function startApp(options: AppOptions): void {
/** Le panneau centré : fin de partie, ou double de l'ordinateur en
* paysage (en portrait, la barre d'actions s'en charge). */
function renderPanel(g: Game, landscape: boolean, doubled: boolean): void {
const over = g.state.phase === "over" && !panelDismissed;
// Le panneau appartient à l'écran de jeu : ni sur une page, ni sous
// le bilan.
const over =
g.state.phase === "over" &&
!panelDismissed &&
!ui.game.hidden &&
navigation.route.screen !== "summary";
const ask = landscape && doubled;
ui.panelVeil.hidden = !(over || ask);
const overline = byId("panel-overline");
@@ -1123,6 +1145,9 @@ export function startApp(options: AppOptions): void {
/** Commence une partie : celle des choix de l'écran « Nouvelle partie »
* si rien d'autre n'est demandé. */
function startNewGame(chosen: NewGame = chosenGame(settings)): void {
// L'analyse d'une partie passée laisse le moteur à part au conseil
// du maître.
reviewRunner.stop();
game = options.newGame(chosen);
panelDismissed = false;
announcedEnd = null;
@@ -1212,7 +1237,8 @@ export function startApp(options: AppOptions): void {
route.screen === "menu" ||
route.screen === "games" ||
route.screen === "install" ||
route.screen === "explanation";
route.screen === "explanation" ||
route.screen === "summary";
// « Mes parties » : reprendre une partie (ou une branche) en change à
// l'écran, sans recharger la page.
@@ -1245,6 +1271,174 @@ export function startApp(options: AppOptions): void {
close: () => leaveSheet(),
});
// ------------------------------------------- Bilan et analyse (1e)
// Les analyses qui manquent à une partie, en arrière-plan, sur le
// moteur à part ; la partie regardée (bilan, analyse) et son journal.
const reviewRunner = createReviewRunner(options.analyseGame);
let reviewed: { id: string; journal: GameJournal; game: Game } | null = null;
const summaryView = createSummaryView({
veil: ui.summaryVeil,
close: () => leaveSheet(),
again: () => afterGame(),
});
const analysisView = createAnalysisView({
section: ui.analysis,
reducedMotion,
select: (turn) => {
const route = navigation.route;
if (route.screen === "analysis") {
navigation.replace(routeHash({ ...route, turn }));
}
},
replay: (id, eventId) => void replayFrom(id, eventId),
back: (link) => leavingLink(link, (route) => route.screen === "analysis"),
});
/** La partie à l'écran, si c'est la partie id. */
function liveGame(id: string): Game | null {
return game !== null && options.games.shownId() === id ? game : null;
}
/** Ouvre le bilan ou l'analyse de la partie id, et lance ce qui manque
* à son analyse. */
async function openReview(id: string): Promise<void> {
const live = liveGame(id);
if (live !== null) {
reviewed = { id, journal: live.journal, game: live };
} else if (reviewed?.id !== id) {
const [journal, viewed] = await Promise.all([
options.games.journal(id),
options.games.view(id),
]);
reviewed = journal === null || viewed === null ? null : { id, journal, game: viewed };
if (reviewed === null) {
showReview(id, true);
return;
}
}
if (reviewed !== null) {
review(reviewed.id, reviewed.journal);
}
showReview(id, false);
}
/** Lance l'analyse de ce qui manque à la partie id (rien si tout est
* là, ou si elle est déjà en cours). */
function review(id: string, journal: GameJournal): void {
if (missingAnalyses(journal).length === 0) {
return;
}
reviewRunner.start(id, journal, (analysed) => {
const live = liveGame(id);
if (live !== null) {
live.recordAnalyses(analysed);
save();
} else {
options.games.saveAnalyses(id, analysed);
}
if (reviewed?.id === id) {
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
}
showReview(id, false);
render();
});
}
/** L'analyse en cours d'une partie qui n'est ni regardée ni à l'écran
* s'arrête : le moteur à part revient au conseil du maître. */
function stopForeignReview(): void {
const running = reviewRunner.running;
if (running !== null && liveGame(running) === null) {
reviewRunner.stop();
}
}
/** Redessine le bilan ou l'analyse de la partie id, s'ils sont à
* l'écran. */
function showReview(id: string, missing: boolean): void {
const route = navigation.route;
if ((route.screen !== "summary" && route.screen !== "analysis") || route.game !== id) {
return;
}
const live = liveGame(id);
const shown =
missing || reviewed?.id !== id
? null
: { ...reviewed, journal: live?.journal ?? reviewed.journal };
if (route.screen === "summary") {
summaryView.show(
shown === null
? null
: {
id,
journal: shown.journal,
game: shown.game,
end: shown.game.state.phase === "over" ? endParts(shown.game) : null,
afterLabel: live === null ? null : afterGameLabel(live),
phrases,
},
);
} else {
analysisView.show(
shown === null
? null
: {
id,
journal: shown.journal,
game: shown.game,
turn: route.turn,
phrases,
},
);
}
}
/**
* 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à.
*/
function reviewFinishedGame(): void {
const id = options.games.shownId();
if (game === null || id === null || busy || game.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);
}
if (
navigation.route.screen === "game" &&
!panelDismissed &&
phrases !== null &&
readyForSummary(game.journal)
) {
queueMicrotask(() => {
if (navigation.route.screen === "game" && !panelDismissed) {
navigation.go(routeHash({ screen: "summary", game: id }));
}
});
}
}
/** « Rejouer d'ici » : la partie id reprend à l'événement eventId ; le
* prochain coup ouvre une branche, la partie d'origine reste. */
async function replayFrom(id: string, eventId: string): Promise<void> {
if (busy) {
return;
}
reviewRunner.stop();
const other = await options.games.open(id);
other.goBackTo(eventId);
hideBanner();
navigation.replace("#/");
await act(() => showGame(other));
}
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
function playHalfMove(halfMove: HalfMove): void {
@@ -1372,6 +1566,8 @@ export function startApp(options: AppOptions): void {
navigation.leave((r) => r.screen === "position");
} else if (route.screen === "newGame") {
navigation.leave((r) => r.screen === "newGame");
} else if (route.screen === "analysis") {
navigation.leave((r) => r.screen === "analysis");
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
}
@@ -1415,6 +1611,11 @@ export function startApp(options: AppOptions): void {
ui.installVeil.hidden = true;
gamesView?.hide();
adviceView.hide();
if (summaryView.isOpen) {
// Le bilan fermé laisse voir la partie finie, sans le panneau.
panelDismissed = true;
summaryView.hide();
}
if (menuWasOpen) {
(menuReturnFocus ?? ui.menuButton).focus();
}
@@ -1524,7 +1725,8 @@ export function startApp(options: AppOptions): void {
const home = below.screen === "home";
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
const choosing = below.screen === "newGame";
const page = help || position || home || tutorial || choosing;
const analysing = below.screen === "analysis";
const page = help || position || home || tutorial || choosing || analysing;
const wasPage = ui.game.hidden;
const wasScreen = ui.root.dataset.screen;
ui.game.hidden = page;
@@ -1532,6 +1734,7 @@ export function startApp(options: AppOptions): void {
ui.position.hidden = !position;
ui.home.hidden = !home;
ui.tutorial.hidden = !tutorial;
ui.analysis.hidden = !analysing;
const opening = choosing && ui.newGameSection.hidden;
ui.newGameSection.hidden = !choosing;
ui.root.classList.toggle("page-open", page);
@@ -1591,6 +1794,28 @@ export function startApp(options: AppOptions): void {
} else {
adviceView.hide();
}
// Le bilan et l'analyse d'une partie : son journal, lu dans le
// stockage (ouvert avec le moteur), puis analysé en arrière-plan.
if (route.screen !== "summary" && summaryView.isOpen) {
// Quitter le bilan (pour l'analyse, ou par le retour du
// téléphone) laisse la partie finie sans le panneau.
panelDismissed = true;
summaryView.hide();
}
if (!analysing) {
analysisView.hide();
}
if (route.screen === "summary" || route.screen === "analysis") {
closeTransient();
ui.menuVeil.hidden = true;
const id = route.game;
void options.engineReady.then(
() => openReview(id),
() => undefined,
);
} else {
stopForeignReview();
}
if (route.screen === "games") {
const expanded = route.game;
// La liste vient du stockage, ouvert avec le moteur.
@@ -2059,6 +2284,15 @@ export function startApp(options: AppOptions): void {
}
const onButton =
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
// L'écran d'analyse : les flèches passent d'un coup à l'autre.
if (
navigation.route.screen === "analysis" &&
(event.key === "ArrowLeft" || event.key === "ArrowRight")
) {
event.preventDefault();
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
return;
}
let name: ActionName | null = null;
switch (event.key) {
case " ":
@@ -2143,6 +2377,9 @@ export function startApp(options: AppOptions): void {
if (route.screen === "explanation") {
void adviceView.show(route.event);
}
if (route.screen === "summary" || route.screen === "analysis") {
void openReview(route.game);
}
// Comme après une action du joueur : l'ordinateur joue si c'est
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
// reprise juste après son coup le montre, sans l'animer.
@@ -2290,6 +2527,7 @@ function endParts(game: Game): { title: string; detail: string } {
}
const { winner, kind, points } = result;
const dropped = game.events.some((e) => e.type === "CubeDropped");
const how = dropped ? TEXTS.droppedDouble(winner) : "";
const { match, matchAfter } = game.state;
if (match !== null && matchAfter !== null) {
const { player, computer } = matchAfter.score;
@@ -2304,13 +2542,13 @@ function endParts(game: Game): { title: string; detail: string } {
return {
title: matchAfter.over
? MATCH_TEXTS.matchWonTitle(winner)
: TEXTS.endTitle(winner, kind, dropped),
detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail,
: TEXTS.endTitle(winner, kind),
detail: `${how}${matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail}`,
};
}
return {
title: TEXTS.endTitle(winner, kind, dropped),
detail: TEXTS.endDetail(winner, points, game.state.cube.value),
title: TEXTS.endTitle(winner, kind),
detail: `${how}${TEXTS.endDetail(winner, points, game.state.cube.value)}`,
};
}
+1
View File
@@ -28,6 +28,7 @@ export const GAMES_TEXTS = {
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
summary: "Bilan",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
+10
View File
@@ -123,6 +123,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
"resume",
),
branchesLink(game.id),
...(game.status === "over" ? [summaryLink(game.id)] : []),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
@@ -171,6 +172,15 @@ export function createGamesView(options: GamesViewOptions): GamesView {
return link;
}
/** « Bilan » : le lien vers le bilan d'une partie finie (étape 1e). */
function summaryLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.summary;
link.dataset.action = "summary";
link.href = routeHash({ screen: "summary", game: id });
return link;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
+1
View File
@@ -31,6 +31,7 @@ export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/revoir-ma-partie.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
+109
View File
@@ -0,0 +1,109 @@
// Un petit plateau qui rejoue un coup pion par pion, sur la position
// d'avant le coup, puis laisse ses traces : la feuille d'explication du
// maître (1d), le coup à revoir du bilan et le visualiseur de l'analyse
// (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveSteps } from "../domain/game/game.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un plateau SVG vide, dans son cadre. */
export function boardSvg(box: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
/**
* Dessine le plateau d'un coup après index demi-coups (0 : avant le
* coup), avec ses traces si traced. Les flèches des traces renvoient à leur
* pointe par identifiant : prefix les distingue de celles du plateau de jeu.
*/
export function drawSteps(
svg: SVGSVGElement,
steps: MoveSteps,
index: number,
traced: boolean,
prefix: string,
): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(boards), {
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
});
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `${prefix}${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", `url(#${prefix}`));
}
}
/** Un plateau qui joue des coups ; un coup demandé arrête le précédent. */
export interface MovePlayer {
/** Joue le coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
play(steps: MoveSteps): Promise<void>;
/** Arrête le coup en cours. */
stop(): void;
}
export function createMovePlayer(
svg: SVGSVGElement,
prefix: string,
reducedMotion: MediaQueryList,
): MovePlayer {
let playing = 0;
return {
async play(steps) {
const id = ++playing;
drawSteps(svg, steps, 0, false, prefix);
if (!reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
drawSteps(svg, steps, i, false, prefix);
const start = checkerSpot(svg, from);
const bar = steps.side === "player" ? "bar-computer" : "bar-player";
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
drawSteps(svg, steps, i + 1, false, prefix);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, bar, victim, SLIDE_MS),
]);
}
}
if (id === playing) {
drawSteps(svg, steps, steps.halfMoves.length, true, prefix);
}
},
stop() {
playing++;
},
};
}
+87
View File
@@ -0,0 +1,87 @@
// L'analyse d'une partie en arrière-plan (étape 1e) : les analyses qui
// manquent au bilan et à l'écran d'analyse sont demandées une à une au
// second moteur, rangées au fur et à mesure, et l'ensemble s'arrête dès
// qu'on n'en a plus besoin (une autre partie, une nouvelle partie). Sans
// DOM : testé sous Node (app/tests/review-runner.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une analyse déjà partie vers le moteur ne s'interrompt pas : l'arrêt
// prend effet entre deux analyses, et le résultat d'une analyse arrêtée
// n'est pas rangé.
import { type AnalysisTask, missingAnalyses } from "../domain/analysis/game-analysis.js";
import type { GameJournal } from "../domain/game/events.js";
/** Fait une analyse et renvoie le journal qui la contient. */
export type RunAnalysis = (journal: GameJournal, task: AnalysisTask) => Promise<GameJournal>;
export interface ReviewRunner {
/** La partie en cours d'analyse (null : aucune). */
readonly running: string | null;
/**
* Analyse ce qui manque au journal de la partie id ; record reçoit le
* journal après chaque analyse rangée, done une fois tout analysé.
* Une analyse en cours d'une autre partie est arrêtée ; la même partie
* continue sans repartir.
*/
start(
id: string,
journal: GameJournal,
record: (journal: GameJournal) => void,
done?: () => void,
): void;
/** Arrête l'analyse en cours. */
stop(): void;
}
export function createReviewRunner(run: RunAnalysis): ReviewRunner {
// Chaque analyse lancée a son jeton : stop() ou une autre partie
// l'annulent.
let token: object | null = null;
let running: string | null = null;
return {
get running() {
return running;
},
start(id, journal, record, done) {
if (running === id && token !== null) {
return;
}
const mine = {};
token = mine;
running = id;
void (async () => {
let current = journal;
try {
for (;;) {
const task = missingAnalyses(current)[0];
if (task === undefined) {
break;
}
const next = await run(current, task);
if (token !== mine) {
return;
}
current = next;
record(current);
}
} catch {
// Le moteur a échoué : l'analyse s'arrête là, ce qui est
// rangé reste, et la prochaine demande reprendra.
}
if (token === mine) {
token = null;
running = null;
done?.();
}
})();
},
stop() {
token = null;
running = null;
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les textes de l'interface du bilan et de l'écran d'analyse (étape 1e).
// Les phrases du maître elles-mêmes (lecture du bilan, dés en mots) sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
import type { Roll } from "../domain/game/roll.js";
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
/** « 41 % ». */
export function percent(chance: number): string {
return `${Math.round(chance * 100)} %`;
}
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
export function signed(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
export const REVIEW_TEXTS = {
// Bilan
summaryLabel: "Bilan de la partie",
analysing: "Le maître analyse la partie…",
progress: (done: number, total: number) => `Analyse : ${done} sur ${total}`,
notOver: "Cette partie n'est pas finie : son bilan viendra à la fin.",
missingGame: "Cette partie n'est plus sur ce téléphone.",
counts: {
good: (n: number) => plural(n, "Bon coup", "Bons coups"),
doubtful: (n: number) => plural(n, "Imprécision", "Imprécisions"),
bad: (n: number) => plural(n, "Erreur", "Erreurs"),
},
countsLabel: "Vos décisions, par appréciation",
yourPlay: "Votre jeu",
theDice: "Les dés",
playGauge: (level: string) => `Votre jeu : niveau ${level}`,
diceGauge: (words: string) => `Les dés : ${words}`,
howToRead: "Comment lire ces mesures",
toReview: "Le coup à revoir",
yourMove: (notation: string) => `Votre coup : ${notation}`,
yourDecision: (action: string) => `Votre décision : ${action}`,
reviewMove: "Revoir ce coup",
seeAnalysis: "Voir l'analyse",
newGame: "Nouvelle partie",
close: "Fermer",
cubeActions: {
double: "doubler",
noDouble: "lancer sans doubler",
take: "prendre",
pass: "passer",
} satisfies Record<CubeAction, string>,
toReviewBoard: "La position au moment de la décision à revoir.",
// Analyse
analysisTitle: "Analyse de la partie",
back: "Retour",
curveTitle: "Vos chances de gagner",
curveLabel: (turn: number, total: number, chance: string) =>
`Courbe de vos chances de gagner : coup ${turn} sur ${total}, ${chance}. Flèches gauche et droite pour changer de coup.`,
curveUnknown: "pas encore calculé",
turnOf: (turn: number, total: number) => `Coup ${turn} sur ${total}`,
roll: (roll: Roll) => `${roll[0]}-${roll[1]}`,
previous: "Coup précédent",
next: "Coup suivant",
who: { player: "Vous", computer: "L'ordinateur" },
computerPlayed: (notation: string) => `L'ordinateur a joué ${notation}.`,
computerCannotMove: "L'ordinateur ne pouvait pas jouer.",
playerCannotMove: "Vous ne pouviez pas jouer.",
computerCube: "L'ordinateur a doublé.",
chanceAfter: (chance: string) => `Vos chances de gagner après ce coup : ${chance}`,
theirMove: "Son coup",
masterMove: "Le coup du maître",
yourMoveSegment: "Votre coup",
choiceLabel: "Coup montré sur le plateau",
legendMove: (who: string, notation: string) =>
`${who} : ${notation}, joué pion par pion sur la position d'avant le coup.`,
legendPosition: "La position avant la décision.",
replayFromHere: "Rejouer d'ici",
replayHint: "Une nouvelle branche : la partie d'origine reste intacte.",
listTitle: "Les coups à revoir",
filters: { all: "Tout", errors: "Erreurs", doubtful: "Imprécisions" },
filterLabel: "Filtrer les coups",
emptyFilter: "Aucun coup de ce genre dans la partie.",
rowLabel: (turn: number, roll: string, move: string, word: string) =>
`Coup ${turn}, ${roll}, ${move} : ${word}`,
numbers: "Voir les chiffres du moteur",
numbersRows: {
errorRate: "Erreur par décision",
decisions: "Décisions comptées",
luck: "Chance par tour",
level: "Niveau",
},
numbersHead: { you: "Vous", computer: "L'ordinateur" },
numbersNote:
"Calculés comme GNU Backgammon : équité perdue par décision non forcée, chance des dés par tour, à 2 plis.",
} as const;
+33
View File
@@ -27,6 +27,12 @@ export type Route =
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
| { readonly screen: "summary"; readonly game: string }
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
* #/parties/<identifiant>/analyse. */
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
/** #/menu. */
| { readonly screen: "menu" }
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
@@ -71,6 +77,9 @@ export function parseRoute(hash: string): Route {
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (head === "parties" && rest.length > 1) {
return gameReviewRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
@@ -104,6 +113,24 @@ export function parseRoute(hash: string): Route {
}
}
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
function gameReviewRoute(rest: readonly string[]): Route | null {
const [game = "", part, turn, ...more] = rest;
if (!NAME.test(game) || more.length > 0) {
return null;
}
if (part === "bilan") {
return turn === undefined ? { screen: "summary", game } : null;
}
if (part !== "analyse") {
return null;
}
if (turn === undefined) {
return { screen: "analysis", game, turn: null };
}
return /^[1-9][0-9]{0,3}$/.test(turn) ? { screen: "analysis", game, turn: Number(turn) } : null;
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
@@ -173,6 +200,12 @@ export function routeHash(route: Route): string {
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "summary":
return `#/parties/${route.game}/bilan`;
case "analysis":
return route.turn === null
? `#/parties/${route.game}/analyse`
: `#/parties/${route.game}/analyse/${route.turn}`;
case "menu":
return "#/menu";
case "newGame":
+362
View File
@@ -0,0 +1,362 @@
// Le bilan de fin de partie (#/parties/<id>/bilan, étape 1e ; charte,
// ecrans-etape-1.md, « Bilan ») : un panneau centré sur un voile, à la
// place du panneau de fin de partie. Le résultat et sa lecture, les
// décisions par appréciation, deux jauges (votre jeu, les dés), le coup à
// revoir, puis « Voir l'analyse » et l'action qui suit la partie.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qu'il affiche vient de l'analyse du moteur (game-analysis.ts) et
// des phrases du maître (docs/maitre/phrases.md) ; tant que l'analyse
// n'est pas prête, il dit où elle en est.
import {
type GameSummary,
readyForSummary,
summarizeAnalysis,
} from "../domain/analysis/game-analysis.js";
import {
diceWords,
type MasterPhrases,
masterWords,
nothingToReview,
summaryReading,
} from "../domain/analysis/phrases.js";
import { masterCubeAction } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
import { REVIEW_TEXTS } from "./review-texts.js";
import { routeHash } from "./router.js";
import { TEXTS } from "./texts.js";
/** Ce que montre le bilan. */
export interface SummaryData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour dessiner la position du coup à revoir. */
readonly game: Game;
/** Le titre et le détail du résultat (null : partie pas finie). */
readonly end: { readonly title: string; readonly detail: string } | null;
/** L'action qui suit la partie, si c'est la partie à l'écran :
* « Rejouer » ou « Partie suivante ». Sinon, « Nouvelle partie » mène à
* l'écran des choix. */
readonly afterLabel: string | null;
readonly phrases: MasterPhrases | null;
}
export interface SummaryViewOptions {
/** Le voile du bilan (index.html) ; le panneau est construit ici. */
readonly veil: HTMLElement;
/** La croix : revenir au plateau. */
readonly close: () => void;
/** « Rejouer » ou « Partie suivante ». */
readonly again: () => void;
}
export interface SummaryView {
/** Ouvre (ou met à jour) le bilan. */
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
const DICE_SIDE = {
veryLucky: "for-2",
lucky: "for-1",
none: "even",
unlucky: "against-1",
veryUnlucky: "against-2",
} as const;
export function createSummaryView(options: SummaryViewOptions): SummaryView {
const { veil } = options;
const panel = element(veil, "div", { id: "summary", className: "panel summary-panel" });
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "summary-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Ce qui est dessiné, pour ne redessiner qu'en cas de changement (le
// bilan est mis à jour à chaque analyse rangée).
let drawn = "";
function render(data: SummaryData | null): void {
const ready = data !== null && data.phrases !== null && readyForSummary(data.journal);
const summary = ready ? summarizeAnalysis(data.journal) : null;
// Seulement ce que le bilan montre : les analyses qui arrivent
// ensuite en arrière-plan pour l'écran d'analyse (coups de
// l'ordinateur, chances de gagner) ne le redessinent pas, sinon le
// panneau et son mini-plateau sauteraient sous les yeux du joueur.
const shown =
summary === null || data === null || data.phrases === null
? null
: {
counts: summary.counts,
level: summary.player.errorRate.level,
dice: summary.player.luck.rating,
reading: summaryReading(data.phrases, summary),
toReview: summary.toReview?.advice.eventId ?? null,
turn: summary.toReview?.turn ?? null,
};
const key =
data === null
? "missing"
: [
data.id,
data.end?.title ?? "",
data.end?.detail ?? "",
data.afterLabel ?? "",
ready,
JSON.stringify(shown),
].join("|");
if (key === drawn) {
return;
}
drawn = key;
panel.replaceChildren();
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
const close = element(head, "button", { id: "summary-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", REVIEW_TEXTS.close);
close.onclick = () => options.close();
const title = element(panel, "h2", { id: "summary-title", className: "summary-title" });
if (data === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.missingGame });
return;
}
if (data.end === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.notOver });
actions(data);
return;
}
title.textContent = data.end.title;
const reading = element(panel, "p", {
id: "summary-reading",
className: "summary-reading",
});
reading.textContent =
summary !== null && data.phrases !== null
? `${data.end.detail} ${summaryReading(data.phrases, summary)}`
: data.end.detail;
if (summary === null || data.phrases === null) {
const status = element(panel, "p", {
id: "summary-status",
className: "summary-status",
text: REVIEW_TEXTS.analysing,
});
status.setAttribute("role", "status");
actions(data);
return;
}
panel.dataset.ready = "true";
counts(summary);
gauges(summary, data.phrases);
toReview(data, summary, data.phrases);
actions(data);
}
function counts(summary: GameSummary): void {
const box = element(panel, "div", { id: "summary-counts", className: "summary-counts" });
box.setAttribute("role", "list");
box.setAttribute("aria-label", REVIEW_TEXTS.countsLabel);
const cells = [
{ verdict: "good", n: summary.counts.good, word: REVIEW_TEXTS.counts.good },
{
verdict: "doubtful",
n: summary.counts.doubtful,
word: REVIEW_TEXTS.counts.doubtful,
},
{
verdict: "bad",
n: summary.counts.bad + summary.counts.veryBad,
word: REVIEW_TEXTS.counts.bad,
},
] as const;
for (const cell of cells) {
const item = element(box, "div", { className: "summary-count" });
item.setAttribute("role", "listitem");
item.dataset.verdict = cell.verdict;
element(item, "span", { className: "summary-number", text: String(cell.n) });
const word = element(item, "span", { className: "summary-word" });
word.append(icon(VERDICT_ICONS[cell.verdict]), cell.word(cell.n));
}
}
function gauges(summary: GameSummary, phrases: MasterPhrases): void {
const box = element(panel, "div", { className: "summary-gauges" });
const level = summary.player.errorRate.level;
const levelName = MATCH_TEXTS.levels[level].name;
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
play.setAttribute("role", "img");
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
const track = element(play, "span", { className: "gauge-track" });
element(track, "span", {
className: `gauge-fill level-${COMPUTER_LEVELS.indexOf(level) + 1}`,
});
element(play, "span", { className: "gauge-value", text: levelName });
const words = diceWords(phrases, summary.player.luck.rating);
const dice = element(box, "div", { id: "gauge-dice", className: "gauge" });
dice.setAttribute("aria-label", REVIEW_TEXTS.diceGauge(words));
dice.setAttribute("role", "img");
element(dice, "span", { className: "gauge-name", text: REVIEW_TEXTS.theDice });
const centred = element(dice, "span", { className: "gauge-track centred" });
element(centred, "span", {
className: `gauge-fill dice ${DICE_SIDE[summary.player.luck.rating]}`,
});
element(dice, "span", { className: "gauge-value", text: words });
const link = element(box, "a", {
id: "summary-glossary",
className: "link-button summary-glossary",
text: REVIEW_TEXTS.howToRead,
});
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
}
function toReview(data: SummaryData, summary: GameSummary, phrases: MasterPhrases): void {
const card = element(panel, "section", {
id: "summary-review",
className: "summary-review",
});
card.setAttribute("aria-labelledby", "summary-review-title");
element(card, "h3", {
id: "summary-review-title",
className: "overline",
text: REVIEW_TEXTS.toReview,
});
const reviewed = summary.toReview;
if (reviewed === null) {
element(card, "p", { className: "summary-none", text: nothingToReview(phrases) });
return;
}
const advice = reviewed.advice;
const words = masterWords(phrases, advice);
const body = element(card, "div", { className: "summary-review-body" });
const box = element(body, "div", { className: "summary-board-box" });
const svg = boardSvg(box, "summary-board", "summary-board");
svg.setAttribute("aria-label", REVIEW_TEXTS.toReviewBoard);
const halfMoves = advice.kind === "move" ? advice.played.halfMoves : [];
void data.game
.stepsBefore(advice.eventId, "player", halfMoves)
.then((steps) => drawSteps(svg, steps, steps.halfMoves.length, true, "summary-"))
.catch(() => undefined);
const text = element(body, "div", { className: "summary-review-text" });
const verdict = element(text, "p", { className: "summary-verdict" });
verdict.dataset.verdict = words.verdict;
verdict.append(icon(VERDICT_ICONS[words.verdict]), `${words.word} · ${words.context}`);
element(text, "p", {
className: "summary-played",
text:
advice.kind === "move"
? REVIEW_TEXTS.yourMove(advice.played.notation)
: REVIEW_TEXTS.yourDecision(REVIEW_TEXTS.cubeActions[advice.action]),
});
element(text, "p", {
className: "summary-master",
text:
advice.kind === "move" || masterCubeAction(advice) !== advice.action
? words.title
: words.phrase,
});
const links = element(card, "div", { className: "summary-review-actions" });
const revisit = element(links, "a", {
id: "summary-revisit",
className: "button secondary",
text: REVIEW_TEXTS.reviewMove,
});
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
// L'emplacement de « Demander à une IA » (étape 1f).
const slot = element(links, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
}
function actions(data: SummaryData): void {
// Les actions et le lien des réglages, ensemble : en paysage, en bas
// de la colonne de droite.
const end = element(panel, "div", { className: "summary-end" });
const bar = element(end, "div", { className: "panel-actions summary-actions" });
const analysis = element(bar, "a", {
id: "summary-analysis",
className: "button secondary",
text: REVIEW_TEXTS.seeAnalysis,
});
analysis.href = routeHash({ screen: "analysis", game: data.id, turn: null });
if (data.afterLabel !== null) {
const again = element(bar, "button", {
id: "summary-primary",
text: data.afterLabel,
});
again.type = "button";
again.onclick = () => options.again();
const settings = element(end, "a", {
id: "summary-settings",
className: "panel-link",
text: MATCH_TEXTS.changeSettings,
});
settings.href = routeHash({ screen: "newGame" });
} else {
const start = element(bar, "a", {
id: "summary-primary",
className: "button",
text: REVIEW_TEXTS.newGame,
});
start.href = routeHash({ screen: "newGame" });
}
}
return {
show(data) {
const opening = veil.hidden;
veil.hidden = false;
render(data);
if (opening) {
panel.querySelector<HTMLElement>("#summary-close")?.focus();
}
},
hide() {
if (!veil.hidden) {
veil.hidden = true;
panel.replaceChildren();
delete panel.dataset.ready;
drawn = "";
}
},
get isOpen() {
return !veil.hidden;
},
};
}
+11 -8
View File
@@ -136,15 +136,18 @@ export const TEXTS = {
// Fin de partie
gameOver: "Partie finie",
endOverline: "Fin de partie",
/** Titre du panneau de fin : qui gagne, et comment. */
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
if (dropped) {
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
}
return winner === "player"
/** Titre du panneau de fin et du bilan : toujours l'issue, qui gagne
* et comment (un double passé se dit dans le détail). */
endTitle: (winner: "player" | "computer", kind: ResultKind) =>
winner === "player"
? `Vous gagnez ${RESULT_KIND[kind]}`
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
},
: `L'ordinateur gagne ${RESULT_KIND[kind]}`,
/** Avant le détail des points, quand la partie finit sur un double
* passé : « Vous avez passé son double : 1 point pour lui. ». */
droppedDouble: (winner: "player" | "computer") =>
winner === "player"
? "L'ordinateur a passé votre double : "
: "Vous avez passé son double : ",
/** « 8 points pour lui, videau à 4. » */
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
+318
View File
@@ -0,0 +1,318 @@
// L'analyse de partie (étape 1e) : les tours, le format durable de
// l'analyse et sa migration, les chiffres du bilan à la manière de gnubg,
// et une partie complète analysée par le vrai moteur.
//
// 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 {
chanceCurve,
gameTurns,
levelOfErrorRate,
luckRatingOf,
mainAdvice,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { ADVICE_VERSION, type Advice, type CubeAdvice } from "../src/domain/game/advice.js";
import type { Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
migrateJournal,
type NewGameEvent,
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
/** Un journal fait d'événements donnés, à la suite. */
function journalOf(events: readonly NewGameEvent[]): GameJournal {
return events.reduce(appendEvent, EMPTY_JOURNAL);
}
const START: NewGameEvent = {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
};
const P = START_POSITION;
/** Une petite partie : ouverture (égalité puis 3-1 pour le joueur), un
* coup de chaque camp, puis le joueur double et l'ordinateur passe. */
const SHORT: NewGameEvent[] = [
START,
{ type: "DiceRolled", side: null, roll: [2, 2] },
{ type: "DiceRolled", side: null, roll: [3, 1] },
{ type: "MovePlayed", side: "player", halfMoves: [{ from: 8, to: 5 }], position: P },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [{ from: 24, to: 13 }], position: P },
{ type: "CubeOffered", side: "player" },
{ type: "CubeDropped", side: "computer" },
{ type: "GameEnded", winner: "player", kind: "single", points: 1 },
];
test("les tours d'une partie : ouverture, coups, double passé", () => {
const journal = journalOf(SHORT);
const turns = gameTurns(journal);
assert.deepEqual(
turns.map((t) => [t.number, t.side, t.roll, t.move?.id ?? null, t.end?.id ?? null]),
[
[1, "player", [3, 1], "4", null],
[2, "computer", [6, 5], "6", null],
[3, "player", null, null, "9"],
],
);
// L'égalité d'ouverture appartient au premier tour.
assert.deepEqual(
turns[0]?.events.map((e) => e.id),
["2", "3", "4"],
);
// Le jet d'ouverture qui désigne le joueur a une chance, l'égalité non.
const luck = missingAnalyses(journal).filter((t) => t.kind === "luck");
assert.deepEqual(
luck.map((t) => t.eventId),
["3", "5"],
);
});
/** Un conseil fictif sur un coup du joueur. */
function moveAdvice(eventId: string, equityLoss: number, moveCount = 10): Advice {
const move = { notation: "8/5", halfMoves: [], equity: 0, ply: 2 };
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply: 2,
position: P,
roll: [3, 1],
opening: true,
moveCount,
rank: equityLoss === 0 ? 1 : 2,
played: move,
best: move,
playedFacts: { hits: 0, madePoints: [] },
bestFacts: { hits: 0, madePoints: [] },
ranking: [],
equityLoss,
skill:
equityLoss < -0.12
? "veryBad"
: equityLoss < -0.06
? "bad"
: equityLoss < -0.03
? "doubtful"
: "none",
};
}
function cubeAdvice(eventId: string, action: CubeAdvice["action"], equityLoss: number): Advice {
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply: 2,
action,
position: P,
equities: { noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 },
equityLoss,
skill: equityLoss < -0.06 ? "bad" : "none",
};
}
test("le taux d'erreur, le niveau et la chance à la manière de gnubg", () => {
// arThrsRating de gnubg : 0,032, 0,020, 0,013, 0,008, 0,005 ; les six
// classements de gnubg prennent, dans l'ordre, les noms des six niveaux
// de l'ordinateur (Advanced de gnubg : Intermédiaire).
assert.equal(levelOfErrorRate(0.05), "beginner");
assert.equal(levelOfErrorRate(0.032), "beginner");
assert.equal(levelOfErrorRate(0.031), "amateur");
assert.equal(levelOfErrorRate(0.015), "intermediate");
assert.equal(levelOfErrorRate(0.01), "advanced");
assert.equal(levelOfErrorRate(0.006), "expert");
assert.equal(levelOfErrorRate(0.001), "champion");
// getLuckRating : chance par tour × 10, seuils 0,3 et 0,6.
assert.equal(luckRatingOf(0.07), "veryLucky");
assert.equal(luckRatingOf(0.04), "lucky");
assert.equal(luckRatingOf(0.02), "none");
assert.equal(luckRatingOf(-0.04), "unlucky");
assert.equal(luckRatingOf(-0.07), "veryUnlucky");
const base = journalOf(SHORT);
const journal: GameJournal = {
...base,
advice: {
"4": moveAdvice("4", -0.08),
"7": cubeAdvice("7", "double", 0),
},
review: {
...emptyReview(2),
luck: {
"3": { value: 0.2, rating: "none" },
"5": { value: -0.1, rating: "none" },
},
computer: { "6": moveAdvice("6", 0) },
},
};
assert.ok(readyForSummary(journal));
const summary = summarizeAnalysis(journal);
// Un coup non forcé (0,08) et un double (compté toujours) : 0,04 par
// décision.
assert.equal(summary.player.errorRate.decisions, 2);
assert.ok(Math.abs(summary.player.errorRate.perDecision - 0.04) < 1e-9);
assert.equal(summary.player.errorRate.level, "beginner");
assert.deepEqual(summary.counts, { good: 1, doubtful: 0, bad: 1, veryBad: 0 });
assert.equal(summary.toReview?.advice.eventId, "4");
assert.equal(summary.toReview?.turn, 1);
// Une seule fois la main pour chacun : chance par tour égale au total.
assert.equal(summary.player.luck.rolls, 1);
assert.equal(summary.player.luck.rating, "veryLucky");
assert.equal(summary.computer.luck.rating, "veryUnlucky");
// Gagné 1 point, avec 0,2 de chance contre −0,1 pour l'ordinateur :
// 1 − 0,2 − 0,1.
assert.equal(summary.result, 1);
assert.ok(Math.abs(summary.luckAdjustedResult - 0.7) < 1e-9);
// Rejouer le premier tour : juste avant le coup, avec le même jet.
const first = gameTurns(journal)[0];
assert.ok(first !== undefined);
assert.equal(mainAdvice(journal, first)?.eventId, "4");
assert.equal(replayPoint(journal, first), "3");
// La courbe : le dernier tour finit la partie, gagnée.
assert.deepEqual(
chanceCurve(journal).map((p) => [p.turn, p.chance, p.verdict]),
[
[1, null, "bad"],
[2, null, null],
[3, 1, "good"],
],
);
});
test("un jet sans doubler ne compte que si la décision est serrée (isCloseCubedecision)", () => {
const base = journalOf([
{ ...START, firstTurn: "player" },
{ type: "DiceRolled", side: "player", roll: [3, 1] },
]);
const noDouble = (equities: CubeAdvice["equities"]): GameJournal => ({
...base,
advice: { "2": { ...(cubeAdvice("2", "noDouble", 0) as CubeAdvice), equities } },
});
// Doubler coûterait 0,2 par rapport à l'optimum : pas serré.
const far = noDouble({ noDouble: 0.5, doubleTake: 0.3, doublePass: 1, optimal: 0.5 });
assert.equal(summarizeAnalysis(far).player.errorRate.decisions, 0);
// Doubler est l'optimum : serré, compté.
const close = noDouble({ noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 });
assert.equal(summarizeAnalysis(close).player.errorRate.decisions, 1);
// Un bon jet sans doubler n'est pas une décision visible du bilan.
assert.deepEqual(summarizeAnalysis(close).counts, {
good: 0,
doubtful: 0,
bad: 0,
veryBad: 0,
});
});
test("l'analyse de partie enregistrée : migration du format durable", () => {
const journal = journalOf(SHORT);
const review = {
...emptyReview(2),
luck: { "3": { value: 0.1, rating: "none" as const } },
winChances: { "4": 0.55, "99": 0.5 },
unanalysed: ["5", "98"],
};
// Un journal d'avant l'étape 1e se reprend tel quel.
assert.equal(migrateJournal(journal), journal);
// Les entrées rattachées à un événement absent sont oubliées.
const migrated = migrateJournal({ ...journal, review });
assert.deepEqual(migrated.review?.winChances, { "4": 0.55 });
assert.deepEqual(migrated.review?.unanalysed, ["5"]);
assert.deepEqual(migrated.review?.luck, review.luck);
// Une analyse complète et connue ne change pas.
const clean = { ...journal, review: migrated.review };
assert.equal(migrateJournal(clean), clean);
// Une autre version, ou une analyse mal formée, est oubliée.
const other = migrateJournal({
...journal,
review: { ...review, version: REVIEW_VERSION + 1 },
});
assert.equal(other.review, undefined);
const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal;
assert.equal(migrateJournal(broken).review, undefined);
// Les analyses d'une copie s'ajoutent au journal sans toucher aux
// conseils déjà là.
const live: GameJournal = { ...journal, advice: { "4": moveAdvice("4", 0) } };
const copy: GameJournal = {
...journal,
advice: { "4": moveAdvice("4", -0.5), "7": cubeAdvice("7", "double", 0) },
review,
};
const merged = withAnalysesOf(live, copy);
assert.equal(merged.advice?.["4"]?.equityLoss, 0);
assert.ok(merged.advice?.["7"] !== undefined);
assert.equal(merged.review, review);
});
/** Toutes les analyses qui manquent, une à une. */
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
test("une partie complète analysée par le moteur, comme une partie d'avant 1d", async () => {
const engine = await loadNodeEngine();
// Une course courte (tests/reference/positions.json, race-1).
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "2ply",
dice: seededDice(7),
});
await playToTheEnd(game, engine, 1);
// Aucun conseil n'a été rangé pendant la partie (comme avant l'étape
// 1d) : tout est à analyser.
assert.equal(game.journal.advice, undefined);
assert.ok(!readyForSummary(game.journal));
const analysed = await analyseAll(engine, game.journal);
assert.ok(readyForSummary(analysed));
assert.deepEqual(missingAnalyses(analysed), []);
const summary = summarizeAnalysis(analysed);
const advices = Object.values(analysed.advice ?? {});
assert.ok(advices.length > 0);
// Le coup à revoir est la plus grosse perte d'équité du joueur, s'il en
// a une.
const worst = advices
.filter((a) => a.skill !== "none")
.reduce<Advice | null>((w, a) => (w === null || a.equityLoss < w.equityLoss ? a : w), null);
assert.equal(summary.toReview?.advice.eventId, worst?.eventId);
// La chance de chaque tour d'un camp est comptée.
const moves = game.events.filter((e) => e.type === "MovePlayed");
assert.equal(summary.player.luck.rolls + summary.computer.luck.rolls, moves.length);
// Les chances de gagner sont des probabilités, et la courbe finit sur
// le résultat.
const curve = chanceCurve(analysed);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
const won = game.state.result?.winner === "player";
assert.equal(curve.at(-1)?.chance, won ? 1 : 0);
// Les coups de l'ordinateur sont analysés aussi.
assert.ok(Object.keys(analysed.review?.computer ?? {}).length > 0);
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
});
+1
View File
@@ -81,6 +81,7 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
evaluate: async () => ({ win: 0.5, equity: 0 }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
+151
View File
@@ -0,0 +1,151 @@
// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
// ou par une autre partie ; et les phrases du bilan.
//
// 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 AnalysisTask,
type GameSummary,
missingAnalyses,
} from "../src/domain/analysis/game-analysis.js";
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
import { createReviewRunner } from "../src/ui/review-runner.js";
import { loadPhrases } from "./support/advice.js";
const P = START_POSITION;
/** Un tour de l'ordinateur, sans décision du joueur. */
const EVENTS: NewGameEvent[] = [
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
];
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
* libère. */
function fakeRun() {
const waiting: (() => void)[] = [];
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
await new Promise<void>((resolve) => waiting.push(resolve));
const review: GameReview = journal.review ?? emptyReview(2);
if (task.kind === "luck") {
return {
...journal,
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
};
}
if (task.kind === "chance") {
return {
...journal,
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
};
}
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
};
const release = async () => {
while (waiting.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
waiting.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 1));
};
return { run, release };
}
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: GameJournal[] = [];
let done = false;
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
// après le coup.
const total = missingAnalyses(JOURNAL).length;
assert.equal(total, 3);
runner.start(
"a",
JOURNAL,
(j) => recorded.push(j),
() => {
done = true;
},
);
assert.equal(runner.running, "a");
// La même partie redemandée ne repart pas.
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
for (let i = 0; i < total; i++) {
await release();
}
assert.equal(recorded.length, total);
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
assert.ok(done);
assert.equal(runner.running, null);
});
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: string[] = [];
runner.start("a", JOURNAL, () => recorded.push("a"));
runner.stop();
assert.equal(runner.running, null);
await release();
assert.equal(recorded.length, 0);
runner.start("b", JOURNAL, () => recorded.push("b"));
runner.start("c", JOURNAL, () => recorded.push("c"));
assert.equal(runner.running, "c");
await release();
await release();
assert.deepEqual(recorded, ["c"]);
});
/** Un bilan réduit à ce que lisent les phrases. */
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
const side = {
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
};
return {
player: side,
computer: side,
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
toReview: null,
result,
luckAdjustedResult,
};
}
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
const phrases = loadPhrases();
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
assert.equal(
summaryReading(phrases, summaryWith(1, 1.2)),
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
);
assert.equal(
summaryReading(phrases, summaryWith(-2, -1.5)),
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
);
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
for (const [result, adjusted] of [
[1, -0.3],
[-1, 0.2],
] as const) {
assert.equal(
summaryReading(phrases, summaryWith(result, adjusted)),
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
);
}
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
});
+9
View File
@@ -35,6 +35,9 @@ const TABLE: [string, Route][] = [
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/parties/mh2k3x-a1b2c3/bilan", { screen: "summary", game: "mh2k3x-a1b2c3" }],
["#/parties/mh2k3x-a1b2c3/analyse", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: null }],
["#/parties/mh2k3x-a1b2c3/analyse/7", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7 }],
["#/menu", { screen: "menu" }],
["#/nouvelle-partie", { screen: "newGame" }],
["#/explication/12", { screen: "explanation", event: "12" }],
@@ -75,6 +78,12 @@ test("une adresse inconnue est reconnue comme telle", () => {
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
"#/parties/ab/autre",
"#/parties/ab/bilan/2",
"#/parties/ab/analyse/0",
"#/parties/ab/analyse/x",
"#/parties/ab/analyse/3/plus",
"#/parties/AB/bilan",
]) {
assert.equal(parseRoute(hash).screen, "unknown", hash);
}
+232
View File
@@ -0,0 +1,232 @@
// Étapes du scénario docs/scenarios/revoir-ma-partie.feature (ADR 0008) :
// la partie se joue par le domaine sur le vrai moteur, avec des dés
// imposés ; son analyse passe par l'analyse en arrière-plan de l'interface
// (review-runner.ts, sans DOM), le bilan par le domaine
// (game-analysis.ts) et les phrases du maître, et l'ancienne partie par le
// stockage des parties sur un localStorage simulé.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import {
chanceCurve,
gameTurns,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { diceWords, summaryReading } from "../../src/domain/analysis/phrases.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
import { createReviewRunner } from "../../src/ui/review-runner.js";
import { loadPhrases } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(60_000);
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2 (Position ID de
* gnubg, chacun vu de lui-même). */
const BEAROFF = "BQAACAIAAAAAAA";
const phrases = loadPhrases();
let engine: Engine;
let game: Game;
let dice: number[];
/** Le journal analysé de la partie. */
let analysed: GameJournal;
/** La partie d'origine, avant « Rejouer d'ici ». */
let original: { head: string; events: number };
/** Les analyses rangées par l'analyse en arrière-plan. */
let recorded: number;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Analyse ce qui manque au journal, comme l'application en
* arrière-plan. */
async function analyse(journal: GameJournal): Promise<GameJournal> {
const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task));
recorded = 0;
let latest = journal;
await new Promise<void>((resolve) => {
runner.start(
"partie",
journal,
(j) => {
latest = j;
recorded++;
},
resolve,
);
});
return latest;
}
async function playMove(move: string): Promise<void> {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await game.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await game.confirmMove();
}
Given(
"il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2",
() => {
dice = [];
// Les dés sont tirés au moment voulu : ceux qu'impose le scénario.
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: () => {
const die = dice.shift();
if (die === undefined) {
throw new Error("plus de dés imposés");
}
return die;
},
});
},
);
Given("vous avez {int}-{int} à jouer", async (a: number, b: number) => {
dice.push(a, b);
await game.roll();
assert.deepEqual(game.state.roll, [a, b]);
});
When(/^vous choisissez (?:cette fois )?le coup (.+)$/, async (move: string) => {
await playMove(move);
});
/** L'ordinateur, sûr de sortir ses deux pions au jet suivant, double ; le
* joueur passe. */
async function computerDoublesAndPlayerPasses(): Promise<void> {
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled", "l'ordinateur ne double pas");
await game.drop();
assert.equal(game.state.phase, "over");
}
When("l'ordinateur, sûr de gagner, double et que vous passez", async () => {
await computerDoublesAndPlayerPasses();
analysed = await analyse(game.journal);
});
Then("la partie est finie, gagnée par l'ordinateur", () => {
assert.equal(game.state.result?.winner, "computer");
});
Then("le bilan compte une grosse erreur", () => {
assert.ok(readyForSummary(analysed));
assert.equal(summarizeAnalysis(analysed).counts.veryBad, 1);
});
Then("sa phrase dit ce qui a fait la différence, et comment étaient vos dés", () => {
const summary = summarizeAnalysis(analysed);
const reading = summaryReading(phrases, summary);
assert.ok(reading.startsWith("D'après mon analyse, "), reading);
assert.ok(!reading.includes("!"), reading);
// Perdue : le jeu de l'ordinateur a fait la différence, si le résultat
// corrigé de la chance reste négatif ; sinon les dés.
if (summary.luckAdjustedResult < 0) {
assert.ok(reading.includes(diceWords(phrases, summary.player.luck.rating)), reading);
} else {
assert.ok(reading.includes("les dés ont fait la différence"), reading);
}
});
Then("le coup à revoir est votre coup {}, le premier de la partie", (move: string) => {
const toReview = summarizeAnalysis(analysed).toReview;
assert.equal(toReview?.turn, 1);
assert.equal(toReview?.advice.kind, "move");
assert.equal(toReview?.advice.kind === "move" && toReview.advice.played.notation, move);
});
Then("le maître y aurait joué {}", (move: string) => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.best.notation, move);
assert.equal(advice?.skill, "veryBad");
});
Then("la courbe de vos chances de gagner finit à {int} %", (percent: number) => {
const curve = chanceCurve(analysed);
assert.equal(curve.length, gameTurns(analysed).length);
assert.equal(curve.at(-1)?.chance, percent / 100);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
});
When("vous rejouez d'ici le coup à revoir", () => {
game.recordAnalyses(analysed);
original = { head: game.journal.head ?? "", events: game.events.length };
const toReview = summarizeAnalysis(game.journal).toReview;
const turn = gameTurns(game.journal)[(toReview?.turn ?? 0) - 1];
assert.ok(turn !== undefined);
const point = replayPoint(game.journal, turn);
assert.ok(point !== null);
game.goBackTo(point);
});
Then("vous avez de nouveau {int}-{int} à jouer", (a: number, b: number) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.turn, "player");
assert.deepEqual(game.state.roll, [a, b]);
});
Then("la partie est finie, gagnée par vous", () => {
assert.equal(game.state.result?.winner, "player");
});
Then("la partie a deux branches, dont la partie d'origine, intacte", () => {
const branches = game.branches;
assert.equal(branches.length, 2);
const origin = branches.find((b) => b.forkedFrom === null);
assert.equal(origin?.head, original.head);
assert.ok(origin?.over);
// L'analyse de la partie d'origine reste attachée à ses événements.
assert.ok(game.journal.review !== undefined);
});
Given("une partie finie enregistrée sans aucune analyse", async () => {
dice = [6, 1];
game = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice(dice),
});
await game.roll();
await playMove("6/5 5/off");
await computerDoublesAndPlayerPasses();
const { library } = await openGameLibrary({ localStorage: memoryStorage() });
await library.save("ancienne", game.journal, { level: "2ply" });
const saved = await library.load("ancienne");
assert.ok(saved !== null);
analysed = saved.journal;
assert.equal(analysed.advice, undefined);
assert.equal(analysed.review, undefined);
});
When("vous ouvrez son bilan", () => {
assert.ok(!readyForSummary(analysed));
});
Then("le maître l'analyse d'abord en arrière-plan", async () => {
const before = missingAnalyses(analysed).length;
analysed = await analyse(analysed);
assert.equal(recorded, before);
assert.ok(readyForSummary(analysed));
});
Then("le bilan nomme ensuite le coup à revoir", () => {
const advice = summarizeAnalysis(analysed).toReview?.advice;
assert.equal(advice?.kind === "move" && advice.played.notation, "6/off");
});
+7 -3
View File
@@ -242,11 +242,15 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endTitle("computer", "gammon"), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
assert.equal(TEXTS.endTitle("player", "single"), "Vous gagnez la partie simple");
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
// Un double passé : le titre donne l'issue, le détail dit comment.
assert.equal(
`${TEXTS.droppedDouble("computer")}${TEXTS.endDetail("computer", 1, 1)}`,
"Vous avez passé son double : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",
@@ -150,3 +150,37 @@ relira sans recalcul.
conseils ; un conseil reste attaché à son événement, même hors de la
branche courante.
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
### 2026-11-10 — L'analyse de partie rattachée au journal
Avec le bilan de fin de partie et l'écran d'analyse (étape 1e), le journal
d'une partie (`GameJournal`) gagne un second champ facultatif, `review`
(`GameReview`, `app/src/domain/game/review.ts`) : le reste de l'analyse
de la partie par le moteur, à côté des conseils du maître (`advice`) :
- `luck` : la chance de chaque jet (`DiceRolled`, jet d'ouverture compris)
pour le camp qui le joue ;
- `winChances` : les chances de gagner du joueur après chaque coup
(`MovePlayed`), de 0 à 1 ;
- `computer` : les décisions de l'ordinateur, analysées comme celles du
joueur (même format que les conseils) ;
- `unanalysed` : les décisions que le moteur n'analyse pas (videau
inaccessible, position de départ), pour ne plus les redemander ;
- `version` (`REVIEW_VERSION`, 1) et `ply`, le niveau des analyses (2).
Comme les conseils, ce n'est pas un événement : les événements gardent leur
version (2). Les analyses qui manquent (dont tous les conseils d'une partie
jouée avant l'étape 1d) sont calculées en arrière-plan sur le second moteur,
une à une, rangées au fur et à mesure, et l'ensemble s'arrête quand on n'en
a plus besoin (`app/src/ui/review-runner.ts`).
- À la reprise (`migrateJournal`), une analyse d'une autre version ou mal
formée est oubliée ; ses entrées rattachées à un événement absent aussi.
Un journal enregistré avant l'étape 1e n'a pas de champ `review` et se
reprend tel quel. Testé dans `app/tests/game-analysis.test.ts`.
- Une analyse faite en arrière-plan sur une copie du journal s'y range
sans toucher aux conseils déjà là (`withAnalysesOf`).
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
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 »).
+29
View File
@@ -130,3 +130,32 @@ conseil est enregistré dans le journal (ADR 0007, amendement du
2026-11-10). Un événement sans conseil donne un message, jamais une page
cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce
n'est pas un lien à partager (le lien de la position, lui, se partage).
### 2026-11-10 — Adresses du bilan et de l'analyse d'une partie
Ajoutées avec le bilan de fin de partie et l'écran d'analyse (étape 1e),
dans le même schéma, sous l'adresse de la partie dans « Mes parties » :
| Écran | Adresse |
| --- | --- |
| Bilan de fin de partie | `#/parties/<id>/bilan` |
| Analyse d'une partie, placée sur un coup | `#/parties/<id>/analyse/<coup>` |
| Analyse d'une partie, sur le coup à revoir | `#/parties/<id>/analyse` |
`<id>` est l'identifiant de la partie enregistrée ; `<coup>` le numéro d'un
tour de la partie (ce qu'un camp a fait quand il avait la main : doubler ou
non, lancer, jouer), à partir de 1, sur sa ligne courante.
- Le bilan remplace le panneau de fin de partie dès que l'analyse est
prête : l'application va à l'adresse du bilan, par-dessus celle de la
partie (`#/`). Il monte au-dessus de la partie, comme une
feuille ; le fermer (croix, retour du téléphone) laisse voir la partie
finie, sans le panneau. « Mes parties » donne le bilan de chaque partie
finie.
- L'analyse est une page. Choisir un autre coup (courbe, flèches, liste)
remplace l'adresse : le retour du téléphone quitte l'écran au lieu de
repasser par chaque coup. « Rejouer d'ici » ramène à la partie (`#/`),
reprise juste avant la décision du coup.
- Comme la feuille d'explication, ces adresses n'ont de sens que sur
l'appareil qui a joué la partie. Une partie absente donne un message,
jamais une page cassée.
+1 -1
View File
@@ -21,7 +21,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | 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 |
+2
View File
@@ -45,6 +45,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Compte de points](compte-de-points.md) | Pip count |
| [Conseil du maître](conseil-du-maitre.md) | Advice |
| [Coup](coup.md) | Move |
| [Courbe des chances de gagner](courbe-des-chances.md) | Win chance curve |
| [Demi-coup](demi-coup.md) | Half-move |
| [Course](course.md) | Race |
| [Doubler](doubler.md) | Double |
@@ -70,4 +71,5 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
| [SGF](sgf.md) | SGF |
| [Sortie](sortie.md) | Bear-off |
| [Taux d'erreur](taux-d-erreur.md) | Error rate |
| [Videau](videau.md) | Cube |
+24
View File
@@ -0,0 +1,24 @@
---
term: Courbe des chances de gagner
english: Win chance curve
code: WinChanceCurve
---
# Courbe des chances de gagner
**En bref** : vos chances de gagner la partie après chaque coup, selon le moteur, tracées coup après coup en pourcentage.
Après chaque coup, des deux camps, le moteur évalue la position et donne
vos chances de gagner la partie, sans tenir compte du videau. La courbe
monte quand la partie tourne en votre faveur, descend sinon ; la ligne en
pointillé marque 50 %. Le dernier point est le résultat : 100 % si vous avez
gagné, 0 % sinon.
Les points en couleur sont vos coups commentés par le maître, avec la
couleur de leur [appréciation](appreciation.md) ; les petits points sont les
coups de l'ordinateur. Une chute juste après l'un de vos coups montre ce
qu'une erreur a coûté ; une chute après un jet montre la part des dés.
C'est le « graphe d'équité » de GNU Backgammon, en pourcentage plutôt qu'en
équité, plus parlant pour un débutant.
Voir aussi : [Équité](equite.md), [Taux d'erreur](taux-d-erreur.md), [Chance](chance.md)
+36
View File
@@ -0,0 +1,36 @@
---
term: Taux d'erreur
english: Error rate
code: ErrorRate
---
# Taux d'erreur
**En bref** : l'équité que vous avez perdue en moyenne à chaque décision de la partie, d'après le moteur ; plus il est bas, mieux vous avez joué.
Le moteur compare chacun de vos coups et chacune de vos décisions de videau
au meilleur choix, et note l'équité perdue (0 pour le meilleur coup). Le
taux d'erreur additionne ces pertes et les divise par le nombre de
décisions qui comptent : les coups où vous aviez le choix (un coup forcé ne
compte pas), vos doubles, vos prises et vos refus, et vos jets sans doubler
quand doubler se discutait. C'est le calcul de GNU Backgammon (« Error rate
per decision »).
Le bilan traduit ce taux en niveau, avec les seuils de GNU Backgammon et
les noms des niveaux de l'ordinateur, dans le même ordre :
| Équité perdue par décision | Classement de GNU Backgammon | Niveau dans Ustagammon |
| --- | --- | --- |
| moins de 0,005 | Super Grandmaster | Champion |
| 0,005 à 0,008 | Grandmaster | Expert |
| 0,008 à 0,013 | Master | Avancé |
| 0,013 à 0,020 | Advanced | Intermédiaire |
| 0,020 à 0,032 | Intermediate | Amateur |
| 0,032 et plus | Beginner | Débutant |
Le bilan montre à côté la [chance](chance.md) de vos dés sur la partie : par
tour, la chance de vos jets ; « un peu » pour vous ou contre vous au-delà de
0,03 par tour, « nettement » au-delà de 0,06, comme GNU Backgammon. La
phrase du bilan compare enfin le résultat à ce qu'il aurait été sans la
chance des deux camps : si l'écart vient surtout des dés, elle le dit.
Voir aussi : [Appréciation](appreciation.md), [Chance](chance.md), [Équité](equite.md), [Courbe des chances de gagner](courbe-des-chances.md)
+52 -2
View File
@@ -1,7 +1,8 @@
# Les phrases du maître
Les mots et les phrases du conseil du maître (étape 1d), en une seule source
(ADR 0008) : `make site` copie ce dossier dans le site publié
Les mots et les phrases du conseil du maître (étape 1d) et du bilan de fin
de partie (étape 1e), en une seule source (ADR 0008) : `make site` copie ce
dossier dans le site publié
(`docs/maitre/`), l'application le lit à l'exécution
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
sur de vraies analyses du moteur (`app/tests/advice.test.ts`).
@@ -37,6 +38,7 @@ mots entre accolades sont remplis avec les données du moteur :
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
| `{roll}` | le jet, « 3-1 » |
| `{hits}` | le nombre de pions frappés, en chiffres |
| `{dice}` | la chance de vos dés sur la partie, en mots (bilan, étape 1e) |
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
sous 140 caractères.
@@ -231,3 +233,51 @@ Grosse erreur : le rang n'est jamais cité.
## contexte.videau.reponse
- le double de l'ordinateur
## bilan.jeu.gagne
Bilan de fin de partie (étape 1e), partie gagnée, quand le résultat corrigé
de la chance (celui qu'on aurait eu sans les dés, à la manière de gnubg)
reste en votre faveur. {dice} : la chance de vos dés sur la partie (sections
bilan.des.…).
- D'après mon analyse, votre jeu a fait la différence, avec des dés {dice}.
## bilan.jeu.perdu
Partie perdue, le résultat corrigé de la chance restant en faveur de
l'ordinateur.
- D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés {dice}.
## bilan.des
Partie gagnée ou perdue, le résultat corrigé de la chance allant dans
l'autre sens que le résultat : les dés des deux camps ont pesé plus que les
erreurs.
- D'après mon analyse, les dés ont fait la différence, plus que le jeu.
## bilan.des.tres-pour
- nettement pour vous
## bilan.des.pour
- un peu pour vous
## bilan.des.neutres
- équilibrés
## bilan.des.contre
- un peu contre vous
## bilan.des.tres-contre
- nettement contre vous
## bilan.aucun-coup
- Aucun coup à revoir : vous avez joué comme je l'aurais fait.
-46
View File
@@ -1,46 +0,0 @@
# Fin de partie et analyse (1e)
- **Date** : 2026-11-03
- **Étape** : 1e (`docs/etape-1.md`)
- **Statut** : prévu (après 1d)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Que le joueur comprenne sa partie : le bilan de fin de partie (ADR 0011), puis un écran d'analyse avec graphe d'équité, liste des erreurs, visualiseur et « Rejouer d'ici ».
## Consignes
Respecter les ADR 0007, 0008, 0011 et 0013. Chaque écran a son adresse : `#/parties/<id>/bilan` et `#/parties/<id>/analyse/<coup>`, à ajouter au tableau de l'ADR 0013 par un amendement. Pour la charte, suivre les maquettes « Fin de partie » du canevas. Aucune nouvelle dépendance : le graphe est dessiné en SVG à la main.
1. **Analyse de la partie.** Elle réutilise les analyses rangées par 1d (coups et videau) et complète celles qui manquent en arrière-plan, de façon progressive et annulable. Elle donne :
- le taux d'erreur à la manière de gnubg ;
- la chance aux dés (`luck`) pour chaque camp ;
- le décompte des coups par appréciation.
2. **Bilan**, dans le panneau de fin de partie existant :
- le résultat ;
- les coups par appréciation ;
- le taux d'erreur comparé à la chance, en phrases simples ;
- « le coup à revoir » : la plus grosse erreur, avec sa position, le coup joué et le meilleur coup ;
- un emplacement pour le bouton « Demander à une IA » (1f) ;
- « Voir l'analyse ».
3. **Écran d'analyse** :
- un graphe d'équité où toucher un point ouvre la position ;
- la liste des erreurs, filtrable par appréciation ;
- un visualiseur pas à pas qui montre sur le plateau le coup joué contre le meilleur coup, avec des pions animés ;
- « Rejouer d'ici », qui crée une branche (domaine de 1b) sans toucher l'original.
4. **Tests** :
- scénarios Gherkin : bilan, coup à revoir, rejouer d'ici, chacun avec des dés imposés ;
- fiches du glossaire : taux d'erreur, graphe d'équité si absentes ;
- `make lint`, `make test`, `make test-browser`, avec des captures pour la recette design.
## Critères de réussite
- Après une partie, le bilan nomme le coup à revoir, conforme à l'analyse du moteur.
- L'analyse s'ouvre par son adresse.
- « Rejouer d'ici » crée une branche.
- Les trois commandes passent.
## Résultat
À remplir à la clôture.
+1
View File
@@ -58,6 +58,7 @@ Règles d'écriture :
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
| [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c |
| [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d |
| [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e |
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
+62
View File
@@ -0,0 +1,62 @@
# language: fr
Fonctionnalité: Revoir ma partie avec le bilan et l'analyse
À la fin de chaque partie, le maître fait le bilan : le résultat, vos
décisions par appréciation (bons coups, imprécisions, erreurs), votre jeu
traduit en niveau, la chance de vos dés, et le coup à revoir, celui qui
vous a coûté le plus. « Voir l'analyse » montre la courbe de vos chances de
gagner, coup après coup : parcourez-la du doigt pour revoir chaque coup sur
le plateau, le vôtre et celui du maître. Tout vient de GNU Backgammon,
comme le conseil du maître ; ce qui manque est calculé en arrière-plan,
même pour une partie jouée avant que le conseil existe.
Scénario: Que dit le bilan d'une partie ?
Le bilan compte vos décisions par appréciation, traduit votre jeu en
niveau, et dit en une phrase ce qui a fait la différence : votre jeu,
celui de l'ordinateur, ou les dés. Ici, après 6/5 5/off, l'ordinateur
est sûr de sortir ses deux derniers pions : il double, et vous passez.
Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2
Et que vous avez 6-1 à jouer
Quand vous choisissez le coup 6/5 5/off
Et que l'ordinateur, sûr de gagner, double et que vous passez
Alors la partie est finie, gagnée par l'ordinateur
Et le bilan compte une grosse erreur
Et sa phrase dit ce qui a fait la différence, et comment étaient vos dés
Scénario: Quel est le coup à revoir ?
C'est votre décision qui a perdu le plus d'équité, d'après le maître. Le
bilan la montre sur le plateau, avec le coup que le maître aurait joué ;
« Revoir ce coup » ouvre l'analyse sur ce coup.
Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2
Et que vous avez 6-1 à jouer
Quand vous choisissez le coup 6/5 5/off
Et que l'ordinateur, sûr de gagner, double et que vous passez
Alors le coup à revoir est votre coup 6/off, le premier de la partie
Et le maître y aurait joué 6/off 1/off
Et la courbe de vos chances de gagner finit à 0 %
Scénario: Comment rejouer un coup autrement ?
Dans l'analyse, « Rejouer d'ici » vous remet juste avant le coup, avec le
même jet. Jouez autre chose : une nouvelle branche commence là, et la
partie d'origine reste intacte dans « Mes parties ».
Étant donné qu'il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2
Et que vous avez 6-1 à jouer
Quand vous choisissez le coup 6/5 5/off
Et que l'ordinateur, sûr de gagner, double et que vous passez
Et que vous rejouez d'ici le coup à revoir
Alors vous avez de nouveau 6-1 à jouer
Quand vous choisissez cette fois le coup 1/off 6/off
Alors la partie est finie, gagnée par vous
Et la partie a deux branches, dont la partie d'origine, intacte
Scénario: Puis-je voir le bilan d'une partie plus ancienne ?
Oui : une partie enregistrée sans analyse, par exemple jouée avant le
conseil du maître, est analysée quand vous ouvrez son bilan.
Étant donné une partie finie enregistrée sans aucune analyse
Quand vous ouvrez son bilan
Alors le maître l'analyse d'abord en arrière-plan
Et le bilan nomme ensuite le coup à revoir
+14 -5
View File
@@ -9,10 +9,12 @@
# captures dans build/browser-shots/match/.
#
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
# build/browser-shots/maitre/.
# 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/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le
# test.
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan)
# 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
@@ -25,7 +27,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}"
scripts="${SCRIPTS:-play accueil match maitre bilan}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -63,7 +65,7 @@ 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 "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.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
@@ -96,5 +98,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" bilan "* ]]; then
# Bilan de fin de partie et écran d'analyse ; captures de la recette
# dans build/browser-shots/bilan/.
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 bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+482
View File
@@ -0,0 +1,482 @@
// bilan.mjs -- le bilan de fin de partie et l'écran d'analyse (étape 1e)
// joués par l'interface, dans un vrai navigateur sans écran (Playwright),
// sur le site servi par l'image de production (charte,
// ecrans-etape-1.md, « 1e. Bilan et analyse »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node bilan.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Une fin de course courte, partie depuis un
// lien de position avec des dés imposés : le joueur a 6-1 et deux pions,
// sur 6 et 1 ; 6/off 1/off gagne, 6/5/off perd (l'ordinateur sortira ses
// deux derniers pions au coup suivant : il double, et le joueur passe). Le
// joueur joue 6/5/off. Vérifie que le
// bilan remplace le panneau de fin de partie et nomme ce coup comme le coup
// à revoir, que l'analyse s'ouvre par son adresse (courbe, point touché,
// liste filtrée, visualiseur), que « Rejouer d'ici » crée une branche, la
// règle de la recette (rien d'interactif sous une barre d'action), et
// l'absence d'erreur et de violation de la politique de sécurité. Captures
// (Chromium) dans <dossier des captures>/bilan/, en 412×915, 915×412 et
// 844×320.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node bilan.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/bilan`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SMALL_LANDSCAPE = { width: 844, height: 320 };
const SIZES = [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE];
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2 (positions de gnubg, chacun vu de lui-même).
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",
// Sans animation : les captures montrent les coups posés, avec leurs
// traces.
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 5;
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}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette dans les trois tailles (Chromium seulement),
* après la règle commune : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of SIZES) {
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(", ")}`,
);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une nouvelle partie à partir d'une position, joueur au trait. */
async function startFrom(position) {
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,
);
}
/** Une case du plateau de jeu : « off » pour la sortie du joueur. */
const place = (point) => (point === 0 ? "off-player" : `p${point}`);
/** Joue un coup déjà lancé, demi-coup par demi-coup, puis le valide. */
async function playMove(halfMoves) {
for (const [from, to] of halfMoves) {
await page.click(`#board .hit[data-place="${place(from)}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
await page.click("#confirm");
}
/** Lance les dés imposés et joue un coup. */
async function play(dice, halfMoves) {
await page.evaluate((d) => {
window.__dice.push(...d);
}, dice);
await page.click("#roll");
await idle();
await playMove(halfMoves);
}
// --------------------------------------------- Une partie, puis son bilan
await startFrom(BEAROFF);
await play([6, 1], [[6, 5], [5, 0]]);
// L'ordinateur, sûr de gagner, double. La ligne du maître commente le
// coup : sa feuille d'explication écrit le coup joué dans la notation du
// moteur, comme le bilan et l'analyse plus bas.
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await until("ligne du maître", () => !document.getElementById("advice-line").hidden);
await page.click("#advice-line");
await until("feuille du maître", () => document.getElementById("explain-mine") !== null);
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
await page.click("#drop");
// Le panneau de fin de partie, puis le bilan à sa place, à son adresse.
await until(
"bilan prêt",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
await until("mini-plateau du coup à revoir", () =>
document.querySelector("#summary-board .board-frame") !== null,
);
const summary = await page.evaluate(() => ({
hash: location.hash,
panelHidden: document.getElementById("panel-veil").hidden,
title: document.getElementById("summary-title").textContent,
reading: document.getElementById("summary-reading").textContent,
counts: [...document.querySelectorAll(".summary-count .summary-number")].map(
(n) => n.textContent,
),
verdict: document.querySelector(".summary-verdict")?.textContent ?? "",
played: document.querySelector(".summary-played")?.textContent ?? "",
master: document.querySelector(".summary-master")?.textContent ?? "",
board: document.querySelector("#summary-board .board-frame") !== null,
play: document.querySelector("#gauge-play .gauge-value")?.textContent ?? "",
dice: document.querySelector("#gauge-dice .gauge-value")?.textContent ?? "",
slot: document.querySelector("#summary-review .ask-ai-slot") !== null,
}));
const id = summary.hash.split("/")[2];
check(summary.panelHidden, "le panneau de fin de partie reste sous le bilan");
check(summary.title === "L'ordinateur gagne la partie simple", `titre : ${summary.title}`);
check(
summary.reading.startsWith("Vous avez passé son double : 1 point pour lui. "),
`lecture : ${summary.reading}`,
);
check(summary.reading.includes("D'après mon analyse"), `lecture : ${summary.reading}`);
check(!summary.reading.includes("!"), `lecture : ${summary.reading}`);
// Le refus du double, juste ; le coup 6/5/off, grosse erreur ; le jet
// sans doubler, compté seulement s'il était une faute.
check(
summary.counts[0] === "1" && summary.counts[2] !== "0",
`décisions : ${summary.counts.join()}`,
);
check(summary.verdict.startsWith("Grosse erreur"), `coup à revoir : ${summary.verdict}`);
check(summary.played === "Votre coup : 6/off", `coup joué : ${summary.played}`);
check(summary.master === "J'aurais joué 6/off 1/off", `coup du maître : ${summary.master}`);
check(summary.board, "pas de mini-plateau dans le coup à revoir");
check(summary.play !== "" && summary.dice !== "", "jauges vides");
check(summary.slot, "pas d'emplacement pour « Demander à une IA »");
await capture("1-bilan");
log(`bilan : « ${summary.reading} » ; coup à revoir « ${summary.played} » contre « ${summary.master} »`);
// Le bilan se recharge par son adresse.
await page.reload();
await until(
"bilan rouvert",
() => document.getElementById("summary")?.dataset.ready === "true",
);
// ------------------------------------------------------- Écran d'analyse
await page.click("#summary-revisit");
await until(
"analyse ouverte sur le coup à revoir",
(game) =>
location.hash === `#/parties/${game}/analyse/1` &&
!document.getElementById("analysis").hidden &&
document.querySelector("#analysis-board .board-frame") !== null &&
document.querySelectorAll("#analysis-curve .curve-band").length === 2 &&
document.querySelector("#analysis-curve .curve-line") !== null,
id,
);
const viewer = await page.evaluate(() => ({
turn: document.getElementById("analysis-turn").textContent,
phrase: document.getElementById("analysis-phrase").textContent,
mine: document.getElementById("analysis-mine").getAttribute("aria-checked"),
choice: !document.getElementById("analysis-choice").hidden,
replay: !document.getElementById("analysis-replay").hidden,
slot: document.querySelector("#analysis-viewer .ask-ai-slot") !== null,
}));
check(viewer.turn === "Coup 1 sur 2 · 6-1", `visualiseur : ${viewer.turn}`);
check(viewer.phrase.startsWith("Grosse erreur."), `phrase : ${viewer.phrase}`);
check(viewer.choice && viewer.mine === "true", "votre coup n'est pas montré d'abord");
check(viewer.replay && viewer.slot, "« Rejouer d'ici » ou l'emplacement de l'IA manquent");
await capture("2-analyse-courbe");
// Toucher un point de la courbe : la bande du coup 2 (le double de
// l'ordinateur, que le joueur a passé).
const band = await page.locator('#analysis-curve .curve-band[data-turn="2"]').boundingBox();
await page.mouse.click(band.x + band.width / 2, band.y + band.height / 3);
await until(
"coup 2 choisi",
(game) =>
location.hash === `#/parties/${game}/analyse/2` &&
document.getElementById("analysis-turn").textContent.startsWith("Coup 2 sur 2"),
id,
);
const touched = await page.evaluate(() => ({
phrase: document.getElementById("analysis-phrase").textContent,
ring: document.querySelector("#analysis-curve .curve-ring") !== null,
replay: document.getElementById("analysis-replay").hidden,
}));
check(touched.phrase.startsWith("Bonne décision."), `coup 2 : ${touched.phrase}`);
check(touched.ring, "le point choisi n'est pas cerclé");
check(!touched.replay, "pas de « Rejouer d'ici » sur une décision du joueur");
await capture("3-point-touche");
// Au clavier, ← revient au coup 1.
await page.focus("#analysis-curve");
await page.keyboard.press("ArrowLeft");
await until("coup 1 au clavier", (game) => location.hash === `#/parties/${game}/analyse/1`, id);
// La liste, filtrée : une erreur, aucune imprécision.
await page.click('#analysis-filters .filter[data-filter="errors"]');
const errors = await page.evaluate(() => ({
rows: [...document.querySelectorAll("#analysis-list .analysis-row")].map((r) =>
r.getAttribute("aria-label"),
),
filters: [...document.querySelectorAll("#analysis-filters .filter")].map((f) => f.textContent),
}));
check(errors.rows.length === 1 && errors.rows[0].includes("Grosse erreur"), `liste : ${errors.rows}`);
check(errors.filters.join() === "Tout · 2,Erreurs · 1,Imprécisions · 0", `filtres : ${errors.filters}`);
await page.locator("#analysis-list").scrollIntoViewIfNeeded();
await capture("4-liste-filtree");
await page.click('#analysis-filters .filter[data-filter="doubtful"]');
check(
(await page.locator("#analysis-list .analysis-row").count()) === 0,
"une imprécision dans la liste",
);
await page.click('#analysis-filters .filter[data-filter="errors"]');
await page.click("#analysis-list .analysis-row");
// Le visualiseur : le coup du maître sur la position d'avant le coup.
await page.locator("#analysis-viewer").scrollIntoViewIfNeeded();
await page.click("#analysis-best");
const best = await page.evaluate(() => ({
legend: document.getElementById("analysis-legend").textContent,
move: document.getElementById("analysis-board").dataset.move,
}));
check(best.move === "best" && best.legend.includes("6/off 1/off"), `maître : ${best.legend}`);
// Une seule notation, celle du moteur : le même coup s'écrit pareil dans
// la feuille du maître, le bilan, la liste et le visualiseur.
await page.click("#analysis-mine");
const notations = await page.evaluate(() => ({
legend: document.getElementById("analysis-legend").textContent,
row: document.querySelector('#analysis-list .analysis-row[data-turn="1"] .row-move')
?.textContent,
}));
check(
notations.legend.includes(" 6/off,") && notations.row === "6/off",
`notation : visualiseur « ${notations.legend} », liste « ${notations.row} »`,
);
await page.click("#analysis-best");
await capture("5-visualiseur");
log("analyse : courbe, point touché, clavier, liste filtrée, visualiseur");
// L'analyse s'ouvre aussi par son adresse, dans un nouvel onglet.
const other = await context.newPage();
await other.goto(`${base}#/parties/${id}/analyse/2`);
for (let waited = 0; ; waited += 100) {
const ready = await other.evaluate(
() =>
!document.getElementById("analysis").hidden &&
document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2"),
);
if (ready) {
break;
}
check(waited < 60_000, "l'analyse ne s'ouvre pas par son adresse");
await sleep(100);
}
await other.close();
// ------------------------------------------------------- Rejouer d'ici
await page.click("#analysis-replay");
await until(
"partie reprise avant le coup",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
document.querySelector("#board .hit.can-move") !== null,
);
await capture("6-rejouer-d-ici");
// Le pion du point 1 d'abord : chacun n'a alors qu'une arrivée, la sortie.
await playMove([
[1, 0],
[6, 0],
]);
await until(
"partie gagnée, son bilan",
() =>
/\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
const won = await page.evaluate(() => ({
title: document.getElementById("summary-title").textContent,
none: document.querySelector(".summary-none")?.textContent ?? "",
}));
check(won.title === "Vous gagnez la partie simple", `branche : ${won.title}`);
check(won.none.startsWith("Aucun coup à revoir"), `branche : ${won.none}`);
// Deux branches dans « Mes parties », l'originale intacte.
await page.goto(`${base}#/parties/${id}`);
await until(
"branches dépliées",
() => document.querySelectorAll(".games-branch").length > 0,
);
const branches = await page.evaluate(() =>
[...document.querySelectorAll(".games-branch span")].map((s) => s.textContent),
);
check(branches.length === 2, `branches : ${branches.join(" | ")}`);
check(branches[0].startsWith("Partie d'origine : 1 coup, finie"), `origine : ${branches[0]}`);
log(`rejouer d'ici : ${branches.join(" | ")}`);
// ------------------------------------------- Une partie plus longue
// Pour juger la courbe en usage réel (Chromium seulement) : une longue
// course (tests/reference/positions.json, race-2) jouée jusqu'au bout,
// le joueur prenant à chaque fois le premier pion jouable et sa première
// arrivée, puis son analyse complète.
if (full) {
await startFrom("cLezAQBwN2cGAA");
for (let actions = 0; ; actions++) {
check(actions < 2000, "la longue partie ne finit pas");
const what = await page.evaluate(() => {
if (document.body.classList.contains("busy")) {
return null;
}
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
if (!document.getElementById("panel-veil").hidden) {
return "panel";
}
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 "land";
}
return document.querySelector("#board .hit.can-move") ? "move" : null;
});
if (what === "over") {
break;
}
if (what === null || what === "panel") {
await sleep(100);
continue;
}
const target = {
take: "#take",
skip: "#skip",
confirm: "#confirm",
roll: "#roll",
land: "#board .hit.can-land",
move: "#board .hit.can-move",
}[what];
await page.locator(target).first().click();
await idle();
}
await until("bilan de la longue partie", () =>
document.getElementById("summary")?.dataset.ready === "true");
await page.click("#summary-analysis");
await until("analyse complète de la longue partie", () => {
const bands = document.querySelectorAll("#analysis-curve .curve-band").length;
return (
!document.getElementById("analysis").hidden &&
bands >= 20 &&
document.querySelectorAll("#analysis-curve .curve-dot").length === bands
);
}, null, 600_000);
const turns = await page.locator("#analysis-curve .curve-band").count();
await page.evaluate(() => window.scrollTo(0, 0));
await capture("7-analyse-partie-longue");
log(`longue partie : ${turns} coups, courbe complète`);
}
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(`bilan et analyse : tout est conforme (${captures} captures)`);
+20 -6
View File
@@ -117,6 +117,10 @@ async function todo() {
if (!panel.hidden) {
return panel.dataset.kind;
}
// Le bilan remplace le panneau de fin de partie (étape 1e).
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
@@ -282,12 +286,17 @@ for (;;) {
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
}
if (what === "over") {
const title = await page.textContent("#panel-text");
const detail = await page.textContent("#panel-detail");
const primary = await page.textContent("#panel-primary");
// Le bilan, une fois l'analyse prête, à la place du panneau.
await until("bilan prêt", () =>
!document.getElementById("summary-veil").hidden &&
document.getElementById("summary")?.dataset.ready === "true", null, 120_000);
const title = await page.textContent("#summary-title");
const reading = await page.textContent("#summary-reading");
const detail = reading.split(" D'après mon analyse")[0];
const primary = await page.textContent("#summary-primary");
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
const settingsLink = await page.evaluate(() => {
const a = document.getElementById("panel-settings");
const a = document.getElementById("summary-settings");
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
@@ -309,10 +318,15 @@ for (;;) {
// Après le match, « Changer les réglages » mène à l'écran des
// choix ; le fermer ramène au résultat, où « Rejouer » relance
// tout de suite un match de même longueur.
await page.click("#panel-settings");
await page.click("#summary-settings");
await until("écran « Nouvelle partie » après le match", () =>
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
await page.click("#new-game-close");
// Retour au bilan ; le fermer laisse le résultat sur le plateau.
await until("retour au bilan du match", () =>
/\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true");
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",
@@ -334,7 +348,7 @@ for (;;) {
}
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
`fin de partie dans un match : ${primary} ; ${detail}`);
await page.click("#panel-primary");
await page.click("#summary-primary");
games++;
await until("partie suivante", () => !document.body.classList.contains("busy"));
const next = await shownMatch();
+48 -21
View File
@@ -184,13 +184,21 @@ async function savedGameCount() {
);
}
/** Les événements d'un journal et son événement courant : la partie,
* sans les analyses du moteur qui s'y rangent en arrière-plan (conseils,
* analyse de partie, étapes 1d et 1e). */
function played(journal) {
return journal === null ? null : { events: journal.events, head: journal.head };
}
/** Le journal enregistré, une fois les enregistrements en cours terminés
* (l'application enregistre sans attendre) : deux lectures identiques. */
* (l'application enregistre sans attendre) : deux lectures identiques de
* la partie. */
async function savedJournal() {
let last = await readJournal();
let last = played(await readJournal());
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const next = await readJournal();
const next = played(await readJournal());
if (JSON.stringify(next) === JSON.stringify(last)) {
return next;
}
@@ -222,6 +230,10 @@ async function waitForPlayer() {
if (!panel.hidden) {
return panel.dataset.kind;
}
// Le bilan remplace le panneau de fin de partie (étape 1e).
if (!document.getElementById("summary-veil").hidden) {
return "over";
}
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
@@ -1523,13 +1535,25 @@ for (let actions = 0; ; actions++) {
// --------------------------------------------------------- Fin de partie
// Le panneau de fin de partie, puis le bilan à sa place une fois
// l'analyse prête (étape 1e, testé en détail par bilan.mjs).
await page.waitForSelector('#summary[data-ready="true"]', { timeout: 120_000 });
// Le bilan posé : son mini-plateau dessiné (ou « Aucun coup à revoir »).
await page.waitForSelector("#summary-board .board-frame, #summary .summary-none", { state: "attached" });
const journal = await savedJournal();
const result = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`;
check((await page.textContent("#panel-overline")) === "Fin de partie" &&
/^(Vous gagnez|L'ordinateur gagne|Vous passez|L'ordinateur passe)/.test(
await page.textContent("#panel-text")) &&
/^\d+ points? pour (vous|lui)(, videau à \d+)?\.$/.test(await page.textContent("#panel-detail")),
`panneau de fin de partie : ${result}`);
/** Le titre du bilan et la phrase du résultat. */
async function summaryResult() {
const title = await page.textContent("#summary-title");
const reading = await page.textContent("#summary-reading");
const detail = /^(Vous avez passé son double : |L'ordinateur a passé votre double : )?\d+ points? pour (vous|lui)(, videau à \d+)?\./.exec(reading)?.[0] ?? reading;
return `${title}, ${detail}`;
}
const result = await summaryResult();
check((await page.textContent("#summary .overline")) === "Fin de partie" &&
/^(Vous gagnez|L'ordinateur gagne)/.test(await page.textContent("#summary-title")) &&
/^(Vous avez passé son double : |L'ordinateur a passé votre double : )?\d+ points? pour (vous|lui)(, videau à \d+)?\. /.test(
await page.textContent("#summary-reading")),
`bilan de fin de partie : ${result}`);
check((await page.textContent("#prompt")) === "", "fin de partie : la consigne répète le panneau");
log(
`partie finie : ${result} (${countMoves(journal, "player")} coups du joueur, ` +
@@ -1538,11 +1562,11 @@ log(
const won = journal.events.at(-1).winner === "player";
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
(await page.textContent("#panel-primary")) === "Rejouer" &&
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
check((await page.textContent("#summary-analysis")) === "Voir l'analyse" &&
(await page.textContent("#summary-primary")) === "Rejouer" &&
(await page.textContent("#summary-settings")) === "Changer les réglages" &&
(await page.getAttribute("#summary-settings", "href")) === "#/nouvelle-partie",
"fin de partie : bilan sans « Voir l'analyse », « Rejouer » et « Changer les réglages »");
await review("6-fin-de-partie");
await page.setViewportSize({ width: 844, height: 320 });
await sleep(150);
@@ -1573,12 +1597,13 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
log(`vibrations demandées : ${(await vibrations()).length}`);
await shot("4-fin-de-partie");
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
// d'actions.
await page.click("#panel-secondary");
// Fermer le bilan laisse voir la partie finie, sans panneau ; « Rejouer »
// 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" &&
(await page.isVisible("#again")),
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
step("mes parties");
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
@@ -1628,14 +1653,16 @@ step("mes parties");
}));
throw new Error(`reprise bloquée : ${JSON.stringify(state)}`);
});
await page.waitForSelector("#panel");
// La partie finie reprise : son bilan, déjà analysé, à la place du
// panneau.
await page.waitForSelector('#summary[data-ready="true"]', { timeout: 60_000 });
const resumed = await savedJournal();
check(JSON.stringify(resumed) === JSON.stringify(finished), "la première partie n'a pas été reprise");
const shown = `${await page.textContent("#panel-text")}, ${await page.textContent("#panel-detail")}`;
const shown = await summaryResult();
check(shown === result, `écran différent après la reprise : ${shown}`);
check((await page.evaluate(() => performance.getEntriesByType("navigation").length)) === loads,
"la page a été rechargée");
await page.click("#panel-secondary");
await page.click("#summary-close");
log(
`mes parties : ${listed} parties listées (${branchCount} branche(s) pour la première), ` +
`SGF exporté (${sgf.length} octets), première partie reprise sans rechargement`,
+26
View File
@@ -5,6 +5,32 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Les boutons visibles (et les liens qui en ont l'allure) plus hauts que
* 56 px : un bouton fait 48 px, il ne s'étire pas. Renvoie la liste des
* boutons trop hauts, décrits (vide si tout va bien).
*/
export async function tallButtons(target) {
return target.evaluate(() => {
const found = [];
for (const el of document.querySelectorAll("button, a.button")) {
if (el.closest("[hidden]")) {
continue;
}
const style = getComputedStyle(el);
const r = el.getBoundingClientRect();
if (style.display === "none" || style.visibility === "hidden" || r.width === 0) {
continue;
}
if (r.height > 56) {
const name = el.id ? `#${el.id}` : `« ${el.textContent.trim().slice(0, 30)} »`;
found.push(`${name} (${Math.round(r.height)} px)`);
}
}
return found;
});
}
/**
* Les éléments interactifs visibles cachés, même en partie, sous une barre
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
+514
View File
@@ -2892,3 +2892,517 @@ html.landscape .explain-sheet > .explain-body {
grid-column: 1;
grid-row: 1 / span 8;
}
/* ------------------------------------------ Bilan de fin de partie (1e) */
/* Maquette « Bilan de fin de partie » (charte, ecrans-etape-1.md) : un
* panneau centré, à la place du panneau de fin de partie ; il défile si
* l'écran est trop bas. */
.summary-panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
width: min(26rem, 100%);
max-height: 100%;
overflow-y: auto;
}
.summary-panel p {
margin: 0;
font-size: 17px;
line-height: 25px;
font-weight: 400;
}
.summary-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
margin: calc(-1 * var(--space-2)) 0;
}
.summary-title {
margin: 0;
font-size: 28px;
line-height: 34px;
}
.summary-panel .summary-status {
font-size: 15px;
line-height: 20px;
color: var(--muted);
}
/* Trois cases : bons coups, imprécisions, erreurs. */
.summary-counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
.summary-count {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2) var(--space-1);
background: var(--surface-sunken);
border-radius: var(--radius-sm);
}
.summary-number {
font-size: 28px;
line-height: 34px;
font-weight: 800;
font-variant-numeric: tabular-nums lining-nums;
}
.summary-word {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 13px;
line-height: 18px;
font-weight: 700;
text-align: center;
}
.summary-word .icon {
width: 16px;
height: 16px;
}
[data-verdict="good"] > .summary-word,
.summary-verdict[data-verdict="good"],
.row-word[data-verdict="good"],
.viewer-phrase[data-verdict="good"] strong { color: var(--verdict-good); }
[data-verdict="doubtful"] > .summary-word,
.summary-verdict[data-verdict="doubtful"],
.row-word[data-verdict="doubtful"],
.viewer-phrase[data-verdict="doubtful"] strong { color: var(--verdict-doubtful); }
[data-verdict="bad"] > .summary-word,
.summary-verdict[data-verdict="bad"],
.summary-verdict[data-verdict="veryBad"],
.row-word[data-verdict="bad"],
.row-word[data-verdict="veryBad"],
.viewer-phrase[data-verdict="bad"] strong,
.viewer-phrase[data-verdict="veryBad"] strong { color: var(--verdict-bad); }
/* Deux jauges sur une ligne chacune : votre jeu (niveau), les dés. */
.summary-gauges {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.gauge {
display: grid;
grid-template-columns: 5.5rem minmax(3rem, 1fr) auto;
gap: var(--space-2);
align-items: center;
min-height: 28px;
font-size: 15px;
line-height: 20px;
}
.gauge-name {
font-weight: 700;
}
.gauge-value {
color: var(--muted);
text-align: right;
}
.gauge-track {
position: relative;
height: 8px;
overflow: hidden;
background: var(--surface-sunken);
border-radius: 4px;
}
.gauge-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--verdict-good);
border-radius: 4px;
}
.gauge-fill.level-1 { width: 16.7%; }
.gauge-fill.level-2 { width: 33.3%; }
.gauge-fill.level-3 { width: 50%; }
.gauge-fill.level-4 { width: 66.7%; }
.gauge-fill.level-5 { width: 83.3%; }
.gauge-fill.level-6 { width: 100%; }
/* La chance : jauge centrée, à droite si elle vous a aidé. */
.gauge-track.centred::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(50% - 1px);
width: 2px;
background: var(--muted);
}
.gauge-fill.dice { background: var(--border-strong); border-radius: 0; }
.gauge-fill.dice.for-1 { left: 50%; width: 25%; }
.gauge-fill.dice.for-2 { left: 50%; width: 50%; }
.gauge-fill.dice.against-1 { left: 25%; width: 25%; }
.gauge-fill.dice.against-2 { left: 0; width: 50%; }
.gauge-fill.dice.even { left: calc(50% - 6px); width: 12px; }
.summary-glossary {
display: inline-flex;
align-items: center;
align-self: flex-start;
font-size: 15px;
}
/* Le coup à revoir : mini-plateau, mot, votre coup, celui du maître. */
.summary-review {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.summary-review-body {
display: grid;
grid-template-columns: minmax(0, 9rem) minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.summary-panel .summary-verdict {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: 15px;
line-height: 20px;
font-weight: 700;
}
.summary-verdict .icon {
width: 18px;
height: 18px;
}
.summary-panel .summary-played,
.summary-panel .summary-master {
font-size: 15px;
line-height: 20px;
}
.summary-panel .summary-master {
font-style: italic;
}
.summary-review-actions,
.viewer-actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
/* L'emplacement de « Demander à une IA » (étape 1f) : réservé, vide. */
.ask-ai-slot {
flex: 1 1 0;
min-height: 44px;
}
/* Deux boutons de 48 px, côte à côte, jamais étirés en hauteur. */
.summary-actions {
align-items: center;
}
.summary-actions > * {
flex: 1 1 0;
height: var(--button-height);
padding: 0 12px;
}
/* En paysage : deux colonnes, le résultat et les mesures à gauche, le
* coup à revoir et les actions à droite. */
html.landscape .summary-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-auto-flow: row dense;
column-gap: var(--space-6);
align-content: start;
width: min(52rem, 100%);
}
html.landscape .summary-panel > * {
grid-column: 1;
}
html.landscape .summary-panel > .summary-review,
html.landscape .summary-panel > .summary-end {
grid-column: 2;
}
html.landscape .summary-panel > .summary-review {
grid-row: 1 / span 4;
padding-top: 0;
border-top: 0;
}
/* Les actions en bas de la colonne de droite, « Changer les réglages »
* dessous, comme en portrait. */
html.landscape .summary-panel > .summary-end {
grid-row: 5 / span 2;
align-self: end;
}
/* ------------------------------------------- Analyse d'une partie (1e) */
/* Maquette interactive « Analyse » : une page qui défile. */
.analysis {
max-width: var(--page-width);
margin: 0 auto;
padding-bottom: var(--space-6);
}
.analysis-head {
display: flex;
gap: var(--space-3);
align-items: center;
margin: var(--space-3) 0;
}
.analysis-title {
margin: 0;
font-size: 24px;
line-height: 30px;
}
.analysis-message:empty {
display: none;
}
.analysis-card {
margin: 0 0 var(--space-4);
padding: var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.analysis-card > .overline {
margin-bottom: var(--space-2);
}
/* La courbe de vos chances de gagner : on la parcourt du doigt. */
.analysis-curve {
display: block;
width: 100%;
height: 168px;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
font-family: var(--font-sans);
}
.analysis-curve:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.curve-axis { stroke: var(--border); stroke-width: 1; }
.curve-half { stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 4 4; }
.curve-label { fill: var(--muted); font-size: 12px; }
.curve-area { fill: var(--accent-soft); }
.curve-line { fill: none; stroke: var(--text); stroke-width: 2; stroke-linejoin: round; }
.curve-dot.other { fill: var(--text); }
.curve-dot.verdict-good { fill: var(--verdict-good); }
.curve-dot.verdict-doubtful { fill: var(--verdict-doubtful); }
.curve-dot.verdict-bad,
.curve-dot.verdict-veryBad { fill: var(--verdict-bad); }
.curve-cursor { stroke: var(--accent); stroke-width: 2; }
.curve-ring { fill: none; stroke: var(--accent); stroke-width: 2; }
.curve-band { fill: transparent; cursor: pointer; }
.curve-band.selected { fill: var(--accent); fill-opacity: 0.08; }
/* Le visualiseur : « ‹ Coup 7 sur 24 · 6-1 › », le plateau d'avant le
* coup, la phrase du maître, votre coup contre celui du maître. */
.viewer-nav {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
}
#analysis-prev .icon {
transform: rotate(180deg);
}
.viewer-step[aria-disabled="true"] {
opacity: 0.35;
pointer-events: none;
}
.viewer-turn {
margin: 0;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums lining-nums;
}
.viewer-body,
.viewer-text {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.viewer-phrase {
margin: 0;
}
.viewer-phrase strong {
display: inline-flex;
gap: 4px;
align-items: center;
}
.viewer-phrase strong .icon {
width: 18px;
height: 18px;
}
.viewer-legend {
margin: 0;
font-size: 13px;
line-height: 18px;
color: var(--muted);
}
.viewer-chance {
margin: 0;
font-size: 15px;
line-height: 20px;
}
.viewer-chance:empty {
display: none;
}
.viewer-text .segment {
min-height: 44px;
font-size: 15px;
}
.analysis-board[data-move="mine"] { --last-move: var(--text); }
.analysis-board[data-move="best"] { --last-move: var(--verdict-good); }
/* Les coups à revoir : filtres en pastilles, une ligne par coup. */
.filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.filters .filter {
min-height: 44px;
padding: 0 14px;
font-size: 15px;
font-weight: 500;
color: var(--text);
background: var(--surface-sunken);
border: 1px solid var(--border);
}
/* Le filtre choisi en accent-soft, comme l'onglet choisi de l'aide. */
.filters .filter[aria-pressed="true"] {
font-weight: 700;
color: var(--text);
background: var(--accent-soft);
border-color: var(--accent);
}
.analysis-list {
margin: 0;
padding: 0;
list-style: none;
}
.analysis-row {
display: grid;
grid-template-columns: 2rem 2.5rem minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
min-height: 44px;
padding: 0 var(--space-1);
color: var(--text);
text-decoration: none;
border-bottom: 1px solid var(--border);
}
.analysis-row[aria-current="true"] {
background: var(--accent-soft);
}
.row-number,
.row-roll {
color: var(--muted);
font-variant-numeric: tabular-nums lining-nums;
}
.row-move {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-word {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: 15px;
font-weight: 700;
}
.row-word .icon {
width: 16px;
height: 16px;
}
.analysis-empty {
padding: var(--space-2) 0;
color: var(--muted);
}
.analysis-table {
grid-template-columns: minmax(0, 1fr) auto auto;
}
/* En paysage : le plateau du visualiseur à gauche, tenu dans la hauteur
* de l'écran ; le texte à droite. */
html.landscape .viewer-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
html.landscape .analysis-board {
width: auto;
max-width: 100%;
height: calc(100vh - 120px);
height: calc(100dvh - 120px);
aspect-ratio: 640 / 520;
}
/* Le mini-plateau du coup à revoir : trop petit pour les numéros des
* flèches, qui se chevaucheraient. */
.summary-board .point-number {
display: none;
}
+8
View File
@@ -101,6 +101,10 @@
app/src/ui/new-game-view.ts. -->
<section id="new-game" class="new-game page" hidden></section>
<!-- L'analyse d'une partie (#/parties/<id>/analyse/<coup>), construite par
app/src/ui/analysis-view.ts. -->
<section id="analysis" class="analysis page" hidden></section>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
@@ -158,6 +162,10 @@
construite par app/src/ui/advice-view.ts. -->
<div id="explain-veil" class="veil sheet-veil" hidden></div>
<!-- Le bilan de fin de partie (#/parties/<id>/bilan) : un panneau centré,
construit par app/src/ui/summary-view.ts. -->
<div id="summary-veil" class="veil summary-veil" hidden></div>
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
<div id="install-veil" class="veil sheet-veil" hidden>
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">