diff --git a/app/src/main.ts b/app/src/main.ts index 321ad31..e34bcba 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -40,15 +40,12 @@ const opening = (async () => { indexedDB: attempt(() => window.indexedDB), localStorage, }); - // Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY). - const session = createGameSession( - library, - { - start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }), - resume: (journal) => Game.resume(engine, journal), - }, - "2ply", - ); + // Le niveau et le match de chaque partie viennent de l'écran « Nouvelle + // partie » (ou de la partie précédente du match). + const session = createGameSession(library, { + start: (newGame) => Game.start(engine, newGame ?? {}), + resume: (journal) => Game.resume(engine, journal), + }); const saved = await session.restore(); return { library, session, saved }; })(); @@ -68,7 +65,7 @@ startApp({ stored = opened; }), ]), - newGame: (setup) => storage().session.newGame(setup), + newGame: (newGame) => storage().session.newGame(newGame), positions: { read: (link) => readPosition(engine, link), linkOf: (game) => linkOf(engine, game.state), diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index d347ff2..33729c6 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -23,11 +23,12 @@ import type { Cube } from "../domain/game/cube.js"; import type { GameJournal } from "../domain/game/events.js"; -import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js"; +import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js"; import { formatHalfMoves, type HalfMove } from "../domain/game/position.js"; import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js"; import type { SavedGameSummary } from "../domain/game/saved-game.js"; import { otherSide, type Side } from "../domain/game/side.js"; +import type { GameState } from "../domain/game/state.js"; import { boardModel, moveTargets, @@ -42,6 +43,7 @@ import { type BoardDecor, checkerSpot, type DiceAction, + type MatchScore, NO_MARKS, renderBoard, type ShownDie, @@ -58,9 +60,18 @@ import { HOME_TEXTS } from "./home-texts.js"; import { createHomeView } from "./home-view.js"; import { icon } from "./icons.js"; 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 { isHelpScreen, type Route, routeHash } from "./router.js"; -import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js"; +import { + chosenGame, + isFirstLaunch, + longPrompts, + nextTheme, + parseSettings, + type Settings, +} from "./settings.js"; import { type PromptText, TEXTS } from "./texts.js"; import { seenAfter } from "./tutorial-nav.js"; import { createTutorialView, type TutorialAccess } from "./tutorial-view.js"; @@ -84,9 +95,9 @@ export interface CachedBoard { export interface AppOptions { /** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */ readonly engineReady: Promise; - /** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis - * une position donnée (setup). */ - readonly newGame: (setup?: GameSetup) => Game; + /** Une nouvelle partie (le jet d'ouverture est déjà tiré) : niveau, + * match, ou position de départ (setup). */ + readonly newGame: (newGame?: NewGame) => Game; /** Les liens vers une position (ADR 0013). */ readonly positions: PositionsAccess; /** La partie enregistrée, ou null s'il n'y en a pas. */ @@ -173,6 +184,7 @@ export function startApp(options: AppOptions): void { strip, who: child(strip, ".who"), lead: child(strip, ".lead"), + score: child(strip, ".match-score"), count: child(strip, ".count"), }; }; @@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void { menu: byId("menu"), menuTitle: byId("menu-title"), menuClose: byId("menu-close"), - menuNewGame: byId("menu-new-game"), + menuNewGame: byId("menu-new-game"), + newGameSection: byId("new-game"), + matchLine: byId("match-line"), menuGames: byId("menu-games"), menuCopyLink: byId("menu-copy-link"), menuFullscreen: byId("menu-fullscreen"), @@ -510,7 +524,8 @@ export function startApp(options: AppOptions): void { if (!animating) { renderBoard(ui.board, g, model, { dice: shownDice(game, stuck), - cube: state.cube, + // Partie Crawford : pas de videau. + cube: state.match?.crawford === true ? null : state.cube, movable: new Set(targets.keys()), selected, targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])), @@ -526,6 +541,7 @@ export function startApp(options: AppOptions): void { }); } renderStrips(model.pipCount, state.phase === "over" ? null : state.turn); + renderMatch(state); // Ligne d'information : un jet sans coup possible reste annoncé // jusqu'à l'action suivante, même après un rechargement de la page. @@ -561,6 +577,7 @@ export function startApp(options: AppOptions): void { show(ui.take, doubled); show(ui.drop, doubled); show(ui.again, over && panelDismissed); + ui.again.textContent = afterGameLabel(game); show(ui.replay, shownMove !== null); // Le coup de l'ordinateur montré : sa position se copie en lien. show(ui.copyLink, shownMove !== null && currentLink !== null); @@ -573,7 +590,6 @@ export function startApp(options: AppOptions): void { ui.skip, ui.replay, ui.again, - ui.menuNewGame, ui.positionPlay, ]) { button.disabled = busy; @@ -624,7 +640,6 @@ export function startApp(options: AppOptions): void { button.hidden = true; } ui.moveHint.hidden = true; - ui.menuNewGame.disabled = true; ui.menuCopyLink.disabled = true; ui.positionPlay.disabled = true; if (notice !== null) { @@ -646,9 +661,42 @@ export function startApp(options: AppOptions): void { back: replay ? "replay" : "undo", backEnabled: !busy && (replay || undoable), menuEnabled: true, + match: game === null ? null : matchScore(game.state), }; } + /** Le match : la ligne « Match en N points » sous la barre haute, et le + * score de chaque camp dans son bandeau (portrait). */ + function renderMatch(state: GameState): void { + const score = matchScore(state); + ui.matchLine.hidden = score === null; + for (const side of ["computer", "player"] as const) { + const badge = ui.strips[side].score; + badge.hidden = score === null; + badge.textContent = score === null ? "" : String(score[side]); + badge.setAttribute( + "aria-label", + score === null + ? "" + : side === "player" + ? MATCH_TEXTS.playerScore(score.player) + : MATCH_TEXTS.computerScore(score.computer), + ); + } + if (score === null || state.match === null) { + ui.matchLine.textContent = ""; + ui.matchLine.removeAttribute("aria-label"); + return; + } + ui.matchLine.textContent = MATCH_TEXTS.matchLine(score.length, state.match.crawford); + ui.matchLine.setAttribute( + "aria-label", + `${MATCH_TEXTS.scoreSpoken(score.length, score.computer, score.player)}${ + state.match.crawford ? ", partie Crawford" : "" + }`, + ); + } + /** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */ function renderStrips(pips: Readonly>, turn: Side | null): void { const lead = pipLead(pips); @@ -681,7 +729,7 @@ export function startApp(options: AppOptions): void { ui.panelText.textContent = end.title; detail.textContent = end.detail; ui.panelSecondary.textContent = TEXTS.reviewGame; - ui.panelPrimary.textContent = TEXTS.newGame; + ui.panelPrimary.textContent = afterGameLabel(g); ui.panelVeil.dataset.kind = "over"; } else if (ask) { overline.textContent = ""; @@ -737,6 +785,9 @@ export function startApp(options: AppOptions): void { if (navigation.route.screen === "home") { renderHome(); } + if (navigation.route.screen === "newGame") { + newGameView.render(busy || game === null); + } } /** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */ @@ -851,7 +902,7 @@ export function startApp(options: AppOptions): void { } const decor = { dice: shownDice(game, false), - cube: game.state.cube, + cube: game.state.match?.crawford === true ? null : game.state.cube, ...NO_MARKS, band: null, controls: null, @@ -936,24 +987,27 @@ export function startApp(options: AppOptions): void { } } - function startNewGame(setup?: GameSetup): void { - game = options.newGame(setup); + /** 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 { + game = options.newGame(chosen); panelDismissed = false; announcedEnd = null; saveSettings({ gamesStarted: settings.gamesStarted + 1 }); const opening = game.events.findLast((e) => e.type === "DiceRolled"); info = - setup !== undefined + chosen.setup !== undefined ? TEXTS.startedFromPosition : opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : ""; } - /** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une - * position) : tout de suite, sans confirmation ; une partie en cours - * reste reprenable pendant 10 s (interaction.md). */ - function newGame(setup?: GameSetup): void { + /** Une nouvelle partie (« Commencer » de l'écran « Nouvelle partie », + * « Jouer à partir d'ici » depuis une position, « Partie suivante » d'un + * match) : tout de suite, sans confirmation ; une partie en cours reste + * reprenable pendant 10 s (interaction.md). */ + function newGame(chosen?: NewGame): void { closeOverlays(); if (busy || game === null) { return; @@ -962,7 +1016,19 @@ export function startApp(options: AppOptions): void { if (old !== null) { showBanner(old); } - void act(() => startNewGame(setup)); + void act(() => startNewGame(chosen)); + } + + /** Après une partie finie : la partie suivante du match s'il continue, + * sinon l'écran « Nouvelle partie ». */ + function afterGame(): void { + const next = game?.nextGame ?? null; + if (next !== null) { + newGame(next); + } else { + closeOverlays(); + navigation.go(routeHash({ screen: "newGame" })); + } } function showBanner(journal: GameJournal): void { @@ -1102,8 +1168,8 @@ export function startApp(options: AppOptions): void { drop: () => { const g = canAct(); if (g?.state.phase === "doubled" && g.state.turn === "computer") { - void act((g) => { - g.drop(); + void act(async (g) => { + await g.drop(); info = ""; }); } @@ -1130,7 +1196,7 @@ export function startApp(options: AppOptions): void { selected = null; void settle(true); }, - again: () => newGame(), + again: () => afterGame(), // Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son // lien) ; le refermer, revenir à l'écran d'avant. menu: () => { @@ -1162,6 +1228,8 @@ export function startApp(options: AppOptions): void { navigation.leave(isHelpScreen); } else if (route.screen === "position") { navigation.leave((r) => r.screen === "position"); + } else if (route.screen === "newGame") { + navigation.leave((r) => r.screen === "newGame"); } else if (route.screen === "tutorial" || route.screen === "tutorialEnd") { navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd"); } @@ -1234,7 +1302,7 @@ export function startApp(options: AppOptions): void { if (ui.panelVeil.dataset.kind === "doubled") { actions.take(); } else { - newGame(); + afterGame(); } }; ui.panelSecondary.onclick = () => { @@ -1251,12 +1319,12 @@ export function startApp(options: AppOptions): void { // Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à // brancher. Les liens du menu remplacent son adresse : le retour du // téléphone ne rouvre pas le menu. + replacingLink(ui.menuNewGame, navigation); replacingLink(ui.menuGames, navigation); replacingLink(ui.menuHelp, navigation); replacingLink(ui.menuHome, navigation); replacingLink(ui.menuTutorial, navigation); ui.menuClose.onclick = () => closeOverlays(); - ui.menuNewGame.onclick = () => newGame(); ui.menuCopyLink.onclick = () => { const link = currentLink; closeOverlays(); @@ -1304,7 +1372,8 @@ export function startApp(options: AppOptions): void { const position = below.screen === "position"; const home = below.screen === "home"; const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd"; - const page = help || position || home || tutorial; + const choosing = below.screen === "newGame"; + const page = help || position || home || tutorial || choosing; const wasPage = ui.game.hidden; const wasScreen = ui.root.dataset.screen; ui.game.hidden = page; @@ -1312,9 +1381,16 @@ export function startApp(options: AppOptions): void { ui.position.hidden = !position; ui.home.hidden = !home; ui.tutorial.hidden = !tutorial; + const opening = choosing && ui.newGameSection.hidden; + ui.newGameSection.hidden = !choosing; ui.root.classList.toggle("page-open", page); - // Le tutoriel et le premier lancement ont leur propre en-tête. - ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings))); + // Le tutoriel, le premier lancement et l'écran « Nouvelle partie » + // ont leur propre en-tête. + ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings))); + if (opening) { + newGameView.render(busy || game === null); + newGameView.focus(); + } ui.root.dataset.screen = below.screen; if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) { window.scrollTo(0, 0); @@ -1398,18 +1474,29 @@ export function startApp(options: AppOptions): void { const homeView = createHomeView({ section: ui.home, - newGame: () => { - if (busy || game === null) { - return; - } - navigation.go("#/"); - newGame(); - }, + // « Nouvelle partie » : l'écran des choix, qui la commence. + newGame: () => navigation.go(routeHash({ screen: "newGame" })), install: () => void install(false), dismissInstall: () => saveSettings(dismissInstall(settings)), 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). + const newGameView = createNewGameView({ + section: ui.newGameSection, + settings: () => settings, + change: (changes) => saveSettings(changes), + start: () => { + if (busy || game === null) { + return; + } + navigation.replace("#/"); + newGame(chosenGame(settings)); + }, + }); + leavingLink(newGameView.closeLink, (route) => route.screen === "newGame"); + let progressLoading = false; function renderHome(): void { const playing = game === null ? null : game.state.phase !== "over"; @@ -1606,7 +1693,7 @@ export function startApp(options: AppOptions): void { // La nouvelle partie est l'écran de jeu, à la place de la position // (le retour du téléphone ne rouvre pas la position). navigation.replace("#/"); - newGame(setupOf(shared)); + newGame({ level: chosenGame(settings).level, setup: setupOf(shared) }); }; ui.positionCopy.onclick = () => { if (shownPosition !== null) { @@ -2022,12 +2109,46 @@ function endParts(game: Game): { title: string; detail: string } { } const { winner, kind, points } = result; const dropped = game.events.some((e) => e.type === "CubeDropped"); + const { match, matchAfter } = game.state; + if (match !== null && matchAfter !== null) { + const { player, computer } = matchAfter.score; + const detail = MATCH_TEXTS.matchDetail( + winner, + points, + match.length, + player, + computer, + matchAfter.over, + ); + return { + title: matchAfter.over + ? MATCH_TEXTS.matchWonTitle(winner) + : TEXTS.endTitle(winner, kind, dropped), + detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail, + }; + } return { title: TEXTS.endTitle(winner, kind, dropped), detail: TEXTS.endDetail(winner, points, game.state.cube.value), }; } +/** Le score du match à montrer : avant la partie, puis après une fois + * qu'elle est finie (null en partie libre). */ +function matchScore(state: GameState): MatchScore | null { + if (state.match === null) { + return null; + } + const score = state.matchAfter?.score ?? state.match.score; + return { length: state.match.length, computer: score.computer, player: score.player }; +} + +/** Le bouton qui suit une partie finie : « Partie suivante » dans un match + * qui continue, sinon « Nouvelle partie » (l'écran des choix). */ +function afterGameLabel(game: Game): string { + return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame; +} + /** Le résultat d'une partie finie, sur une ligne (consigne, une fois le * panneau fermé). */ function resultText(game: Game): string { diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 15a08ec..35eeead 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -27,6 +27,7 @@ import { } from "./board-model.js"; import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js"; import { ICON_PATHS, type IconName } from "./icons.js"; +import { MATCH_TEXTS } from "./match-texts.js"; import { TEXTS } from "./texts.js"; /** Un dé affiché, de la couleur du camp qui l'a lancé. */ @@ -49,12 +50,22 @@ export interface BoardControls { readonly back: "undo" | "replay"; readonly backEnabled: boolean; readonly menuEnabled: boolean; + /** Le score du match, sous le menu (null : partie libre). */ + readonly match: MatchScore | null; +} + +/** Le score d'un match, à afficher. */ +export interface MatchScore { + readonly length: number; + readonly computer: number; + readonly player: number; } /** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */ export interface BoardDecor { readonly dice: readonly ShownDie[]; - readonly cube: Cube; + /** null : pas de videau (partie Crawford). */ + readonly cube: Cube | null; /** Les pions que le joueur peut déplacer. */ readonly movable: ReadonlySet; /** Le pion touché, dont on montre les cases d'arrivée. */ @@ -180,7 +191,7 @@ export function renderBoard( drawBand(svg, g, decor.band); } drawDice(svg, g, decor.dice); - const cube = drawCube(svg, g, decor.cube); + const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube); if (decor.trace !== null) { drawTraceRings(svg, g, board, decor.trace); } @@ -196,7 +207,7 @@ export function renderBoard( addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width); } } - if (decor.canDouble) { + if (decor.canDouble && cube !== null && decor.cube !== null) { cube.classList.add("can-double"); addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target); } @@ -512,6 +523,49 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void { ); } +/** + * Paysage : le score du match, dans une case sous le menu, dans l'ordre des + * camps à l'écran : l'ordinateur en haut, « sur N », vous en bas + * (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran. + */ +function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void { + const line = g.chipFont * 1.2; + const width = g.left - 12; + const group = add(svg, "g", { + class: "match-box", + role: "img", + "aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player), + }); + add(group, "rect", { + class: "match-box-frame", + x: x - width / 2, + y: top, + width, + height: 3 * line + 8, + rx: 6, + }); + const texts: [string, string][] = [ + [String(match.computer), "match-box-score"], + [MATCH_TEXTS.outOf(match.length), "match-box-of"], + [String(match.player), "match-box-score"], + ]; + for (const [i, [text, cssClass]] of texts.entries()) { + add( + group, + "text", + { + class: cssClass, + x, + y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35, + "font-size": g.chipFont, + "text-anchor": "middle", + "aria-hidden": "true", + }, + text, + ); + } +} + /** Paysage : la colonne de commandes à gauche, dans le cadre. */ function drawControls( svg: SVGSVGElement, @@ -556,6 +610,9 @@ function drawControls( } }; control("menu", "menu", TEXTS.menu, top, controls.menuEnabled); + if (controls.match !== null) { + drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match); + } if (controls.back === "replay") { control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled); } else { diff --git a/app/src/ui/match-texts.ts b/app/src/ui/match-texts.ts new file mode 100644 index 0000000..0266498 --- /dev/null +++ b/app/src/ui/match-texts.ts @@ -0,0 +1,93 @@ +// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ; +// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et +// descriptions des niveaux sont ceux de la charte ; le reste est une +// première version, à valider par Pierre. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { ComputerLevel } from "../domain/game/level.js"; + +function points(n: number): string { + return n > 1 ? `${n} points` : `${n} point`; +} + +export const MATCH_TEXTS = { + // Écran « Nouvelle partie » + title: "Nouvelle partie", + close: "Fermer sans rien changer", + kindLabel: "Type de partie", + free: "Partie libre", + match: "Match", + /** « Le premier à [3] [5] [7] [11] points ». */ + lengthBefore: "Le premier à", + lengthAfter: "points", + lengthLabel: (n: number) => `Match en ${n} points`, + crawfordRule: + "Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la", + crawfordLink: "règle de Crawford", + jacobyRule: + "Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la", + jacobyLink: "règle de Jacoby", + levelTitle: "Niveau de l'ordinateur", + levels: { + beginner: { + name: "Débutant", + text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.", + }, + amateur: { + name: "Amateur", + text: "Un joueur qui connaît les règles et commet encore des erreurs.", + }, + intermediate: { + name: "Intermédiaire", + text: "Un habitué de la table : solide, sans être implacable.", + }, + advanced: { + name: "Avancé", + text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.", + }, + expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." }, + champion: { + name: "Champion", + text: "GNU Backgammon à son meilleur niveau sur ce téléphone.", + }, + } satisfies Record, + duringGame: "Pendant la partie", + 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`, + kept: "Vos choix sont gardés pour la prochaine fois.", + + // Score du match sur l'écran de jeu + /** « Match en 5 points », et « · partie Crawford ». */ + matchLine: (length: number, crawford: boolean) => + `Match en ${length} points${crawford ? " · partie Crawford" : ""}`, + /** Ce que lit le lecteur d'écran. */ + scoreSpoken: (length: number, computer: number, player: number) => + `Match en ${length} points : l'ordinateur ${computer}, vous ${player}`, + /** Paysage : « sur 5 ». */ + outOf: (length: number) => `sur ${length}`, + playerScore: (n: number) => `Votre score : ${n}`, + computerScore: (n: number) => `Score de l'ordinateur : ${n}`, + + // Fin d'une partie de match + nextGame: "Partie suivante", + nextGameStarted: "Partie suivante du match.", + matchWonTitle: (winner: "player" | "computer") => + winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match", + /** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */ + matchDetail: ( + winner: "player" | "computer", + n: number, + length: number, + player: number, + computer: number, + over: boolean, + ) => + `${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${ + over ? "Score final" : `Match en ${length} points` + } : vous ${player}, l'ordinateur ${computer}.`, + crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.", +}; diff --git a/app/src/ui/new-game-view.ts b/app/src/ui/new-game-view.ts new file mode 100644 index 0000000..b002839 --- /dev/null +++ b/app/src/ui/new-game-view.ts @@ -0,0 +1,199 @@ +// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie +// libre ou match en N points), niveau de l'ordinateur, conseil du maître +// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont +// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie +// en cours ; seul « Commencer » en lance une. + +import { COMPUTER_LEVELS } from "../domain/game/level.js"; +import { MATCH_LENGTHS } from "../domain/game/match.js"; +import { icon } from "./icons.js"; +import { MATCH_TEXTS } from "./match-texts.js"; +import { routeHash } from "./router.js"; +import type { Settings } from "./settings.js"; + +export interface NewGameViewOptions { + readonly section: HTMLElement; + readonly settings: () => Settings; + /** Un choix a changé : le garder. */ + readonly change: (changes: Partial) => void; + /** « Commencer la partie » ou « Commencer le match ». */ + readonly start: () => void; +} + +export interface NewGameView { + /** La croix : un lien « Retour » (l'écran de jeu le branche). */ + readonly closeLink: HTMLAnchorElement; + /** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */ + render(busy: boolean): void; + /** Le premier élément à recevoir le focus à l'ouverture. */ + focus(): void; +} + +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; +} + +/** Un bouton qui fait partie d'un groupe de choix exclusifs. */ +function radio(parent: HTMLElement, id: string, className: string, text?: string) { + const button = element(parent, "button", { + id, + className, + ...(text === undefined ? {} : { text }), + }); + button.type = "button"; + button.setAttribute("role", "radio"); + return button; +} + +/** Une phrase sur une règle, terminée par le lien vers sa fiche. */ +function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) { + const p = element(parent, "p", { id, className: "new-game-rule" }); + p.append(`${text} `); + const a = element(p, "a", { text: link }); + a.href = routeHash({ screen: "glossary", term }); + p.append("."); + return p; +} + +export function createNewGameView(options: NewGameViewOptions): NewGameView { + const { section } = options; + section.replaceChildren(); + section.setAttribute("aria-labelledby", "new-game-title"); + + const head = element(section, "div", { className: "new-game-head" }); + const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" }); + closeLink.href = "#/"; + closeLink.setAttribute("aria-label", MATCH_TEXTS.close); + closeLink.append(icon("close")); + element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title }); + + const body = element(section, "div", { className: "new-game-body" }); + + // 1. Type de partie. + const kinds = element(body, "div", { className: "segmented" }); + kinds.setAttribute("role", "radiogroup"); + kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel); + const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free); + const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match); + free.addEventListener("click", () => options.change({ gameKind: "free" })); + match.addEventListener("click", () => options.change({ gameKind: "match" })); + + const matchOptions = element(body, "div", { + id: "new-game-match-options", + className: "new-game-options", + }); + const lengths = element(matchOptions, "div", { className: "lengths" }); + lengths.setAttribute("role", "radiogroup"); + element(lengths, "span", { text: MATCH_TEXTS.lengthBefore }); + const lengthButtons = MATCH_LENGTHS.map((n) => { + const button = radio(lengths, `new-game-length-${n}`, "length", String(n)); + button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n)); + button.addEventListener("click", () => options.change({ matchLength: n })); + return button; + }); + element(lengths, "span", { text: MATCH_TEXTS.lengthAfter }); + rule( + matchOptions, + "new-game-crawford", + MATCH_TEXTS.crawfordRule, + MATCH_TEXTS.crawfordLink, + "regle-de-crawford", + ); + const freeOptions = element(body, "div", { + id: "new-game-free-options", + className: "new-game-options", + }); + rule( + freeOptions, + "new-game-jacoby", + MATCH_TEXTS.jacobyRule, + MATCH_TEXTS.jacobyLink, + "regle-de-jacoby", + ); + + // 2. Niveau de l'ordinateur : six barres de hauteur croissante. + element(body, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle }); + const bars = element(body, "div", { className: "level-bars" }); + bars.setAttribute("role", "radiogroup"); + bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle); + const levelButtons = COMPUTER_LEVELS.map((level, i) => { + const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`); + button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name); + button.dataset.level = level; + element(button, "span", { className: "level-bar" }); + button.addEventListener("click", () => options.change({ level })); + return button; + }); + const levelName = element(body, "p", { id: "new-game-level-name", className: "level-name" }); + const levelText = element(body, "p", { id: "new-game-level-text", className: "level-text" }); + + // 3. Pendant la partie. + element(body, "h3", { className: "overline", text: MATCH_TEXTS.duringGame }); + const advice = element(body, "button", { id: "new-game-advice", className: "item" }); + advice.type = "button"; + advice.setAttribute("role", "switch"); + const adviceLabel = element(advice, "span", { className: "item-label" }); + element(adviceLabel, "span", { text: MATCH_TEXTS.advice }); + element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail }); + element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true"); + advice.addEventListener("click", () => + options.change({ masterAdvice: !options.settings().masterAdvice }), + ); + + // 4. Barre d'action collée en bas. + const actions = element(section, "div", { className: "new-game-actions" }); + const start = element(actions, "button", { id: "new-game-start" }); + start.type = "button"; + start.addEventListener("click", () => options.start()); + element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept }); + + return { + closeLink, + render(busy) { + const s = options.settings(); + const isMatch = s.gameKind === "match"; + free.setAttribute("aria-checked", String(!isMatch)); + match.setAttribute("aria-checked", String(isMatch)); + matchOptions.hidden = !isMatch; + freeOptions.hidden = isMatch; + for (const [i, button] of lengthButtons.entries()) { + button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength)); + } + const chosen = COMPUTER_LEVELS.indexOf(s.level); + for (const [i, button] of levelButtons.entries()) { + button.setAttribute("aria-checked", String(i === chosen)); + button.classList.toggle("reached", i <= chosen); + } + 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.disabled = busy; + }, + focus() { + (section.querySelector("[aria-checked='true']") ?? start).focus(); + }, + }; +} diff --git a/tests/browser.sh b/tests/browser.sh index a91a8fa..9d61203 100755 --- a/tests/browser.sh +++ b/tests/browser.sh @@ -4,9 +4,12 @@ # construite en local, comme tests/test_site.sh. Les captures d'écran vont # dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide, # l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs -# captures dans build/browser-shots/accueil-tutoriel/. +# captures dans build/browser-shots/accueil-tutoriel/. Enfin l'écran « Nouvelle +# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs), +# captures dans build/browser-shots/match/. # -# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test. +# BROWSERS=chromium et SCRIPTS=accueil (ou play, ou match) 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 @@ -19,7 +22,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}" +scripts="${SCRIPTS:-play accueil match}" unset MAKEFLAGS MFLAGS MAKELEVEL make -s package >/dev/null @@ -56,7 +59,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 "$tools/" +cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.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 @@ -66,13 +69,21 @@ for browser in $browsers; do -e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \ node play.mjs "http://localhost:$port/" "$browser" /shots || status=1 fi - [[ " $scripts " == *" accueil "* ]] || continue - # Accueil, tutoriel, aide, installation et mise à jour ; captures de la - # recette dans build/browser-shots/accueil-tutoriel/. - 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" -v "$PWD/$updates:/site" \ - -w /tools "$image" \ - node accueil.mjs "http://localhost:$port/" "$browser" /shots \ - "http://localhost:$update_port/" /site || status=1 + if [[ " $scripts " == *" accueil "* ]]; then + # Accueil, tutoriel, aide, installation et mise à jour ; captures de + # la recette dans build/browser-shots/accueil-tutoriel/. + 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" -v "$PWD/$updates:/site" \ + -w /tools "$image" \ + node accueil.mjs "http://localhost:$port/" "$browser" /shots \ + "http://localhost:$update_port/" /site || status=1 + fi + if [[ " $scripts " == *" match "* ]]; then + # Écran « Nouvelle partie » et match en 3 points ; captures de la + # recette dans build/browser-shots/match/. + 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 match.mjs "http://localhost:$port/" "$browser" /shots || status=1 + fi done exit "$status" diff --git a/tests/browser/match.mjs b/tests/browser/match.mjs new file mode 100644 index 0000000..7603f57 --- /dev/null +++ b/tests/browser/match.mjs @@ -0,0 +1,326 @@ +// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué +// jusqu'au bout par l'interface, dans un vrai navigateur sans écran +// (Playwright), sur le site servi par l'image de production +// (docs/plans/2026-11-03-match-et-reglages.md, point 6). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Usage : node match.mjs +// Lancé par tests/browser.sh. Les dés de l'application viennent de +// Math.random : il est remplacé ici par un générateur à graine, pour que le +// match soit le même à chaque fois (il passe par une partie Crawford). Le +// joueur joue toujours le premier pion jouable vers sa première case +// d'arrivée, prend les doubles et ne double jamais. Captures de la recette +// design (Chromium) dans /match/ : écran « Nouvelle +// partie », score du match et partie Crawford, en 412×915 et 915×412. + +import { mkdirSync } from "node:fs"; +import { chromium, firefox } from "playwright"; + +const [base, browserName, shots] = process.argv.slice(2); +const browserType = { chromium, firefox }[browserName]; +if (!base || !browserType || !shots) { + console.error("usage : node match.mjs "); + process.exit(2); +} +const full = browserName === "chromium"; +const dir = `${shots}/match`; +mkdirSync(dir, { recursive: true }); +const PORTRAIT = { width: 412, height: 915 }; +const LANDSCAPE = { width: 915, height: 412 }; +// La graine des dés : un match qui passe par une partie Crawford. +const SEED = 7; + +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 : le match va plus vite, rien d'autre ne change. + reducedMotion: "reduce", +}); +await context.addInitScript((seed) => { + let state = seed; + Math.random = () => { + state = (state * 1103515245 + 12345) % 2147483648; + return state / 2147483648; + }; + window.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => + window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`), + ); +}, SEED); +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 = 60_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}`); +} + +/** Une capture de la recette, dans les deux tailles (Chromium seulement). */ +async function capture(name) { + if (!full) { + return; + } + for (const size of [PORTRAIT, LANDSCAPE]) { + await page.setViewportSize(size); + await sleep(200); + await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` }); + captures++; + } + await page.setViewportSize(PORTRAIT); + await sleep(200); +} + +/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */ +async function todo() { + for (let i = 0; i < 600; i++) { + const what = await page.evaluate(() => { + if (document.body.classList.contains("busy")) { + return null; + } + const panel = document.getElementById("panel-veil"); + if (!panel.hidden) { + return panel.dataset.kind; + } + 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; + } + } + return document.querySelector(".hit.can-move") ? "move" : null; + }); + if (what !== null) { + return what; + } + await sleep(100); + } + await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {}); + throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`); +} + +/** Le match tel que l'écran le montre (portrait). */ +async function shownMatch() { + return page.evaluate(() => ({ + line: document.getElementById("match-line").textContent, + lineShown: !document.getElementById("match-line").hidden, + spoken: document.getElementById("match-line").getAttribute("aria-label"), + computer: document.querySelector("#strip-computer .match-score").textContent, + player: document.querySelector("#strip-player .match-score").textContent, + cube: document.querySelectorAll("#board .cube").length, + double: !document.getElementById("double").hidden, + })); +} + +// ---------------------------------------------------- Écran « Nouvelle partie » + +await page.goto(`${base}#/nouvelle-partie`); +await until("moteur chargé", () => !document.getElementById("new-game-start").disabled); +const screen = () => + page.evaluate(() => ({ + free: document.getElementById("new-game-free").getAttribute("aria-checked"), + match: document.getElementById("new-game-match").getAttribute("aria-checked"), + lengthsShown: !document.getElementById("new-game-match-options").hidden, + jacobyShown: !document.getElementById("new-game-free-options").hidden, + level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level, + reached: document.querySelectorAll(".level-bars .reached").length, + name: document.getElementById("new-game-level-name").textContent, + advice: document.getElementById("new-game-advice").getAttribute("aria-checked"), + start: document.getElementById("new-game-start").textContent, + topbar: document.querySelector(".topbar").getClientRects().length > 0, + game: !document.getElementById("game").hidden, + })); +let s = await screen(); +check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown, + `par défaut, partie libre : ${JSON.stringify(s)}`); +check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire", + `par défaut, Intermédiaire : ${JSON.stringify(s)}`); +check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`); +check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie"); +check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby", + "lien vers la règle de Jacoby"); +// Les cibles tactiles : 44 px au moins. +const small = await page.evaluate(() => + [...document.querySelectorAll("#new-game button, #new-game a")] + .filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null) + .map((el) => el.getBoundingClientRect()) + .filter((r) => r.width < 44 || r.height < 44).length, +); +check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`); + +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", + `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", + "lien vers la règle de Crawford"); +check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"), + "description du niveau Débutant"); +await capture("1-nouvelle-partie"); +// Fermer ne change rien : on revient à la partie, et les choix sont gardés. +const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings")); +await page.click("#new-game-close"); +await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden); +const settings = JSON.parse(before); +check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner", + `choix gardés : ${before}`); +// Le menu y ramène, avec les choix de la dernière fois. +await page.click("#menu-button"); +await page.waitForSelector("#menu", { state: "visible" }); +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", + `choix retrouvés : ${JSON.stringify(s)}`); +await page.click("#new-game-start"); +await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy")); +log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés"); + +// ------------------------------------------------------------- Le match + +let m = await shownMatch(); +check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0", + `début du match : ${JSON.stringify(m)}`); +check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`); + +// Un match ne passe pas toujours par une partie Crawford (un gammon peut +// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les +// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus). +let games = 1; +let crawfordGames = 0; +let scoreShot = false; +let crawfordShot = false; +let reloaded = false; +let actions = 0; +let matches = 1; +for (;;) { + check(actions++ < 20000, "le match ne finit pas"); + const what = await todo(); + const now = await shownMatch(); + const crawford = now.line.endsWith("· partie Crawford"); + if (crawford && what !== "over") { + 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"); + log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`); + if (primary === "Nouvelle partie") { + check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"), + `fin du match : ${title} ; ${detail}`); + const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? []; + check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`); + // Paysage : le score dans la colonne de commandes. + await page.setViewportSize(LANDSCAPE); + await sleep(200); + const box = await page.evaluate(() => + document.querySelector("#board .match-box")?.getAttribute("aria-label")); + check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""), + `score en paysage : « ${box} »`); + await page.setViewportSize(PORTRAIT); + await sleep(200); + // Après le match, « Nouvelle partie » mène à l'écran des choix. + await page.click("#panel-primary"); + await until("écran « Nouvelle partie » après le match", () => + location.hash === "#/nouvelle-partie"); + log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`); + if (crawfordGames > 0 && scoreShot) { + break; + } + check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`); + matches++; + games = 1; + await page.click("#new-game-start"); + await until("match suivant", () => location.hash === "#/" && + !document.body.classList.contains("busy")); + continue; + } + 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"); + games++; + await until("partie suivante", () => !document.body.classList.contains("busy")); + const next = await shownMatch(); + if (next.line.endsWith("· partie Crawford")) { + crawfordGames++; + check(next.cube === 0, "partie Crawford : le videau est dessiné"); + if (!crawfordShot) { + crawfordShot = true; + await capture("3-partie-crawford"); + } + } else if (!scoreShot) { + scoreShot = true; + await capture("2-score-du-match"); + } + if (!reloaded) { + // Le match continue après un rechargement de la page. + reloaded = true; + await page.reload(); + await until("partie reprise", () => !document.body.classList.contains("busy")); + const again = await shownMatch(); + check(again.line === next.line && again.player === next.player && again.computer === next.computer, + `après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`); + } + continue; + } + // Les doubles de l'ordinateur sont passés : le match avance d'un point + // à la fois, ce qui rend la partie Crawford probable. + if (what === "doubled") { + await page.click("#panel-secondary"); + } else if (what === "take") { + await page.click("#drop"); + } else if (what === "skip" || what === "confirm" || what === "roll") { + await page.click(`#${what}`); + } else if (what === "move") { + await page.click(".hit.can-move"); + if ((await page.locator(".hit.can-land").count()) > 0) { + await page.click(".hit.can-land"); + } + } +} +log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` + + `${actions} actions`); + +const csp = await page.evaluate(() => window.__csp); +check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`); +await browser.close(); +if (problems.length > 0) { + console.error(problems.join("\n")); + process.exit(1); +} +log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index f3fb289..4f72b23 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -819,8 +819,7 @@ async function checkAddresses() { for (const selector of ["#menu-games", "#menu-help"]) { check(await isLink(tab, selector), `${selector} n'est pas un lien`); } - check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1, - "« Nouvelle partie » n'est plus un bouton"); + check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien"); await tab.goBack(); await expectScreen(tab, "retour : menu fermé", playing); await tab.click("#menu-button"); @@ -881,8 +880,20 @@ async function openMenu() { check(await page.isVisible("#menu"), "le menu ne s'ouvre pas"); } -/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie - * le journal de la partie quittée. */ +/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie + * choisie, et l'adresse redevient celle de la partie. */ +async function startChosenGame() { + await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {}); + check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas"); + const hash = await page.evaluate(() => location.hash); + check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`); + await page.click("#new-game-start"); + check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")), + "« Commencer » ne ramène pas à la partie"); +} + +/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet + * immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */ async function newGameFromMenu() { const before = await savedJournal(); const started = () => @@ -891,6 +902,7 @@ async function newGameFromMenu() { await openMenu(); const start = Date.now(); await page.click("#menu-new-game"); + await startChosenGame(); check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »"); check((await page.textContent("#banner")).includes("Reprendre l'ancienne"), `bandeau : « ${await page.textContent("#banner")} »`); @@ -1454,6 +1466,7 @@ step("mes parties"); { const finished = await savedJournal(); await page.click("#again"); + await startChosenGame(); await waitForPlayer(); const second = await savedJournal(); check(second.head !== finished.head || second.events.length !== finished.events.length, @@ -1535,6 +1548,7 @@ log("compte de points : bulle avec la fiche du glossaire"); step("thème sombre"); await page.emulateMedia({ colorScheme: "dark" }); await page.click("#again"); +await startChosenGame(); await waitForPlayer(); await shot("6-sombre"); await page.emulateMedia({ colorScheme: "light" }); diff --git a/web/app.css b/web/app.css index fcc7352..3e99563 100644 --- a/web/app.css +++ b/web/app.css @@ -2122,3 +2122,248 @@ html.landscape .welcome { padding-inline: 0; background: none; } + +/* ------------------------------------------------------------ Match */ + +/* « Match en 5 points » sous la barre haute (portrait), hauteur fixe. */ +.match-line { + flex: none; + height: 20px; + text-align: center; +} + +/* Le score de chaque camp, dans son bandeau, juste avant son compte de + * points : une pastille carrée. */ +.strip .match-score { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 4px; + font-weight: 700; + font-variant-numeric: tabular-nums lining-nums; + border: 2px solid var(--border-strong); + border-radius: var(--radius-sm); +} + +/* Paysage : le score dans la colonne de commandes, sous le menu. */ +.match-box-frame { + fill: none; + stroke: var(--point-number); + stroke-opacity: 0.5; + stroke-width: 1.5; +} + +.match-box-score { + font-weight: 700; + fill: var(--point-number); +} + +.match-box-of { + fill: var(--point-number); +} + +/* ------------------------------------------------- Nouvelle partie */ + +/* La page remplit l'écran : la barre d'action reste collée en bas, même + * quand le contenu est plus court que l'écran. */ +.new-game { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + max-width: var(--page-width); + margin: 0 auto; +} + +.new-game .new-game-body { + flex: 1; +} + +.new-game-head { + display: flex; + gap: var(--space-2); + align-items: center; + min-height: 60px; +} + +.new-game-head h2 { + margin: 0; + font-size: 22px; + line-height: 28px; +} + +.new-game-body { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-bottom: var(--space-6); +} + +.new-game-body .overline { + margin-top: var(--space-4); +} + +/* Sélecteur à deux segments : fond creusé, segment choisi en relief. */ +.segmented { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-1); + padding: var(--space-1); + background: var(--surface-sunken); + border-radius: var(--radius-pill); +} + +.segmented .segment { + color: var(--text); + background: transparent; + border-color: transparent; +} + +.segmented .segment[aria-checked="true"] { + background: var(--surface); + box-shadow: var(--shadow-raised); +} + +.new-game-options { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.lengths { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} + +.lengths .length { + width: 44px; + min-width: 44px; + height: 44px; + min-height: 44px; + padding: 0; + color: var(--text); + background: transparent; + border-color: var(--border-strong); + border-radius: var(--radius-sm); +} + +.lengths .length[aria-checked="true"] { + color: var(--on-accent); + background: var(--accent); + border-color: var(--accent); +} + +.new-game-rule { + margin: 0; + font-size: 15px; + line-height: 21px; + color: var(--muted); +} + +/* Six barres de hauteur croissante ; celles jusqu'au niveau choisi sont en + * brique, le niveau choisi porte l'anneau de focus. */ +.level-bars { + display: flex; + gap: var(--space-2); + align-items: flex-end; +} + +.level-bars .level { + display: flex; + flex: 1; + align-items: flex-end; + min-width: 44px; + height: 84px; + padding: 4px; + background: transparent; + border: 0; + border-radius: var(--radius-sm); +} + +.level-bar { + display: block; + width: 100%; + background: var(--border); + border-radius: 4px; +} + +.level-1 .level-bar { height: 18px; } +.level-2 .level-bar { height: 28px; } +.level-3 .level-bar { height: 38px; } +.level-4 .level-bar { height: 50px; } +.level-5 .level-bar { height: 62px; } +.level-6 .level-bar { height: 76px; } + +.level-bars .level.reached .level-bar { + background: var(--accent); +} + +.level-bars .level[aria-checked="true"] { + outline: 3px solid var(--focus); + outline-offset: 0; +} + +.level-name { + margin: 0; + font-size: 22px; + line-height: 28px; + font-weight: 700; +} + +.level-text { + margin: 0; + color: var(--muted); +} + +.new-game-body .item { + display: flex; + gap: 14px; + align-items: center; + width: 100%; + min-height: 60px; + padding: 0; + font-weight: 500; + text-align: left; + color: var(--text); + background: transparent; + border: 0; + border-radius: 0; +} + +.new-game-body .item-label { + display: flex; + flex-direction: column; +} + +.new-game-body .item small { + font-size: 15px; + line-height: 20px; + font-weight: 400; + color: var(--muted); +} + +/* Barre d'action collée en bas : le seul bouton principal de l'écran. */ +.new-game-actions { + position: sticky; + bottom: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); + align-items: stretch; + padding: var(--space-3) 0 var(--space-4); + background: var(--background); + border-top: 1px solid var(--border); +} + +.new-game-kept { + margin: 0; + font-size: 13px; + line-height: 18px; + text-align: center; + color: var(--muted); +} diff --git a/web/index.html b/web/index.html index dcd8aa9..54cc68e 100644 --- a/web/index.html +++ b/web/index.html @@ -39,10 +39,13 @@
+ +
+
@@ -57,6 +60,7 @@ +
@@ -89,6 +93,10 @@ + + +
- +