Compare commits

..
11 Commits
Author SHA1 Message Date
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 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
21 changed files with 937 additions and 212 deletions
+52 -9
View File
@@ -27,7 +27,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 +193,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 +283,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<string> {
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 +329,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 +406,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 +429,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";
}
+6 -3
View File
@@ -18,7 +18,7 @@ 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 { routeHash } from "./router.js";
@@ -48,6 +48,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 +94,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");
@@ -332,12 +334,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
return {
show,
sheet,
hide() {
opened++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
clearSheet(sheet);
}
},
};
+37 -11
View File
@@ -38,7 +38,7 @@ import type { Advice } from "../domain/game/advice.js";
import type { Cube } from "../domain/game/cube.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";
@@ -246,7 +246,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"),
@@ -1038,7 +1037,7 @@ export function startApp(options: AppOptions): void {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
await game.playComputerTurn();
info = describeComputerTurn(game);
info = await describeComputerTurn(game);
save();
played = true;
}
@@ -1319,7 +1318,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 +1328,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 +1343,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
@@ -2636,8 +2661,9 @@ 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 {
/** 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<string> {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
@@ -2650,7 +2676,7 @@ function describeComputerTurn(game: Game): string {
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
return TEXTS.computerMoved(dice, await game.moveNotation(played.id));
}
/** 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;
+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()) {
+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.
+15 -3
View File
@@ -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>`)
+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
+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})` : ""}` : ""));
+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"
+69 -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;
}
@@ -3430,17 +3439,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 +3535,7 @@ a.button.ask-ai .icon,
.prompt-actions {
display: flex;
flex: none;
gap: var(--space-2);
justify-content: flex-end;
}
@@ -3490,39 +3548,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>