diff --git a/app/src/domain/game/advice.ts b/app/src/domain/game/advice.ts index 772c55b..c9a876c 100644 --- a/app/src/domain/game/advice.ts +++ b/app/src/domain/game/advice.ts @@ -10,7 +10,7 @@ // stockage, dans le journal (events.ts, champ advice). Le format est // durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION). -import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js"; +import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js"; import type { HalfMove, PositionId } from "./position.js"; import type { Roll } from "./roll.js"; @@ -29,7 +29,7 @@ export type Advice = MoveAdvice | CubeAdvice; /** Un coup, tel que le conseil le retient. */ export interface AdvisedMove { /** Notation de gnubg, par exemple « 8/5 6/5 ». */ - readonly notation: string; + readonly notation: MoveNotation; readonly halfMoves: readonly HalfMove[]; /** Équité (avec videau) du point de vue du joueur, et le niveau auquel * le moteur l'a évaluée. */ diff --git a/app/src/domain/game/engine.ts b/app/src/domain/game/engine.ts index 10228dd..e8429e1 100644 --- a/app/src/domain/game/engine.ts +++ b/app/src/domain/game/engine.ts @@ -281,6 +281,15 @@ export interface EngineMatchAfter { readonly crawford: boolean; } +/** + * Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* », + * « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne + * fait que l'afficher. + * + * Glossaire : docs/glossaire/notation-des-coups.md + */ +export type MoveNotation = string; + /** * Un coup légal complet. * @@ -288,7 +297,7 @@ export interface EngineMatchAfter { */ export interface Move { /** Notation de gnubg, par exemple « 8/5 6/5 ». */ - readonly notation: string; + readonly notation: MoveNotation; /** Ses demi-coups, dans un ordre jouable. */ readonly halfMoves: readonly HalfMove[]; /** La position après le coup, vue du joueur qui vient de jouer. */ diff --git a/app/src/domain/game/game.ts b/app/src/domain/game/game.ts index 70963d5..f638309 100644 --- a/app/src/domain/game/game.ts +++ b/app/src/domain/game/game.ts @@ -12,7 +12,15 @@ import type { Advice } from "./advice.js"; import { type Branch, branches, switchToBranch } from "./branches.js"; import { CENTERED_CUBE, type Cube } from "./cube.js"; -import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js"; +import type { + BoardSide, + Engine, + EngineCube, + Level, + MoveNotation, + Ply, + ResultKind, +} from "./engine.js"; import { appendEvent, currentLine, @@ -290,7 +298,7 @@ export class Game { * À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne * vide si l'événement n'est pas un coup ou si aucun pion n'a bougé. */ - async moveNotation(eventId: string): Promise { + async moveNotation(eventId: string): Promise { const line = currentLine({ ...this.#journal, head: eventId }); const event = line.at(-1); if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) { diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index 7b3fc6b..9541b8d 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -19,6 +19,7 @@ import type { Game } from "../domain/game/game.js"; import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js"; import { type IconName, icon } from "./icons.js"; import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js"; +import { showWithNotation } from "./notation.js"; import { routeHash } from "./router.js"; /** Préfixe des identifiants du plateau de la feuille. */ @@ -190,8 +191,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { const svg = sheetBoard(boardBox); if (best === null) { svg.dataset.move = "mine"; - svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation)); - legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation); + const text = ADVICE_TEXTS.legendSame(advice.played.notation); + svg.setAttribute("aria-label", text); + showWithNotation(legend, text, advice.played.notation); void player?.play(mine); return; } @@ -220,7 +222,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { shown === "mine" ? ADVICE_TEXTS.legendMine(notation) : ADVICE_TEXTS.legendBest(notation); - legend.textContent = text; + showWithNotation(legend, text, notation); svg.setAttribute("aria-label", text); svg.dataset.move = shown; void player?.play(shown === "mine" ? mine : best); diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 40cde57..82ec1ec 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -30,6 +30,7 @@ import { } from "../domain/analysis/phrases.js"; import type { Advice } from "../domain/game/advice.js"; import type { Cube } from "../domain/game/cube.js"; +import type { MoveNotation } from "../domain/game/engine.js"; import type { GameJournal } from "../domain/game/events.js"; import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; import type { HalfMove } from "../domain/game/position.js"; @@ -76,6 +77,7 @@ import { installPlatform, startInstaller } from "./install.js"; import { MATCH_TEXTS } from "./match-texts.js"; import { replacingLink, startNavigation } from "./navigation.js"; import { createNewGameView } from "./new-game-view.js"; +import { showWithNotation } from "./notation.js"; import { createReviewRunner, type RunAnalysis } from "./review-runner.js"; import { isHelpScreen, type Route, routeHash } from "./router.js"; import { @@ -424,6 +426,10 @@ export function startApp(options: AppOptions): void { let stuck = false; let busy = true; let info: string = TEXTS.loading; + // La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite : + // tant qu'elle est la ligne d'information, ce coup mène à la fiche + // « Notation des coups ». + let computerLine: ComputerTurnLine | null = null; let engineFailed = false; // Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à // la prochaine action du joueur ; null : rien à montrer. @@ -627,7 +633,11 @@ export function startApp(options: AppOptions): void { } else if (computerCouldNotMove(game)) { ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game)); } else { - ui.info.textContent = info; + showWithNotation( + ui.info, + info, + computerLine?.text === info ? computerLine.notation : null, + ); } ui.prompt.textContent = panelShown ? "" @@ -1018,7 +1028,8 @@ export function startApp(options: AppOptions): void { while (game !== null && game.state.turn === "computer" && isPlaying(game)) { await refresh(); await game.playComputerTurn(); - info = await describeComputerTurn(game); + computerLine = await describeComputerTurn(game); + info = computerLine.text; save(); played = true; } @@ -2514,22 +2525,30 @@ async function computerMoveToShow(game: Game): Promise { return game.lastMoveSteps(); } +/** La ligne d'information après le tour de l'ordinateur, et le coup + * qu'elle cite (null s'il n'a pas joué de coup). */ +interface ComputerTurnLine { + readonly text: string; + readonly notation: MoveNotation | null; +} + /** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son * coup s'écrit dans la notation du moteur, comme dans l'analyse. */ -async function describeComputerTurn(game: Game): Promise { +async function describeComputerTurn(game: Game): Promise { const last = game.events.at(-1); if (last?.type === "CubeOffered") { - return ""; + return { text: "", notation: null }; } const played = game.events.findLast((e) => e.type === "MovePlayed"); if (played?.type !== "MovePlayed") { - return ""; + return { text: "", notation: null }; } const dice = rollBefore(game); if (played.halfMoves.length === 0) { - return TEXTS.computerCannotMove(dice); + return { text: TEXTS.computerCannotMove(dice), notation: null }; } - return TEXTS.computerMoved(dice, await game.moveNotation(played.id)); + const notation = await game.moveNotation(played.id); + return { text: TEXTS.computerMoved(dice, notation), notation }; } /** Le résultat d'une partie finie, en deux parties : le titre du panneau diff --git a/app/src/ui/notation.ts b/app/src/ui/notation.ts new file mode 100644 index 0000000..29196a5 --- /dev/null +++ b/app/src/ui/notation.ts @@ -0,0 +1,34 @@ +// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »), +// touchable vers sa fiche du glossaire : un débutant y apprend ce que +// veulent dire l'étoile, « off », « bar » et « (2) ». +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { MoveNotation } from "../domain/game/engine.js"; +import { routeHash } from "./router.js"; + +/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */ +export const NOTATION_TERM = "notation-des-coups"; + +/** + * Remplace le contenu de parent par text, où la première occurrence de + * notation devient un lien vers la fiche « Notation des coups ». Sans + * notation dans le texte, le texte seul. + */ +export function showWithNotation( + parent: HTMLElement, + text: string, + notation: MoveNotation | null, +): void { + const at = notation === null || notation === "" ? -1 : text.indexOf(notation); + if (notation === null || at < 0) { + parent.textContent = text; + return; + } + const link = document.createElement("a"); + link.className = "notation"; + link.href = routeHash({ screen: "glossary", term: NOTATION_TERM }); + link.textContent = notation; + parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length)); +} diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index ddf8fe0..d8ae530 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -59,6 +59,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Match](match.md) | Match | | [Moteur](moteur.md) | Engine | | [Niveau de jeu](niveau-de-jeu.md) | Level | +| [Notation des coups](notation-des-coups.md) | Move notation | | [Partie](partie.md) | Game | | [Partie enregistrée](partie-enregistree.md) | Saved game | | [Passer](passer.md) | Drop | diff --git a/docs/glossaire/notation-des-coups.md b/docs/glossaire/notation-des-coups.md new file mode 100644 index 0000000..0dd2e79 --- /dev/null +++ b/docs/glossaire/notation-des-coups.md @@ -0,0 +1,27 @@ +--- +term: Notation des coups +english: Move notation +code: MoveNotation +--- +# Notation des coups + +**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)). + +Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule. +Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la +plus proche de sa sortie) à 24 : + +- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux + dés compte en général deux déplacements, séparés par une espace : + `13/7 8/7`. +- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion + seul de l'adversaire, qui part sur la barre. +- `6/off` : le pion de la case 6 sort du plateau. +- `bar/22` : un pion qui était sur la barre rentre sur la case 22. +- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue + quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de + 6 à 2. +- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait, + de sa case de départ à sa case d'arrivée. + +Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md) diff --git a/tests/browser/bilan.mjs b/tests/browser/bilan.mjs index 0650864..68331a4 100644 --- a/tests/browser/bilan.mjs +++ b/tests/browser/bilan.mjs @@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine") await page.click("#explain-mine"); const sheetLegend = await page.textContent("#explain-legend"); check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`); +// Le coup y est un lien vers la fiche « Notation des coups ». +const notationLink = await page.evaluate(() => { + const a = document.querySelector("#explain-legend a.notation"); + return a === null ? null : { text: a.textContent, href: a.getAttribute("href") }; +}); +check( + notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups", + `feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`, +); await page.click("#explain-done"); await until("feuille fermée", () => document.getElementById("explain-veil").hidden); // Le joueur passe.