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 d3207b6..c546657 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -21,6 +21,7 @@ import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js"; import { askAiLink, clearSheet } from "./ask-ai.js"; import { type IconName, icon } from "./icons.js"; import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js"; +import { showWithNotation } from "./notation.js"; import { routeHash } from "./router.js"; /** Préfixe des identifiants du plateau de la feuille. */ @@ -203,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { const svg = sheetBoard(boardBox); if (best === null) { svg.dataset.move = "mine"; - svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation)); - legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation); + const text = ADVICE_TEXTS.legendSame(advice.played.notation); + svg.setAttribute("aria-label", text); + showWithNotation(legend, text, advice.played.notation); void player?.play(mine); return; } @@ -233,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { shown === "mine" ? ADVICE_TEXTS.legendMine(notation) : ADVICE_TEXTS.legendBest(notation); - legend.textContent = text; + showWithNotation(legend, text, notation); svg.setAttribute("aria-label", text); svg.dataset.move = shown; void player?.play(shown === "mine" ? mine : best); diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 93b8ae6..e1e4e8a 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -36,6 +36,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"; @@ -84,6 +85,7 @@ import { installPlatform, startInstaller } from "./install.js"; import { MATCH_TEXTS } from "./match-texts.js"; import { replacingLink, startNavigation } from "./navigation.js"; import { createNewGameView } from "./new-game-view.js"; +import { showWithNotation } from "./notation.js"; import { createPromptSource, type PromptsAccess } from "./prompt-source.js"; import { createReviewRunner, type RunAnalysis } from "./review-runner.js"; import { @@ -443,6 +445,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. @@ -646,7 +652,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 ? "" @@ -1037,7 +1047,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; } @@ -1529,25 +1540,37 @@ export function startApp(options: AppOptions): void { * La partie à l'écran vient de finir : son analyse part en * arrière-plan, et le bilan remplace le panneau de fin de partie dès * qu'il est prêt (charte 1e), si le panneau est toujours là. + * + * Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette + * partie finie est encore celle de l'écran. Sinon, « Commencer » de + * l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie + * par-dessus la nouvelle, qui venait de commencer entre-temps (défaut + * signalé le 2026-11-11). */ function reviewFinishedGame(): void { const id = options.games.shownId(); - if (game === null || id === null || busy || game.state.phase !== "over") { + const finished = game; + if (finished === null || id === null || busy || finished.state.phase !== "over") { return; } // L'analyse d'une autre partie, regardée, passe d'abord. const running = reviewRunner.running; if (running === null || running === id) { - review(id, game.journal); + review(id, finished.journal); } if ( navigation.route.screen === "game" && !panelDismissed && phrases !== null && - readyForSummary(game.journal) + readyForSummary(finished.journal) ) { queueMicrotask(() => { - if (navigation.route.screen === "game" && !panelDismissed) { + if ( + game === finished && + options.games.shownId() === id && + navigation.route.screen === "game" && + !panelDismissed + ) { navigation.go(routeHash({ screen: "summary", game: id })); } }); @@ -2015,8 +2038,10 @@ export function startApp(options: AppOptions): void { welcomed: () => saveSettings(welcomed()), }); - // L'écran « Nouvelle partie » : « Commencer » remplace son adresse par - // celle de la partie (le retour du téléphone ne le rouvre pas). + // L'écran « Nouvelle partie » : « Commencer » lance la partie, puis + // remplace son adresse par celle de la partie (le retour du téléphone + // ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine + // jamais avec l'ancienne partie, finie, et son panneau de fin. const newGameView = createNewGameView({ section: ui.newGameSection, settings: () => settings, @@ -2025,8 +2050,8 @@ export function startApp(options: AppOptions): void { if (busy || game === null) { return; } - navigation.replace("#/"); newGame(chosenGame(settings)); + navigation.replace("#/"); }, }); leavingLink(newGameView.closeLink, (route) => route.screen === "newGame"); @@ -2661,22 +2686,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/match-texts.ts b/app/src/ui/match-texts.ts index 4536050..5bae1ca 100644 --- a/app/src/ui/match-texts.ts +++ b/app/src/ui/match-texts.ts @@ -57,7 +57,7 @@ export const MATCH_TEXTS = { advice: "Conseil du maître", adviceDetail: "Une ligne après chacun de vos coups", startGame: "Commencer la partie", - startMatch: (n: number) => `Commencer le match en ${n} points`, + startMatch: "Commencer le match", kept: "Vos choix sont gardés pour la prochaine fois.", // Score du match sur l'écran de jeu diff --git a/app/src/ui/new-game-view.ts b/app/src/ui/new-game-view.ts index 9caa596..1551d2e 100644 --- a/app/src/ui/new-game-view.ts +++ b/app/src/ui/new-game-view.ts @@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView { levelName.textContent = MATCH_TEXTS.levels[s.level].name; levelText.textContent = MATCH_TEXTS.levels[s.level].text; advice.setAttribute("aria-checked", String(s.masterAdvice)); - start.textContent = isMatch - ? MATCH_TEXTS.startMatch(s.matchLength) - : MATCH_TEXTS.startGame; + start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame; start.disabled = busy; }, focus() { 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 fb393df..b49b1c8 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/docs/identite/charte/ecrans-etape-1.md b/docs/identite/charte/ecrans-etape-1.md index 95fc8d8..424889f 100644 --- a/docs/identite/charte/ecrans-etape-1.md +++ b/docs/identite/charte/ecrans-etape-1.md @@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans 3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre « Une ligne après chacun de vos coups ». 4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce - qu'il lance (« Commencer la partie » ou « Commencer le match en 5 - points »), et la mention « Vos choix sont gardés pour la prochaine fois. ». + qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix + de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ». Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en haut à gauche, ou retour du téléphone) ne change rien à la partie en cours. diff --git a/docs/scenarios/jouer-un-match.feature b/docs/scenarios/jouer-un-match.feature index 37e85ab..9eb3583 100644 --- a/docs/scenarios/jouer-un-match.feature +++ b/docs/scenarios/jouer-un-match.feature @@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7 ou 11 points, le niveau de l'ordinateur, et si le maître commente vos - coups. Vos choix sont gardés pour la prochaine fois. + coups. Vos choix sont gardés pour la prochaine fois. « Commencer la + partie » (ou « Commencer le match ») lance aussitôt la partie, sur le + plateau, même juste après le bilan d'une partie finie. Scénario: Comment choisir le niveau de l'ordinateur ? Six niveaux, du Débutant, qui laisse passer des occasions, au Champion, diff --git a/tests/browser.sh b/tests/browser.sh index bb8eaed..8ca14e9 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -12,10 +12,13 @@ # build/browser-shots/maitre/. Le bilan de fin de partie et l'écran # d'analyse (tests/browser/bilan.mjs), captures dans # build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs), -# captures dans build/browser-shots/prompts/. +# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une +# partie finie et son bilan, par chaque entrée +# (tests/browser/nouvelle-partie.mjs), captures dans +# build/browser-shots/nouvelle-partie/. # # BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan, -# prompts) restreignent le test. +# prompts, nouvelle-partie) restreignent le test. # # Outils : l'image de Playwright (navigateurs) et le paquet playwright, # installé une fois par npm dans build/browser-tools/ (réseau nécessaire la @@ -28,7 +31,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 bilan prompts}" +scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -66,7 +69,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://' rm -rf build/browser-shots mkdir -p build/browser-shots cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \ - tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs "$tools/" + tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \ + "$tools/" status=0 for browser in $browsers; do # Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker @@ -113,5 +117,13 @@ for browser in $browsers; do -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi + if [[ " $scripts " == *" nouvelle-partie "* ]]; then + # « Nouvelle partie » après une partie finie et son bilan : la + # nouvelle partie tout de suite ; captures dans + # build/browser-shots/nouvelle-partie/. + docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ + node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" 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. diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs index 55cafde..120bef8 100644 --- a/tests/browser/match.mjs +++ b/tests/browser/match.mjs @@ -194,7 +194,7 @@ await page.click("#new-game-match"); await page.click("#new-game-length-3"); await page.click("#new-game-level-beginner"); s = await screen(); -check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points", +check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match", `match en 3 points : ${JSON.stringify(s)}`); check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`); check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford", @@ -254,7 +254,7 @@ await page.click("#menu-new-game"); await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" && !document.getElementById("new-game").hidden); s = await screen(); -check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points", +check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match", `choix retrouvés : ${JSON.stringify(s)}`); await page.click("#new-game-start"); await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy")); diff --git a/tests/browser/nouvelle-partie.mjs b/tests/browser/nouvelle-partie.mjs new file mode 100644 index 0000000..03e8535 --- /dev/null +++ b/tests/browser/nouvelle-partie.mjs @@ -0,0 +1,321 @@ +// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle +// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les +// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil +// (l'application rouverte), le menu, « Changer les réglages » du bilan. +// Dans un vrai navigateur sans écran (Playwright), sur le site servi par +// l'image de production. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node nouvelle-partie.mjs +// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais +// nouvelle partie […] tu te retrouves sur le résultat du match précédent et +// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est +// une fin de course courte, partie d'un lien de position avec des dés +// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton +// (« Commencer la partie », « Commencer le match ») et que le coup de +// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des +// coups ». Captures (Chromium) dans /nouvelle-partie/, +// en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; +import { hiddenUnderActionBar } from "./recette.mjs"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node nouvelle-partie.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/nouvelle-partie`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1, +// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe. +const BEAROFF = "BQAACAIAAAAAAA"; + +const problems = []; +const log = (text) => console.log(`[${browserName}] ${text}`); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let captures = 0; + +function check(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const browser = await browserType.launch(); +const context = await browser.newContext({ + viewport: PORTRAIT, + deviceScaleFactor: 1, + locale: "fr-FR", + reducedMotion: "reduce", +}); +await context.addInitScript(() => { + // Les dés imposés d'abord (un dé par valeur), puis une graine fixe. + window.__dice = []; + let state = 7; + Math.random = () => { + const die = window.__dice.shift(); + if (die !== undefined) { + return (die - 0.5) / 6; + } + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}); +const page = await context.newPage(); +page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`)); +page.on("console", (message) => { + if (message.type() === "error") { + problems.push(`console : ${message.text()}`); + } +}); + +/** Attend une condition dans la page (sans page.waitForFunction, qui passe + * par eval, interdit par la politique de sécurité du site). */ +async function until(what, fn, arg, ms = 120_000) { + for (let waited = 0; waited < ms; waited += 100) { + if (await page.evaluate(fn, arg)) { + return; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`); +} + +const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); + +/** Une capture en portrait et en paysage (Chromium seulement), après la + * règle de la recette : rien d'interactif sous une barre d'action. */ +async function capture(name) { + if (!full) { + return; + } + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(300); + const hidden = await hiddenUnderActionBar(page); + check( + hidden.length === 0, + `${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`, + ); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(300); +} + +/** Une partie finie et son bilan à l'écran : une fin de course depuis un + * lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie + * l'identifiant de la partie. */ +async function finishedGameWithSummary() { + await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`); + await until("position lue", () => { + const play = document.getElementById("position-play"); + return !play.hidden && !play.disabled; + }); + await page.click("#position-play"); + await until( + "partie commencée", + () => + location.hash === "#/" && + !document.body.classList.contains("busy") && + !document.getElementById("roll").hidden, + ); + await page.evaluate(() => window.__dice.push(6, 1)); + await page.click("#roll"); + await idle(); + for (const [from, to] of [ + ["p6", "p5"], + ["p5", "off-player"], + ]) { + await page.click(`#board .hit[data-place="${from}"]`); + await idle(); + const land = page.locator(`#board .hit.can-land[data-place="${to}"]`); + if ((await land.count()) > 0) { + await land.click(); + await idle(); + } + } + await page.click("#confirm"); + await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); + await page.click("#drop"); + await until( + "bilan de la partie finie", + () => + /^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) && + document.getElementById("summary")?.dataset.ready === "true", + ); + return page.evaluate(() => location.hash.split("/")[2]); +} + +/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé + * du bouton, puis commence. */ +async function chooseAndStart(choice, shot) { + await until( + "écran « Nouvelle partie »", + () => + location.hash === "#/nouvelle-partie" && + !document.getElementById("new-game").hidden && + !document.getElementById("new-game-start").disabled, + ); + await page.click(choice.match ? "#new-game-match" : "#new-game-free"); + if (choice.match) { + await page.click(`#new-game-length-${choice.length}`); + } + await page.click(`#new-game-level-${choice.level}`); + const label = await page.textContent("#new-game-start"); + const expected = choice.match ? "Commencer le match" : "Commencer la partie"; + check(label === expected, `bouton : « ${label} », attendu « ${expected} »`); + if (shot) { + await capture("1-nouvelle-partie"); + } + await page.click("#new-game-start"); +} + +/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages + * choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un + * peu plus tard. */ +async function checkNewGame(entry, oldId, choice) { + await until( + `${entry} : partie commencée`, + () => location.hash === "#/" && !document.body.classList.contains("busy"), + ); + // Le défaut apparaissait juste après : l'adresse passait au bilan de + // l'ancienne partie. On regarde pendant deux secondes. + for (let waited = 0; waited < 2000; waited += 100) { + const s = await page.evaluate(() => ({ + hash: location.hash, + summary: !document.getElementById("summary-veil").hidden, + panel: !document.getElementById("panel-veil").hidden, + board: !document.getElementById("game").hidden, + newGame: !document.getElementById("new-game").hidden, + })); + check( + s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame, + `${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`, + ); + await sleep(100); + } + await idle(); + const shown = await page.evaluate(() => ({ + line: document.getElementById("match-line").textContent, + lineShown: !document.getElementById("match-line").hidden, + level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level, + over: document.getElementById("again").hidden === false, + })); + check(!shown.over, `${entry} : la partie à l'écran est finie`); + check(shown.level === choice.level, `${entry} : niveau ${shown.level}`); + if (choice.match) { + check( + shown.lineShown && shown.line === `Match en ${choice.length} points`, + `${entry} : ligne du match « ${shown.line} »`, + ); + } else { + check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`); + } + log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`); +} + +// --------------------------- 1. L'application rouverte, puis l'accueil + +let oldId = await finishedGameWithSummary(); +// L'application rouverte : sans adresse, elle ouvre l'accueil. +await page.goto(base); +await until( + "accueil", + () => + location.hash === "#/accueil" && + !document.getElementById("home-new").hidden && + !document.getElementById("home-new").disabled, +); +await page.click("#home-new"); +let choice = { match: true, length: 3, level: "beginner" }; +await chooseAndStart(choice, true); +await checkNewGame("accueil", oldId, choice); +await capture("2-partie-demarree"); + +// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche +// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup. +for (let actions = 0; ; actions++) { + check(actions < 200, "l'ordinateur ne joue pas de coup"); + const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null); + if (link !== null) { + check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`); + break; + } + const what = await page.evaluate(() => { + const usable = (id) => { + const b = document.getElementById(id); + return !b.hidden && !b.disabled; + }; + for (const id of ["take", "skip", "confirm", "roll"]) { + if (usable(id)) { + return `#${id}`; + } + } + if (document.querySelector("#board .hit.can-land")) { + return "#board .hit.can-land"; + } + return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null; + }); + if (what === null) { + await sleep(100); + continue; + } + await page.locator(what).first().click(); + await idle(); +} +await page.click("#info a.notation"); +await until( + "fiche « Notation des coups »", + () => + location.hash === "#/glossaire/notation-des-coups" && + document.getElementById("glossaire-notation-des-coups") !== null, +); +await page.goBack(); +await until("retour à la partie", () => location.hash === "#/"); +log("ligne de l'ordinateur : la notation mène à sa fiche"); + +// --------------------------------- 2. L'application rouverte sur le menu + +oldId = await finishedGameWithSummary(); +await page.goto(`${base}#/menu`); +await until("menu", () => !document.getElementById("menu-veil").hidden); +await page.click("#menu-new-game"); +choice = { match: false, level: "amateur" }; +await chooseAndStart(choice, false); +await checkNewGame("menu", oldId, choice); + +// ------------------------------- 3. « Changer les réglages » du bilan + +oldId = await finishedGameWithSummary(); +await page.click("#summary-settings"); +choice = { match: true, length: 5, level: "intermediate" }; +await chooseAndStart(choice, false); +await checkNewGame("bilan, « Changer les réglages »", oldId, choice); + +const csp = await page.evaluate(() => window.__csp); +for (const violation of csp) { + problems.push(`politique de sécurité : ${violation}`); +} +await browser.close(); +if (problems.length > 0) { + for (const problem of problems) { + console.error(`[${browserName}] ${problem}`); + } + process.exit(1); +} +log(`nouvelle partie : tout est conforme (${captures} captures)`); diff --git a/web/app.css b/web/app.css index 40adcc4..5d658f0 100644 --- a/web/app.css +++ b/web/app.css @@ -586,6 +586,19 @@ button:disabled { line-clamp: 2; } +/* Un coup en notation du moteur, lien vers sa fiche du glossaire (ligne de + * l'ordinateur, feuille du maître) : couleur du texte, soulignement en + * pointillé discret. Le remplissage vertical d'un élément en ligne agrandit + * la zone d'appui sans changer la hauteur des lignes. */ +a.notation { + padding-block: 8px; + color: inherit; + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--muted); + text-decoration-thickness: 1px; +} + .infoline .replay { color: var(--accent); }