diff --git a/app/src/domain/analysis/analyse.ts b/app/src/domain/analysis/analyse.ts index 22c38a3..665249b 100644 --- a/app/src/domain/analysis/analyse.ts +++ b/app/src/domain/analysis/analyse.ts @@ -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 { + 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, diff --git a/app/src/domain/analysis/game-analysis.ts b/app/src/domain/analysis/game-analysis.ts new file mode 100644 index 0000000..3296204 --- /dev/null +++ b/app/src/domain/analysis/game-analysis.ts @@ -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//analyse/). + */ +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 { + 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 { + 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>; + /** 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(); + 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 = { + 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 = { 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; +} diff --git a/app/src/domain/analysis/phrases.ts b/app/src/domain/analysis/phrases.ts index 49b77d7..3b82766 100644 --- a/app/src/domain/analysis/phrases.ts +++ b/app/src/domain/analysis/phrases.ts @@ -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; @@ -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> = { + 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); diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 935f6da..10228dd 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -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; + /** 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; + /** 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; @@ -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; diff --git a/app/src/domain/game/events.ts b/app/src/domain/game/events.ts index 9f66ac4..15d76ee 100644 --- a/app/src/domain/game/events.ts +++ b/app/src/domain/game/events.ts @@ -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 = (table: Readonly>) => + 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 extends unknown ? Omit : 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>; + 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])); diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index c19b1de..0c56d4c 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -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 diff --git a/app/src/domain/game/review.ts b/app/src/domain/game/review.ts new file mode 100644 index 0000000..1bed493 --- /dev/null +++ b/app/src/domain/game/review.ts @@ -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>; + /** 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>; + /** 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>; + /** 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: [], + }; +} diff --git a/app/src/engine/wasm-engine.ts b/app/src/engine/wasm-engine.ts index b6fbdc2..58e73f1 100644 --- a/app/src/engine/wasm-engine.ts +++ b/app/src/engine/wasm-engine.ts @@ -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 { + 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 { diff --git a/app/src/main.ts b/app/src/main.ts index aa9ecbe..b1aaed8 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -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), }); diff --git a/app/src/storage/game-library.ts b/app/src/storage/game-library.ts index 2bdb6dd..4d7703c 100644 --- a/app/src/storage/game-library.ts +++ b/app/src/storage/game-library.ts @@ -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; + /** 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; /** Supprime une partie (rien si elle n'existe pas). */ remove(id: string): Promise; /** 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 () => { diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index 3f43fbf..7b3fc6b 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -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> = { @@ -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 { 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 { - 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(); diff --git a/app/src/ui/analysis-view.ts b/app/src/ui/analysis-view.ts new file mode 100644 index 0000000..03827c1 --- /dev/null +++ b/app/src/ui/analysis-view.ts @@ -0,0 +1,682 @@ +// L'écran d'analyse d'une partie (#/parties//analyse/, é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( + 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( + parent: Element, + tag: K, + attributes: Record, +): 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> = { + 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 = { + 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 = { + 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, + }; +} diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 4c385ae..6b0dea7 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -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; + /** 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; + /** Enregistre les analyses d'une partie qui n'est pas à l'écran. */ + readonly saveAnalyses: (id: string, journal: GameJournal) => void; readonly remove: (id: string) => Promise; readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>; } @@ -213,6 +226,8 @@ export function startApp(options: AppOptions): void { info: byId("info"), adviceLine: byId("advice-line"), explainVeil: byId("explain-veil"), + summaryVeil: byId("summary-veil"), + analysis: byId("analysis"), menuAdvice: byId("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 { + 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 { + 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)}`, }; } diff --git a/app/src/ui/games-texts.ts b/app/src/ui/games-texts.ts index 973f794..d7a4999 100644 --- a/app/src/ui/games-texts.ts +++ b/app/src/ui/games-texts.ts @@ -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", diff --git a/app/src/ui/games-view.ts b/app/src/ui/games-view.ts index 3d3f9d6..191208a 100644 --- a/app/src/ui/games-view.ts +++ b/app/src/ui/games-view.ts @@ -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 { const journal = await options.journal(id); const all = journal === null ? [] : branches(journal); diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts index 345fad5..d374108 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -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", diff --git a/app/src/ui/move-board.ts b/app/src/ui/move-board.ts new file mode 100644 index 0000000..c7e2a65 --- /dev/null +++ b/app/src/ui/move-board.ts @@ -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; + /** 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++; + }, + }; +} diff --git a/app/src/ui/review-runner.ts b/app/src/ui/review-runner.ts new file mode 100644 index 0000000..ae31605 --- /dev/null +++ b/app/src/ui/review-runner.ts @@ -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; + +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; + }, + }; +} diff --git a/app/src/ui/review-texts.ts b/app/src/ui/review-texts.ts new file mode 100644 index 0000000..bc99509 --- /dev/null +++ b/app/src/ui/review-texts.ts @@ -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, + 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; diff --git a/app/src/ui/router.ts b/app/src/ui/router.ts index 46fbe18..18c1ee9 100644 --- a/app/src/ui/router.ts +++ b/app/src/ui/router.ts @@ -27,6 +27,12 @@ export type Route = | { readonly screen: "install" } /** #/parties, et une partie : #/parties/. */ | { readonly screen: "games"; readonly game: string | null } + /** #/parties//bilan : le bilan de fin de partie (1e). */ + | { readonly screen: "summary"; readonly game: string } + /** #/parties//analyse/ : l'écran d'analyse, placé + * sur un coup (un tour de la partie, à partir de 1) ; sans coup : + * #/parties//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//bilan, #/parties//analyse, #/parties//analyse/. */ +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/, #/tutoriel//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": diff --git a/app/src/ui/summary-view.ts b/app/src/ui/summary-view.ts new file mode 100644 index 0000000..3b22ecf --- /dev/null +++ b/app/src/ui/summary-view.ts @@ -0,0 +1,362 @@ +// Le bilan de fin de partie (#/parties//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( + 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("#summary-close")?.focus(); + } + }, + hide() { + if (!veil.hidden) { + veil.hidden = true; + panel.replaceChildren(); + delete panel.dataset.ready; + drawn = ""; + } + }, + get isOpen() { + return !veil.hidden; + }, + }; +} diff --git a/app/src/ui/texts.ts b/app/src/ui/texts.ts index c73e28a..aab99c1 100644 --- a/app/src/ui/texts.ts +++ b/app/src/ui/texts.ts @@ -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"}${ diff --git a/app/tests/game-analysis.test.ts b/app/tests/game-analysis.test.ts new file mode 100644 index 0000000..28fd014 --- /dev/null +++ b/app/tests/game-analysis.test.ts @@ -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 { + 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((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); +}); diff --git a/app/tests/game.test.ts b/app/tests/game.test.ts index a511981..77ede5f 100644 --- a/app/tests/game.test.ts +++ b/app/tests/game.test.ts @@ -81,6 +81,7 @@ function fakeEngine(overrides: Partial = {}): Engine { skill: "none", }), luck: async () => ({ value: 0, rating: "none" }), + evaluate: async () => ({ win: 0.5, equity: 0 }), encodeMatch: async () => "MATCH", decodeMatch: async () => ({ matchLength: 0, diff --git a/app/tests/review-runner.test.ts b/app/tests/review-runner.test.ts new file mode 100644 index 0000000..e86c7bf --- /dev/null +++ b/app/tests/review-runner.test.ts @@ -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 => { + await new Promise((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")); +}); diff --git a/app/tests/router.test.ts b/app/tests/router.test.ts index 92b9fba..b976dd6 100644 --- a/app/tests/router.test.ts +++ b/app/tests/router.test.ts @@ -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); } diff --git a/app/tests/steps/revoir-ma-partie.steps.ts b/app/tests/steps/revoir-ma-partie.steps.ts new file mode 100644 index 0000000..be9ca8a --- /dev/null +++ b/app/tests/steps/revoir-ma-partie.steps.ts @@ -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 { + const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task)); + recorded = 0; + let latest = journal; + await new Promise((resolve) => { + runner.start( + "partie", + journal, + (j) => { + latest = j; + recorded++; + }, + resolve, + ); + }); + return latest; +} + +async function playMove(move: string): Promise { + 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 { + 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"); +}); diff --git a/app/tests/ui.test.ts b/app/tests/ui.test.ts index 48815ac..dc7a5e8 100644 --- a/app/tests/ui.test.ts +++ b/app/tests/ui.test.ts @@ -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.", diff --git a/docs/adr/0007-architecture-et-conventions-de-code.md b/docs/adr/0007-architecture-et-conventions-de-code.md index 22a7ec9..f16099b 100644 --- a/docs/adr/0007-architecture-et-conventions-de-code.md +++ b/docs/adr/0007-architecture-et-conventions-de-code.md @@ -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 »). diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 1b26b81..e443000 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -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//bilan` | +| Analyse d'une partie, placée sur un coup | `#/parties//analyse/` | +| Analyse d'une partie, sur le coup à revoir | `#/parties//analyse` | + +`` est l'identifiant de la partie enregistrée ; `` 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. diff --git a/docs/etape-1.md b/docs/etape-1.md index f506d6b..0caed13 100644 --- a/docs/etape-1.md +++ b/docs/etape-1.md @@ -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 | diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index a4e3e45..ddf8fe0 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -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 | diff --git a/docs/glossaire/courbe-des-chances.md b/docs/glossaire/courbe-des-chances.md new file mode 100644 index 0000000..89f13f6 --- /dev/null +++ b/docs/glossaire/courbe-des-chances.md @@ -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) diff --git a/docs/glossaire/taux-d-erreur.md b/docs/glossaire/taux-d-erreur.md new file mode 100644 index 0000000..a91419b --- /dev/null +++ b/docs/glossaire/taux-d-erreur.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) diff --git a/docs/maitre/phrases.md b/docs/maitre/phrases.md index d1f2658..60e0ddd 100644 --- a/docs/maitre/phrases.md +++ b/docs/maitre/phrases.md @@ -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. diff --git a/docs/plans/2026-11-03-bilan-et-analyse.md b/docs/plans/2026-11-03-bilan-et-analyse.md deleted file mode 100644 index 5a221c5..0000000 --- a/docs/plans/2026-11-03-bilan-et-analyse.md +++ /dev/null @@ -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//bilan` et `#/parties//analyse/`, à 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. diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md index 9b14727..20c5adf 100644 --- a/docs/scenarios/README.md +++ b/docs/scenarios/README.md @@ -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 | diff --git a/docs/scenarios/revoir-ma-partie.feature b/docs/scenarios/revoir-ma-partie.feature new file mode 100644 index 0000000..774fba1 --- /dev/null +++ b/docs/scenarios/revoir-ma-partie.feature @@ -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 diff --git a/tests/browser.sh b/tests/browser.sh index beccdd5..9867947 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -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/.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" diff --git a/tests/browser/bilan.mjs b/tests/browser/bilan.mjs new file mode 100644 index 0000000..0650864 --- /dev/null +++ b/tests/browser/bilan.mjs @@ -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 +// 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 /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 "); + 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)`); diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs index ce52877..55cafde 100644 --- a/tests/browser/match.mjs +++ b/tests/browser/match.mjs @@ -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(); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index ea064b3..94f1cd8 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -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`, diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index 66c66e4..a3d7b6f 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -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) : diff --git a/web/app.css b/web/app.css index bdd0964..368b25c 100644 --- a/web/app.css +++ b/web/app.css @@ -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; +} diff --git a/web/index.html b/web/index.html index 1ca9a8e..c3510cb 100644 --- a/web/index.html +++ b/web/index.html @@ -101,6 +101,10 @@ app/src/ui/new-game-view.ts. --> + + +