Match, niveaux et réglages : écran Nouvelle partie, Crawford, Jacoby (1c) #49

Manually merged
pierre merged 8 commits from match-et-reglages into main 2026-11-10 04:20:15 +00:00
10 changed files with 1136 additions and 65 deletions
Showing only changes of commit 85bdf4bb93 - Show all commits
+7 -10
View File
@@ -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),
+156 -35
View File
@@ -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<unknown>;
/** 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<HTMLElement>(strip, ".who"),
lead: child<HTMLElement>(strip, ".lead"),
score: child<HTMLElement>(strip, ".match-score"),
count: child<HTMLButtonElement>(strip, ".count"),
};
};
@@ -216,7 +228,9 @@ export function startApp(options: AppOptions): void {
menu: byId("menu"),
menuTitle: byId("menu-title"),
menuClose: byId<HTMLButtonElement>("menu-close"),
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
menuNewGame: byId<HTMLAnchorElement>("menu-new-game"),
newGameSection: byId("new-game"),
matchLine: byId("match-line"),
menuGames: byId<HTMLAnchorElement>("menu-games"),
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
menuFullscreen: byId<HTMLButtonElement>("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<Record<Side, number>>, 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 {
+60 -3
View File
@@ -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<Place>;
/** 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 {
+93
View File
@@ -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<ComputerLevel, { name: string; text: string }>,
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.",
};
+199
View File
@@ -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<Settings>) => 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<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** 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<HTMLElement>("[aria-checked='true']") ?? start).focus();
},
};
}
+23 -12
View File
@@ -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/<nom>.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"
+326
View File
@@ -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 <adresse du site> <chromium|firefox> <dossier des captures>
// 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 <dossier des captures>/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 <adresse> <chromium|firefox> <dossier des captures>");
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/` : ""}`);
+18 -4
View File
@@ -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" });
+245
View File
@@ -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);
}
+9 -1
View File
@@ -39,10 +39,13 @@
</header>
<main id="game" class="game">
<!-- Dans un match : « Match en 5 points », et la partie Crawford. -->
<p id="match-line" class="match-line overline" hidden></p>
<div id="strip-computer" class="strip">
<span class="mini mini-computer"></span>
<span class="who"></span>
<span class="lead"></span>
<span class="match-score" hidden></span>
<button type="button" class="count"></button>
</div>
<div class="board-box">
@@ -57,6 +60,7 @@
<span class="mini mini-player"></span>
<span class="who"></span>
<span class="lead"></span>
<span class="match-score" hidden></span>
<button type="button" class="count"></button>
</div>
<div class="infoline">
@@ -89,6 +93,10 @@
<section id="home" class="home page" hidden></section>
<section id="tutorial" class="tutorial page" hidden></section>
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
app/src/ui/new-game-view.ts. -->
<section id="new-game" class="new-game page" hidden></section>
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
<section id="help" class="help page" hidden>
<a id="help-back" href="#/" class="button secondary"></a>
@@ -122,7 +130,7 @@
<h2 id="menu-title" class="sheet-title"></h2>
<button id="menu-close" type="button" class="icon-button"></button>
</div>
<button id="menu-new-game" type="button" class="item"></button>
<a id="menu-new-game" href="#/nouvelle-partie" class="item"></a>
<a id="menu-games" href="#/parties" class="item"></a>
<a id="menu-home" href="#/accueil" class="item"></a>
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>