Compare commits

..
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 fb161789bf Liens de notation : pointillé discret, zone d'appui agrandie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 e20f215030 Fusion de main (1f, prompts à copier) dans nouvelle-partie-directe
Conflits résolus en gardant les deux apports : la feuille du prompt et la
correction de reviewFinishedGame dans app.ts ; prompts et nouvelle-partie
dans tests/browser.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 abff843519 Plan de la nouvelle partie directe clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:39:47 +01:00
Pierre MARTINandClaude Opus 5.5 05bd06c946 Fusion de la MR !56 : demander à une IA, prompts à copier (1f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:28:40 +01:00
Pierre MARTINandClaude Opus 5.5 4050517904 Glossaire : fiche « Notation des coups », touchable depuis la partie
La fiche explique 13/7, 24/22*, 6/off, bar/22 et 8/4(2), dans la notation
de gnubg. Le type MoveNotation la relie au code. Le coup de la ligne
« L'ordinateur a joué » et celui de la feuille du maître mènent à la fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 05d2479bd3 Écran « Nouvelle partie » : « Commencer la partie » ou « Commencer le match »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 5da3baecde Nouvelle partie : plus de retour au bilan de l'ancienne partie
Le passage différé au bilan (reviewFinishedGame) ne vérifiait pas que la
partie finie était encore à l'écran : « Commencer » redessinait d'abord
l'ancienne partie finie (adresse #/), puis lançait la nouvelle, et la
microtâche ouvrait le bilan de l'ancienne par-dessus. Le bilan ne s'ouvre
plus que pour la partie finie encore affichée, et « Commencer » lance la
partie avant de changer d'adresse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
pierre 65ee563794 Fusion de la MR !55 : pions blancs et noirs 2026-11-11 21:14:56 +00:00
Pierre MARTINandClaude Opus 5.5 145c19ac77 Identité : pions blancs et noirs avec contour (ADR 0010 amendé, choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:54 +01:00
Pierre MARTINandClaude Opus 5.5 0bb41524bf Test navigateur : nouvelle partie après une partie finie et son bilan
Reproduit le défaut signalé : après une partie finie, l'application
rouverte, « Commencer » de l'écran « Nouvelle partie » ramenait au bilan
de l'ancienne partie. Vérifie l'accueil, le menu et « Changer les réglages ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:46 +01:00
pierre 6a1ee6f65a Fusion de la MR !54 : retours des premiers joueurs 2026-11-11 21:00:44 +00:00
Pierre MARTINandClaude Opus 5.5 22e415bb81 Charte : retours des premiers joueurs (pions sombres lisibles, numéros sur le cadre, jans, flèches circulaires)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:00:42 +01:00
Pierre MARTIN 850d860658 Merge remote-tracking branch 'origin/main' into prompts 2026-11-11 21:54:32 +01:00
Pierre MARTINandClaude Opus 5.5 d5f08fe393 Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton
Recette de 1f (F1, F2). Depuis la feuille du maître ou le bilan, la page
du prompt prend la place de leur contenu, à la même hauteur, avec
« ‹ Explication » ou « ‹ Bilan » pour revenir ; depuis l'analyse et le
glossaire, elle monte dans sa propre feuille. La croix et « Fermer »
ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à
« ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix,
un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et
amendement de l'ADR 0013 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:32 +01:00
Pierre MARTINandClaude Opus 5.5 62ecff245f Plan : nouvelle partie directe, libellé « Commencer », notation des coups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:18 +01:00
Pierre MARTINandClaude Opus 5.5 21baddcea3 Fusion de la MR !53 : corrections, saisie de la sortie, largeur du tutoriel, notation du coup de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:20 +01:00
Pierre MARTINandClaude Opus 5.5 7a6c0ac2ac Aide : texte de « Sortir ses pions dans l'ordre de son choix » choisi par Pierre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:14 +01:00
Pierre MARTINandClaude Opus 5.5 90f835869a Plan des corrections clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:24:22 +01:00
Pierre MARTINandClaude Opus 5.5 ce36d738fb Saisie : plus de demi-coup demandé une fois tous les dés joués
Après un double complet, la saisie restée ouverte demandait au moteur un
cinquième demi-coup, que la ligne de commande refuse (erreur dans la page,
relevée par make test-browser). Test : un moteur qui refuse plus de
demi-coups que de dés.

Coup de l'ordinateur : la recette du navigateur vérifie la ligne
« L'ordinateur a joué … » au premier coup montré.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:05:14 +01:00
Pierre MARTINandClaude Opus 5.5 fbf317909a Scénario de sortie : mise en forme Biome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:44:36 +01:00
Pierre MARTINandClaude Opus 5.5 b2263c7e3d Tutoriel : même largeur à chaque étape ; règle ajoutée à la recette
Les pages sont des éléments de la colonne flexible #screen ; leurs marges
automatiques annulaient l'étirement, et une page au contenu court se
réduisait à la largeur de ce contenu : l'étape 6 « à vous » (consigne
courte, pas d'explication) avait un plateau de 300 px au lieu de 396 en
412 × 915. Les pages prennent toute la largeur, bornée par max-width.
La recette mesure le plateau et le texte à chaque écran du tutoriel, dans
les trois tailles, et refuse un plateau de largeur différente ou une page
plus large que l'écran (mesures dans tutoriel-largeurs.txt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:42:41 +01:00
Pierre MARTINandClaude Opus 5.5 4e74bab3ac Écran de jeu : le coup de l'ordinateur dans la notation du moteur
« L'ordinateur a joué 6-4 : 6/off » au lieu de « 6/2 2/off » : la
notation vient du coup légal de gnubg qui mène à la même position
(Game.moveNotation), comme dans l'analyse et le bilan. Testé : sur une
partie analysée, chaque coup s'écrit pareil des deux côtés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:46 +01:00
Pierre MARTINandClaude Opus 5.5 ab8dc512ba Sortie : un coup déjà complet peut encore se prolonger (6/off puis 1/off avec 6-1)
La partie fermait la saisie dès que les demi-coups formaient un coup
légal. En sortie, 6/off seul mène à la même position que 6/5/off : le
1/off qui suivait était refusé. La saisie reste ouverte tant que le
moteur accepte un demi-coup de plus ; le tutoriel suit la même règle.
Tests : tous les ordres de saisie des fins de partie d'un à quatre pions,
sur les 21 jets, scénario Gherkin et tests du moteur pour les deux ordres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:36:15 +01:00
Pierre MARTINandClaude Opus 5.5 e9bb9ae88d Plan des corrections : sortie, largeur du tutoriel, notation de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:12:29 +01:00
38 changed files with 1524 additions and 291 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
@@ -29,7 +29,7 @@ export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
+10 -1
View File
@@ -281,6 +281,15 @@ export interface EngineMatchAfter {
readonly crawford: boolean;
}
/**
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
* fait que l'afficher.
*
* Glossaire : docs/glossaire/notation-des-coups.md
*/
export type MoveNotation = string;
/**
* Un coup légal complet.
*
@@ -288,7 +297,7 @@ export interface EngineMatchAfter {
*/
export interface Move {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
/** Ses demi-coups, dans un ordre jouable. */
readonly halfMoves: readonly HalfMove[];
/** La position après le coup, vue du joueur qui vient de jouer. */
+61 -10
View File
@@ -12,7 +12,15 @@
import type { Advice } from "./advice.js";
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
import type {
BoardSide,
Engine,
EngineCube,
Level,
MoveNotation,
Ply,
ResultKind,
} from "./engine.js";
import {
appendEvent,
currentLine,
@@ -27,7 +35,14 @@ import {
withAnalysesOf,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import {
BAR,
formatHalfMoves,
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";
import { type GameState, replay } from "./state.js";
@@ -186,8 +201,11 @@ export class Game {
}
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
* n'attend plus que d'être validé (confirmMove) ou défait
* (undoLastHalfMove). */
* peut être validé (confirmMove) ou défait (undoLastHalfMove). La saisie
* n'est pas close pour autant : en sortie, un coup complet peut encore
* se prolonger en un autre coup légal (avec 6-1 et des pions sur 6 et 1,
* 6/off seul mène à la même position que 6/5/off, et 1/off reste
* possible) ; nextHalfMoves dit lesquels, selon le moteur. */
get moveComplete(): boolean {
return this.#pending.at(-1)?.complete === true;
}
@@ -273,6 +291,29 @@ export class Game {
return { side, halfMoves, boards, hits };
}
/**
* Le coup joué à l'événement eventId (un MovePlayed), dans la notation
* du moteur : celle du coup légal de gnubg qui mène à la même position
* (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan.
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
*/
async moveNotation(eventId: string): Promise<MoveNotation> {
const line = currentLine({ ...this.#journal, head: eventId });
const event = line.at(-1);
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
return "";
}
const before = replay(line.slice(0, -1));
const roll = rollToPlay(before);
const [check, legal] = await Promise.all([
this.#engine.checkHalfMoves(before.position, roll, event.halfMoves),
this.#engine.legalMoves(before.position, roll),
]);
const move = legal.find((m) => m.position === check.position);
return move?.notation ?? formatHalfMoves(event.halfMoves);
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
@@ -296,11 +337,14 @@ export class Game {
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
* un coup légal. Aucune règle n'est jugée ici.
* un coup légal. Aucune règle n'est jugée ici : même après un coup
* complet, c'est le moteur qui dit si un dé reste à jouer. Seule
* exception : une fois tous les dés utilisés (diceLeft), rien n'est
* demandé, le moteur n'acceptant pas plus de demi-coups que de dés.
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
return [];
}
const roll = rollToPlay(state);
@@ -370,13 +414,13 @@ export class Game {
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
* complet (moveComplete), il attend d'être validé (confirmMove).
* mène à aucun coup légal : il est refusé et rien ne change. Le coup
* n'est pas joué pour autant : une fois complet (moveComplete), il attend
* d'être validé (confirmMove), ou prolongé si le moteur le permet.
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
if (this.moveComplete) {
if (!this.#diceLeft(state)) {
return false;
}
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
@@ -393,6 +437,13 @@ export class Game {
return true;
}
/** Il reste un dé à jouer : moins de demi-coups saisis que de dés
* (quatre pour un double). */
#diceLeft(state: GameState): boolean {
const roll = rollToPlay(state);
return this.#pending.length < (roll[0] === roll[1] ? 4 : 2);
}
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
* pas) : il n'a jamais été dans le journal. */
undoLastHalfMove(): void {
+7 -3
View File
@@ -209,10 +209,14 @@ export class MovePractice {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
if (
this.#game.moveComplete &&
this.#expected.some((m) => m.position === this.#game.displayedPosition)
) {
return "right";
}
// Un coup complet peut encore se prolonger (en sortie) : il n'est
// refusé que s'il ne peut plus mener à un coup attendu.
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
+11 -6
View File
@@ -18,9 +18,10 @@ import {
} from "../domain/game/advice.js";
import type { Game } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { askAiLink } from "./ask-ai.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { showWithNotation } from "./notation.js";
import { routeHash } from "./router.js";
/** Préfixe des identifiants du plateau de la feuille. */
@@ -48,6 +49,8 @@ export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
/** La feuille, où la page du prompt prend la place du contenu (1f). */
readonly sheet: HTMLElement;
}
type Shown = "mine" | "best";
@@ -92,7 +95,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
clearSheet(sheet);
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
@@ -201,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const svg = sheetBoard(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
svg.setAttribute("aria-label", text);
showWithNotation(legend, text, advice.played.notation);
void player?.play(mine);
return;
}
@@ -231,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
showWithNotation(legend, text, notation);
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
void player?.play(shown === "mine" ? mine : best);
@@ -332,12 +336,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
return {
show,
sheet,
hide() {
opened++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
clearSheet(sheet);
}
},
};
+81 -22
View File
@@ -36,9 +36,10 @@ import {
} from "../domain/analysis/phrases.js";
import type { Advice } from "../domain/game/advice.js";
import type { Cube } from "../domain/game/cube.js";
import type { MoveNotation } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import 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";
@@ -84,6 +85,7 @@ import { installPlatform, startInstaller } from "./install.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { replacingLink, startNavigation } from "./navigation.js";
import { createNewGameView } from "./new-game-view.js";
import { showWithNotation } from "./notation.js";
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
import {
@@ -246,7 +248,6 @@ export function startApp(options: AppOptions): void {
explainVeil: byId("explain-veil"),
summaryVeil: byId("summary-veil"),
promptVeil: byId("prompt-veil"),
promptBanner: byId("prompt-banner"),
analysis: byId("analysis"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
@@ -444,6 +445,10 @@ export function startApp(options: AppOptions): void {
let stuck = false;
let busy = true;
let info: string = TEXTS.loading;
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
// « Notation des coups ».
let computerLine: ComputerTurnLine | null = null;
let engineFailed = false;
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
// la prochaine action du joueur ; null : rien à montrer.
@@ -647,7 +652,11 @@ export function startApp(options: AppOptions): void {
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
} else {
ui.info.textContent = info;
showWithNotation(
ui.info,
info,
computerLine?.text === info ? computerLine.notation : null,
);
}
ui.prompt.textContent = panelShown
? ""
@@ -1038,7 +1047,8 @@ export function startApp(options: AppOptions): void {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
await game.playComputerTurn();
info = describeComputerTurn(game);
computerLine = await describeComputerTurn(game);
info = computerLine.text;
save();
played = true;
}
@@ -1319,7 +1329,9 @@ export function startApp(options: AppOptions): void {
// ------------------------------------------- Demander à une IA (1f)
// La feuille du prompt, à l'adresse …/ia, au-dessus de l'écran d'où on le demande.
// La feuille du prompt, à l'adresse …/ia : à la place de la feuille du
// maître ou du bilan, ou dans sa propre feuille au-dessus de l'analyse et
// du glossaire. Une seule feuille à la fois.
const promptSource = createPromptSource({
access: options.prompts,
phrases: () => phrasesLoading,
@@ -1327,13 +1339,14 @@ export function startApp(options: AppOptions): void {
});
const promptView = createPromptView({
veil: ui.promptVeil,
banner: ui.promptBanner,
close: () => closePrompt(),
back: () => closePrompt(),
close: () => closePromptSheet(),
});
// L'adresse de la feuille ouverte (null : fermée).
let promptShown: string | null = null;
/** Ferme la feuille du prompt : retour à l'écran d'où on l'a ouverte. */
/** « ‹ Explication », Échap ou le retour du téléphone : retour à l'écran
* d'où on l'a ouverte. */
function closePrompt(): void {
const route = navigation.route;
promptView.hide();
@@ -1341,10 +1354,33 @@ export function startApp(options: AppOptions): void {
navigation.leave(isAiRoute, isAiRoute(route) ? routeHash(withoutAi(route)) : "#/");
}
/** La croix et « Fermer » : la feuille se ferme. Celle du maître ou le
* bilan, dont elle avait pris la place, se ferme avec elle. */
function closePromptSheet(): void {
const route = navigation.route;
if (route.screen === "explanation" || route.screen === "summary") {
promptView.hide();
promptShown = null;
leaveSheet();
return;
}
closePrompt();
}
/** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */
function showPrompt(route: AiRoute): void {
promptShown = routeHash(route);
promptView.show(promptOf(route));
const host =
route.screen === "explanation"
? adviceView.sheet
: route.screen === "summary"
? summaryView.panel
: null;
promptView.show(promptOf(route), {
host,
back: ASK_AI_TEXTS.back[route.screen],
backHref: routeHash(withoutAi(route)),
});
}
/** Le journal de la partie id, pour son bilan ou son analyse : celui de
@@ -1504,25 +1540,37 @@ export function startApp(options: AppOptions): void {
* La partie à l'écran vient de finir : son analyse part en
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
* qu'il est prêt (charte 1e), si le panneau est toujours là.
*
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
* signalé le 2026-11-11).
*/
function reviewFinishedGame(): void {
const id = options.games.shownId();
if (game === null || id === null || busy || game.state.phase !== "over") {
const finished = game;
if (finished === null || id === null || busy || finished.state.phase !== "over") {
return;
}
// L'analyse d'une autre partie, regardée, passe d'abord.
const running = reviewRunner.running;
if (running === null || running === id) {
review(id, game.journal);
review(id, finished.journal);
}
if (
navigation.route.screen === "game" &&
!panelDismissed &&
phrases !== null &&
readyForSummary(game.journal)
readyForSummary(finished.journal)
) {
queueMicrotask(() => {
if (navigation.route.screen === "game" && !panelDismissed) {
if (
game === finished &&
options.games.shownId() === id &&
navigation.route.screen === "game" &&
!panelDismissed
) {
navigation.go(routeHash({ screen: "summary", game: id }));
}
});
@@ -1990,8 +2038,10 @@ export function startApp(options: AppOptions): void {
welcomed: () => saveSettings(welcomed()),
});
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
// celle de la partie (le retour du téléphone ne le rouvre pas).
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
// remplace son adresse par celle de la partie (le retour du téléphone
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
// jamais avec l'ancienne partie, finie, et son panneau de fin.
const newGameView = createNewGameView({
section: ui.newGameSection,
settings: () => settings,
@@ -2000,8 +2050,8 @@ export function startApp(options: AppOptions): void {
if (busy || game === null) {
return;
}
navigation.replace("#/");
newGame(chosenGame(settings));
navigation.replace("#/");
},
});
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
@@ -2636,21 +2686,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
return game.lastMoveSteps();
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */
function describeComputerTurn(game: Game): string {
/** La ligne d'information après le tour de l'ordinateur, et le coup
* qu'elle cite (null s'il n'a pas joué de coup). */
interface ComputerTurnLine {
readonly text: string;
readonly notation: MoveNotation | null;
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
return { text: "", notation: null };
}
const played = game.events.findLast((e) => e.type === "MovePlayed");
if (played?.type !== "MovePlayed") {
return "";
return { text: "", notation: null };
}
const dice = rollBefore(game);
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
return { text: TEXTS.computerCannotMove(dice), notation: null };
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
const notation = await game.moveNotation(played.id);
return { text: TEXTS.computerMoved(dice, notation), notation };
}
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
+8 -1
View File
@@ -10,11 +10,18 @@ export const ASK_AI_TEXTS = {
lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.",
textLabel: "Le prompt, à relire avant de le copier",
copy: "Copier le prompt",
copied: "Prompt copié",
copied: "Copié",
notCopied:
"Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.",
preparing: "Préparation du prompt…",
close: "Fermer",
/** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */
back: {
explanation: "Explication",
summary: "Bilan",
analysis: "Analyse",
glossary: "Glossaire",
},
failed: "Le prompt n'a pas pu être préparé.",
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
+125 -61
View File
@@ -1,9 +1,15 @@
// « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») :
// le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt
// (…/ia, ADR 0013), où le texte se relit avant d'être copié. La copie passe
// par navigator.clipboard ; à défaut, le texte est sélectionné dans la
// feuille et copié par le navigateur, ou laissé sélectionné pour être
// copié à la main. Le bandeau « Prompt copié » le confirme.
// (…/ia, ADR 0013), où le texte se relit avant d'être copié.
//
// Une seule feuille à la fois (recette de 1f) : ouverte depuis la feuille du
// maître ou le bilan, la page du prompt prend la place de leur contenu, à la
// même hauteur, avec « ‹ Explication » (ou « ‹ Bilan ») pour y revenir ;
// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte
// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut,
// le texte est sélectionné dans la feuille et copié par le navigateur, ou
// laissé sélectionné pour être copié à la main. Le bouton la confirme
// (« ✓ Copié », 2 s).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -13,12 +19,15 @@
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import { icon } from "./icons.js";
/** Durée du bandeau « Prompt copié » (charte : 2 s). */
/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */
export const COPIED_MS = 2000;
/** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */
export type PromptResult = { readonly text: string } | { readonly message: string };
/** Classe de la page du prompt, posée dans une autre feuille. */
const PAGE_CLASS = "prompt-page";
/** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */
export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
@@ -30,18 +39,36 @@ export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAn
return link;
}
/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */
export function clearSheet(sheet: HTMLElement): void {
for (const child of [...sheet.children]) {
if (!child.classList.contains(PAGE_CLASS)) {
child.remove();
}
}
}
/** D'où l'on vient : la feuille dont la page prend la place (null : sa
* propre feuille) et le nom de l'écran, pour « ‹ Explication ». */
export interface PromptPlace {
readonly host: HTMLElement | null;
readonly back: string;
/** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */
readonly backHref: string;
}
export interface PromptViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
/** Le voile de sa propre feuille (index.html) ; elle est construite ici. */
readonly veil: HTMLElement;
/** Le bandeau « Prompt copié » (index.html). */
readonly banner: HTMLElement;
/** Fermer : quitter l'adresse de la feuille. */
/** « ‹ Explication » : revenir à l'écran d'où l'on vient. */
readonly back: () => void;
/** La croix et « Fermer » : fermer la feuille. */
readonly close: () => void;
}
export interface PromptView {
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
show(prompt: Promise<PromptResult>): void;
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
hide(): void;
readonly isOpen: boolean;
/** Le prompt est affiché (pas un message). */
@@ -68,26 +95,42 @@ function element<K extends keyof HTMLElementTagNameMap>(
}
export function createPromptView(options: PromptViewOptions): PromptView {
const { veil, banner } = options;
const { veil } = options;
// Sa propre feuille, pour l'analyse et le glossaire.
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "prompt-title");
const head = element(sheet, "div", { className: "sheet-head" });
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient.
const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS });
page.setAttribute("role", "dialog");
page.setAttribute("aria-modal", "true");
page.setAttribute("aria-labelledby", "prompt-title");
const head = element(page, "div", { className: "sheet-head prompt-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
element(head, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
const back = element(head, "a", { id: "prompt-back", className: "prompt-back" });
back.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
options.back();
});
const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" });
closeIcon.type = "button";
closeIcon.append(icon("close"));
closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close);
closeIcon.onclick = () => options.close();
element(sheet, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(sheet, "pre", { id: "prompt-text", className: "prompt-text" });
element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" });
text.tabIndex = 0;
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
const status = element(sheet, "p", { id: "prompt-status", className: "prompt-status" });
const status = element(page, "p", { id: "prompt-status", className: "prompt-status" });
status.setAttribute("role", "status");
const actions = element(sheet, "div", { className: "prompt-actions" });
const actions = element(page, "div", { className: "prompt-actions" });
const done = element(actions, "button", {
id: "prompt-done",
className: "secondary",
@@ -97,43 +140,53 @@ export function createPromptView(options: PromptViewOptions): PromptView {
done.onclick = () => options.close();
const copy = element(actions, "button", { id: "prompt-copy" });
copy.type = "button";
copy.append(icon("copy"), ASK_AI_TEXTS.copy);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
const bannerText = element(banner, "span", { text: ASK_AI_TEXTS.copied });
banner.prepend(icon("verdictGood"));
const timer = element(banner, "span", { className: "banner-timer prompt-timer" });
timer.setAttribute("aria-hidden", "true");
let bannerTimeout: ReturnType<typeof setTimeout> | null = null;
// Le libellé du bouton est annoncé quand il passe à « Copié ».
const copyLabel = document.createElement("span");
copyLabel.setAttribute("aria-live", "polite");
// Chaque ouverture a son numéro : un prompt qui arrive après une autre
// ouverture, ou après la fermeture, n'est pas montré.
let opened = 0;
let ready = false;
let open = false;
// La feuille d'où l'on vient, dont la page a pris la place.
let host: HTMLElement | null = null;
let copiedTimeout: ReturnType<typeof setTimeout> | null = null;
function showBanner(): void {
bannerText.textContent = ASK_AI_TEXTS.copied;
banner.hidden = false;
// La barre de temps repart de zéro (charte : 2 s).
timer.classList.remove("running");
void timer.offsetWidth;
timer.classList.add("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
/** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */
function showCopied(copied: boolean): void {
if (copiedTimeout !== null) {
clearTimeout(copiedTimeout);
copiedTimeout = null;
}
copy.classList.toggle("copied", copied);
copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy;
copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel);
if (copied) {
copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS);
}
bannerTimeout = setTimeout(hideBanner, COPIED_MS);
}
showCopied(false);
function hideBanner(): void {
banner.hidden = true;
timer.classList.remove("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
bannerTimeout = null;
/** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */
function place(next: HTMLElement | null): void {
if (host !== next) {
host?.classList.remove("prompting");
host = next;
}
if (next === null) {
if (page.parentElement !== sheet) {
sheet.append(page);
}
veil.hidden = false;
return;
}
veil.hidden = true;
// La page couvre la feuille telle qu'elle s'affiche, défilée en haut.
next.scrollTop = 0;
next.classList.add("prompting");
if (page.parentElement !== next) {
next.append(page);
}
}
@@ -177,25 +230,31 @@ export function createPromptView(options: PromptViewOptions): PromptView {
}
if (copied) {
status.textContent = "";
showBanner();
showCopied(true);
} else {
selectText();
showCopied(false);
status.textContent = ASK_AI_TEXTS.notCopied;
}
};
return {
show(prompt) {
show(prompt, where) {
const id = ++opened;
const opening = veil.hidden;
veil.hidden = false;
const opening = !open || host !== where.host;
open = true;
back.textContent = `‹ ${where.back}`;
back.href = where.backHref;
place(where.host);
if (opening) {
ready = false;
text.textContent = "";
text.hidden = true;
copy.disabled = true;
showCopied(false);
status.textContent = ASK_AI_TEXTS.preparing;
closeIcon.focus();
delete page.dataset.ready;
back.focus();
}
prompt.then(
(result) => {
@@ -208,13 +267,13 @@ export function createPromptView(options: PromptViewOptions): PromptView {
text.hidden = false;
copy.disabled = false;
status.textContent = "";
sheet.dataset.ready = "true";
page.dataset.ready = "true";
} else {
ready = false;
text.hidden = true;
copy.disabled = true;
status.textContent = result.message;
delete sheet.dataset.ready;
delete page.dataset.ready;
}
},
() => {
@@ -229,15 +288,20 @@ export function createPromptView(options: PromptViewOptions): PromptView {
hide() {
opened++;
ready = false;
// Le bandeau « Prompt copié » finit ses 2 s, feuille fermée.
if (!veil.hidden) {
veil.hidden = true;
text.textContent = "";
delete sheet.dataset.ready;
if (!open) {
return;
}
open = false;
showCopied(false);
host?.classList.remove("prompting");
host = null;
veil.hidden = true;
sheet.append(page);
text.textContent = "";
delete page.dataset.ready;
},
get isOpen() {
return !veil.hidden;
return open;
},
get ready() {
return ready;
+1 -1
View File
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
advice: "Conseil du maître",
adviceDetail: "Une ligne après chacun de vos coups",
startGame: "Commencer la partie",
startMatch: (n: number) => `Commencer le match en ${n} points`,
startMatch: "Commencer le match",
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
+1 -3
View File
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch
? MATCH_TEXTS.startMatch(s.matchLength)
: MATCH_TEXTS.startGame;
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
+34
View File
@@ -0,0 +1,34 @@
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
// veulent dire l'étoile, « off », « bar » et « (2) ».
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveNotation } from "../domain/game/engine.js";
import { routeHash } from "./router.js";
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
export const NOTATION_TERM = "notation-des-coups";
/**
* Remplace le contenu de parent par text, où la première occurrence de
* notation devient un lien vers la fiche « Notation des coups ». Sans
* notation dans le texte, le texte seul.
*/
export function showWithNotation(
parent: HTMLElement,
text: string,
notation: MoveNotation | null,
): void {
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
if (notation === null || at < 0) {
parent.textContent = text;
return;
}
const link = document.createElement("a");
link.className = "notation";
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
link.textContent = notation;
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
}
+6 -3
View File
@@ -28,7 +28,7 @@ import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink } from "./ask-ai.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
@@ -65,6 +65,8 @@ export interface SummaryView {
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
/** Le panneau, où la page du prompt prend la place du bilan (1f). */
readonly panel: HTMLElement;
}
function element<K extends keyof HTMLElementTagNameMap>(
@@ -142,7 +144,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
return;
}
drawn = key;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
@@ -356,7 +358,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
hide() {
if (!veil.hidden) {
veil.hidden = true;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
drawn = "";
}
@@ -364,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
get isOpen() {
return !veil.hidden;
},
panel,
};
}
+230
View File
@@ -0,0 +1,230 @@
// Saisie d'un coup de sortie demi-coup par demi-coup, sur le vrai moteur
// WebAssembly : tout ordre de saisie que le moteur accepte doit être accepté
// par la partie, même quand un début de coup forme déjà un coup complet.
//
// Défaut corrigé (2026-11-10) : avec 6-1 et des pions sur 6 et 1, 6/off
// mène à la même position que le coup légal « 6/5/off » ; la partie le
// tenait pour complet et refusait ensuite 1/off, alors que 1/off puis 6/off
// passait.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Engine } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice, type Roll } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait, a un pion sur sa case 6 et un sur sa case 1 ;
// l'ordinateur un sur sa case 1 et un sur sa case 2. Le cas signalé par
// Pierre (#/position/BQAACAIAAAAAAA, « Jouer à partir d'ici »).
const SIX_AND_ONE = "BQAACAIAAAAAAA";
/**
* Position ID (format de gnubg) d'une fin de partie : les pions du joueur au
* trait sur ses cases 1 à 6 (player[i] : nombre de pions sur la case i + 1),
* l'ordinateur avec un pion sur sa case 1 et un sur sa case 2 ; les autres
* pions sont sortis. Outil de test seulement : chaque position produite est
* relue par le moteur, qui doit y retrouver ces pions.
*/
function bearOffPosition(player: readonly number[]): string {
const bits: number[] = [];
const side = (counts: readonly number[]) => {
for (let point = 0; point < 25; point++) {
for (let k = 0; k < (counts[point] ?? 0); k++) {
bits.push(1);
}
bits.push(0);
}
};
side([1, 1]); // l'adversaire d'abord, comme dans gnubg
side(player);
const bytes = new Uint8Array(10);
bits.forEach((bit, i) => {
if (bit === 1) {
bytes[i >> 3] = (bytes[i >> 3] ?? 0) | (1 << (i & 7));
}
});
return Buffer.from(bytes).toString("base64").slice(0, 14);
}
/** Une partie où le joueur, au trait dans position, vient de lancer roll. */
async function rolled(engine: Engine, position: string, roll: Roll): Promise<Game> {
const game = Game.start(engine, {
dice: fixedDice(roll),
setup: { position, firstTurn: "player" },
});
await game.roll();
return game;
}
/** Toutes les permutations distinctes des demi-coups. */
function orders(halfMoves: readonly HalfMove[]): HalfMove[][] {
if (halfMoves.length <= 1) {
return [[...halfMoves]];
}
const seen = new Set<string>();
const result: HalfMove[][] = [];
halfMoves.forEach((h, i) => {
const rest = [...halfMoves.slice(0, i), ...halfMoves.slice(i + 1)];
for (const tail of orders(rest)) {
const order = [h, ...tail];
const key = formatHalfMoves(order);
if (!seen.has(key)) {
seen.add(key);
result.push(order);
}
}
});
return result;
}
test("le cas signalé est bien celui que décrit le test", async () => {
const engine = await loadNodeEngine();
assert.equal(bearOffPosition([1, 0, 0, 0, 0, 1]), SIX_AND_ONE);
const board = await engine.board(SIX_AND_ONE, { value: 1, owner: "centered" });
assert.deepEqual(board.onRoll.points.slice(0, 6), [1, 0, 0, 0, 0, 1]);
});
test("6-1 en sortie : 6/off puis 1/off, comme 1/off puis 6/off", async () => {
const engine = await loadNodeEngine();
const allOff = (await engine.legalMoves(SIX_AND_ONE, [6, 1])).find(
(m) => m.notation === "6/off 1/off",
);
assert.ok(allOff, "le moteur propose 6/off 1/off");
// 6/off d'abord : la position est déjà celle d'un coup légal (6/5/off),
// mais le 1 reste jouable et le moteur l'accepte.
const six = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await six.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.deepEqual(await six.nextHalfMoves(), [{ from: 1, to: 0 }]);
assert.equal(await six.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.equal(six.displayedPosition, allOff.position);
assert.deepEqual(
six.diceToPlay.map((d) => d.used),
[true, true],
);
await six.confirmMove();
assert.equal(six.state.phase, "over");
// 1/off d'abord : pas encore complet, puis 6/off.
const one = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await one.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(one.moveComplete, false);
assert.equal(await one.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(one.displayedPosition, allOff.position);
// Et le coup 6/off seul (6/5/off) se valide toujours tel quel.
const alone = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await alone.playHalfMove({ from: 6, to: 0 }), true);
await alone.confirmMove();
assert.equal(alone.state.turn, "computer");
});
test("tous les dés joués, le moteur n'est plus interrogé", async () => {
// Le moteur de la page refuse plus de quatre demi-coups (erreur de la
// ligne de commande) : la partie ne doit pas en demander un cinquième
// après un double, ni un troisième après un jet simple.
const real = await loadNodeEngine();
const engine: Engine = {
...real,
checkHalfMoves: (position, roll, halfMoves) => {
const dice = roll[0] === roll[1] ? 4 : 2;
if (halfMoves.length > dice) {
throw new Error(`${halfMoves.length} demi-coups pour ${dice} dés`);
}
return real.checkHalfMoves(position, roll, halfMoves);
},
};
const double = await rolled(engine, bearOffPosition([0, 0, 2, 2, 0, 0]), [2, 2]);
for (const h of [
{ from: 4, to: 2 },
{ from: 4, to: 2 },
{ from: 3, to: 1 },
{ from: 3, to: 1 },
]) {
assert.equal(await double.playHalfMove(h), true);
}
assert.equal(double.moveComplete, true);
assert.deepEqual(await double.nextHalfMoves(), []);
assert.equal(await double.playHalfMove({ from: 2, to: 0 }), false);
const simple = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await simple.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(await simple.playHalfMove({ from: 6, to: 0 }), true);
assert.deepEqual(await simple.nextHalfMoves(), []);
});
test("en sortie, tout ordre accepté par le moteur l'est par la partie", async () => {
const engine = await loadNodeEngine();
// Toutes les fins de partie d'un à trois pions dans le jan intérieur,
// et quelques-unes à quatre (doubles), avec les 21 jets.
const positions: number[][] = [];
for (let a = 0; a < 6; a++) {
positions.push([a]);
for (let b = a; b < 6; b++) {
positions.push([a, b]);
for (let c = b; c < 6; c++) {
positions.push([a, b, c]);
}
}
}
positions.push([0, 0, 0, 0], [1, 2, 3, 4], [5, 5, 4, 4], [0, 2, 4, 5], [3, 3, 3, 3]);
const rolls: Roll[] = [];
for (let a = 1; a <= 6; a++) {
for (let b = 1; b <= a; b++) {
rolls.push([a, b]);
}
}
const failures: string[] = [];
let checked = 0;
for (const pips of positions) {
const counts = [0, 0, 0, 0, 0, 0];
for (const p of pips) {
counts[p] = (counts[p] ?? 0) + 1;
}
const position = bearOffPosition(counts);
for (const roll of rolls) {
const moves = await engine.legalMoves(position, roll);
const game = await rolled(engine, position, roll);
for (const move of moves) {
for (const order of orders(move.halfMoves)) {
// Le moteur doit accepter chaque début de cet ordre…
let accepted = true;
for (let i = 1; i <= order.length && accepted; i++) {
accepted = (await engine.checkHalfMoves(position, roll, order.slice(0, i)))
.valid;
}
if (!accepted) {
continue;
}
// … et alors la partie aussi, jusqu'au coup complet.
game.cancelHalfMoves();
checked++;
const label = `${position} ${roll.join("-")} ${formatHalfMoves(order)}`;
for (const h of order) {
const offered = (await game.nextHalfMoves()).some(
(n) => n.from === h.from && n.to === h.to,
);
if (!offered || !(await game.playHalfMove(h))) {
failures.push(`${label} : ${formatHalfMoves([h])} refusé`);
break;
}
}
if (game.pendingHalfMoves.length === order.length) {
if (!game.moveComplete || game.displayedPosition !== move.position) {
failures.push(`${label} : coup incomplet ou position différente`);
}
}
}
}
}
}
assert.ok(checked > 1000, `${checked} ordres vérifiés`);
assert.deepEqual(failures, []);
});
+27 -1
View File
@@ -30,7 +30,7 @@ import {
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
@@ -315,4 +315,30 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d"
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
// Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a
// joué … », moveNotation) et dans l'analyse : la notation du moteur.
let compared = 0;
let rewritten = 0;
for (const move of moves) {
if (move.type !== "MovePlayed" || move.halfMoves.length === 0) {
continue;
}
const advice =
move.side === "computer"
? analysed.review?.computer[move.id]
: analysed.advice?.[move.id];
if (advice?.kind !== "move") {
continue;
}
const notation = await resumed.moveNotation(move.id);
assert.equal(notation, advice.played.notation, `coup ${move.id}`);
compared++;
if (notation !== formatHalfMoves(move.halfMoves)) {
rewritten++;
}
}
assert.ok(compared > 5, `${compared} coups comparés`);
// La course finit en sortie : au moins un coup s'écrit autrement que
// ses demi-coups (« 6/off » pour « 6/2 2/off »…).
assert.ok(rewritten > 0, "aucun coup réécrit par le moteur");
});
+30
View File
@@ -476,6 +476,36 @@ Given("un joueur a sorti tous ses pions", async () => {
assert.equal(await playAndConfirm("6/off 5/off"), true);
});
// ------------------------------------------------- Sortir dans tout ordre
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1 ;
// l'ordinateur un pion sur sa case 1 et un sur sa case 2.
const SIX_AND_ONE = "BQAACAIAAAAAAA";
Given("vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", async () => {
game = Game.start(engine, {
setup: { position: SIX_AND_ONE, firstTurn: "player" },
dice: fixedDice([6, 1]),
});
await game.roll();
});
Then(/^vous pouvez valider ou jouer encore (.+)$/, async (text: string) => {
assert.equal(accepted, true);
// 6/off seul mène à la même position que 6/5/off, un coup légal.
assert.equal(game.moveComplete, true);
assert.deepEqual(await game.nextHalfMoves(), parseHalfMoves(text));
});
Then("vos deux pions sont sortis", async () => {
assert.equal(accepted, true);
assert.equal(game.moveComplete, true);
const board = await game.displayedBoard();
assert.equal(board.player.off, 15);
await game.confirmMove();
assert.equal(game.state.phase, "over");
});
Then("le résultat indique le gagnant et les points gagnés", () => {
// Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement :
// règle Jacoby, le videau n'a pas bougé.
+15
View File
@@ -174,6 +174,21 @@ test("le joueur peut revenir en arrière après un coup refusé", async () => {
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("en sortie, un coup déjà complet peut encore mener au coup attendu", async () => {
// Pions sur 6 et 1, jet 6-1 : 6/off seul est déjà un coup complet (même
// position que 6/5/off), mais le coup attendu sort les deux pions.
const engine = await loadNodeEngine();
const first = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await first.play({ from: 6, to: 0 }), "continue");
assert.equal(await first.play({ from: 1, to: 0 }), "right");
const other = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await other.play({ from: 1, to: 0 }), "continue");
assert.equal(await other.play({ from: 6, to: 0 }), "right");
// 6/5 puis 5/off : complet, mais ce n'est pas le coup attendu.
const wrong = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await wrong.play({ from: 6, to: 5 }), "wrong");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
+18
View File
@@ -60,3 +60,21 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
en dessous de 32 px.
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
## Amendements
### 2026-11-11 — Pions blancs et noirs, avec un contour
Retour des premiers joueurs, relayé par Pierre : les pions sombres se voyaient
mal sur la barre, et les joueurs préféraient le blanc et le noir à l'ivoire et
au noyer. Après comparaison sur le canevas (rangée « Retours des premiers
joueurs »), Pierre retient le blanc et le noir, avec un contour.
- Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé
(`#6E6E69`) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris
clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions.
- Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1
en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1).
- Le logo ne change pas : ses pions restent ivoire et noyer, qui sont la
matière de la marque, distincte des pièces du jeu.
- Valeurs dans `docs/identite/charte/tokens.json`.
+10 -7
View File
@@ -174,18 +174,21 @@ demande suivie de `/ia` :
| Prompt de la décision d'un coup, depuis l'analyse | `#/parties/<id>/analyse/<coup>/ia` (ou `…/analyse/ia`, sur le coup à revoir) |
| Prompt d'un terme du glossaire | `#/glossaire/<fiche sans .md>/ia` |
- La feuille monte au-dessus de l'écran d'en dessous, qui reste dessiné
(la feuille du maître, le bilan, l'analyse ou le glossaire). « Fermer »,
la croix, Échap et le retour du téléphone reviennent à cet écran ; ouverte
par son adresse, sans historique, la fermer remplace l'adresse par celle
de l'écran d'en dessous.
- Une seule feuille à la fois : depuis la feuille du maître ou le bilan,
la page du prompt prend la place de leur contenu, à la même hauteur ;
depuis l'analyse ou le glossaire, elle monte dans sa propre feuille.
« ‹ Explication » (« ‹ Bilan », « ‹ Analyse », « ‹ Glossaire »), Échap et
le retour du téléphone reviennent à l'écran d'où on l'a demandée ; la
croix et « Fermer » ferment la feuille, et avec elle la feuille du maître
ou le bilan dont elle avait pris la place. Ouverte par son adresse, sans
historique, le retour remplace l'adresse par celle de l'écran d'avant.
- Rechargée, l'adresse rouvre la même feuille : le prompt est recalculé à
partir du journal enregistré. Comme ses écrans, elle n'a de sens que sur
l'appareil qui a joué la partie (sauf pour le glossaire). Une décision
sans conseil, une partie absente ou pas encore analysée donne un message,
jamais une page cassée.
- Le bandeau « Prompt copié » n'a pas d'adresse : c'est un bandeau
éphémère.
- La confirmation de la copie (« Copié » sur le bouton, 2 s) n'a pas
d'adresse.
- Le prompt contient, lui, le lien public de la position
(`https://ustagammon.sans.pub/#/position/<Position ID>/<Match ID>`),
qui se partage.
+1
View File
@@ -59,6 +59,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Match](match.md) | Match |
| [Moteur](moteur.md) | Engine |
| [Niveau de jeu](niveau-de-jeu.md) | Level |
| [Notation des coups](notation-des-coups.md) | Move notation |
| [Partie](partie.md) | Game |
| [Partie enregistrée](partie-enregistree.md) | Saved game |
| [Passer](passer.md) | Drop |
+27
View File
@@ -0,0 +1,27 @@
---
term: Notation des coups
english: Move notation
code: MoveNotation
---
# Notation des coups
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
plus proche de sa sortie) à 24 :
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
dés compte en général deux déplacements, séparés par une espace :
`13/7 8/7`.
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
seul de l'adversaire, qui part sur la barre.
- `6/off` : le pion de la case 6 sort du plateau.
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
6 à 2.
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
de sa case de départ à sa case d'arrivée.
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
+34 -15
View File
@@ -76,9 +76,11 @@ interrompre.
demande au moteur si le coup du joueur fait partie des coups permis attendus.
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
veut dire « à faire »).
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
circulaires** : une claire pour vos pions, une sombre pour ceux de
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
courte, « Continuer » devient l'action principale. Un geste faux : le pion
revient, et une phrase dit pourquoi, sans le mot « erreur ».
@@ -87,14 +89,14 @@ interrompre.
### Texte, première version
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
1. **Le but du jeu.** « Vous avez quinze pions blancs, l'ordinateur quinze
pions noirs. Chacun fait faire le tour du plateau à ses pions, puis les
sort. Le premier qui a sorti tous les siens gagne la partie. »
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
l'ordinateur font le même chemin, en sens inverse. »
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
@@ -106,16 +108,16 @@ interrompre.
point 7 avec 6-1.
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée :
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
passe. »
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
(flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
## Vocabulaire : les jans
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
explications (retour des premiers joueurs, 2026-11-11) :
| Flèches | Nom |
| --- | --- |
| 1 à 6 | votre jan intérieur |
| 7 à 12 | votre jan extérieur |
| 13 à 18 | le jan extérieur de l'ordinateur |
| 19 à 24 | le jan intérieur de l'ordinateur |
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
vers leur fiche du glossaire à la première apparition. Fiches à créer :
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
outer board). « Camp » n'est plus employé dans les textes.
+17 -5
View File
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
@@ -206,9 +206,21 @@ Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
puis le texte du prompt reste lisible dans une zone `surface-sunken`
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
glossaire. Le bouton ouvre la feuille du prompt : le texte y est lisible
dans une zone `surface-sunken`, pour qui veut le relire avant de le copier
(composant `AskAI`).
Recette de 1f :
- **Une seule feuille à la fois.** Depuis la feuille du maître, la feuille
du prompt en remplace le contenu, à la même hauteur, avec « ‹ Explication »
en haut à gauche pour revenir ; de même depuis le bilan (« ‹ Bilan »).
Depuis l'analyse et le glossaire, elle monte seule (« ‹ Analyse »,
« ‹ Glossaire »). Le retour du téléphone ramène à l'écran précédent.
- **La copie se confirme sur le bouton**, sans bandeau : « Copier le
prompt » devient « ✓ Copié » (coche, `verdict-good`) pendant 2 s, puis
revient. Si le navigateur refuse la copie, le texte est sélectionné et
une phrase invite à le copier avec le menu du téléphone.
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
+1 -1
View File
@@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions blancs (`checker-player`), l'ordinateur les pions noirs (`checker-computer`), chacun avec un contour et un filet (`checker-player-edge`, `checker-computer-edge`) qui le détachent de tous les fonds ; les pions du logo restent ivoire et noyer (ADR 0010, amendement du 2026-11-11).
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
+2
View File
@@ -8,6 +8,8 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
## Toutes tailles
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées.
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre.
- [ ] 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).
+32 -32
View File
@@ -216,96 +216,96 @@
{
"name": "checker-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Pions du joueur : ivoire, l'apprenti."
"usage": "Pions du joueur : blancs."
},
{
"name": "checker-player-edge",
"value": {
"light": "#8C7A62",
"dark": "#8E7C64"
"light": "#6E6E69",
"dark": "#6E6E69"
},
"usage": "Contour et filet des pions du joueur."
"usage": "Contour et filet des pions blancs : gris foncé, pour qu'ils se détachent du fond clair du plateau."
},
{
"name": "checker-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
"usage": "Pions de l'ordinateur : noirs."
},
{
"name": "checker-computer-edge",
"value": {
"light": "#120A05",
"dark": "#A8957D"
"light": "#B5B5B0",
"dark": "#B5B5B0"
},
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
"usage": "Contour et filet des pions noirs : gris clair dans les deux thèmes, pour qu'ils se détachent de la barre, des flèches sombres et du fond sombre."
},
{
"name": "count-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Nombre écrit sur une pile de pions du joueur."
"usage": "Nombre écrit sur une pile de pions blancs."
},
{
"name": "count-computer",
"value": {
"light": "#F3EAD8",
"dark": "#F3EAD8"
"light": "#FAFAF7",
"dark": "#FAFAF7"
},
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
"usage": "Nombre écrit sur une pile de pions noirs."
},
{
"name": "die-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Dés du joueur."
"usage": "Dés du joueur, blancs comme ses pions."
},
{
"name": "pip-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Points des dés du joueur."
},
{
"name": "die-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Dés de l'ordinateur."
"usage": "Dés de l'ordinateur, noirs comme ses pions."
},
{
"name": "pip-computer",
"value": {
"light": "#F3EAD8",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Points des dés de l'ordinateur."
},
{
"name": "cube",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Face du videau."
},
{
"name": "cube-text",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Valeur du videau (2, 4, 8…)."
},
+3 -1
View File
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
coups. Vos choix sont gardés pour la prochaine fois.
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
plateau, même juste après le bilan d'une partie finie.
Scénario: Comment choisir le niveau de l'ordinateur ?
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
+18
View File
@@ -142,6 +142,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors la partie s'arrête
Et vous perdez la partie à sa valeur d'avant le double
@position:BQAACAIAAAAAAA
Scénario: Sortir ses pions dans l'ordre de son choix
Une fois tous vos pions dans les six dernières flèches, vous les
sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6
et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous
ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce
pion a pu utiliser vos deux dés (6/5, puis 5/off). Touchez l'autre pion
pour le sortir aussi.
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
Quand vous jouez 6/off
Alors vous pouvez valider ou jouer encore 1/off
Quand vous jouez 1/off
Alors vos deux pions sont sortis
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
Quand vous jouez 1/off 6/off
Alors vos deux pions sont sortis
Scénario: Comment se termine une partie ?
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
fin dit qui gagne, et combien de points : la valeur du videau, doublée
+16 -4
View File
@@ -12,10 +12,13 @@
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
# d'analyse (tests/browser/bilan.mjs), captures dans
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
# captures dans build/browser-shots/prompts/.
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
# partie finie et son bilan, par chaque entrée
# (tests/browser/nouvelle-partie.mjs), captures dans
# build/browser-shots/nouvelle-partie/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts) restreignent le test.
# prompts, nouvelle-partie) restreignent le test.
#
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
@@ -28,7 +31,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan prompts}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -66,7 +69,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
"$tools/"
status=0
for browser in $browsers; do
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
@@ -113,5 +117,13 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
# « Nouvelle partie » après une partie finie et son bilan : la
# nouvelle partie tout de suite ; captures dans
# build/browser-shots/nouvelle-partie/.
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+19 -1
View File
@@ -16,7 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -171,6 +171,7 @@ async function checkTutorialView(page, name) {
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
width: innerWidth,
buttons,
};
});
@@ -189,8 +190,13 @@ async function checkTutorialView(page, name) {
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
// Largeurs du plateau et du texte, comparées d'une étape à l'autre à la
// fin du tutoriel (tutorialWidthProblems).
const widths = await tutorialWidths(page);
tutorialWidthViews.push({ name, size: `${view.width}x${view.height}`, ...widths });
}
let tutorialViews = 0;
const tutorialWidthViews = [];
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
@@ -331,6 +337,18 @@ async function playTouches(page, touches) {
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
if (full) {
// Les mesures, écran par écran, à côté des captures.
writeFileSync(`${dir}/tutoriel-largeurs.txt`, tutorialWidthViews.map((v) =>
`${v.size}\t${v.name}\tplateau ${v.board.toFixed(1)}\ttexte ${v.text.toFixed(1)}\tpage ${v.page}`)
.join("\n") + "\n");
const widthProblems = tutorialWidthProblems(tutorialWidthViews);
check(widthProblems.length === 0, `largeurs du tutoriel :\n ${widthProblems.join("\n ")}`);
const sizes = [...new Set(tutorialWidthViews.map((v) => v.size))];
log(`tutoriel : plateau de même largeur à chaque étape (${sizes.map((s) =>
`${s} : ${tutorialWidthViews.find((v) => v.size === s).board.toFixed(1)} px`).join(", ")}), ` +
"aucune page plus large que l'écran");
}
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
+9
View File
@@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine")
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
// Le coup y est un lien vers la fiche « Notation des coups ».
const notationLink = await page.evaluate(() => {
const a = document.querySelector("#explain-legend a.notation");
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
+2 -2
View File
@@ -194,7 +194,7 @@ await page.click("#new-game-match");
await page.click("#new-game-length-3");
await page.click("#new-game-level-beginner");
s = await screen();
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match",
`match en 3 points : ${JSON.stringify(s)}`);
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
@@ -254,7 +254,7 @@ await page.click("#menu-new-game");
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden);
s = await screen();
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match",
`choix retrouvés : ${JSON.stringify(s)}`);
await page.click("#new-game-start");
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
+321
View File
@@ -0,0 +1,321 @@
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
// l'image de production.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
// une fin de course courte, partie d'un lien de position avec des dés
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
// (« Commencer la partie », « Commencer le match ») et que le coup de
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
// en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/nouvelle-partie`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
const BEAROFF = "BQAACAIAAAAAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window.__dice = [];
let state = 7;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until(what, fn, arg, ms = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture en portrait et en paysage (Chromium seulement), après la
* règle de la recette : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
* l'identifiant de la partie. */
async function finishedGameWithSummary() {
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
await until("position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play");
await until(
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
await page.evaluate(() => window.__dice.push(6, 1));
await page.click("#roll");
await idle();
for (const [from, to] of [
["p6", "p5"],
["p5", "off-player"],
]) {
await page.click(`#board .hit[data-place="${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
await page.click("#confirm");
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
await until(
"bilan de la partie finie",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
return page.evaluate(() => location.hash.split("/")[2]);
}
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
* du bouton, puis commence. */
async function chooseAndStart(choice, shot) {
await until(
"écran « Nouvelle partie »",
() =>
location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden &&
!document.getElementById("new-game-start").disabled,
);
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
if (choice.match) {
await page.click(`#new-game-length-${choice.length}`);
}
await page.click(`#new-game-level-${choice.level}`);
const label = await page.textContent("#new-game-start");
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
if (shot) {
await capture("1-nouvelle-partie");
}
await page.click("#new-game-start");
}
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
* peu plus tard. */
async function checkNewGame(entry, oldId, choice) {
await until(
`${entry} : partie commencée`,
() => location.hash === "#/" && !document.body.classList.contains("busy"),
);
// Le défaut apparaissait juste après : l'adresse passait au bilan de
// l'ancienne partie. On regarde pendant deux secondes.
for (let waited = 0; waited < 2000; waited += 100) {
const s = await page.evaluate(() => ({
hash: location.hash,
summary: !document.getElementById("summary-veil").hidden,
panel: !document.getElementById("panel-veil").hidden,
board: !document.getElementById("game").hidden,
newGame: !document.getElementById("new-game").hidden,
}));
check(
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
);
await sleep(100);
}
await idle();
const shown = await page.evaluate(() => ({
line: document.getElementById("match-line").textContent,
lineShown: !document.getElementById("match-line").hidden,
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
over: document.getElementById("again").hidden === false,
}));
check(!shown.over, `${entry} : la partie à l'écran est finie`);
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
if (choice.match) {
check(
shown.lineShown && shown.line === `Match en ${choice.length} points`,
`${entry} : ligne du match « ${shown.line} »`,
);
} else {
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
}
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
}
// --------------------------- 1. L'application rouverte, puis l'accueil
let oldId = await finishedGameWithSummary();
// L'application rouverte : sans adresse, elle ouvre l'accueil.
await page.goto(base);
await until(
"accueil",
() =>
location.hash === "#/accueil" &&
!document.getElementById("home-new").hidden &&
!document.getElementById("home-new").disabled,
);
await page.click("#home-new");
let choice = { match: true, length: 3, level: "beginner" };
await chooseAndStart(choice, true);
await checkNewGame("accueil", oldId, choice);
await capture("2-partie-demarree");
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
for (let actions = 0; ; actions++) {
check(actions < 200, "l'ordinateur ne joue pas de coup");
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
if (link !== null) {
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
break;
}
const what = await page.evaluate(() => {
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return `#${id}`;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "#board .hit.can-land";
}
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
});
if (what === null) {
await sleep(100);
continue;
}
await page.locator(what).first().click();
await idle();
}
await page.click("#info a.notation");
await until(
"fiche « Notation des coups »",
() =>
location.hash === "#/glossaire/notation-des-coups" &&
document.getElementById("glossaire-notation-des-coups") !== null,
);
await page.goBack();
await until("retour à la partie", () => location.hash === "#/");
log("ligne de l'ordinateur : la notation mène à sa fiche");
// --------------------------------- 2. L'application rouverte sur le menu
oldId = await finishedGameWithSummary();
await page.goto(`${base}#/menu`);
await until("menu", () => !document.getElementById("menu-veil").hidden);
await page.click("#menu-new-game");
choice = { match: false, level: "amateur" };
await chooseAndStart(choice, false);
await checkNewGame("menu", oldId, choice);
// ------------------------------- 3. « Changer les réglages » du bilan
oldId = await finishedGameWithSummary();
await page.click("#summary-settings");
choice = { match: true, length: 5, level: "intermediate" };
await chooseAndStart(choice, false);
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
problems.push(`politique de sécurité : ${violation}`);
}
await browser.close();
if (problems.length > 0) {
for (const problem of problems) {
console.error(`[${browserName}] ${problem}`);
}
process.exit(1);
}
log(`nouvelle partie : tout est conforme (${captures} captures)`);
+6
View File
@@ -1193,6 +1193,12 @@ for (let actions = 0; ; actions++) {
tracesSeen++;
if (!moments.has("coup-ordinateur")) {
moments.add("coup-ordinateur");
// La ligne du dernier coup (portrait seulement : en paysage, le
// plateau occupe tout l'écran), dans la notation du moteur.
const played = (await page.textContent("#info")) ?? "";
check(/^L'ordinateur a joué \d-\d : \S.*\.$/.test(played),
`coup de l'ordinateur mal écrit : « ${played} »`);
log(`coup de l'ordinateur : « ${played} »`);
await review("4-coup-ordinateur");
}
if (replays < 2) {
+174 -73
View File
@@ -11,11 +11,14 @@
// l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une
// IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une
// fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible
// avant la copie, rechargeable ; la copie (dans Chromium, le presse-papiers
// est relu et comparé au texte de la feuille, et aux chiffres de la feuille
// du maître) ; le bandeau « Prompt copié » ; la fermeture qui ramène à
// l'écran d'en dessous ; aucune requête vers un autre site ; l'absence
// d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// avant la copie, rechargeable ; une seule feuille à la fois (depuis la
// feuille du maître ou le bilan, la page du prompt prend leur place, à la
// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans
// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et
// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié »,
// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à
// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ;
// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// dans <dossier des captures>/prompts/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
@@ -109,20 +112,15 @@ const idle = () => until("écran libre", () => !document.body.classList.contains
/** Une capture de la recette dans les deux tailles (Chromium seulement),
* après la règle commune : rien d'interactif sous une barre d'action, pas
* de bouton étiré. before() est rejoué avant chaque taille (le bandeau
* « Prompt copié » ne dure que 2 s). */
* de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne
* dure que 2 s sur le bouton). */
async function capture(name, before = null) {
if (!full) {
return;
}
// Le bandeau « Prompt copié » d'une copie précédente est parti (sauf
// pour sa propre capture, où before le fait réapparaître).
await until(
"bandeau parti",
() => document.getElementById("prompt-banner").hidden,
null,
10_000,
);
// « Copié » d'une copie précédente est reparti (sauf pour sa propre
// capture, où before le fait réapparaître).
await until("bouton revenu", notCopied, null, 10_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
@@ -141,26 +139,71 @@ async function capture(name, before = null) {
await sleep(300);
}
/** La feuille du prompt, prête : son texte. */
/** Le bouton de copie n'est pas (ou plus) en « Copié ». */
const notCopied = () => {
const copy = document.getElementById("prompt-copy");
return copy === null || !copy.classList.contains("copied");
};
/** La page du prompt, prête et visible : son texte. */
async function openPrompt(button, hashEnd) {
await page.locator(button).scrollIntoViewIfNeeded();
await page.click(button);
await until(
"feuille du prompt prête",
(end) =>
location.hash.endsWith(end) &&
!document.getElementById("prompt-veil").hidden &&
document.getElementById("prompt-sheet")?.dataset.ready === "true",
(end) => {
const prompt = document.getElementById("prompt-page");
return (
location.hash.endsWith(end) &&
prompt?.dataset.ready === "true" &&
prompt.getClientRects().length > 0
);
},
hashEnd,
);
return page.textContent("#prompt-text");
}
/** Une seule feuille visible : la page du prompt, dans la feuille hostId
* (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une
* croix et un titre visibles au plus ; « ‹ back ». */
async function checkOneSheet(hostId, back, what) {
const seen = await page.evaluate((host) => {
const visible = (el) =>
el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible";
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
const prompt = document.getElementById("prompt-page");
return {
parent: prompt.parentElement.id,
ownSheet: !document.getElementById("prompt-veil").hidden,
grips: count(".grip"),
closes: count('[role="dialog"] .icon-button'),
titles: count('[role="dialog"] h2'),
back: document.getElementById("prompt-back").textContent,
hostVisible: host === null || !document.getElementById(host).parentElement.hidden,
};
}, hostId);
check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`);
check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`);
check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`);
check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`);
check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`);
check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`);
check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`);
}
/** La hauteur et le haut d'un élément. */
const boxOf = (selector) =>
page.evaluate((s) => {
const r = document.querySelector(s).getBoundingClientRect();
return { top: Math.round(r.top), height: Math.round(r.height) };
}, selector);
/** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */
function checkPrompt(text, what) {
for (const part of [
"Tu es un coach de backgammon",
"Je suis un joueur de niveau ",
"Je débute au backgammon.",
"Position ID : ",
"Match ID : ",
"https://ustagammon.sans.pub/#/position/",
@@ -174,42 +217,82 @@ function checkPrompt(text, what) {
check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`);
}
/** Copie le prompt, attend le bandeau, et (Chromium) relit le
* presse-papiers. */
/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */
const copiedOnButton = () => {
const copy = document.getElementById("prompt-copy");
return copy.classList.contains("copied") && copy.textContent === "Copié";
};
/** Copie faite (« Copié ») ou repli (le message sous le texte). */
const copyDone = () => {
const copy = document.getElementById("prompt-copy");
return (
copy.classList.contains("copied") ||
document.getElementById("prompt-status").textContent !== ""
);
};
/** Copie le prompt, attend « Copié » sur le bouton puis son retour à
* « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */
async function copyPrompt(what) {
await page.click("#prompt-copy");
await until(
"copie faite",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
const shown = await page.evaluate(() => ({
banner: !document.getElementById("prompt-banner").hidden,
bannerText: document.getElementById("prompt-banner").textContent,
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
}));
await until("copie faite", copyDone, null, 10_000);
const shown = await page.evaluate(() => {
const copy = document.getElementById("prompt-copy");
return {
copied: copy.classList.contains("copied"),
label: copy.textContent,
check: copy.querySelector("svg.icon") !== null,
color: getComputedStyle(copy).color,
good: getComputedStyle(document.documentElement)
.getPropertyValue("--verdict-good")
.trim(),
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
banners: document.querySelectorAll("#prompt-banner").length,
};
});
check(shown.banners === 0, `${what} : un bandeau de copie`);
if (shown.copied) {
check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`);
// La couleur du bouton est verdict-good (#rrggbb dans app.css).
const rgb = shown.good
.replace("#", "")
.match(/../g)
.map((h) => Number.parseInt(h, 16))
.join(", ");
check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`);
// Deux secondes après, le bouton revient.
await until("« Copier le prompt » revenu", notCopied, null, 5_000);
const label = await page.textContent("#prompt-copy");
check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`);
}
if (full) {
check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`);
check(shown.copied, `${what} : pas de « Copié » sur le bouton`);
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`);
return clipboard;
}
// Firefox : la copie, ou son repli (texte sélectionné, et un message).
check(shown.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`);
check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`);
return shown.text;
}
/** Ferme la feuille du prompt : l'écran d'en dessous revient. */
async function closePrompt(hashBelow) {
await page.click("#prompt-done");
/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */
async function backFromPrompt(hashBelow) {
await page.click("#prompt-back");
await promptGone(hashBelow);
}
/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */
async function promptGone(hashBelow) {
await until(
"feuille du prompt fermée",
(below) => document.getElementById("prompt-veil").hidden && location.hash === below,
(below) =>
document.getElementById("prompt-veil").hidden &&
document.querySelector(".prompting") === null &&
location.hash === below,
hashBelow,
);
}
@@ -254,7 +337,16 @@ const sheetNumbers = await page.evaluate(() =>
await capture("1-bouton-feuille-du-maitre", () =>
page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(),
);
// La feuille du maître, avant : sa hauteur, que la page du prompt garde.
const explainBox = await boxOf("#explain-sheet");
const moveText = await openPrompt("#explain-ask-ai", "/ia");
await checkOneSheet("explain-sheet", "Explication", "feuille du maître");
const promptBox = await boxOf("#prompt-page");
check(
Math.abs(promptBox.top - explainBox.top) <= 1 &&
Math.abs(promptBox.height - explainBox.height) <= 1,
`la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`,
);
check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille");
checkPrompt(moveText, "prompt du coup");
check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué");
@@ -276,30 +368,39 @@ await capture("2-feuille-du-prompt");
const copied = await copyPrompt("prompt du coup");
await capture("3-prompt-copie", async () => {
await page.click("#prompt-copy");
await until("bandeau", () => !document.getElementById("prompt-banner").hidden);
await until("« Copié »", copiedOnButton);
});
log(`prompt du coup copié (${copied.length} caractères)`);
// Échap ferme la feuille du prompt, pas l'explication dessous.
// Échap ramène à l'explication, qui n'est pas fermée.
await page.keyboard.press("Escape");
await until(
"retour à l'explication",
(below) =>
document.getElementById("prompt-veil").hidden &&
!document.getElementById("explain-veil").hidden &&
location.hash === below,
explainHash,
await promptGone(explainHash);
check(
await page.evaluate(
() =>
!document.getElementById("explain-veil").hidden &&
getComputedStyle(document.getElementById("explain-close")).visibility === "visible",
),
"l'explication n'est pas revenue",
);
// Rechargée, l'adresse de la feuille la rouvre.
await page.goto(`${base}${explainHash}/ia`);
await page.reload();
await until(
"feuille rouverte au rechargement",
() => document.getElementById("prompt-sheet")?.dataset.ready === "true",
() => document.getElementById("prompt-page")?.dataset.ready === "true",
);
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
await closePrompt(explainHash);
await page.click("#explain-done");
await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden);
await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée");
// « ‹ Explication » y ramène.
await backFromPrompt(explainHash);
// La croix ferme la feuille : l'explication avec.
await openPrompt("#explain-ask-ai", "/ia");
await page.click("#prompt-close");
await until(
"feuille du maître fermée",
() => document.getElementById("explain-veil").hidden && location.hash === "#/",
);
await promptGone("#/");
// ------------------------------------------------------------- Le bilan
@@ -318,6 +419,7 @@ const summaryHash = await page.evaluate(() => location.hash);
const id = summaryHash.split("/")[2];
await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded());
const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia");
await checkOneSheet("summary", "Bilan", "bilan");
checkPrompt(summaryText, "prompt du bilan");
check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat");
check(
@@ -327,7 +429,9 @@ check(
check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir");
await capture("5-feuille-du-prompt-bilan");
await copyPrompt("prompt du bilan");
await closePrompt(summaryHash);
// Le retour du téléphone ramène au bilan.
await page.goBack();
await promptGone(summaryHash);
check(
await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"),
"le bilan n'est plus là après la feuille du prompt",
@@ -349,6 +453,7 @@ await capture("6-bouton-analyse", () =>
);
const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`);
checkPrompt(analysisText, "prompt de l'analyse");
await checkOneSheet(null, "Analyse", "analyse");
// Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau
// du joueur, lui, compte maintenant toutes ses décisions de la partie).
const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations"));
@@ -356,7 +461,7 @@ check(
numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50,
"les chiffres du coup diffèrent entre la feuille du maître et l'analyse",
);
await closePrompt(`#/parties/${id}/analyse/1`);
await backFromPrompt(`#/parties/${id}/analyse/1`);
// Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de
// videau.
await page.goto(`${base}#/parties/${id}/analyse/2`);
@@ -368,7 +473,9 @@ const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia");
checkPrompt(cubeText, "prompt du videau");
check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau");
check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision");
await closePrompt(`#/parties/${id}/analyse/2`);
// « Fermer » : sans feuille dessous, retour à l'analyse.
await page.click("#prompt-done");
await promptGone(`#/parties/${id}/analyse/2`);
// --------------------------------------------------------- Le glossaire
@@ -389,6 +496,7 @@ await capture("7-bouton-glossaire", () =>
);
const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia");
checkPrompt(conceptText, "prompt du concept");
await checkOneSheet(null, "Glossaire", "glossaire");
check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept");
check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation");
await capture("8-feuille-du-prompt-glossaire");
@@ -396,24 +504,17 @@ await copyPrompt("prompt du concept");
if (full) {
// Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le
// texte est sélectionné dans la feuille et copié par le navigateur.
await until("bandeau parti", () => document.getElementById("prompt-banner").hidden);
await until("bouton revenu", notCopied, null, 5_000);
await page.evaluate(async () => {
await navigator.clipboard.writeText("");
navigator.clipboard.writeText = () => Promise.reject(new Error("refusé"));
});
await page.click("#prompt-copy");
await until(
"copie de repli",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
await until("copie de repli", copyDone, null, 10_000);
const fallback = await page.evaluate(async () => ({
selected: window.getSelection().toString(),
text: document.getElementById("prompt-text").textContent,
banner: !document.getElementById("prompt-banner").hidden,
copied: document.getElementById("prompt-copy").classList.contains("copied"),
clipboard: await navigator.clipboard.readText(),
}));
// Copié depuis une sélection, le texte perd ses espaces insécables
@@ -421,12 +522,12 @@ if (full) {
const plain = (text) => text.replace(/ /g, " ").trim();
check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné");
check(
fallback.banner && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (bandeau ${fallback.banner}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
fallback.copied && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
);
log("repli par sélection du texte : copié");
}
await closePrompt("#/glossaire/videau");
await backFromPrompt("#/glossaire/videau");
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
+69
View File
@@ -98,3 +98,72 @@ export async function hiddenUnderActionBar(target) {
return hidden;
});
}
/**
* Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne
* de texte, et la largeur de la page (scrollWidth) face à celle de l'écran.
* Une page plus large que l'écran défile de côté ; sur un téléphone, Chrome
* la dézoome pour la montrer en entier, et tout l'écran paraît rétréci.
*/
export async function tutorialWidths(target) {
return target.evaluate(() => {
const width = (selector) => {
const el = document.querySelector(selector);
return el === null ? 0 : el.getBoundingClientRect().width;
};
const page = document.scrollingElement;
// Le plus à droite des descendants du tutoriel, pour nommer le
// coupable d'un débordement.
let widest = { what: "", right: 0 };
for (const el of document.querySelectorAll("#tutorial *")) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > widest.right) {
const name = el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")}`;
widest = { what: name, right: r.right };
}
}
return {
board: width("#tutorial-board"),
text: width("#tutorial .tutorial-body"),
page: Math.max(page.scrollWidth, document.body.scrollWidth),
screen: innerWidth,
widest,
};
});
}
/**
* Règle de la recette : pour une taille d'écran donnée, le plateau du
* tutoriel a la même largeur à toutes les étapes, et aucune page n'est plus
* large que l'écran. views : [{ name, size, board, text, page, screen,
* widest }]. Renvoie la liste des écarts, décrits (vide si tout va bien).
*/
export function tutorialWidthProblems(views) {
const problems = [];
const bySize = new Map();
for (const v of views) {
bySize.set(v.size, [...(bySize.get(v.size) ?? []), v]);
if (v.page > v.screen + 0.5) {
problems.push(
`${v.name} en ${v.size} : page de ${v.page} px pour ${v.screen} px ` +
`(le plus à droite : ${v.widest.what}, ${Math.round(v.widest.right)} px)`,
);
}
}
for (const [size, list] of bySize) {
const widths = list.map((v) => v.board);
const min = Math.min(...widths);
const max = Math.max(...widths);
if (max - min > 0.5) {
const odd = list
.filter((v) => Math.abs(v.board - max) > 0.5)
.map((v) => `${v.name} ${v.board.toFixed(1)}`);
problems.push(
`${size} : plateau de ${min.toFixed(1)} à ${max.toFixed(1)} px (${odd.join(", ")})`,
);
}
}
return problems;
}
+11
View File
@@ -205,6 +205,17 @@ attendre_demi_coups "true false /38AAAAgAAAAAA" "$course" 65 "5/off"
# Course, jet 21 : il faut jouer les deux dés.
attendre_demi_coups "false false " "$course" 21 "6/3"
attendre_demi_coups "true true /38AAAAkAAAAAA" "$course" 21 "6/5 5/3"
# Sortie avec 6-1, pions sur 6 et 1 (BQAACAIAAAAAAA) : gnubg donne deux
# coups légaux, « 6/off 1/off » et « 6/off » (6/5/off, qui laisse le pion
# sur 1). 6/off seul mène déjà à la position de ce second coup (complet),
# mais 1/off reste permis ensuite : les deux ordres mènent à « tout sorti ».
sortie_61=BQAACAIAAAAAAA
grep -qF '"move": "6/off 1/off"' <<<"$($cli legal "$sortie_61" "$libre_j0" 61)" \
|| echouer "sortie 6-1 : coup légal « 6/off 1/off » attendu"
attendre_demi_coups "true true BQAACAAAAAAAAA" "$sortie_61" 61 "6/off"
attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "6/off 1/off"
attendre_demi_coups "true false BQAAAAEAAAAAAA" "$sortie_61" 61 "1/off"
attendre_demi_coups "true true BQAAAAAAAAAAAA" "$sortie_61" 61 "1/off 6/off"
# Aucun coup possible : rien n'est valide.
attendre_demi_coups "false false " 27YBBwAcz8EDQA 66
attendre_erreur usage halfmoves "$depart" 31 "8-5"
+82 -30
View File
@@ -295,6 +295,15 @@ a {
flex-direction: column;
}
/* Les pages sont des éléments de la colonne flexible #screen. Leurs marges
* automatiques les centrent, mais annulent leur étirement en largeur : sans
* largeur explicite, une page au contenu court se réduit à la largeur de ce
* contenu (le plateau de l'étape 6 « à vous » du tutoriel passait de 396 à
* 300 px en 412 × 915). Leur max-width les borne toujours. */
.screen > .page {
width: 100%;
}
.spacer {
flex: 1;
}
@@ -577,6 +586,19 @@ button:disabled {
line-clamp: 2;
}
/* Un coup en notation du moteur, lien vers sa fiche du glossaire (ligne de
* l'ordinateur, feuille du maître) : couleur du texte, soulignement en
* pointillé discret. Le remplissage vertical d'un élément en ligne agrandit
* la zone d'appui sans changer la hauteur des lignes. */
a.notation {
padding-block: 8px;
color: inherit;
text-decoration-line: underline;
text-decoration-style: dotted;
text-decoration-color: var(--muted);
text-decoration-thickness: 1px;
}
.infoline .replay {
color: var(--accent);
}
@@ -3430,17 +3452,65 @@ a.button.ask-ai .icon,
margin-top: var(--space-2);
}
/* La feuille du prompt passe devant la feuille du maître et le bilan. */
/* Sa propre feuille, au-dessus de l'analyse et du glossaire. */
.prompt-veil {
z-index: 12;
}
.prompt-sheet {
.prompt-page {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Depuis la feuille du maître ou le bilan, la page du prompt prend la place
* de leur contenu, à la même hauteur : la feuille garde sa taille, son
* contenu reste en place mais invisible, et la page la couvre ; le texte du
* prompt défile dans la hauteur qui reste. Une seule feuille, une seule
* poignée, une seule croix. */
.sheet.prompting,
.panel.prompting {
position: relative;
overflow: hidden;
}
.prompting > :not(.prompt-page) {
visibility: hidden;
}
/* (plus précis que « html.landscape .explain-sheet > * », qui la placerait
* dans une colonne) */
.sheet.prompting > .prompt-page,
.panel.prompting > .prompt-page {
position: absolute;
inset: 0;
grid-area: auto;
padding: inherit;
}
.prompting .prompt-text {
flex: 1 1 auto;
max-height: none;
}
/* Le bilan est un panneau : pas de poignée. */
.panel .prompt-page .grip {
display: none;
}
/* « ‹ Explication » : revenir à l'écran d'où l'on vient. */
a.prompt-back {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-right: var(--space-2);
font-weight: 500;
color: var(--accent);
text-decoration: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.prompt-lead {
margin: 0;
color: var(--muted);
@@ -3478,6 +3548,7 @@ a.button.ask-ai .icon,
.prompt-actions {
display: flex;
flex: none;
gap: var(--space-2);
justify-content: flex-end;
}
@@ -3490,39 +3561,20 @@ a.button.ask-ai .icon,
min-width: 120px;
}
/* « ✓ Copié », 2 s : la copie se confirme sur le bouton (recette de 1f). */
#prompt-copy.copied {
color: var(--verdict-good);
background: var(--surface);
border-color: var(--verdict-good);
}
html.landscape .prompt-text {
max-height: calc(100vh - 190px);
max-height: calc(100dvh - 190px);
}
/* « Prompt copié » : le bandeau des autres, en verdict-good, 2 s, devant
* la feuille du prompt ; en haut de l'écran, pour ne pas couvrir les
* boutons de la feuille. */
.prompt-banner,
html.landscape .prompt-banner {
top: var(--space-4);
bottom: auto;
z-index: 13;
color: var(--verdict-good);
font-weight: 700;
}
.prompt-banner > span:not(.banner-timer) {
flex: 1;
}
.prompt-banner .icon {
flex: none;
width: 20px;
height: 20px;
}
.prompt-banner .banner-timer {
background: var(--verdict-good);
}
.prompt-banner .banner-timer.running {
animation-duration: 2s;
html.landscape .prompting .prompt-text {
max-height: none;
}
/* Le bouton ne se coupe pas : trop étroit à côté de « Revoir ce coup » ou
+3 -6
View File
@@ -166,8 +166,9 @@
construit par app/src/ui/summary-view.ts. -->
<div id="summary-veil" class="veil summary-veil" hidden></div>
<!-- Le prompt pour une IA (…/ia) : une feuille, au-dessus de l'écran d'où
on le demande, construite par app/src/ui/ask-ai.ts. -->
<!-- Le prompt pour une IA (…/ia), depuis l'analyse ou le glossaire : une
feuille, construite par app/src/ui/ask-ai.ts (depuis la feuille du
maître ou le bilan, il prend leur place). -->
<div id="prompt-veil" class="veil sheet-veil prompt-veil" hidden></div>
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
@@ -218,10 +219,6 @@
<span class="banner-timer" aria-hidden="true"></span>
</div>
<!-- « Prompt copié », 2 s, au-dessus de la feuille du prompt (contenu
construit par app/src/ui/ask-ai.ts). -->
<div id="prompt-banner" class="banner prompt-banner" role="status" hidden></div>
<!-- « Nouvelle version prête. » : en bas, comme « Reprendre l'ancienne »,
seulement entre deux tours. -->
<div id="update-banner" class="banner update-banner" role="status" hidden>