Compare commits
8
Commits
7c88b3ba76
...
c7a54a8c81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7a54a8c81 | ||
|
|
4fa54ad3f3 | ||
|
|
f609987f4e | ||
|
|
9dce965164 | ||
|
|
3ae1f23938 | ||
|
|
9ada35e46d | ||
|
|
526e47e79b | ||
|
|
74693c4277 |
@@ -73,6 +73,7 @@ export const PHRASE_KEYS: readonly string[] = [
|
||||
"coup.douteux",
|
||||
"coup.mauvais",
|
||||
"coup.tres-mauvais",
|
||||
"coup.loin",
|
||||
"fait.frappe",
|
||||
"fait.frappe-plusieurs",
|
||||
"fait.point",
|
||||
@@ -131,7 +132,6 @@ function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
|
||||
best: advice.best.notation,
|
||||
played: advice.played.notation,
|
||||
rank: String(advice.rank),
|
||||
count: String(advice.moveCount),
|
||||
roll: `${advice.roll[0]}-${advice.roll[1]}`,
|
||||
};
|
||||
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
|
||||
@@ -145,9 +145,13 @@ function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
|
||||
base = fill("coup.meilleur");
|
||||
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
|
||||
} else {
|
||||
// Le rang n'est cité que pour un bon coup non meilleur ou une
|
||||
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
|
||||
// erreur, et le classement est dans les chiffres du moteur.
|
||||
const near = advice.rank <= 3;
|
||||
const key = {
|
||||
good: "coup.proche",
|
||||
doubtful: "coup.douteux",
|
||||
good: near ? "coup.proche" : "coup.loin",
|
||||
doubtful: near ? "coup.douteux" : "coup.loin",
|
||||
bad: "coup.mauvais",
|
||||
veryBad: "coup.tres-mauvais",
|
||||
}[verdict];
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type NewGameEvent,
|
||||
withAdvice,
|
||||
} from "./events.js";
|
||||
import { type Match, nextGameOf } from "./match.js";
|
||||
import { firstGameOf, type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
@@ -168,6 +168,17 @@ export class Game {
|
||||
return match === null ? null : { level: state.level, match };
|
||||
}
|
||||
|
||||
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
|
||||
* au même niveau ; pour un match, un nouveau match de même longueur,
|
||||
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
|
||||
* la position de départ. */
|
||||
get rematch(): NewGame {
|
||||
const state = this.state;
|
||||
return state.match === null
|
||||
? { level: state.level }
|
||||
: { level: state.level, match: firstGameOf(state.match.length) };
|
||||
}
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
|
||||
@@ -121,14 +121,14 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
|
||||
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
|
||||
title.textContent = words.title;
|
||||
element(sheet, "p", {
|
||||
id: "explain-phrase",
|
||||
className: "explain-phrase",
|
||||
// Le titre dit déjà le coup du maître : la phrase ne le répète pas.
|
||||
text: words.phrase.startsWith(`${words.title}. `)
|
||||
? words.phrase.slice(words.title.length + 2)
|
||||
: words.phrase,
|
||||
});
|
||||
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
|
||||
// (et disparaît si elle ne disait que lui).
|
||||
const rest = words.phrase.startsWith(`${words.title}.`)
|
||||
? words.phrase.slice(words.title.length + 1).trim()
|
||||
: words.phrase;
|
||||
if (rest !== "") {
|
||||
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
|
||||
}
|
||||
|
||||
const body = element(sheet, "div", { className: "explain-body" });
|
||||
const boardBox = element(body, "div", { className: "explain-board-box" });
|
||||
|
||||
+28
-11
@@ -265,6 +265,7 @@ export function startApp(options: AppOptions): void {
|
||||
panelVeil: byId("panel-veil"),
|
||||
panelText: byId("panel-text"),
|
||||
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
|
||||
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
|
||||
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
|
||||
banner: byId("banner"),
|
||||
bannerText: byId("banner-text"),
|
||||
@@ -338,7 +339,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.again.textContent = TEXTS.newGame;
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -588,7 +589,11 @@ export function startApp(options: AppOptions): void {
|
||||
diceAction: action,
|
||||
canDouble: !busy && canDouble,
|
||||
band:
|
||||
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
|
||||
landscape && !panelShown
|
||||
? text === null
|
||||
? TEXTS.gameOver
|
||||
: bandText(text.short, state.match?.crawford === true)
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
});
|
||||
@@ -848,6 +853,7 @@ export function startApp(options: AppOptions): void {
|
||||
detail.textContent = end.detail;
|
||||
ui.panelSecondary.textContent = TEXTS.reviewGame;
|
||||
ui.panelPrimary.textContent = afterGameLabel(g);
|
||||
ui.panelSettings.hidden = false;
|
||||
ui.panelVeil.dataset.kind = "over";
|
||||
} else if (ask) {
|
||||
overline.textContent = "";
|
||||
@@ -855,6 +861,7 @@ export function startApp(options: AppOptions): void {
|
||||
detail.textContent = "";
|
||||
ui.panelSecondary.textContent = TEXTS.drop;
|
||||
ui.panelPrimary.textContent = TEXTS.take;
|
||||
ui.panelSettings.hidden = true;
|
||||
ui.panelVeil.dataset.kind = "doubled";
|
||||
}
|
||||
ui.panelPrimary.disabled = busy;
|
||||
@@ -1146,14 +1153,11 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
|
||||
/** Après une partie finie : la partie suivante du match s'il continue,
|
||||
* sinon l'écran « Nouvelle partie ». */
|
||||
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
|
||||
* même longueur après un match), tout de suite. */
|
||||
function afterGame(): void {
|
||||
const next = game?.nextGame ?? null;
|
||||
if (next !== null) {
|
||||
newGame(next);
|
||||
} else {
|
||||
closeOverlays();
|
||||
navigation.go(routeHash({ screen: "newGame" }));
|
||||
if (game !== null) {
|
||||
newGame(game.nextGame ?? game.rematch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1444,6 +1448,13 @@ export function startApp(options: AppOptions): void {
|
||||
afterGame();
|
||||
}
|
||||
};
|
||||
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
|
||||
// le panneau se ferme, le résultat reste sur l'écran de jeu.
|
||||
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
|
||||
ui.panelSettings.addEventListener("click", () => {
|
||||
panelDismissed = true;
|
||||
render();
|
||||
});
|
||||
ui.panelSecondary.onclick = () => {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.drop();
|
||||
@@ -2313,10 +2324,16 @@ function matchScore(state: GameState): MatchScore | null {
|
||||
return { length: state.match.length, computer: score.computer, player: score.player };
|
||||
}
|
||||
|
||||
/** Paysage : la consigne de la bande centrale ; pendant la partie
|
||||
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
|
||||
function bandText(prompt: string, crawford: boolean): string {
|
||||
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
|
||||
}
|
||||
|
||||
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
|
||||
* qui continue, sinon « Nouvelle partie » (l'écran des choix). */
|
||||
* qui continue, sinon « Rejouer » (mêmes réglages). */
|
||||
function afterGameLabel(game: Game): string {
|
||||
return game.nextGame === null ? TEXTS.newGame : MATCH_TEXTS.nextGame;
|
||||
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
|
||||
|
||||
@@ -523,6 +523,11 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
|
||||
const width = 6 * g.pointWidth - 24 - before;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12 + before;
|
||||
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
|
||||
// la police se resserre, sans descendre sous 13 px (charte). Largeur
|
||||
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
|
||||
// 0,45 em.
|
||||
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
@@ -538,8 +543,8 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): vo
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
y: middle + fontSize * 0.35,
|
||||
"font-size": fontSize,
|
||||
},
|
||||
text,
|
||||
);
|
||||
|
||||
@@ -64,6 +64,8 @@ export const MATCH_TEXTS = {
|
||||
/** « Match en 5 points », et « · partie Crawford ». */
|
||||
matchLine: (length: number, crawford: boolean) =>
|
||||
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
|
||||
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
|
||||
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
@@ -72,7 +74,11 @@ export const MATCH_TEXTS = {
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie de match
|
||||
// Fin d'une partie
|
||||
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
|
||||
* après un match). */
|
||||
replay: "Rejouer",
|
||||
changeSettings: "Changer les réglages",
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
|
||||
@@ -87,10 +87,14 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
closeLink.append(icon("close"));
|
||||
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
|
||||
|
||||
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
|
||||
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
const kindColumn = element(body, "div", { className: "new-game-column" });
|
||||
const levelColumn = element(body, "div", { className: "new-game-column" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(body, "div", { className: "segmented" });
|
||||
const kinds = element(kindColumn, "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);
|
||||
@@ -98,7 +102,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(body, "div", {
|
||||
const matchOptions = element(kindColumn, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
@@ -119,7 +123,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(body, "div", {
|
||||
const freeOptions = element(kindColumn, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
@@ -132,8 +136,8 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
);
|
||||
|
||||
// 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" });
|
||||
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(levelColumn, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
@@ -144,12 +148,21 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
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" });
|
||||
const levelName = element(levelColumn, "p", {
|
||||
id: "new-game-level-name",
|
||||
className: "level-name",
|
||||
});
|
||||
const levelText = element(levelColumn, "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" });
|
||||
element(levelColumn, "h3", {
|
||||
className: "overline during-game",
|
||||
text: MATCH_TEXTS.duringGame,
|
||||
});
|
||||
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
|
||||
@@ -163,10 +163,27 @@ test("le coup du maître est nommé, et le fait qui distingue les coups vient du
|
||||
assert.deepEqual(worst.bestFacts.madePoints, [5]);
|
||||
const said = masterWords(phrases, worst);
|
||||
assert.equal(said.title, "J'aurais joué 8/5 6/5");
|
||||
assert.equal(
|
||||
said.phrase,
|
||||
`J'aurais joué 8/5 6/5. Votre 24/23 13/10 était le ${worst.rank}e choix sur 16. Mon coup fait votre point 5.`,
|
||||
);
|
||||
assert.equal(said.phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
});
|
||||
|
||||
test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const worst = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "24/23 13/10"),
|
||||
)) as MoveAdvice;
|
||||
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
|
||||
for (const rank of [2, 3, 4, 7, 15]) {
|
||||
const { phrase } = masterWords(phrases, { ...worst, skill, rank });
|
||||
const cited = (skill === "none" || skill === "doubtful") && rank <= 3;
|
||||
if (cited) {
|
||||
assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`);
|
||||
} else {
|
||||
assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`);
|
||||
assert.equal(phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("décisions de videau : la décision du maître et le contexte", async () => {
|
||||
|
||||
@@ -506,6 +506,12 @@ test("une partie de match finie : le moteur dit le score, la fin du match et la
|
||||
crawford: false,
|
||||
});
|
||||
assert.equal(crawford.nextGame, null);
|
||||
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
|
||||
// 0, au même niveau.
|
||||
assert.deepEqual(crawford.rematch, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("passer un double dans un match : le moteur compte le match", async () => {
|
||||
@@ -538,6 +544,11 @@ test("une partie libre n'a ni match ni partie suivante", () => {
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
|
||||
assert.deepEqual(game.rematch, { level: "0ply" });
|
||||
});
|
||||
|
||||
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
|
||||
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
|
||||
// sans level ni match, un coup joué, une branche.
|
||||
|
||||
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
|
||||
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
@@ -17,6 +18,7 @@ import { createGameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
@@ -167,6 +169,63 @@ Then("la partie Crawford s'est jouée sans videau", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Rejouer
|
||||
|
||||
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
|
||||
function engineLevelNamed(name: string) {
|
||||
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
|
||||
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
|
||||
return ENGINE_LEVELS[level];
|
||||
}
|
||||
|
||||
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.phase, "over");
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
Given(
|
||||
"un match en {int} points contre un ordinateur {word} vient de se terminer",
|
||||
async (n: number, name: string) => {
|
||||
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
|
||||
// fini.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.matchAfter?.over, true);
|
||||
assert.equal(game.nextGame, null);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous rejouez", () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
|
||||
});
|
||||
|
||||
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
});
|
||||
|
||||
Then(
|
||||
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
|
||||
(n: number, name: string) => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.deepEqual(game.state.match, firstGameOf(n));
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- La partie Crawford
|
||||
|
||||
let crawfordGame: Game;
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
|
||||
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
|
||||
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
|
||||
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
|
||||
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
|
||||
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
|
||||
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
|
||||
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
|
||||
|
||||
## Comportement
|
||||
|
||||
|
||||
+22
-5
@@ -33,8 +33,7 @@ mots entre accolades sont remplis avec les données du moteur :
|
||||
| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») |
|
||||
| `{played}` | votre coup, même notation |
|
||||
| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) |
|
||||
| `{rank}` | le rang de votre coup dans le classement du moteur (2, 3…) |
|
||||
| `{count}` | le nombre de coups possibles avec ce jet |
|
||||
| `{rank}` | le rang de votre coup dans le classement du moteur (2 ou 3 seulement) |
|
||||
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
|
||||
| `{roll}` | le jet, « 3-1 » |
|
||||
| `{hits}` | le nombre de pions frappés, en chiffres |
|
||||
@@ -77,19 +76,37 @@ Tout ce qui précède la première section `## mot.…` n'est pas lu.
|
||||
|
||||
## coup.proche
|
||||
|
||||
« Bon coup » sans être le meilleur, votre coup étant le 2e ou le 3e choix du
|
||||
moteur. Le rang n'est cité qu'ici et dans coup.douteux : il mesure mal la
|
||||
gravité d'une erreur (un 2e choix peut être loin du premier), et le
|
||||
classement complet est dans « Voir les chiffres du moteur ».
|
||||
|
||||
- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix.
|
||||
|
||||
## coup.douteux
|
||||
|
||||
Imprécision, votre coup étant le 2e ou le 3e choix.
|
||||
|
||||
- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près.
|
||||
|
||||
## coup.loin
|
||||
|
||||
« Bon coup » non meilleur ou imprécision, votre coup étant au-delà du 3e
|
||||
choix : le rang n'est pas cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.mauvais
|
||||
|
||||
- J'aurais joué {best}. Votre {played} était le {rank}e choix.
|
||||
Erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.tres-mauvais
|
||||
|
||||
- J'aurais joué {best}. Votre {played} était le {rank}e choix sur {count}.
|
||||
Grosse erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## fait.frappe
|
||||
|
||||
@@ -109,7 +126,7 @@ Tout ce qui précède la première section `## mot.…` n'est pas lu.
|
||||
|
||||
## fait.maitre-frappe
|
||||
|
||||
- Mon coup frappe un pion, le vôtre non.
|
||||
- Mon coup frappe un pion.
|
||||
|
||||
## fait.maitre-point
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Conseil du maître (1d)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1d (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1c)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
|
||||
après chaque coup et chaque décision de videau du joueur, jamais avant
|
||||
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
|
||||
du moteur.
|
||||
|
||||
## Références
|
||||
|
||||
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
|
||||
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
|
||||
de la révision UX.
|
||||
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
|
||||
(gnubg : 0,03 / 0,06 / 0,12).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
|
||||
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
|
||||
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
|
||||
le 2026-11-09 : la section « Les phrases du maître » de
|
||||
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
|
||||
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
|
||||
exclamation, formules qui tournent pour les bons coups, exemples). Un test
|
||||
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
|
||||
phrases livrées passent par la session « identité » puis par Pierre pour
|
||||
la relecture finale.
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
|
||||
désactivé, décision de videau commentée) avec positions et dés imposés ;
|
||||
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
|
||||
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
|
||||
recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après une erreur volontaire dans une position connue, la ligne du maître dit
|
||||
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
|
||||
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,8 +1,8 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Choisir sa partie et jouer un match
|
||||
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et la fin
|
||||
d'une partie. Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
|
||||
@@ -25,7 +25,8 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
|
||||
valeur du videau), et le premier qui atteint le nombre de points du
|
||||
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
|
||||
suivante, avec le score du match.
|
||||
suivante, avec le score du match. Le match fini, « Rejouer » en commence
|
||||
un autre de même longueur.
|
||||
|
||||
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
|
||||
Quand le match se joue jusqu'au bout, partie après partie
|
||||
@@ -33,6 +34,20 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
Et chaque partie commence avec le score laissé par la précédente
|
||||
Et la partie Crawford s'est jouée sans videau
|
||||
|
||||
Scénario: Comment rejouer avec les mêmes réglages ?
|
||||
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
|
||||
avec les mêmes réglages : le même niveau, et après un match, un nouveau
|
||||
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
|
||||
« Partie suivante » qui le continue. Pour changer de niveau ou de type
|
||||
de partie, touchez « Changer les réglages », sous les boutons.
|
||||
|
||||
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors une partie libre commence, contre un ordinateur Expert
|
||||
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
|
||||
|
||||
Scénario: Qu'est-ce que la partie Crawford ?
|
||||
Dans un match, quand un joueur arrive à un point de la victoire, la
|
||||
partie suivante se joue sans videau : personne ne peut doubler. C'est la
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/maitre.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
|
||||
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
|
||||
// de production (docs/plans/2026-11-03-conseil-du-maitre.md).
|
||||
// de production (étape 1d, docs/etape-1.md ; charte, ecrans-etape-1.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
+92
-12
@@ -13,10 +13,13 @@
|
||||
// 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.
|
||||
// partie » (aussi en 844×320), score du match et partie Crawford, en
|
||||
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
|
||||
// caché sous la barre d'action collée (recette.mjs).
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -29,6 +32,7 @@ const dir = `${shots}/match`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const SMALL_LANDSCAPE = { width: 844, height: 320 };
|
||||
// La graine des dés : un match qui passe par une partie Crawford.
|
||||
const SEED = 7;
|
||||
|
||||
@@ -83,14 +87,18 @@ async function until(what, fn, arg, ms = 60_000) {
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
/** Une capture de la recette, dans chaque taille (Chromium seulement),
|
||||
* après la règle commune : rien d'interactif sous la barre d'action. */
|
||||
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
for (const size of sizes) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
@@ -189,7 +197,45 @@ check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire
|
||||
"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");
|
||||
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
|
||||
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
|
||||
// conseil du maître à droite.
|
||||
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const where = `${size.width}x${size.height}`;
|
||||
const layout = await page.evaluate(() => {
|
||||
const box = (selector) => {
|
||||
const r = document.querySelector(selector).getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
|
||||
};
|
||||
return {
|
||||
scroll: document.scrollingElement.scrollHeight,
|
||||
height: innerHeight,
|
||||
bar: box(".new-game-actions"),
|
||||
kinds: box(".segmented"),
|
||||
lengths: box(".lengths"),
|
||||
rule: box("#new-game-crawford"),
|
||||
bars: box(".level-bars"),
|
||||
name: box("#new-game-level-name"),
|
||||
text: box("#new-game-level-text"),
|
||||
advice: box("#new-game-advice"),
|
||||
};
|
||||
});
|
||||
check(layout.scroll <= layout.height + 1,
|
||||
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
|
||||
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
|
||||
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
|
||||
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
for (const part of ["bars", "name", "text", "advice"]) {
|
||||
check(layout[part].left >= layout.kinds.right,
|
||||
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
|
||||
// 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");
|
||||
@@ -240,7 +286,13 @@ for (;;) {
|
||||
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") {
|
||||
const settingsLink = await page.evaluate(() => {
|
||||
const a = document.getElementById("panel-settings");
|
||||
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
|
||||
});
|
||||
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
|
||||
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
|
||||
if (primary === "Rejouer") {
|
||||
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) ?? [];
|
||||
@@ -254,20 +306,30 @@ for (;;) {
|
||||
`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");
|
||||
// Après le match, « Changer les réglages » mène à l'écran des
|
||||
// choix ; le fermer ramène au résultat, où « Rejouer » relance
|
||||
// tout de suite un match de même longueur.
|
||||
await page.click("#panel-settings");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie");
|
||||
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
|
||||
await page.click("#new-game-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
|
||||
`« Rejouer » après le match : ${JSON.stringify(again)}`);
|
||||
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),
|
||||
@@ -281,6 +343,24 @@ for (;;) {
|
||||
check(next.cube === 0, "partie Crawford : le videau est dessiné");
|
||||
if (!crawfordShot) {
|
||||
crawfordShot = true;
|
||||
check(next.line === "Match en 3 points · partie Crawford",
|
||||
`partie Crawford, portrait : « ${next.line} »`);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const band = await page.evaluate(() => {
|
||||
const text = document.getElementById("band-text");
|
||||
const pill = document.querySelector("#board .band-pill");
|
||||
return {
|
||||
text: text?.textContent ?? "",
|
||||
fits: text !== null && pill !== null &&
|
||||
text.getBBox().width <= pill.getBBox().width - 16,
|
||||
};
|
||||
});
|
||||
check(band.text.startsWith("Partie Crawford · "),
|
||||
`partie Crawford, paysage : « ${band.text} »`);
|
||||
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("3-partie-crawford");
|
||||
}
|
||||
} else if (!scoreShot) {
|
||||
|
||||
+25
-10
@@ -21,6 +21,7 @@
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const wrapped = await wrappedButtons(page);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -809,6 +812,8 @@ async function checkAddresses() {
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(tab);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
if (name === "question" || name === "terme") {
|
||||
// La barre haute de l'aide reste en vue, et ce qu'on a
|
||||
// ouvert s'arrête juste sous elle.
|
||||
@@ -996,6 +1001,15 @@ async function startChosenGame() {
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
|
||||
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
|
||||
* partie ». */
|
||||
async function rematch() {
|
||||
await page.click("#again");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Rejouer » ouvre un autre écran que 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() {
|
||||
@@ -1525,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
|
||||
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
|
||||
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
|
||||
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
|
||||
(await page.textContent("#panel-primary")) === "Nouvelle partie",
|
||||
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
|
||||
(await page.textContent("#panel-primary")) === "Rejouer" &&
|
||||
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
|
||||
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
|
||||
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
|
||||
await review("6-fin-de-partie");
|
||||
await page.setViewportSize({ width: 844, height: 320 });
|
||||
await sleep(150);
|
||||
@@ -1557,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
|
||||
log(`vibrations demandées : ${(await vibrations()).length}`);
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
|
||||
// barre d'actions.
|
||||
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
|
||||
// d'actions.
|
||||
await page.click("#panel-secondary");
|
||||
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
step("mes parties");
|
||||
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
|
||||
@@ -1569,8 +1586,7 @@ step("mes parties");
|
||||
// recharger la page.
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await startChosenGame();
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1651,8 +1667,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 rematch();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// recette.mjs -- règles de la recette design communes aux tests dans un
|
||||
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
|
||||
// capturé.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Les éléments interactifs visibles cachés, même en partie, sous une barre
|
||||
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
|
||||
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
|
||||
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
|
||||
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
|
||||
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
|
||||
* vraiment, au point du recouvrement.
|
||||
*
|
||||
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
|
||||
*/
|
||||
export async function hiddenUnderActionBar(target) {
|
||||
return target.evaluate(() => {
|
||||
const shown = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const bars = [...document.querySelectorAll("body *")].filter((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
|
||||
});
|
||||
const describe = (el) =>
|
||||
el.id
|
||||
? `#${el.id}`
|
||||
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
|
||||
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
|
||||
} »`;
|
||||
const hidden = [];
|
||||
const interactive = document.querySelectorAll(
|
||||
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
for (const bar of bars) {
|
||||
const b = bar.getBoundingClientRect();
|
||||
for (const el of interactive) {
|
||||
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top >= innerHeight) {
|
||||
continue;
|
||||
}
|
||||
const top = Math.max(r.top, b.top);
|
||||
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
|
||||
const left = Math.max(r.left, b.left);
|
||||
const right = Math.min(r.right, b.right);
|
||||
if (bottom - top <= 1 || right - left <= 1) {
|
||||
continue;
|
||||
}
|
||||
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
if (hit !== null && bar.contains(hit)) {
|
||||
hidden.push(
|
||||
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
});
|
||||
}
|
||||
+122
@@ -835,6 +835,17 @@ button:disabled {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Fin de partie : « Changer les réglages », lien secondaire sous les
|
||||
* boutons. */
|
||||
.panel-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
|
||||
* barre de temps. */
|
||||
.banner {
|
||||
@@ -2339,6 +2350,14 @@ html.landscape .welcome {
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
|
||||
.new-game-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-game-body .overline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
@@ -2505,6 +2524,109 @@ html.landscape .welcome {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* En paysage (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
|
||||
* deux colonnes au-dessus de la barre d'action, le type de partie à
|
||||
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
|
||||
* et la barre d'action tient sur une ligne, le bouton à droite sous le
|
||||
* pouce. */
|
||||
html.landscape .new-game {
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head h2 {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
align-content: start;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .overline {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .during-game {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 48px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 10px; }
|
||||
html.landscape .level-2 .level-bar { height: 17px; }
|
||||
html.landscape .level-3 .level-bar { height: 24px; }
|
||||
html.landscape .level-4 .level-bar { height: 30px; }
|
||||
html.landscape .level-5 .level-bar { height: 36px; }
|
||||
html.landscape .level-6 .level-bar { height: 42px; }
|
||||
|
||||
html.landscape .level-name {
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .level-text {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item-label {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item small {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-actions {
|
||||
flex-direction: row;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-kept {
|
||||
order: -1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
|
||||
@media (min-height: 380px) {
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 14px; }
|
||||
html.landscape .level-2 .level-bar { height: 22px; }
|
||||
html.landscape .level-3 .level-bar { height: 30px; }
|
||||
html.landscape .level-4 .level-bar { height: 39px; }
|
||||
html.landscape .level-5 .level-bar { height: 48px; }
|
||||
html.landscape .level-6 .level-bar { height: 58px; }
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
<button id="panel-secondary" type="button" class="secondary"></button>
|
||||
<button id="panel-primary" type="button"></button>
|
||||
</div>
|
||||
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user