Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850d860658 | ||
|
|
d5f08fe393 | ||
|
|
21baddcea3 | ||
|
|
7a6c0ac2ac | ||
|
|
90f835869a | ||
|
|
ce36d738fb | ||
|
|
fbf317909a | ||
|
|
b2263c7e3d | ||
|
|
4e74bab3ac | ||
|
|
ab8dc512ba | ||
|
|
e9bb9ae88d |
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
|
||||
@@ -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,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})` : ""}` : ""));
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user