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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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++;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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"}${
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 »).
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user