Compare commits
37
Commits
@@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - l'application compilée (build/web/src/) sous app/ ;
|
||||
# - le moteur sous wasm/ ;
|
||||
# - les copies du glossaire, des scénarios, du tutoriel et des phrases du
|
||||
# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître
|
||||
# - les copies du glossaire, des scénarios, du tutoriel, des phrases du
|
||||
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
|
||||
# tutoriel, le conseil du maître et « Demander à une IA »
|
||||
# (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
@@ -142,7 +143,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/
|
||||
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
|
||||
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
|
||||
scripts/service-worker.sh $(BUILD_DIR)/site
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Les chiffres du moteur tels qu'on les écrit : à l'écran (feuille du
|
||||
// maître, bilan, analyse) et dans les prompts pour une IA (étape 1f). Une
|
||||
// seule écriture, pour que le prompt donne exactement les chiffres que
|
||||
// l'application affiche.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { AdvisedMove, MoveAdvice } from "../game/advice.js";
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
|
||||
/** Des chances de 0 à 1, en pourcentage arrondi, avec une espace
|
||||
* insécable : « 41 % ». */
|
||||
export function formatPercent(chance: number): string {
|
||||
return `${Math.round(chance * 100)} %`;
|
||||
}
|
||||
|
||||
/** Une ligne du classement des coups montré par « Voir les chiffres du
|
||||
* moteur ». */
|
||||
export interface RankingRow {
|
||||
readonly move: AdvisedMove;
|
||||
/** Rang dans le classement du moteur, à partir de 1. */
|
||||
readonly rank: number;
|
||||
/** Écart d'équité avec le premier coup du classement : 0 ou négatif. */
|
||||
readonly loss: number;
|
||||
/** C'est le coup joué. */
|
||||
readonly played: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le classement d'un conseil tel que l'affiche la feuille du maître : les
|
||||
* coups du classement du moteur, chacun avec son écart par rapport au
|
||||
* premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de
|
||||
* l'analyse du coup joué.
|
||||
*/
|
||||
export function rankingRows(advice: MoveAdvice): RankingRow[] {
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const rows = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
played: move.notation === advice.played.notation,
|
||||
}));
|
||||
if (!rows.some((row) => row.played)) {
|
||||
rows.push({
|
||||
move: advice.played,
|
||||
rank: advice.rank,
|
||||
loss: advice.equityLoss,
|
||||
played: true,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// stockage, dans le journal (events.ts, champ advice). Le format est
|
||||
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
|
||||
|
||||
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
|
||||
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ export type Advice = MoveAdvice | CubeAdvice;
|
||||
/** Un coup, tel que le conseil le retient. */
|
||||
export interface AdvisedMove {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
|
||||
* le moteur l'a évaluée. */
|
||||
|
||||
@@ -281,6 +281,15 @@ export interface EngineMatchAfter {
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
|
||||
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
|
||||
* fait que l'afficher.
|
||||
*
|
||||
* Glossaire : docs/glossaire/notation-des-coups.md
|
||||
*/
|
||||
export type MoveNotation = string;
|
||||
|
||||
/**
|
||||
* Un coup légal complet.
|
||||
*
|
||||
@@ -288,7 +297,7 @@ export interface EngineMatchAfter {
|
||||
*/
|
||||
export interface Move {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
/** Ses demi-coups, dans un ordre jouable. */
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** La position après le coup, vue du joueur qui vient de jouer. */
|
||||
|
||||
+61
-10
@@ -12,7 +12,15 @@
|
||||
import type { Advice } from "./advice.js";
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
import type {
|
||||
BoardSide,
|
||||
Engine,
|
||||
EngineCube,
|
||||
Level,
|
||||
MoveNotation,
|
||||
Ply,
|
||||
ResultKind,
|
||||
} from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
@@ -27,7 +35,14 @@ import {
|
||||
withAnalysesOf,
|
||||
} from "./events.js";
|
||||
import { firstGameOf, type Match, nextGameOf } from "./match.js";
|
||||
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
|
||||
import {
|
||||
BAR,
|
||||
formatHalfMoves,
|
||||
type HalfMove,
|
||||
OFF,
|
||||
type PositionId,
|
||||
START_POSITION,
|
||||
} from "./position.js";
|
||||
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
import { type GameState, replay } from "./state.js";
|
||||
@@ -186,8 +201,11 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
|
||||
* n'attend plus que d'être validé (confirmMove) ou défait
|
||||
* (undoLastHalfMove). */
|
||||
* peut être validé (confirmMove) ou défait (undoLastHalfMove). La saisie
|
||||
* n'est pas close pour autant : en sortie, un coup complet peut encore
|
||||
* se prolonger en un autre coup légal (avec 6-1 et des pions sur 6 et 1,
|
||||
* 6/off seul mène à la même position que 6/5/off, et 1/off reste
|
||||
* possible) ; nextHalfMoves dit lesquels, selon le moteur. */
|
||||
get moveComplete(): boolean {
|
||||
return this.#pending.at(-1)?.complete === true;
|
||||
}
|
||||
@@ -273,6 +291,29 @@ export class Game {
|
||||
return { side, halfMoves, boards, hits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le coup joué à l'événement eventId (un MovePlayed), dans la notation
|
||||
* du moteur : celle du coup légal de gnubg qui mène à la même position
|
||||
* (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan.
|
||||
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
|
||||
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
|
||||
*/
|
||||
async moveNotation(eventId: string): Promise<MoveNotation> {
|
||||
const line = currentLine({ ...this.#journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const before = replay(line.slice(0, -1));
|
||||
const roll = rollToPlay(before);
|
||||
const [check, legal] = await Promise.all([
|
||||
this.#engine.checkHalfMoves(before.position, roll, event.halfMoves),
|
||||
this.#engine.legalMoves(before.position, roll),
|
||||
]);
|
||||
const move = legal.find((m) => m.position === check.position);
|
||||
return move?.notation ?? formatHalfMoves(event.halfMoves);
|
||||
}
|
||||
|
||||
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
|
||||
* identifiant d'événement. */
|
||||
get advice(): Readonly<Record<string, Advice>> {
|
||||
@@ -296,11 +337,14 @@ export class Game {
|
||||
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
|
||||
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
|
||||
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
|
||||
* un coup légal. Aucune règle n'est jugée ici.
|
||||
* un coup légal. Aucune règle n'est jugée ici : même après un coup
|
||||
* complet, c'est le moteur qui dit si un dé reste à jouer. Seule
|
||||
* exception : une fois tous les dés utilisés (diceLeft), rien n'est
|
||||
* demandé, le moteur n'acceptant pas plus de demi-coups que de dés.
|
||||
*/
|
||||
async nextHalfMoves(): Promise<HalfMove[]> {
|
||||
const state = this.state;
|
||||
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
|
||||
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
|
||||
return [];
|
||||
}
|
||||
const roll = rollToPlay(state);
|
||||
@@ -370,13 +414,13 @@ export class Game {
|
||||
|
||||
/**
|
||||
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
|
||||
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
|
||||
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
|
||||
* complet (moveComplete), il attend d'être validé (confirmMove).
|
||||
* mène à aucun coup légal : il est refusé et rien ne change. Le coup
|
||||
* n'est pas joué pour autant : une fois complet (moveComplete), il attend
|
||||
* d'être validé (confirmMove), ou prolongé si le moteur le permet.
|
||||
*/
|
||||
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
|
||||
const state = this.#expect("player", "moving");
|
||||
if (this.moveComplete) {
|
||||
if (!this.#diceLeft(state)) {
|
||||
return false;
|
||||
}
|
||||
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
|
||||
@@ -393,6 +437,13 @@ export class Game {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Il reste un dé à jouer : moins de demi-coups saisis que de dés
|
||||
* (quatre pour un double). */
|
||||
#diceLeft(state: GameState): boolean {
|
||||
const roll = rollToPlay(state);
|
||||
return this.#pending.length < (roll[0] === roll[1] ? 4 : 2);
|
||||
}
|
||||
|
||||
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
|
||||
* pas) : il n'a jamais été dans le journal. */
|
||||
undoLastHalfMove(): void {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// La position que décrit un prompt, étape 1f : ses identifiants de GNU
|
||||
// Backgammon, son plateau décodé par le moteur, le videau et le match ; et
|
||||
// son diagramme texte.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Rien n'est décodé ici : le moteur lit la position (board) et écrit le
|
||||
// Match ID (encodeMatch). Le diagramme ne fait que recopier le plateau
|
||||
// qu'il a décodé.
|
||||
|
||||
import type { Cube } from "../game/cube.js";
|
||||
import type { Engine, Evaluation, MatchId } from "../game/engine.js";
|
||||
import { currentLine, type GameJournal } from "../game/events.js";
|
||||
import { engineCube, type SideBoards } from "../game/game.js";
|
||||
import type { Match } from "../game/match.js";
|
||||
import type { PositionId } from "../game/position.js";
|
||||
import type { Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
import { type GameState, replay } from "../game/state.js";
|
||||
|
||||
/** L'adresse publique de l'application (ADR 0013 : les liens de position
|
||||
* s'ouvrent sur elle). */
|
||||
export const PUBLIC_SITE = "https://ustagammon.sans.pub/";
|
||||
|
||||
/** Une position telle que la décrit un prompt. */
|
||||
export interface PromptPosition {
|
||||
/** Position ID de gnubg, vue du camp au trait. */
|
||||
readonly positionId: PositionId;
|
||||
/** Match ID écrit par le moteur, du point de vue du camp au trait, sans
|
||||
* dés. */
|
||||
readonly matchId: MatchId;
|
||||
readonly onRoll: Side;
|
||||
/** Le plateau décodé par le moteur, chaque camp dans sa numérotation. */
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
/** null en partie libre. */
|
||||
readonly match: Match | null;
|
||||
/** Le jet à jouer, s'il est déjà tiré. */
|
||||
readonly roll: Roll | null;
|
||||
}
|
||||
|
||||
/** La position d'avant l'événement eventId du journal (la décision qu'on
|
||||
* commente), lue par le moteur. */
|
||||
export async function positionBefore(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
eventId: string,
|
||||
): Promise<PromptPosition> {
|
||||
const line = currentLine({ ...journal, head: eventId });
|
||||
return positionOf(engine, replay(line.slice(0, -1)));
|
||||
}
|
||||
|
||||
/** La position actuelle d'une partie, ou la position de départ sans
|
||||
* partie, lue par le moteur. */
|
||||
export async function positionNow(
|
||||
engine: Engine,
|
||||
journal: GameJournal | null,
|
||||
): Promise<PromptPosition> {
|
||||
return positionOf(engine, replay(journal === null ? [] : currentLine(journal)));
|
||||
}
|
||||
|
||||
async function positionOf(engine: Engine, state: GameState): Promise<PromptPosition> {
|
||||
// Pendant le jet d'ouverture, la position de départ, vue du joueur.
|
||||
const onRoll = state.turn ?? "player";
|
||||
const cube = engineCube(state, onRoll);
|
||||
const [board, matchId] = await Promise.all([
|
||||
engine.board(state.position, cube),
|
||||
engine.encodeMatch(cube),
|
||||
]);
|
||||
const sides =
|
||||
onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
return {
|
||||
positionId: state.position,
|
||||
matchId,
|
||||
onRoll,
|
||||
sides,
|
||||
cube: state.cube,
|
||||
match: state.match,
|
||||
roll: state.phase === "moving" ? state.roll : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** L'évaluation par le moteur d'une position, pour le camp au trait avant
|
||||
* de lancer les dés. */
|
||||
export function evaluatePosition(
|
||||
engine: Engine,
|
||||
position: PromptPosition,
|
||||
ply: number,
|
||||
): Promise<Evaluation> {
|
||||
const cube = engineCube({ cube: position.cube, match: position.match }, position.onRoll);
|
||||
return engine.evaluate(position.positionId, cube, ply);
|
||||
}
|
||||
|
||||
/** L'adresse publique de la position (#/position/<Position ID>/<Match ID>,
|
||||
* ADR 0013, identifiants encodés avec encodeURIComponent). */
|
||||
export function positionAddress(position: PromptPosition): string {
|
||||
const id = encodeURIComponent(position.positionId);
|
||||
return `${PUBLIC_SITE}#/position/${id}/${encodeURIComponent(position.matchId)}`;
|
||||
}
|
||||
|
||||
/** Nombre de lignes de pions dessinées par moitié du plateau ; au-delà, la
|
||||
* dernière ligne porte le nombre de pions. */
|
||||
const ROWS = 5;
|
||||
|
||||
/**
|
||||
* Le diagramme texte d'une position, vu du joueur : ses pions X, ceux de
|
||||
* l'ordinateur O, dans la numérotation du joueur (sa case 1 est la plus
|
||||
* proche de sa sortie, en bas à droite). Sous le dessin, la liste des pions
|
||||
* case par case, la barre, les pions sortis et les comptes de points, tels
|
||||
* que le moteur les donne.
|
||||
*/
|
||||
export function boardDiagram(sides: SideBoards): string {
|
||||
// La case p du joueur est la case 25 - p de l'ordinateur.
|
||||
const at = (point: number): { mark: string; count: number } => {
|
||||
const mine = sides.player.points[point - 1] ?? 0;
|
||||
const theirs = sides.computer.points[24 - point] ?? 0;
|
||||
if (mine > 0) {
|
||||
return { mark: "X", count: mine };
|
||||
}
|
||||
return theirs > 0 ? { mark: "O", count: theirs } : { mark: ".", count: 0 };
|
||||
};
|
||||
const cell = (text: string) => text.padStart(3, " ");
|
||||
const half = (points: readonly number[]) =>
|
||||
`${points.slice(0, 6).map(String).map(cell).join("")} |${points.slice(6).map(String).map(cell).join("")}`;
|
||||
const row = (points: readonly number[], level: number) => {
|
||||
const cells = points.map((p) => {
|
||||
const { mark, count } = at(p);
|
||||
if (count < level) {
|
||||
return level === 1 ? cell(".") : cell("");
|
||||
}
|
||||
return cell(level === ROWS && count > ROWS ? String(count) : mark);
|
||||
});
|
||||
return `${cells.slice(0, 6).join("")} |${cells.slice(6).join("")}`.trimEnd();
|
||||
};
|
||||
const top = [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];
|
||||
const bottom = [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
||||
const lines = [half(top)];
|
||||
for (let level = 1; level <= ROWS; level++) {
|
||||
lines.push(row(top, level));
|
||||
}
|
||||
lines.push("");
|
||||
for (let level = ROWS; level >= 1; level--) {
|
||||
lines.push(row(bottom, level));
|
||||
}
|
||||
lines.push(half(bottom));
|
||||
|
||||
const list = (mark: "X" | "O") => {
|
||||
const parts: string[] = [];
|
||||
for (let point = 24; point >= 1; point--) {
|
||||
const here = at(point);
|
||||
if (here.mark === mark) {
|
||||
parts.push(`${point} (${here.count})`);
|
||||
}
|
||||
}
|
||||
return parts.length === 0 ? "aucun" : parts.join(", ");
|
||||
};
|
||||
return [
|
||||
"```",
|
||||
...lines,
|
||||
"```",
|
||||
"X : mes pions ; O : ceux de l'ordinateur. Les cases sont numérotées de mon point de vue : je joue de la case 24 vers la case 1, mon jan intérieur est en bas à droite (cases 1 à 6).",
|
||||
`Mes pions (X), case (nombre) : ${list("X")}.`,
|
||||
`Pions de l'ordinateur (O), dans ma numérotation : ${list("O")}.`,
|
||||
`Sur la barre : moi ${sides.player.bar}, l'ordinateur ${sides.computer.bar}. Sortis : moi ${sides.player.off}, l'ordinateur ${sides.computer.off}.`,
|
||||
`Compte de points : moi ${sides.player.pipCount}, l'ordinateur ${sides.computer.pipCount}.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Le camp, à la première personne du joueur : « moi », « l'ordinateur ». */
|
||||
export function sideName(side: Side): string {
|
||||
return side === "player" ? "moi" : "l'ordinateur";
|
||||
}
|
||||
|
||||
/** Le score : « partie libre » ou celui du match. */
|
||||
export function scoreText(position: Pick<PromptPosition, "match">): string {
|
||||
const match = position.match;
|
||||
if (match === null) {
|
||||
return "partie libre, sans match (règle Jacoby)";
|
||||
}
|
||||
const crawford = match.crawford ? ", partie Crawford" : "";
|
||||
return `match en ${match.length} points, moi ${match.score.player}, l'ordinateur ${match.score.computer}${crawford}`;
|
||||
}
|
||||
|
||||
/** Le videau : sa valeur et qui le possède. */
|
||||
export function cubeText(position: Pick<PromptPosition, "cube" | "match">): string {
|
||||
if (position.match?.crawford === true) {
|
||||
return "aucun (partie Crawford : personne ne peut doubler)";
|
||||
}
|
||||
const { value, owner } = position.cube;
|
||||
const where = owner === null ? "au centre" : owner === "player" ? "à moi" : "à l'ordinateur";
|
||||
return `${value}, ${where}`;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Les prompts pour une IA (étape 1f ; cadrage, « IA générative en V1 ») :
|
||||
// chaque gabarit de docs/prompts/ rempli avec la position, décodée par le
|
||||
// moteur, et les chiffres du moteur, écrits comme l'application les
|
||||
// affiche. Le moteur décide, l'IA explique : le prompt lui demande de ne
|
||||
// pas contredire ces chiffres.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les fonctions qui remplissent (decisionPrompt, summaryPrompt,
|
||||
// conceptPrompt, exercisesPrompt) sont pures : elles reçoivent les
|
||||
// analyses du moteur et la position déjà lue. Les fonctions asynchrones
|
||||
// (summaryPosition, conceptExample, exercisesPosition) ne font que demander
|
||||
// au moteur la position à décrire.
|
||||
|
||||
import {
|
||||
type ErrorRate,
|
||||
gameTurns,
|
||||
levelOfErrorRate,
|
||||
type ReviewedDecision,
|
||||
summarizeAnalysis,
|
||||
} from "../analysis/game-analysis.js";
|
||||
import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js";
|
||||
import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
masterCubeAction,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction, Engine, Evaluation, Ply } from "../game/engine.js";
|
||||
import { currentLine, type GameJournal } from "../game/events.js";
|
||||
import { type ComputerLevel, computerLevelOf } from "../game/level.js";
|
||||
import { replay } from "../game/state.js";
|
||||
import {
|
||||
boardDiagram,
|
||||
cubeText,
|
||||
evaluatePosition,
|
||||
type PromptPosition,
|
||||
positionAddress,
|
||||
positionBefore,
|
||||
positionNow,
|
||||
scoreText,
|
||||
sideName,
|
||||
} from "./position.js";
|
||||
import { fillPrompt, type Prompt, type PromptTemplates } from "./templates.js";
|
||||
|
||||
/** La langue des réponses demandées à l'IA. */
|
||||
export const PROMPT_LANGUAGE = "français";
|
||||
|
||||
/** Les noms des niveaux, les mêmes que l'écran « Nouvelle partie » et le
|
||||
* bilan (vérifié par app/tests/prompts.test.ts). */
|
||||
export const LEVEL_NAMES: Readonly<Record<ComputerLevel, string>> = {
|
||||
beginner: "Débutant",
|
||||
amateur: "Amateur",
|
||||
intermediate: "Intermédiaire",
|
||||
advanced: "Avancé",
|
||||
expert: "Expert",
|
||||
champion: "Champion",
|
||||
};
|
||||
|
||||
/** Les décisions de videau, comme le bilan les nomme. */
|
||||
export const CUBE_ACTION_WORDS: Readonly<Record<CubeAction, string>> = {
|
||||
double: "doubler",
|
||||
noDouble: "lancer sans doubler",
|
||||
take: "prendre",
|
||||
pass: "passer",
|
||||
};
|
||||
|
||||
/** Les lignes des équités de videau, comme « Voir les chiffres du
|
||||
* moteur ». */
|
||||
export const CUBE_ROW_WORDS = {
|
||||
noDouble: "Pas de double",
|
||||
doubleTake: "Double, prend",
|
||||
doublePass: "Double, passe",
|
||||
} as const;
|
||||
|
||||
/** Les erreurs citées au plus dans un prompt : bilan, exercices. */
|
||||
const MAX_ERRORS = 8;
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
|
||||
|
||||
/** « analyse à 2 plis ». */
|
||||
export function plyText(ply: Ply): string {
|
||||
return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`;
|
||||
}
|
||||
|
||||
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
|
||||
* dit dans un prompt : en dessous, l'erreur par décision dit peu de chose. */
|
||||
export const LEVEL_MIN_DECISIONS = 20;
|
||||
|
||||
/** Ce que le joueur dit de lui-même dans un prompt : son niveau, d'après
|
||||
* son erreur par décision mesurée par GNU Backgammon, s'il est mesuré sur
|
||||
* au moins LEVEL_MIN_DECISIONS décisions ; sinon, qu'il débute (pas encore
|
||||
* d'historique des parties : étape 1h). Jamais le taux lui-même, qui n'est
|
||||
* que dans le bilan. */
|
||||
export function playerSentence(rate: Pick<ErrorRate, "decisions" | "level"> | null): string {
|
||||
if (rate === null || rate.decisions < LEVEL_MIN_DECISIONS) {
|
||||
return "Je débute au backgammon.";
|
||||
}
|
||||
return `Je suis un joueur de niveau ${LEVEL_NAMES[rate.level]}.`;
|
||||
}
|
||||
|
||||
/** playerSentence, sur les décisions d'une partie. */
|
||||
function playerOf(journal: GameJournal | null): string {
|
||||
return playerSentence(journal === null ? null : summarizeAnalysis(journal).player.errorRate);
|
||||
}
|
||||
|
||||
/** Les repères de la position, communs à tous les gabarits. */
|
||||
function positionValues(position: PromptPosition): Record<string, string> {
|
||||
return {
|
||||
position_id: position.positionId,
|
||||
match_id: position.matchId,
|
||||
au_trait: sideName(position.onRoll),
|
||||
lien: positionAddress(position),
|
||||
diagramme: boardDiagram(position.sides),
|
||||
score: scoreText(position),
|
||||
videau: cubeText(position),
|
||||
langue: PROMPT_LANGUAGE,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Une décision
|
||||
|
||||
/** Ce que demande le prompt d'une décision du joueur. */
|
||||
export interface DecisionPromptData {
|
||||
/** La partie : le niveau du joueur se lit sur ses décisions. */
|
||||
readonly journal: GameJournal;
|
||||
/** Le conseil du moteur sur la décision. */
|
||||
readonly advice: Advice;
|
||||
/** La position d'avant la décision (positionBefore). */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Pourquoi ce coup est meilleur » ou « Expliquer cette
|
||||
* décision de videau ». */
|
||||
export function decisionPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: DecisionPromptData,
|
||||
): Prompt {
|
||||
const common = {
|
||||
...positionValues(data.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(data.advice.ply),
|
||||
appreciation: masterWords(phrases, data.advice).word,
|
||||
};
|
||||
return data.advice.kind === "move"
|
||||
? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) })
|
||||
: fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) });
|
||||
}
|
||||
|
||||
function moveValues(advice: MoveAdvice): Record<string, string> {
|
||||
const count = plural(advice.moveCount, "coup possible", "coups possibles");
|
||||
const rank =
|
||||
advice.moveCount <= 1
|
||||
? "le seul coup possible"
|
||||
: advice.rank === 1
|
||||
? `le meilleur des ${count}`
|
||||
: `le ${advice.rank}e choix sur ${count}`;
|
||||
// Les trois meilleurs coups, et le vôtre s'il n'en est pas : les lignes
|
||||
// de « Voir les chiffres du moteur », avec les mêmes chiffres.
|
||||
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
|
||||
const ranking = rows.map((row) => {
|
||||
const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`;
|
||||
const mine = row.played ? " (mon coup)" : "";
|
||||
return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`;
|
||||
});
|
||||
return {
|
||||
des: `${advice.roll[0]}-${advice.roll[1]}`,
|
||||
coup_joue: advice.played.notation,
|
||||
rang: rank,
|
||||
classement: ranking.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function cubeValues(advice: CubeAdvice): Record<string, string> {
|
||||
const answering = advice.action === "take" || advice.action === "pass";
|
||||
// Les lignes de votre décision : doubler mène à l'une des deux lignes
|
||||
// « Double », selon la réponse de l'ordinateur (comme la feuille).
|
||||
const mine = {
|
||||
noDouble: ["noDouble"],
|
||||
double: ["doubleTake", "doublePass"],
|
||||
take: ["doubleTake"],
|
||||
pass: ["doublePass"],
|
||||
}[advice.action];
|
||||
const rows = (["noDouble", "doubleTake", "doublePass"] as const).map((key) => {
|
||||
const note = mine.length === 1 && mine.includes(key) ? " (ma décision)" : "";
|
||||
return `- ${CUBE_ROW_WORDS[key]}${note} : ${formatEquity(advice.equities[key])}`;
|
||||
});
|
||||
return {
|
||||
decision: answering
|
||||
? "prendre ou passer le double de l'ordinateur"
|
||||
: "doubler ou lancer les dés sans doubler",
|
||||
choix: CUBE_ACTION_WORDS[advice.action],
|
||||
choix_moteur: CUBE_ACTION_WORDS[masterCubeAction(advice)],
|
||||
qui_double: answering ? "l'ordinateur" : "moi",
|
||||
equites: rows.join("\n"),
|
||||
ecart: formatEquity(advice.equityLoss),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Le bilan
|
||||
|
||||
/** Une imprécision ou une erreur du joueur, dans son tour. */
|
||||
interface Mistake {
|
||||
readonly turn: number;
|
||||
readonly advice: Advice;
|
||||
/** Le jet du tour, « 6-1 » (vide pour un tour sans jet). */
|
||||
readonly roll: string;
|
||||
}
|
||||
|
||||
/** Les imprécisions et erreurs du joueur sur la ligne courante, dans
|
||||
* l'ordre de la partie. */
|
||||
function mistakesOf(journal: GameJournal): Mistake[] {
|
||||
const advice = journal.advice ?? {};
|
||||
const mistakes: Mistake[] = [];
|
||||
for (const turn of gameTurns(journal)) {
|
||||
for (const event of turn.events) {
|
||||
const a = advice[event.id];
|
||||
if (a !== undefined && verdictOf(a.skill) !== "good") {
|
||||
const roll = turn.roll === null ? "" : `${turn.roll[0]}-${turn.roll[1]}`;
|
||||
mistakes.push({ turn: turn.number, advice: a, roll });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mistakes;
|
||||
}
|
||||
|
||||
/** Une ligne de la liste des erreurs : ce qui a été joué, ce que le moteur
|
||||
* préférait, l'écart et le mot du maître. */
|
||||
function mistakeLine(phrases: MasterPhrases, mistake: Mistake, where: string): string {
|
||||
const a = mistake.advice;
|
||||
const word = masterWords(phrases, a).word;
|
||||
const cost = `écart ${formatEquity(a.equityLoss)} (${word})`;
|
||||
if (a.kind === "move") {
|
||||
return `- ${where}, ${mistake.roll} : j'ai joué ${a.played.notation}, GNU Backgammon préférait ${a.best.notation} ; ${cost}.`;
|
||||
}
|
||||
return `- ${where} : j'ai choisi de ${CUBE_ACTION_WORDS[a.action]}, GNU Backgammon aurait choisi de ${CUBE_ACTION_WORDS[masterCubeAction(a)]} ; ${cost}.`;
|
||||
}
|
||||
|
||||
/** Le niveau de l'ordinateur d'une partie, par son nom. */
|
||||
function computerLevelName(journal: GameJournal): string {
|
||||
const level = replay(currentLine(journal)).level;
|
||||
const name = computerLevelOf(level);
|
||||
return name === null ? `GNU Backgammon, réglage ${level}` : LEVEL_NAMES[name];
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, et le match après elle. */
|
||||
function resultText(journal: GameJournal): string {
|
||||
const line = currentLine(journal);
|
||||
const state = replay(line);
|
||||
const result = state.result;
|
||||
if (result === null) {
|
||||
return "partie pas encore finie";
|
||||
}
|
||||
const who = result.winner === "player" ? "j'ai gagné" : "l'ordinateur a gagné";
|
||||
const kind = {
|
||||
single: "une partie simple",
|
||||
gammon: "un gammon",
|
||||
backgammon: "un backgammon",
|
||||
}[result.kind];
|
||||
const dropped = line.some((e) => e.type === "CubeDropped")
|
||||
? result.winner === "player"
|
||||
? ", l'ordinateur ayant refusé mon double"
|
||||
: ", après mon refus de son double"
|
||||
: "";
|
||||
const cube = state.cube.value > 1 ? `, videau à ${state.cube.value}` : "";
|
||||
const text = `${who} ${kind}, ${plural(result.points, "point", "points")}${cube}${dropped}`;
|
||||
const after = state.matchAfter;
|
||||
if (state.match === null || after === null) {
|
||||
return text;
|
||||
}
|
||||
const over = after.over ? ", match terminé" : "";
|
||||
return `${text} ; match en ${state.match.length} points, score après la partie : moi ${after.score.player}, l'ordinateur ${after.score.computer}${over}`;
|
||||
}
|
||||
|
||||
/** La position que montre le prompt du bilan : celle d'avant le coup à
|
||||
* revoir, ou la position finale si le joueur n'a fait aucune erreur. */
|
||||
export async function summaryPosition(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
): Promise<PromptPosition> {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
return toReview === null
|
||||
? positionNow(engine, journal)
|
||||
: positionBefore(engine, journal, toReview.advice.eventId);
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt du bilan. */
|
||||
export interface SummaryPromptData {
|
||||
/** La partie finie, analysée (readyForSummary). */
|
||||
readonly journal: GameJournal;
|
||||
/** summaryPosition. */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Bilan de ma partie ». */
|
||||
export function summaryPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: SummaryPromptData,
|
||||
): Prompt {
|
||||
const summary = summarizeAnalysis(data.journal);
|
||||
const { player, computer, counts } = summary;
|
||||
const errors = counts.bad + counts.veryBad;
|
||||
const veryBad =
|
||||
counts.veryBad > 0
|
||||
? ` (dont ${plural(counts.veryBad, "grosse erreur", "grosses erreurs")})`
|
||||
: "";
|
||||
const mistakes = mistakesOf(data.journal);
|
||||
const listed = [...mistakes]
|
||||
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss)
|
||||
.slice(0, MAX_ERRORS)
|
||||
.sort((a, b) => a.turn - b.turn)
|
||||
.map((m) => mistakeLine(phrases, m, `Coup ${m.turn}`));
|
||||
if (mistakes.length > listed.length) {
|
||||
listed.push(`- et ${mistakes.length - listed.length} de moins coûteuses.`);
|
||||
}
|
||||
const toReview = summary.toReview;
|
||||
return fillPrompt(templates.summary, {
|
||||
...positionValues(data.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(summaryPly(data.journal, toReview)),
|
||||
ordinateur: computerLevelName(data.journal),
|
||||
resultat: resultText(data.journal),
|
||||
decomptes: `${plural(counts.good, "bon coup", "bons coups")}, ${plural(counts.doubtful, "imprécision", "imprécisions")}, ${plural(errors, "erreur", "erreurs")}${veryBad}`,
|
||||
taux: formatEquity(-player.errorRate.perDecision),
|
||||
decisions: plural(player.errorRate.decisions, "décision", "décisions"),
|
||||
niveau_jeu: LEVEL_NAMES[player.errorRate.level],
|
||||
taux_ordinateur: `${formatEquity(-computer.errorRate.perDecision)} sur ${plural(computer.errorRate.decisions, "décision", "décisions")}, soit le niveau ${LEVEL_NAMES[computer.errorRate.level]}`,
|
||||
chance: formatEquity(player.luck.perRoll),
|
||||
chance_mots: diceWords(phrases, player.luck.rating),
|
||||
chance_ordinateur: formatEquity(computer.luck.perRoll),
|
||||
erreurs:
|
||||
listed.length === 0 ? "- aucune : j'ai joué comme GNU Backgammon." : listed.join("\n"),
|
||||
position_titre:
|
||||
toReview === null
|
||||
? "Je n'ai fait ni imprécision ni erreur. Voici la position finale"
|
||||
: `Le coup à revoir, ma plus grosse erreur (coup ${toReview.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Le niveau des analyses du bilan : celui du coup à revoir, ou de
|
||||
* l'analyse de partie (2 plis). */
|
||||
function summaryPly(journal: GameJournal, toReview: ReviewedDecision | null): Ply {
|
||||
return toReview?.advice.ply ?? journal.review?.ply ?? 2;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Un concept
|
||||
|
||||
/** La position d'exemple d'un concept : la partie en cours, ou la
|
||||
* position de départ s'il n'y en a pas (ou si elle est finie), avec
|
||||
* l'évaluation du moteur. */
|
||||
export interface ConceptExample {
|
||||
readonly position: PromptPosition;
|
||||
readonly evaluation: Evaluation;
|
||||
readonly ply: Ply;
|
||||
readonly fromGame: boolean;
|
||||
}
|
||||
|
||||
/** Lit la position d'exemple d'un concept et l'évalue, au niveau ply. */
|
||||
export async function conceptExample(
|
||||
engine: Engine,
|
||||
journal: GameJournal | null,
|
||||
ply: Ply,
|
||||
): Promise<ConceptExample> {
|
||||
const playing =
|
||||
journal !== null && !["opening", "over"].includes(replay(currentLine(journal)).phase);
|
||||
const position = await positionNow(engine, playing ? journal : null);
|
||||
const evaluation = await evaluatePosition(engine, position, ply);
|
||||
return { position, evaluation, ply, fromGame: playing };
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt d'un concept. */
|
||||
export interface ConceptPromptData {
|
||||
/** Le terme, son nom anglais (peut être vide) et sa phrase « En bref ». */
|
||||
readonly term: string;
|
||||
readonly english: string;
|
||||
readonly brief: string;
|
||||
/** La partie en cours, pour le niveau du joueur (null : aucune). */
|
||||
readonly journal: GameJournal | null;
|
||||
readonly example: ConceptExample;
|
||||
}
|
||||
|
||||
/** Le prompt « Expliquer ce concept ». */
|
||||
export function conceptPrompt(templates: PromptTemplates, data: ConceptPromptData): Prompt {
|
||||
const { example } = data;
|
||||
const english =
|
||||
data.english !== "" && data.english.toLowerCase() !== data.term.toLowerCase()
|
||||
? ` (en anglais : ${data.english})`
|
||||
: "";
|
||||
return fillPrompt(templates.concept, {
|
||||
...positionValues(example.position),
|
||||
joueur: playerOf(data.journal),
|
||||
niveau_eval: plyText(example.ply),
|
||||
concept: `« ${data.term} »${english}`,
|
||||
definition: data.brief,
|
||||
position_titre: example.fromGame
|
||||
? "une position de ma partie en cours"
|
||||
: "la position de départ",
|
||||
evaluation: `chances de gagner ${formatPercent(example.evaluation.win)}, équité ${formatEquity(example.evaluation.equity)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Des exercices
|
||||
|
||||
/** Une erreur dans une des parties. */
|
||||
interface GameMistake extends Mistake {
|
||||
/** Rang de la partie, à partir de 1 (la plus ancienne). */
|
||||
readonly game: number;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
/** Les imprécisions et erreurs de plusieurs parties, de la plus coûteuse à
|
||||
* la moins coûteuse. */
|
||||
function costliest(journals: readonly GameJournal[]): GameMistake[] {
|
||||
return journals
|
||||
.flatMap((journal, i) => mistakesOf(journal).map((m) => ({ ...m, game: i + 1, journal })))
|
||||
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss);
|
||||
}
|
||||
|
||||
/** La position de la plus coûteuse des erreurs de ces parties (null s'il
|
||||
* n'y en a aucune : pas d'exercice à proposer). */
|
||||
export async function exercisesPosition(
|
||||
engine: Engine,
|
||||
journals: readonly GameJournal[],
|
||||
): Promise<PromptPosition | null> {
|
||||
const worst = costliest(journals)[0];
|
||||
return worst === undefined ? null : positionBefore(engine, worst.journal, worst.advice.eventId);
|
||||
}
|
||||
|
||||
/** Ce que demande le prompt des exercices. */
|
||||
export interface ExercisesPromptData {
|
||||
/** Les parties analysées, de la plus ancienne à la plus récente. */
|
||||
readonly journals: readonly GameJournal[];
|
||||
/** exercisesPosition (non null). */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h). Erreur si
|
||||
* les parties n'ont aucune imprécision ni erreur. */
|
||||
export function exercisesPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: ExercisesPromptData,
|
||||
): Prompt {
|
||||
const mistakes = costliest(data.journals);
|
||||
const worst = mistakes[0];
|
||||
if (worst === undefined) {
|
||||
throw new Error("exercices : aucune erreur dans ces parties");
|
||||
}
|
||||
// Le niveau, sur toutes les décisions de ces parties.
|
||||
let total = 0;
|
||||
let decisions = 0;
|
||||
for (const journal of data.journals) {
|
||||
const rate = summarizeAnalysis(journal).player.errorRate;
|
||||
total += rate.total;
|
||||
decisions += rate.decisions;
|
||||
}
|
||||
const level = levelOfErrorRate(decisions > 0 ? total / decisions : 0);
|
||||
const listed = mistakes
|
||||
.slice(0, MAX_ERRORS)
|
||||
.map((m) => mistakeLine(phrases, m, `Partie ${m.game}, coup ${m.turn}`));
|
||||
return fillPrompt(templates.exercises, {
|
||||
...positionValues(data.position),
|
||||
joueur: playerSentence({ decisions, level }),
|
||||
niveau_eval: plyText(worst.advice.ply),
|
||||
parties: String(data.journals.length),
|
||||
erreurs: listed.join("\n"),
|
||||
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Les gabarits des prompts pour une IA (étape 1f) : lecture de leur source
|
||||
// (docs/prompts/, ADR 0008 : une seule source) et remplissage des repères.
|
||||
// Fonctions pures, testées sous Node sur de vraies analyses du moteur
|
||||
// (app/tests/prompts.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Un prompt : le texte que l'application prépare pour une IA, rempli avec
|
||||
* la position et les chiffres du moteur, et que le joueur copie.
|
||||
*
|
||||
* Glossaire : docs/glossaire/prompt.md
|
||||
*/
|
||||
export type Prompt = string;
|
||||
|
||||
/** Les cinq gabarits du cadrage (« IA générative en V1 »). */
|
||||
export type PromptKind = "move" | "cube" | "summary" | "concept" | "exercises";
|
||||
|
||||
export const PROMPT_KINDS: readonly PromptKind[] = [
|
||||
"move",
|
||||
"cube",
|
||||
"summary",
|
||||
"concept",
|
||||
"exercises",
|
||||
];
|
||||
|
||||
/** Le fichier de chaque gabarit, dans docs/prompts/. */
|
||||
export const PROMPT_FILES: Readonly<Record<PromptKind, string>> = {
|
||||
move: "coup.md",
|
||||
cube: "videau.md",
|
||||
summary: "bilan.md",
|
||||
concept: "concept.md",
|
||||
exercises: "exercices.md",
|
||||
};
|
||||
|
||||
/** Les gabarits lus, par sorte. */
|
||||
export type PromptTemplates = Readonly<Record<PromptKind, string>>;
|
||||
|
||||
/** Le gabarit d'un fichier de docs/prompts/ : le bloc de texte qui suit le
|
||||
* titre « ## Gabarit ». Erreur s'il manque. */
|
||||
export function parsePromptTemplate(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const heading = lines.findIndex((line) => line.trim() === "## Gabarit");
|
||||
const start = lines.findIndex((line, i) => i > heading && line.trim() === "```text");
|
||||
const end = lines.findIndex((line, i) => i > start && line.trim() === "```");
|
||||
if (heading < 0 || start < 0 || end < 0) {
|
||||
throw new Error("gabarit de prompt : bloc « ## Gabarit » absent");
|
||||
}
|
||||
return lines.slice(start + 1, end).join("\n");
|
||||
}
|
||||
|
||||
/** Les repères d'un gabarit, dans l'ordre, sans doublon. */
|
||||
export function placeholders(template: string): string[] {
|
||||
return [...new Set([...template.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1] as string))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplit les repères {nom} d'un gabarit. Erreur sur un repère inconnu ou
|
||||
* une valeur vide : un prompt n'a jamais de trou.
|
||||
*/
|
||||
export function fillPrompt(template: string, values: Readonly<Record<string, string>>): Prompt {
|
||||
return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} inconnu`);
|
||||
}
|
||||
if (value.trim() === "") {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} vide`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -5,7 +5,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { analyseDecision } from "./domain/analysis/analyse.js";
|
||||
import { ANALYSIS_PLY, analyseDecision } from "./domain/analysis/analyse.js";
|
||||
import { runAnalysis } from "./domain/analysis/game-analysis.js";
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { positionBefore } from "./domain/prompts/position.js";
|
||||
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
@@ -164,6 +166,13 @@ startApp({
|
||||
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
|
||||
// manquent, une à une, sur le même moteur à part.
|
||||
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
|
||||
// Les prompts pour une IA (étape 1f) : la position à décrire, lue par
|
||||
// le moteur, et l'évaluation de la position d'exemple d'un concept.
|
||||
prompts: {
|
||||
before: (journal, eventId) => positionBefore(engine, journal, eventId),
|
||||
summary: (journal) => summaryPosition(engine, journal),
|
||||
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
|
||||
},
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
@@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: st
|
||||
pass: { file: "passer", term: "Passer" },
|
||||
};
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
/** Un nombre à la française, signé, à trois décimales : « −0,123 » ; la
|
||||
* même écriture que les prompts (domain/analysis/numbers.ts). */
|
||||
export { formatEquity } from "../domain/analysis/numbers.js";
|
||||
|
||||
+25
-19
@@ -7,6 +7,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { rankingRows } from "../domain/analysis/numbers.js";
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type Advice,
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
} from "../domain/game/advice.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { askAiLink, clearSheet } from "./ask-ai.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Préfixe des identifiants du plateau de la feuille. */
|
||||
@@ -46,6 +49,8 @@ export interface AdviceView {
|
||||
/** Ouvre la feuille sur le conseil de l'événement eventId. */
|
||||
show(eventId: string): Promise<void>;
|
||||
hide(): void;
|
||||
/** La feuille, où la page du prompt prend la place du contenu (1f). */
|
||||
readonly sheet: HTMLElement;
|
||||
}
|
||||
|
||||
type Shown = "mine" | "best";
|
||||
@@ -90,7 +95,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const id = ++opened;
|
||||
const game = options.game();
|
||||
const advice = game?.advice[eventId] ?? null;
|
||||
sheet.replaceChildren();
|
||||
clearSheet(sheet);
|
||||
veil.hidden = false;
|
||||
const head = element(sheet, "div", { className: "sheet-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
@@ -148,12 +153,21 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: entry.file });
|
||||
}
|
||||
closeButton();
|
||||
closeButton(advice.eventId);
|
||||
close.focus();
|
||||
}
|
||||
|
||||
function closeButton(): void {
|
||||
/** « Demander à une IA » (étape 1f), s'il y a un conseil, et
|
||||
* « Fermer ». */
|
||||
function closeButton(eventId: string | null = null): void {
|
||||
const actions = element(sheet, "div", { className: "explain-actions" });
|
||||
if (eventId !== null) {
|
||||
askAiLink(
|
||||
actions,
|
||||
"explain-ask-ai",
|
||||
routeHash({ screen: "explanation", event: eventId, ai: true }),
|
||||
);
|
||||
}
|
||||
const done = element(actions, "button", {
|
||||
id: "explain-done",
|
||||
className: "secondary",
|
||||
@@ -190,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const svg = sheetBoard(boardBox);
|
||||
if (best === null) {
|
||||
svg.dataset.move = "mine";
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
|
||||
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
showWithNotation(legend, text, advice.played.notation);
|
||||
void player?.play(mine);
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
shown === "mine"
|
||||
? ADVICE_TEXTS.legendMine(notation)
|
||||
: ADVICE_TEXTS.legendBest(notation);
|
||||
legend.textContent = text;
|
||||
showWithNotation(legend, text, notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
svg.dataset.move = shown;
|
||||
void player?.play(shown === "mine" ? mine : best);
|
||||
@@ -276,18 +291,8 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
if (advice.kind === "move") {
|
||||
// Les écarts du classement se lisent par rapport à son premier
|
||||
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
|
||||
// l'analyse du coup joué.
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const listed = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
}));
|
||||
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
|
||||
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
|
||||
}
|
||||
for (const { move, rank, loss } of listed) {
|
||||
const mine = move.notation === advice.played.notation;
|
||||
// l'analyse du coup joué (les mêmes lignes que le prompt).
|
||||
for (const { move, rank, loss, played: mine } of rankingRows(advice)) {
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
@@ -331,12 +336,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
|
||||
return {
|
||||
show,
|
||||
sheet,
|
||||
hide() {
|
||||
opened++;
|
||||
player?.stop();
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
clearSheet(sheet);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps } from "../domain/game/game.js";
|
||||
import { formatHalfMoves } from "../domain/game/position.js";
|
||||
import { VERDICT_ICONS } from "./advice-view.js";
|
||||
import { askAiLink } from "./ask-ai.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
@@ -212,9 +213,11 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
|
||||
text: REVIEW_TEXTS.replayFromHere,
|
||||
});
|
||||
replayButton.type = "button";
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
// « Demander à une IA » (étape 1f) : le prompt de la décision du joueur
|
||||
// dans ce coup, s'il y en a une.
|
||||
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "ask-ai";
|
||||
const askAi = askAiLink(slot, "analysis-ask-ai", "#/");
|
||||
const replayHint = element(viewerText, "p", {
|
||||
className: "viewer-legend",
|
||||
text: REVIEW_TEXTS.replayHint,
|
||||
@@ -412,6 +415,8 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
|
||||
theirs?.eventId ?? "",
|
||||
current.phrases !== null,
|
||||
].join("|");
|
||||
askAi.hidden = main === null;
|
||||
askAi.href = routeHash({ screen: "analysis", game: current.id, turn: selected, ai: true });
|
||||
replayButton.hidden = replayPoint(current.journal, turn) === null;
|
||||
replayHint.hidden = replayButton.hidden;
|
||||
replayButton.onclick = () => {
|
||||
|
||||
+214
-19
@@ -21,7 +21,13 @@
|
||||
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
|
||||
// changement. Les actions de jeu restent des boutons.
|
||||
|
||||
import { missingAnalyses, readyForSummary } from "../domain/analysis/game-analysis.js";
|
||||
import {
|
||||
gameTurns,
|
||||
mainAdvice,
|
||||
missingAnalyses,
|
||||
readyForSummary,
|
||||
summarizeAnalysis,
|
||||
} from "../domain/analysis/game-analysis.js";
|
||||
import {
|
||||
type MasterPhrases,
|
||||
masterLine,
|
||||
@@ -30,9 +36,10 @@ import {
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
@@ -41,6 +48,8 @@ import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import { createAnalysisView } from "./analysis-view.js";
|
||||
import { createPromptView, type PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -76,8 +85,17 @@ import { installPlatform, startInstaller } from "./install.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
|
||||
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
type AiRoute,
|
||||
isAiRoute,
|
||||
isHelpScreen,
|
||||
type Route,
|
||||
routeHash,
|
||||
withoutAi,
|
||||
} from "./router.js";
|
||||
import {
|
||||
chosenGame,
|
||||
isFirstLaunch,
|
||||
@@ -140,6 +158,8 @@ export interface AppOptions {
|
||||
/** L'analyse d'une partie pour son bilan (étape 1e), une analyse à la
|
||||
* fois, sur le même moteur à part. */
|
||||
readonly analyseGame: RunAnalysis;
|
||||
/** Les positions que décrivent les prompts pour une IA (étape 1f). */
|
||||
readonly prompts: PromptsAccess;
|
||||
}
|
||||
|
||||
/** La source des phrases du maître, copiée dans le site (make site). */
|
||||
@@ -227,6 +247,7 @@ export function startApp(options: AppOptions): void {
|
||||
adviceLine: byId<HTMLAnchorElement>("advice-line"),
|
||||
explainVeil: byId("explain-veil"),
|
||||
summaryVeil: byId("summary-veil"),
|
||||
promptVeil: byId("prompt-veil"),
|
||||
analysis: byId("analysis"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
@@ -424,6 +445,10 @@ export function startApp(options: AppOptions): void {
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info: string = TEXTS.loading;
|
||||
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
|
||||
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
|
||||
// « Notation des coups ».
|
||||
let computerLine: ComputerTurnLine | null = null;
|
||||
let engineFailed = false;
|
||||
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
|
||||
// la prochaine action du joueur ; null : rien à montrer.
|
||||
@@ -627,7 +652,11 @@ export function startApp(options: AppOptions): void {
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
showWithNotation(
|
||||
ui.info,
|
||||
info,
|
||||
computerLine?.text === info ? computerLine.notation : null,
|
||||
);
|
||||
}
|
||||
ui.prompt.textContent = panelShown
|
||||
? ""
|
||||
@@ -1018,7 +1047,8 @@ export function startApp(options: AppOptions): void {
|
||||
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
|
||||
await refresh();
|
||||
await game.playComputerTurn();
|
||||
info = describeComputerTurn(game);
|
||||
computerLine = await describeComputerTurn(game);
|
||||
info = computerLine.text;
|
||||
save();
|
||||
played = true;
|
||||
}
|
||||
@@ -1297,6 +1327,112 @@ export function startApp(options: AppOptions): void {
|
||||
back: (link) => leavingLink(link, (route) => route.screen === "analysis"),
|
||||
});
|
||||
|
||||
// ------------------------------------------- Demander à une IA (1f)
|
||||
|
||||
// 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,
|
||||
glossaryEntry: loadGlossaryEntry,
|
||||
});
|
||||
const promptView = createPromptView({
|
||||
veil: ui.promptVeil,
|
||||
back: () => closePrompt(),
|
||||
close: () => closePromptSheet(),
|
||||
});
|
||||
// L'adresse de la feuille ouverte (null : fermée).
|
||||
let promptShown: string | null = null;
|
||||
|
||||
/** « ‹ 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();
|
||||
promptShown = null;
|
||||
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);
|
||||
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
|
||||
* la partie à l'écran, ou celui du stockage. */
|
||||
async function journalOf(id: string): Promise<GameJournal | null> {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
return live.journal;
|
||||
}
|
||||
return reviewed?.id === id ? reviewed.journal : options.games.journal(id);
|
||||
}
|
||||
|
||||
/** Le prompt de l'écran sous la feuille (route sans « /ia »). */
|
||||
async function promptOf(route: AiRoute): Promise<PromptResult> {
|
||||
await options.engineReady;
|
||||
switch (route.screen) {
|
||||
case "explanation": {
|
||||
const advice = game?.advice[route.event];
|
||||
return game === null || advice === undefined
|
||||
? { message: ASK_AI_TEXTS.noAdvice }
|
||||
: promptSource.decision(game.journal, advice);
|
||||
}
|
||||
case "summary": {
|
||||
const journal = await journalOf(route.game);
|
||||
if (journal === null) {
|
||||
return { message: ASK_AI_TEXTS.missingGame };
|
||||
}
|
||||
return readyForSummary(journal)
|
||||
? promptSource.summary(journal)
|
||||
: { message: ASK_AI_TEXTS.notReady };
|
||||
}
|
||||
case "analysis": {
|
||||
const journal = await journalOf(route.game);
|
||||
if (journal === null) {
|
||||
return { message: ASK_AI_TEXTS.missingGame };
|
||||
}
|
||||
// Le coup que montre l'analyse : celui de l'adresse, ou le
|
||||
// coup à revoir (analysis-view.ts).
|
||||
const turns = gameTurns(journal);
|
||||
const fallback = summarizeAnalysis(journal).toReview?.turn ?? 1;
|
||||
const n = Math.min(Math.max(1, route.turn ?? fallback), Math.max(1, turns.length));
|
||||
const turn = turns[n - 1];
|
||||
const advice = turn === undefined ? null : mainAdvice(journal, turn);
|
||||
return advice === null
|
||||
? { message: ASK_AI_TEXTS.noAdvice }
|
||||
: promptSource.decision(journal, advice);
|
||||
}
|
||||
case "glossary":
|
||||
return promptSource.concept(route.term ?? "", game?.journal ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
/** La partie à l'écran, si c'est la partie id. */
|
||||
function liveGame(id: string): Game | null {
|
||||
return game !== null && options.games.shownId() === id ? game : null;
|
||||
@@ -1363,6 +1499,10 @@ export function startApp(options: AppOptions): void {
|
||||
if ((route.screen !== "summary" && route.screen !== "analysis") || route.game !== id) {
|
||||
return;
|
||||
}
|
||||
// La feuille du prompt attendait l'analyse : elle se met à jour.
|
||||
if (isAiRoute(route) && promptView.isOpen && !promptView.ready) {
|
||||
showPrompt(route);
|
||||
}
|
||||
const live = liveGame(id);
|
||||
const shown =
|
||||
missing || reviewed?.id !== id
|
||||
@@ -1400,25 +1540,37 @@ export function startApp(options: AppOptions): void {
|
||||
* La partie à l'écran vient de finir : son analyse part en
|
||||
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
|
||||
* qu'il est prêt (charte 1e), si le panneau est toujours là.
|
||||
*
|
||||
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
|
||||
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
|
||||
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
|
||||
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
|
||||
* signalé le 2026-11-11).
|
||||
*/
|
||||
function reviewFinishedGame(): void {
|
||||
const id = options.games.shownId();
|
||||
if (game === null || id === null || busy || game.state.phase !== "over") {
|
||||
const finished = game;
|
||||
if (finished === null || id === null || busy || finished.state.phase !== "over") {
|
||||
return;
|
||||
}
|
||||
// L'analyse d'une autre partie, regardée, passe d'abord.
|
||||
const running = reviewRunner.running;
|
||||
if (running === null || running === id) {
|
||||
review(id, game.journal);
|
||||
review(id, finished.journal);
|
||||
}
|
||||
if (
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed &&
|
||||
phrases !== null &&
|
||||
readyForSummary(game.journal)
|
||||
readyForSummary(finished.journal)
|
||||
) {
|
||||
queueMicrotask(() => {
|
||||
if (navigation.route.screen === "game" && !panelDismissed) {
|
||||
if (
|
||||
game === finished &&
|
||||
options.games.shownId() === id &&
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed
|
||||
) {
|
||||
navigation.go(routeHash({ screen: "summary", game: id }));
|
||||
}
|
||||
});
|
||||
@@ -1711,6 +1863,8 @@ export function startApp(options: AppOptions): void {
|
||||
let menuReturnFocus: HTMLElement | null = null;
|
||||
// L'écran sous la feuille d'installation : le dernier écran affiché.
|
||||
let underSheet: Route = { screen: "game" };
|
||||
// L'événement de la feuille d'explication ouverte.
|
||||
let explained: string | null = null;
|
||||
|
||||
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
|
||||
function applyRoute(route: Route): void {
|
||||
@@ -1790,8 +1944,14 @@ export function startApp(options: AppOptions): void {
|
||||
if (route.screen === "explanation") {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
void adviceView.show(route.event);
|
||||
// Ouvrir ou fermer la feuille du prompt ne redessine pas
|
||||
// l'explication dessous.
|
||||
if (ui.explainVeil.hidden || explained !== route.event) {
|
||||
explained = route.event;
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
} else {
|
||||
explained = null;
|
||||
adviceView.hide();
|
||||
}
|
||||
// Le bilan et l'analyse d'une partie : son journal, lu dans le
|
||||
@@ -1845,6 +2005,18 @@ export function startApp(options: AppOptions): void {
|
||||
shownPosition = null;
|
||||
}
|
||||
|
||||
// La feuille du prompt pour une IA, au-dessus de son écran.
|
||||
if (isAiRoute(route)) {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
if (!promptView.isOpen || promptShown !== routeHash(route)) {
|
||||
showPrompt(route);
|
||||
}
|
||||
} else if (promptView.isOpen) {
|
||||
promptView.hide();
|
||||
promptShown = null;
|
||||
}
|
||||
|
||||
// Une adresse inconnue : la partie, avec un message, à l'adresse de
|
||||
// la partie.
|
||||
if (route.screen === "unknown") {
|
||||
@@ -1866,8 +2038,10 @@ export function startApp(options: AppOptions): void {
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
|
||||
// remplace son adresse par celle de la partie (le retour du téléphone
|
||||
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
|
||||
// jamais avec l'ancienne partie, finie, et son panneau de fin.
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
@@ -1876,8 +2050,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
navigation.replace("#/");
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
@@ -2282,6 +2456,15 @@ export function startApp(options: AppOptions): void {
|
||||
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
|
||||
return;
|
||||
}
|
||||
// La feuille du prompt ouverte : Échap la ferme, et rien d'autre ne
|
||||
// répond (les flèches font défiler le texte).
|
||||
if (promptView.isOpen) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePrompt();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const onButton =
|
||||
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
|
||||
// L'écran d'analyse : les flèches passent d'un coup à l'autre.
|
||||
@@ -2377,6 +2560,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (route.screen === "explanation") {
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
if (isAiRoute(route)) {
|
||||
showPrompt(route);
|
||||
}
|
||||
if (route.screen === "summary" || route.screen === "analysis") {
|
||||
void openReview(route.game);
|
||||
}
|
||||
@@ -2500,21 +2686,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */
|
||||
function describeComputerTurn(game: Game): string {
|
||||
/** La ligne d'information après le tour de l'ordinateur, et le coup
|
||||
* qu'elle cite (null s'il n'a pas joué de coup). */
|
||||
interface ComputerTurnLine {
|
||||
readonly text: string;
|
||||
readonly notation: MoveNotation | null;
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
|
||||
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
|
||||
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
|
||||
const last = game.events.at(-1);
|
||||
if (last?.type === "CubeOffered") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const dice = rollBefore(game);
|
||||
if (played.halfMoves.length === 0) {
|
||||
return TEXTS.computerCannotMove(dice);
|
||||
return { text: TEXTS.computerCannotMove(dice), notation: null };
|
||||
}
|
||||
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
|
||||
const notation = await game.moveNotation(played.id);
|
||||
return { text: TEXTS.computerMoved(dice, notation), notation };
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Les textes de l'interface de « Demander à une IA » (étape 1f). Les
|
||||
// prompts eux-mêmes sont dans docs/prompts/ (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ASK_AI_TEXTS = {
|
||||
button: "Demander à une IA",
|
||||
title: "Demander à une IA",
|
||||
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: "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.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
missingTerm: "Ce terme n'est pas dans le glossaire.",
|
||||
} as const;
|
||||
@@ -0,0 +1,310 @@
|
||||
// « 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é.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// L'application n'appelle aucune IA et n'envoie rien : elle copie un texte.
|
||||
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
|
||||
/** 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");
|
||||
link.id = id;
|
||||
link.className = "button secondary ask-ai";
|
||||
link.href = href;
|
||||
link.append(icon("copy"), ASK_AI_TEXTS.button);
|
||||
parent.append(link);
|
||||
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 sa propre feuille (index.html) ; elle est construite ici. */
|
||||
readonly veil: HTMLElement;
|
||||
/** « ‹ 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>, place: PromptPlace): void;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
/** Le prompt est affiché (pas un message). */
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export function createPromptView(options: PromptViewOptions): PromptView {
|
||||
const { veil } = options;
|
||||
// Sa propre feuille, pour l'analyse et le glossaire.
|
||||
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
|
||||
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");
|
||||
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(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(page, "p", { id: "prompt-status", className: "prompt-status" });
|
||||
status.setAttribute("role", "status");
|
||||
const actions = element(page, "div", { className: "prompt-actions" });
|
||||
const done = element(actions, "button", {
|
||||
id: "prompt-done",
|
||||
className: "secondary",
|
||||
text: ASK_AI_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
const copy = element(actions, "button", { id: "prompt-copy" });
|
||||
copy.type = "button";
|
||||
// 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;
|
||||
|
||||
/** « 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);
|
||||
}
|
||||
}
|
||||
showCopied(false);
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le texte de la feuille, sélectionné : le repli quand le
|
||||
* presse-papiers n'est pas accessible. */
|
||||
function selectText(): void {
|
||||
const selection = window.getSelection();
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
async function copyText(): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.textContent ?? "");
|
||||
return true;
|
||||
} catch {
|
||||
// Hors contexte sûr (http sur le réseau local), ou refusé :
|
||||
// repli ci-dessous.
|
||||
}
|
||||
selectText();
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
copy.onclick = async () => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
const id = opened;
|
||||
const copied = await copyText();
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if (copied) {
|
||||
status.textContent = "";
|
||||
showCopied(true);
|
||||
} else {
|
||||
selectText();
|
||||
showCopied(false);
|
||||
status.textContent = ASK_AI_TEXTS.notCopied;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
show(prompt, where) {
|
||||
const id = ++opened;
|
||||
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;
|
||||
delete page.dataset.ready;
|
||||
back.focus();
|
||||
}
|
||||
prompt.then(
|
||||
(result) => {
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if ("text" in result) {
|
||||
ready = true;
|
||||
text.textContent = result.text;
|
||||
text.hidden = false;
|
||||
copy.disabled = false;
|
||||
status.textContent = "";
|
||||
page.dataset.ready = "true";
|
||||
} else {
|
||||
ready = false;
|
||||
text.hidden = true;
|
||||
copy.disabled = true;
|
||||
status.textContent = result.message;
|
||||
delete page.dataset.ready;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (id === opened) {
|
||||
ready = false;
|
||||
copy.disabled = true;
|
||||
status.textContent = ASK_AI_TEXTS.failed;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
hide() {
|
||||
opened++;
|
||||
ready = false;
|
||||
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 open;
|
||||
},
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan intérieur d'un camp : ses six dernières flèches avant la sortie,
|
||||
* numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour
|
||||
* l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur
|
||||
* reste seul juge des règles qui en dépendent (sortie, rentrée).
|
||||
* Glossaire : docs/glossaire/jan-interieur.md
|
||||
*/
|
||||
export interface HomeBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan extérieur d'un camp : les six flèches qui précèdent son jan
|
||||
* intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens
|
||||
* de marche du camp.
|
||||
* Glossaire : docs/glossaire/jan-exterieur.md
|
||||
*/
|
||||
export interface OuterBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre
|
||||
* numérotation, renumérotées pour le joueur. */
|
||||
function sixPoints(side: Side, first: number): number[] {
|
||||
return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i)));
|
||||
}
|
||||
|
||||
export function homeBoard(side: Side): HomeBoard {
|
||||
return { side, points: sixPoints(side, 6) };
|
||||
}
|
||||
|
||||
export function outerBoard(side: Side): OuterBoard {
|
||||
return { side, points: sixPoints(side, 12) };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
|
||||
@@ -303,23 +303,17 @@ function drawPoint(
|
||||
"data-point": number,
|
||||
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
|
||||
});
|
||||
// Numéros : sur le cadre en paysage, dans la bande centrale en
|
||||
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// Numéros : sur le cadre, bandes haute et basse, en portrait comme en
|
||||
// paysage. Leur taille (12 px au moins à l'écran) dépend de l'échelle
|
||||
// du dessin : c'est une mesure du dessin, comme x et y.
|
||||
const size = g.numberSize;
|
||||
let y: number;
|
||||
if (g.layout === "landscape") {
|
||||
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
|
||||
} else {
|
||||
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
|
||||
}
|
||||
add(
|
||||
svg,
|
||||
"text",
|
||||
{
|
||||
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
|
||||
class: "point-number",
|
||||
x: center,
|
||||
y,
|
||||
y: top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36,
|
||||
"font-size": size,
|
||||
},
|
||||
String(number),
|
||||
|
||||
+10
-5
@@ -25,7 +25,7 @@ export interface Geometry {
|
||||
readonly height: number;
|
||||
/** Épaisseur du cadre sur les côtés. */
|
||||
readonly frame: number;
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
|
||||
/** Hauteur du cadre en haut et en bas : la bande des numéros des flèches. */
|
||||
readonly edge: number;
|
||||
/** Colonne de commandes à gauche (0 en portrait). */
|
||||
readonly left: number;
|
||||
@@ -95,14 +95,19 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
arrowWidth: 2.5 / pxPerUnit,
|
||||
};
|
||||
if (layout === "portrait") {
|
||||
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
|
||||
// Les mesures du plateau d'origine : flèches de 44. Les bandes haute
|
||||
// et basse du cadre portent les numéros des flèches, comme en
|
||||
// paysage (12 px au moins à l'écran) : pions de 19 au plus. La bande ne
|
||||
// dépasse pas 36 unités : sur une miniature (moins de 275 px de
|
||||
// large), les numéros rapetissent plutôt que d'écraser les flèches.
|
||||
const frame = 8;
|
||||
const edge = Math.min(36, Math.max(24, numberSize + 8));
|
||||
const tray = 48;
|
||||
const bar = 40;
|
||||
const width = 640;
|
||||
const pointWidth = (width - 3 * frame - tray - bar) / 12;
|
||||
const band = 84;
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
|
||||
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2) / 10);
|
||||
const x0 = frame;
|
||||
const barLeft = x0 + 6 * pointWidth;
|
||||
return {
|
||||
@@ -110,7 +115,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
edge: frame,
|
||||
edge,
|
||||
left: 0,
|
||||
tray,
|
||||
bar,
|
||||
@@ -123,7 +128,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
|
||||
rightHalf: barLeft + bar,
|
||||
trayLeft: barLeft + bar + 6 * pointWidth + frame,
|
||||
target,
|
||||
numberSize,
|
||||
numberSize: Math.min(numberSize, edge - 8),
|
||||
bandFont: atLeast(13.5, pxPerUnit, 17),
|
||||
chipFont: atLeast(13.5, pxPerUnit, 17),
|
||||
...sizes,
|
||||
|
||||
+16
-3
@@ -13,6 +13,7 @@
|
||||
// que dit la réponse, ne s'affichent pas
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
|
||||
|
||||
import { askAiLink } from "./ask-ai.js";
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
@@ -32,6 +33,7 @@ export const FEATURES = [
|
||||
"docs/scenarios/jouer-un-match.feature",
|
||||
"docs/scenarios/comprendre-mes-coups.feature",
|
||||
"docs/scenarios/revoir-ma-partie.feature",
|
||||
"docs/scenarios/demander-a-une-ia.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
@@ -124,7 +126,10 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
notice.className = "help-notice";
|
||||
notice.setAttribute("role", "status");
|
||||
const questions: Question[] = [];
|
||||
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
|
||||
const terms = new Map<
|
||||
string,
|
||||
{ dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; askAi: HTMLAnchorElement }
|
||||
>();
|
||||
let loading: Promise<boolean> | null = null;
|
||||
// La question ouverte au dernier affichage : refermée, elle reste en vue.
|
||||
let lastOpen: Question | null = null;
|
||||
@@ -213,7 +218,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
a.href = routeHash({ screen: "glossary", term: other.name });
|
||||
});
|
||||
}
|
||||
terms.set(entry.name, { dt, card, entry });
|
||||
// « Demander à une IA » (étape 1f), sur la fiche ouverte.
|
||||
const askAi = askAiLink(
|
||||
dd,
|
||||
`glossary-ask-ai-${entry.name}`,
|
||||
routeHash({ screen: "glossary", term: entry.name, ai: true }),
|
||||
);
|
||||
askAi.hidden = true;
|
||||
terms.set(entry.name, { dt, card, entry, askAi });
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
let shown = 0;
|
||||
@@ -307,7 +319,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
|
||||
q.section.classList.toggle("open", open);
|
||||
}
|
||||
const term = route.screen === "glossary" ? route.term : null;
|
||||
for (const [name, { dt, card }] of terms) {
|
||||
for (const [name, { dt, card, askAi }] of terms) {
|
||||
askAi.hidden = name !== term;
|
||||
dt.classList.toggle("current", name === term);
|
||||
card.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
|
||||
@@ -94,14 +94,15 @@ export const HOME_TEXTS = {
|
||||
drop: "Passer",
|
||||
tutorialLoading: "Chargement du tutoriel…",
|
||||
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
|
||||
pathLabel: "Le sens du jeu de vos pions, en pointillés",
|
||||
pathLabel:
|
||||
"Le sens du jeu : la flèche claire pour vos pions, la sombre pour ceux de l'ordinateur",
|
||||
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
|
||||
illustratedEnd: (kind: ResultKind) =>
|
||||
kind === "single"
|
||||
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
|
||||
: kind === "gammon"
|
||||
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
|
||||
: "Partie finie : il reste un pion de l'ordinateur dans votre jan intérieur, c'est un backgammon.",
|
||||
computerDoubles: "L'ordinateur double.",
|
||||
|
||||
// Aide
|
||||
|
||||
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
|
||||
advice: "Conseil du maître",
|
||||
adviceDetail: "Une ligne après chacun de vos coups",
|
||||
startGame: "Commencer la partie",
|
||||
startMatch: (n: number) => `Commencer le match en ${n} points`,
|
||||
startMatch: "Commencer le match",
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
|
||||
@@ -26,8 +26,9 @@ export interface Navigation {
|
||||
replace(hash: string): void;
|
||||
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
|
||||
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
|
||||
* nouvel onglet), remplace l'adresse par celle de la partie. */
|
||||
leave(stay: (route: Route) => boolean): void;
|
||||
* nouvel onglet), remplace l'adresse par fallback (celle de la partie,
|
||||
* par défaut). */
|
||||
leave(stay: (route: Route) => boolean, fallback?: string): void;
|
||||
}
|
||||
|
||||
interface TrailState {
|
||||
@@ -123,13 +124,13 @@ export function startNavigation(onChange: (route: Route) => void): Navigation {
|
||||
},
|
||||
go,
|
||||
replace,
|
||||
leave(stay) {
|
||||
leave(stay, fallback = "#/") {
|
||||
if (leaving !== null) {
|
||||
return; // déjà en route
|
||||
}
|
||||
const steps = stepsBack(trail, stay);
|
||||
if (steps === null) {
|
||||
replace("#/");
|
||||
replace(fallback);
|
||||
return;
|
||||
}
|
||||
// Au cas où le navigateur ne reviendrait pas (historique
|
||||
|
||||
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
|
||||
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
|
||||
// veulent dire l'étoile, « off », « bar » et « (2) ».
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
|
||||
export const NOTATION_TERM = "notation-des-coups";
|
||||
|
||||
/**
|
||||
* Remplace le contenu de parent par text, où la première occurrence de
|
||||
* notation devient un lien vers la fiche « Notation des coups ». Sans
|
||||
* notation dans le texte, le texte seul.
|
||||
*/
|
||||
export function showWithNotation(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
notation: MoveNotation | null,
|
||||
): void {
|
||||
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
|
||||
if (notation === null || at < 0) {
|
||||
parent.textContent = text;
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.className = "notation";
|
||||
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
|
||||
link.textContent = notation;
|
||||
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Les prompts pour une IA tels que les demande l'interface (étape 1f) :
|
||||
// les gabarits, lus dans la copie de docs/prompts/ faite par make site
|
||||
// (ADR 0008), les phrases du maître, et les positions que le domaine lit
|
||||
// avec le moteur (PromptsAccess, branché par main.ts). Le remplissage
|
||||
// lui-même est dans le domaine (domain/prompts/prompts.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { PromptPosition } from "../domain/prompts/position.js";
|
||||
import {
|
||||
type ConceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
summaryPrompt,
|
||||
} from "../domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
PROMPT_KINDS,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../domain/prompts/templates.js";
|
||||
import type { PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import type { GlossaryEntry } from "./help-text.js";
|
||||
|
||||
/** La copie des gabarits dans le site (make site). */
|
||||
const PROMPTS_DIR = "docs/prompts/";
|
||||
|
||||
/** Ce que les prompts demandent au domaine et au moteur : la position à
|
||||
* décrire. */
|
||||
export interface PromptsAccess {
|
||||
/** La position d'avant une décision de la partie. */
|
||||
readonly before: (journal: GameJournal, eventId: string) => Promise<PromptPosition>;
|
||||
/** La position que montre le prompt du bilan (coup à revoir, ou
|
||||
* position finale). */
|
||||
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
|
||||
/** La position d'exemple d'un concept et son évaluation. */
|
||||
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
|
||||
}
|
||||
|
||||
export interface PromptSourceOptions {
|
||||
readonly access: PromptsAccess;
|
||||
readonly phrases: () => Promise<MasterPhrases>;
|
||||
/** Une fiche du glossaire, par son nom de fichier. */
|
||||
readonly glossaryEntry: (file: string) => Promise<GlossaryEntry | null>;
|
||||
}
|
||||
|
||||
export interface PromptSource {
|
||||
/** Le prompt d'une décision du joueur (coup ou videau). */
|
||||
decision(journal: GameJournal, advice: Advice): Promise<PromptResult>;
|
||||
/** Le prompt du bilan d'une partie finie et analysée. */
|
||||
summary(journal: GameJournal): Promise<PromptResult>;
|
||||
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
|
||||
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export function createPromptSource(options: PromptSourceOptions): PromptSource {
|
||||
let loading: Promise<PromptTemplates> | null = null;
|
||||
|
||||
/** Les gabarits, lus une fois (une nouvelle tentative après un
|
||||
* échec). */
|
||||
function templates(): Promise<PromptTemplates> {
|
||||
loading ??= Promise.all(
|
||||
PROMPT_KINDS.map(async (kind) =>
|
||||
parsePromptTemplate(await fetchText(PROMPTS_DIR + PROMPT_FILES[kind])),
|
||||
),
|
||||
).then(
|
||||
([move, cube, summary, concept, exercises]) => ({
|
||||
move: move ?? "",
|
||||
cube: cube ?? "",
|
||||
summary: summary ?? "",
|
||||
concept: concept ?? "",
|
||||
exercises: exercises ?? "",
|
||||
}),
|
||||
(error: unknown) => {
|
||||
loading = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
async decision(journal, advice) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.before(journal, advice.eventId),
|
||||
]);
|
||||
return { text: decisionPrompt(all, phrases, { journal, advice, position }) };
|
||||
},
|
||||
async summary(journal) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.summary(journal),
|
||||
]);
|
||||
return { text: summaryPrompt(all, phrases, { journal, position }) };
|
||||
},
|
||||
async concept(term, journal) {
|
||||
const [all, entry, example] = await Promise.all([
|
||||
templates(),
|
||||
options.glossaryEntry(`${term}.md`).catch(() => null),
|
||||
options.access.concept(journal),
|
||||
]);
|
||||
if (entry === null) {
|
||||
return { message: ASK_AI_TEXTS.missingTerm };
|
||||
}
|
||||
return {
|
||||
text: conceptPrompt(all, {
|
||||
term: entry.term,
|
||||
english: entry.english,
|
||||
brief: entry.brief,
|
||||
journal,
|
||||
example,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,16 +10,12 @@ import type { Roll } from "../domain/game/roll.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
|
||||
|
||||
/** « 41 % ». */
|
||||
export function percent(chance: number): string {
|
||||
return `${Math.round(chance * 100)} %`;
|
||||
}
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
|
||||
export function signed(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
/** « 41 % » et « +0,021 » : la même écriture que les prompts
|
||||
* (domain/analysis/numbers.ts). */
|
||||
export {
|
||||
formatEquity as signed,
|
||||
formatPercent as percent,
|
||||
} from "../domain/analysis/numbers.js";
|
||||
|
||||
export const REVIEW_TEXTS = {
|
||||
// Bilan
|
||||
|
||||
+58
-8
@@ -14,8 +14,9 @@ export type Route =
|
||||
| { readonly screen: "home" }
|
||||
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
|
||||
| { readonly screen: "help"; readonly question: string | null }
|
||||
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
|
||||
| { readonly screen: "glossary"; readonly term: string | null }
|
||||
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md> ; son
|
||||
* prompt pour une IA : #/glossaire/<fiche>/ia. */
|
||||
| { readonly screen: "glossary"; readonly term: string | null; readonly ai?: boolean }
|
||||
/** #/a-propos. */
|
||||
| { readonly screen: "about" }
|
||||
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
|
||||
@@ -27,19 +28,27 @@ export type Route =
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { readonly screen: "games"; readonly game: string | null }
|
||||
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
|
||||
| { readonly screen: "summary"; readonly game: string }
|
||||
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e) ;
|
||||
* son prompt pour une IA : #/parties/<identifiant>/bilan/ia (1f). */
|
||||
| { readonly screen: "summary"; readonly game: string; readonly ai?: boolean }
|
||||
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
|
||||
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
|
||||
* #/parties/<identifiant>/analyse. */
|
||||
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
|
||||
* #/parties/<identifiant>/analyse ; le prompt du coup pour une IA :
|
||||
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
|
||||
| {
|
||||
readonly screen: "analysis";
|
||||
readonly game: string;
|
||||
readonly turn: number | null;
|
||||
readonly ai?: boolean;
|
||||
}
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
| { readonly screen: "newGame" }
|
||||
/** #/explication/<événement> : la feuille d'explication du maître sur
|
||||
* une décision de la partie en cours (étape 1d). */
|
||||
| { readonly screen: "explanation"; readonly event: string }
|
||||
* une décision de la partie en cours (étape 1d) ; son prompt pour une
|
||||
* IA : #/explication/<événement>/ia (1f). */
|
||||
| { readonly screen: "explanation"; readonly event: string; readonly ai?: boolean }
|
||||
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
|
||||
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
|
||||
/** Une adresse que l'application ne connaît pas. */
|
||||
@@ -71,6 +80,11 @@ export function parseRoute(hash: string): Route {
|
||||
if (rest.at(-1) === "") {
|
||||
rest.pop();
|
||||
}
|
||||
// La feuille du prompt pour une IA (étape 1f) : « /ia » au bout de
|
||||
// l'adresse de l'écran d'où on la demande.
|
||||
if (rest.length > 1 && rest.at(-1) === "ia") {
|
||||
return aiRoute(parseRoute(`#/${[head, ...rest.slice(0, -1)].join("/")}`)) ?? unknown;
|
||||
}
|
||||
if (head === "position") {
|
||||
return positionRoute(rest.join("/")) ?? unknown;
|
||||
}
|
||||
@@ -113,6 +127,37 @@ export function parseRoute(hash: string): Route {
|
||||
}
|
||||
}
|
||||
|
||||
/** Les écrans qui ont une feuille de prompt pour une IA. */
|
||||
export type AiRoute = Extract<
|
||||
Route,
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
|
||||
>;
|
||||
|
||||
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
|
||||
function aiRoute(route: Route): AiRoute | null {
|
||||
switch (route.screen) {
|
||||
case "explanation":
|
||||
case "summary":
|
||||
case "analysis":
|
||||
return route.ai === true ? null : { ...route, ai: true };
|
||||
case "glossary":
|
||||
return route.term === null || route.ai === true ? null : { ...route, ai: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** La feuille du prompt pour une IA est-elle ouverte ? */
|
||||
export function isAiRoute(route: Route): route is AiRoute {
|
||||
return "ai" in route && route.ai === true;
|
||||
}
|
||||
|
||||
/** L'écran sous la feuille du prompt. */
|
||||
export function withoutAi(route: AiRoute): AiRoute {
|
||||
const { ai: _, ...below } = route;
|
||||
return below;
|
||||
}
|
||||
|
||||
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
|
||||
function gameReviewRoute(rest: readonly string[]): Route | null {
|
||||
const [game = "", part, turn, ...more] = rest;
|
||||
@@ -179,6 +224,11 @@ function positionRoute(text: string): Route | null {
|
||||
|
||||
/** Le fragment d'un écran, # compris. */
|
||||
export function routeHash(route: Route): string {
|
||||
const hash = screenHash(route);
|
||||
return isAiRoute(route) ? `${hash}/ia` : hash;
|
||||
}
|
||||
|
||||
function screenHash(route: Route): string {
|
||||
switch (route.screen) {
|
||||
case "game":
|
||||
return "#/";
|
||||
|
||||
@@ -28,6 +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, clearSheet } from "./ask-ai.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { boardSvg, drawSteps } from "./move-board.js";
|
||||
@@ -64,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>(
|
||||
@@ -141,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 });
|
||||
@@ -299,9 +302,14 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
text: REVIEW_TEXTS.reviewMove,
|
||||
});
|
||||
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
// « Demander à une IA » (étape 1f) : le prompt du bilan.
|
||||
const slot = element(links, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "ask-ai";
|
||||
askAiLink(
|
||||
slot,
|
||||
"summary-ask-ai",
|
||||
routeHash({ screen: "summary", game: data.id, ai: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function actions(data: SummaryData): void {
|
||||
@@ -350,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 = "";
|
||||
}
|
||||
@@ -358,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
panel,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const TEXTS = {
|
||||
) =>
|
||||
[
|
||||
winner === null
|
||||
? "Vous avez la main : vos pions ivoire, en bas."
|
||||
? "Vous avez la main : vos pions blancs, en bas."
|
||||
: `La partie est finie dans cette position : ${
|
||||
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
|
||||
}.`,
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface TutorialScreen {
|
||||
readonly roll: readonly [number, number] | null;
|
||||
/** Explication : le coup illustré, en notation de gnubg. */
|
||||
readonly move: string | null;
|
||||
/** Explication : le sens du jeu dessiné en pointillés. */
|
||||
/** Explication : le sens du jeu, deux flèches circulaires. */
|
||||
readonly path: boolean;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
|
||||
+62
-45
@@ -18,11 +18,14 @@ import type { Side } from "../domain/game/side.js";
|
||||
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
|
||||
import {
|
||||
boardModel,
|
||||
homeBoard,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
outerBoard,
|
||||
type Place,
|
||||
placeOf as placeOfChecker,
|
||||
pointSlot,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardDecor,
|
||||
@@ -149,57 +152,71 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
|
||||
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
|
||||
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
|
||||
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
|
||||
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
|
||||
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
/** Le sens du jeu : deux flèches circulaires, une claire pour les pions du
|
||||
* joueur (du haut à droite vers la gauche, puis en bas jusqu'à son jan
|
||||
* intérieur), une sombre pour ceux de l'ordinateur, en sens inverse et à
|
||||
* l'intérieur de la première. Chacune a un liseré de la couleur opposée et
|
||||
* finit par une pointe ; jamais en brique (la brique veut dire « à faire »).
|
||||
* Dessinées sous les pions, qu'elles ne cachent jamais. pxPerUnit : pixels
|
||||
* à l'écran par unité du dessin (trait de 5 px sur un liseré de 9 px,
|
||||
* quelle que soit la taille du plateau). */
|
||||
function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
|
||||
const top = g.height * 0.3;
|
||||
const bottom = g.height * 0.7;
|
||||
const right = center(11);
|
||||
const left = center(0) - g.pointWidth * 0.25;
|
||||
const turn = g.pointWidth * 0.6;
|
||||
const end = g.trayLeft + g.tray * 0.25;
|
||||
const d =
|
||||
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
|
||||
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("class", "tutorial-path");
|
||||
group.setAttribute("role", "img");
|
||||
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
|
||||
const px = (n: number) => n / pxPerUnit;
|
||||
const width = px(4);
|
||||
for (const [className, stroke] of [
|
||||
["path-edge", px(8)],
|
||||
["path-dots", width],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", className);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
if (className === "path-dots") {
|
||||
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
|
||||
// Chaque flèche part du jan intérieur adverse (sa flèche 24), longe les
|
||||
// deux jans extérieurs, tourne au bout du sien et finit dans son jan
|
||||
// intérieur. Celle de l'ordinateur passe à l'intérieur de la claire.
|
||||
const column = (point: number) => pointSlot(point).column;
|
||||
const loops = [
|
||||
{
|
||||
side: "player",
|
||||
from: g.height * 0.29,
|
||||
to: g.height * 0.71,
|
||||
left: center(column(outerBoard("player").points[0] ?? 12)) - g.pointWidth * 0.1,
|
||||
},
|
||||
{
|
||||
side: "computer",
|
||||
from: g.height * 0.62,
|
||||
to: g.height * 0.38,
|
||||
left: center(column(outerBoard("computer").points[0] ?? 13) + 1) + g.pointWidth * 0.5,
|
||||
},
|
||||
] as const;
|
||||
const head = px(18);
|
||||
const half = px(10);
|
||||
for (const { side, from, to, left } of loops) {
|
||||
const other = side === "player" ? "computer" : "player";
|
||||
const start = center(column(homeBoard(other).points[5] ?? 1));
|
||||
const end = center(column(homeBoard(side).points[4] ?? 2)) + g.pointWidth * 0.3;
|
||||
const turn = Math.min(Math.abs(to - from) / 2, g.pointWidth * 0.9);
|
||||
const down = to > from ? 1 : -1;
|
||||
const d =
|
||||
`M ${start} ${from} L ${left + turn} ${from} Q ${left} ${from} ${left} ${from + down * turn} ` +
|
||||
`L ${left} ${to - down * turn} Q ${left} ${to} ${left + turn} ${to} L ${end - head / 2} ${to}`;
|
||||
for (const [part, stroke] of [
|
||||
["edge", px(9)],
|
||||
["line", px(5)],
|
||||
] as const) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("class", `direction-${part} direction-${side}`);
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke-width", String(stroke));
|
||||
group.append(path);
|
||||
}
|
||||
group.append(path);
|
||||
}
|
||||
const head = document.createElementNS(SVG_NS, "path");
|
||||
head.setAttribute("class", "path-head");
|
||||
const size = px(7);
|
||||
head.setAttribute(
|
||||
"d",
|
||||
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
|
||||
);
|
||||
head.setAttribute("stroke-width", String(px(3)));
|
||||
group.append(head);
|
||||
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
|
||||
// du tracé ; les zones à toucher restent devant tout.
|
||||
const pieces = svg.querySelector(":scope > g.pieces");
|
||||
svg.insertBefore(group, pieces);
|
||||
for (const number of svg.querySelectorAll(":scope > .point-number")) {
|
||||
svg.insertBefore(number, pieces);
|
||||
const tip = document.createElementNS(SVG_NS, "path");
|
||||
tip.setAttribute("class", `direction-head direction-${side}`);
|
||||
tip.setAttribute(
|
||||
"d",
|
||||
`M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`,
|
||||
);
|
||||
tip.setAttribute("stroke-width", String(px(2)));
|
||||
group.append(tip);
|
||||
}
|
||||
// Sous les pions ; les zones à toucher restent devant tout.
|
||||
svg.insertBefore(group, svg.querySelector(":scope > g.pieces"));
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
@@ -359,7 +376,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { createSettingsStore } from "../src/storage/settings-store.js";
|
||||
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
|
||||
import { homeBoard, OPENING_BOARDS, outerBoard, pipLead } from "../src/ui/board-model.js";
|
||||
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
|
||||
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
|
||||
import { ICON_PATHS } from "../src/ui/icons.js";
|
||||
@@ -37,15 +37,32 @@ const LANDSCAPES: [number, number][] = [
|
||||
[800, 308],
|
||||
];
|
||||
|
||||
test("géométrie en portrait : les mesures du plateau d'origine", () => {
|
||||
test("géométrie en portrait : les mesures du plateau d'origine, numéros sur le cadre", () => {
|
||||
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
|
||||
assert.equal(g.width, 640);
|
||||
assert.equal(g.pointWidth, 44);
|
||||
assert.equal(g.radius, 20);
|
||||
assert.equal(g.step, 42);
|
||||
// Pions d'un peu moins de 19 unités (20 avant les numéros sur le cadre).
|
||||
assert.ok(g.radius >= 18.5 && g.radius <= 19, `pions de ${g.radius}`);
|
||||
assert.equal(g.step, 2 * g.radius + 2);
|
||||
assert.equal(g.trayLeft + g.tray + g.frame, 640);
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
|
||||
// Numéros d'au moins 12 px à l'écran, à 412 px de large, logés dans les
|
||||
// bandes haute et basse du cadre (retours des premiers joueurs).
|
||||
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
|
||||
assert.ok(g.numberSize + 8 <= g.edge, `bande de ${g.edge} pour des numéros de ${g.numberSize}`);
|
||||
});
|
||||
|
||||
test("géométrie en portrait : numéros d'au moins 12 px sur le cadre dès 275 px de large", () => {
|
||||
for (const width of [275, 320, 358, 380, 396, 600]) {
|
||||
const px = width / 640;
|
||||
const g = boardGeometry("portrait", 640 / 520, px);
|
||||
assert.ok(g.numberSize * px >= 12, `${width} px : numéros de ${g.numberSize * px} px`);
|
||||
assert.ok(g.numberSize < g.edge, `${width} px : numéros hors du cadre`);
|
||||
assert.ok(g.edge <= 36, `${width} px : bande de ${g.edge}`);
|
||||
}
|
||||
// Une miniature : la bande reste de 36 unités, les numéros y tiennent.
|
||||
const thumbnail = boardGeometry("portrait", 640 / 520, 200 / 640);
|
||||
assert.equal(thumbnail.edge, 36);
|
||||
assert.ok(thumbnail.numberSize < thumbnail.edge);
|
||||
});
|
||||
|
||||
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
|
||||
@@ -93,6 +110,13 @@ test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comp
|
||||
}
|
||||
});
|
||||
|
||||
test("les jans de chaque camp, numérotés pour le joueur, dans le sens de marche", () => {
|
||||
assert.deepEqual(homeBoard("player").points, [6, 5, 4, 3, 2, 1]);
|
||||
assert.deepEqual(outerBoard("player").points, [12, 11, 10, 9, 8, 7]);
|
||||
assert.deepEqual(homeBoard("computer").points, [19, 20, 21, 22, 23, 24]);
|
||||
assert.deepEqual(outerBoard("computer").points, [13, 14, 15, 16, 17, 18]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- Vibrations
|
||||
|
||||
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// Les prompts pour une IA (étape 1f) : gabarits lus dans leur source
|
||||
// (docs/prompts/), remplis avec de vraies analyses du moteur. Aucun repère
|
||||
// vide ni inconnu, et les chiffres sont ceux que l'application affiche.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
missingAnalyses,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { formatEquity, rankingRows } from "../src/domain/analysis/numbers.js";
|
||||
import { diceWords, masterWords } from "../src/domain/analysis/phrases.js";
|
||||
import type { Advice } from "../src/domain/game/advice.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS } from "../src/domain/game/level.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import {
|
||||
boardDiagram,
|
||||
PUBLIC_SITE,
|
||||
positionAddress,
|
||||
positionBefore,
|
||||
positionNow,
|
||||
} from "../src/domain/prompts/position.js";
|
||||
import {
|
||||
CUBE_ACTION_WORDS,
|
||||
CUBE_ROW_WORDS,
|
||||
conceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
exercisesPosition,
|
||||
exercisesPrompt,
|
||||
LEVEL_MIN_DECISIONS,
|
||||
LEVEL_NAMES,
|
||||
playerSentence,
|
||||
summaryPosition,
|
||||
summaryPrompt,
|
||||
} from "../src/domain/prompts/prompts.js";
|
||||
import {
|
||||
fillPrompt,
|
||||
PROMPT_FILES,
|
||||
PROMPT_KINDS,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
placeholders,
|
||||
} from "../src/domain/prompts/templates.js";
|
||||
import { ADVICE_TEXTS } from "../src/ui/advice-texts.js";
|
||||
import { MATCH_TEXTS } from "../src/ui/match-texts.js";
|
||||
import { REVIEW_TEXTS, signed } from "../src/ui/review-texts.js";
|
||||
import { parseRoute, routeHash } from "../src/ui/router.js";
|
||||
import {
|
||||
adviceOn,
|
||||
loadPhrases,
|
||||
playerAnswers,
|
||||
playerDoubles,
|
||||
playerRollsWithoutDoubling,
|
||||
playOpening,
|
||||
} from "./support/advice.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "./support/play.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const phrases = loadPhrases();
|
||||
|
||||
/** Les gabarits, lus dans leur source. */
|
||||
function loadTemplates(): PromptTemplates {
|
||||
const read = (file: string) =>
|
||||
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
|
||||
return {
|
||||
move: read(PROMPT_FILES.move),
|
||||
cube: read(PROMPT_FILES.cube),
|
||||
summary: read(PROMPT_FILES.summary),
|
||||
concept: read(PROMPT_FILES.concept),
|
||||
exercises: read(PROMPT_FILES.exercises),
|
||||
};
|
||||
}
|
||||
const templates = loadTemplates();
|
||||
|
||||
/** Ce que chaque prompt doit contenir (cadrage, « IA générative en V1 »). */
|
||||
function assertComplete(prompt: string, position: { positionId: string; matchId: string }): void {
|
||||
for (const hole of ["{", "}", "inconnu", "undefined", "NaN", "null", " :"]) {
|
||||
assert.ok(!prompt.includes(hole), `« ${hole} » dans le prompt :\n${prompt}`);
|
||||
}
|
||||
assert.ok(prompt.startsWith("Tu es un coach de backgammon"), "rôle absent");
|
||||
assert.match(
|
||||
prompt,
|
||||
/^Tu es un coach de backgammon, bienveillant et précis\. (Je débute au backgammon\.|Je suis un joueur de niveau [A-ZÉ]\S+\.)\n/,
|
||||
);
|
||||
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), "Position ID absent");
|
||||
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), "Match ID absent");
|
||||
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), "lien absent");
|
||||
assert.ok(prompt.includes("X : mes pions"), "diagramme absent");
|
||||
assert.match(prompt, /GNU Backgammon \(analyse à \d plis?\)/);
|
||||
assert.ok(prompt.includes("ne les contredis pas"), "consigne absente");
|
||||
assert.ok(prompt.includes("Réponds en français"), "langue absente");
|
||||
}
|
||||
|
||||
test("les cinq gabarits se lisent dans leur source, avec les repères du cadrage", () => {
|
||||
assert.equal(PROMPT_KINDS.length, 5);
|
||||
for (const kind of PROMPT_KINDS) {
|
||||
const names = placeholders(templates[kind]);
|
||||
for (const name of [
|
||||
"joueur",
|
||||
"position_id",
|
||||
"match_id",
|
||||
"au_trait",
|
||||
"lien",
|
||||
"diagramme",
|
||||
"score",
|
||||
"videau",
|
||||
"niveau_eval",
|
||||
"langue",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `{${name}} absent du gabarit ${kind}`);
|
||||
}
|
||||
assert.ok(templates[kind].includes("ne les contredis pas"), `consigne absente : ${kind}`);
|
||||
}
|
||||
assert.throws(() => parsePromptTemplate("# Sans gabarit\n"), /Gabarit/);
|
||||
});
|
||||
|
||||
test("un repère inconnu ou vide est une erreur", () => {
|
||||
assert.equal(fillPrompt("a {b} c", { b: "x" }), "a x c");
|
||||
assert.throws(() => fillPrompt("a {b}", {}), /inconnu/);
|
||||
assert.throws(() => fillPrompt("a {b}", { b: " " }), /vide/);
|
||||
});
|
||||
|
||||
test("les mots du prompt sont ceux de l'écran", () => {
|
||||
for (const level of COMPUTER_LEVELS) {
|
||||
assert.equal(LEVEL_NAMES[level], MATCH_TEXTS.levels[level].name);
|
||||
}
|
||||
assert.deepEqual(CUBE_ACTION_WORDS, REVIEW_TEXTS.cubeActions);
|
||||
assert.deepEqual(CUBE_ROW_WORDS, ADVICE_TEXTS.cubeRows);
|
||||
});
|
||||
|
||||
test("le niveau du joueur ne se dit qu'à partir de 20 décisions, sans son taux", () => {
|
||||
assert.equal(LEVEL_MIN_DECISIONS, 20);
|
||||
assert.equal(playerSentence(null), "Je débute au backgammon.");
|
||||
for (const level of COMPUTER_LEVELS) {
|
||||
assert.equal(playerSentence({ decisions: 19, level }), "Je débute au backgammon.");
|
||||
assert.equal(
|
||||
playerSentence({ decisions: 20, level }),
|
||||
`Je suis un joueur de niveau ${MATCH_TEXTS.levels[level].name}.`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
playerSentence({ decisions: 120, level: "advanced" }),
|
||||
"Je suis un joueur de niveau Avancé.",
|
||||
);
|
||||
});
|
||||
|
||||
test("le diagramme recopie le plateau décodé par le moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const start = await positionNow(engine, null);
|
||||
assert.equal(start.positionId, START_POSITION);
|
||||
const diagram = boardDiagram(start.sides);
|
||||
assert.ok(diagram.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
|
||||
assert.ok(
|
||||
diagram.includes(
|
||||
"Pions de l'ordinateur (O), dans ma numérotation : 19 (5), 17 (3), 12 (5), 1 (2).",
|
||||
),
|
||||
);
|
||||
assert.ok(diagram.includes("Compte de points : moi 167, l'ordinateur 167."));
|
||||
// La rangée du haut : 13 (X, 5 pions) à gauche, 24 (X, 2 pions) à
|
||||
// droite ; 19 (O) juste après la barre.
|
||||
const lines = diagram.split("\n");
|
||||
assert.equal(lines[1], " 13 14 15 16 17 18 | 19 20 21 22 23 24");
|
||||
assert.equal(lines[2], " X . . . O . | O . . . . X");
|
||||
assert.equal(lines[3], " X O | O X");
|
||||
assert.equal(lines[0], "```");
|
||||
assert.equal(lines[lines.indexOf("```", 1) - 1], " 12 11 10 9 8 7 | 6 5 4 3 2 1");
|
||||
});
|
||||
|
||||
test("le lien de la position est l'adresse publique de l'écran « position »", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const played = await playOpening(engine, [3, 1], "24/23 13/10");
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
const address = positionAddress(position);
|
||||
const hash = routeHash({
|
||||
screen: "position",
|
||||
position: position.positionId,
|
||||
match: position.matchId,
|
||||
});
|
||||
assert.equal(address, `${PUBLIC_SITE}${hash}`);
|
||||
assert.deepEqual(parseRoute(new URL(address).hash), {
|
||||
screen: "position",
|
||||
position: position.positionId,
|
||||
match: position.matchId,
|
||||
});
|
||||
});
|
||||
|
||||
/** Les lignes du classement telles que la feuille du maître les affiche :
|
||||
* rang, coup, écart. */
|
||||
function shownRanking(advice: Advice): string[] {
|
||||
if (advice.kind !== "move") {
|
||||
return [];
|
||||
}
|
||||
return rankingRows(advice).map((row) => `${row.move.notation}|${formatEquity(row.loss)}`);
|
||||
}
|
||||
|
||||
test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, mêmes chiffres", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [dice, move] of [
|
||||
[[3, 1], "24/23 13/10"],
|
||||
[[3, 1], "8/5 6/5"],
|
||||
[[2, 1], "13/11 6/5"],
|
||||
[[6, 5], "24/18 13/8"],
|
||||
] as const) {
|
||||
const played = await playOpening(engine, dice, move);
|
||||
const advice = await adviceOn(engine, played);
|
||||
assert.equal(advice.kind, "move");
|
||||
if (advice.kind !== "move") {
|
||||
continue;
|
||||
}
|
||||
// Le conseil, rattaché au journal comme dans l'application.
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
assert.equal(position.positionId, advice.position);
|
||||
const prompt = decisionPrompt(templates, phrases, {
|
||||
journal: played.game.journal,
|
||||
advice,
|
||||
position,
|
||||
});
|
||||
assertComplete(prompt, position);
|
||||
assert.ok(prompt.includes(`Dés : ${dice[0]}-${dice[1]}.`), prompt);
|
||||
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
|
||||
assert.ok(prompt.includes(`« ${masterWords(phrases, advice).word} »`), prompt);
|
||||
// Les trois premiers coups du moteur, et le vôtre, avec les écarts
|
||||
// de « Voir les chiffres du moteur ».
|
||||
const shown = shownRanking(advice);
|
||||
for (const row of rankingRows(advice).filter((r) => r.rank <= 3 || r.played)) {
|
||||
const line = `${row.rank}. ${row.move.notation}${row.played ? " (mon coup)" : ""} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}`;
|
||||
assert.ok(prompt.includes(line), `ligne absente : ${line}\n${prompt}`);
|
||||
assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`));
|
||||
}
|
||||
assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt);
|
||||
assert.ok(prompt.includes("analyse à 2 plis"), prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("prompt d'une décision de videau : les équités de la feuille du maître", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const cases = [
|
||||
{ play: playerDoubles, who: "moi", question: "doubler ou lancer" },
|
||||
{ play: playerRollsWithoutDoubling, who: "moi", question: "doubler ou lancer" },
|
||||
{ play: (e: Engine) => playerAnswers(e, true), who: "l'ordinateur", question: "prendre" },
|
||||
{ play: (e: Engine) => playerAnswers(e, false), who: "l'ordinateur", question: "prendre" },
|
||||
];
|
||||
for (const { play, who, question } of cases) {
|
||||
const played = await play(engine);
|
||||
const advice = await adviceOn(engine, played);
|
||||
assert.equal(advice.kind, "cube");
|
||||
if (advice.kind !== "cube") {
|
||||
continue;
|
||||
}
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
assert.equal(position.positionId, advice.position);
|
||||
const prompt = decisionPrompt(templates, phrases, {
|
||||
journal: played.game.journal,
|
||||
advice,
|
||||
position,
|
||||
});
|
||||
assertComplete(prompt, position);
|
||||
assert.ok(prompt.includes(`Il fallait choisir : ${question}`), prompt);
|
||||
assert.ok(prompt.includes(`(${who}), pour un videau de 1`), prompt);
|
||||
assert.ok(prompt.includes(`J'ai choisi de ${CUBE_ACTION_WORDS[advice.action]},`), prompt);
|
||||
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
|
||||
const value = formatEquity(advice.equities[key]);
|
||||
assert.match(
|
||||
prompt,
|
||||
new RegExp(
|
||||
`- ${ADVICE_TEXTS.cubeRows[key]}( \\(ma décision\\))? : ${value.replace("+", "\\+")}\\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.ok(prompt.includes(`Écart de ma décision : ${formatEquity(advice.equityLoss)}.`));
|
||||
}
|
||||
});
|
||||
|
||||
/** Toutes les analyses qui manquent, une à une, comme l'application en
|
||||
* arrière-plan. */
|
||||
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
|
||||
let current = journal;
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const next = missingAnalyses(current)[0];
|
||||
if (next === undefined) {
|
||||
return current;
|
||||
}
|
||||
current = await runAnalysis(engine, current, next);
|
||||
}
|
||||
throw new Error("l'analyse ne finit pas");
|
||||
}
|
||||
|
||||
/** Une course courte jouée jusqu'au bout et analysée (le joueur prend le
|
||||
* choice-ième coup légal). */
|
||||
async function analysedRace(engine: Engine, seed: number, choice: number): Promise<GameJournal> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
return analyseAll(engine, game.journal);
|
||||
}
|
||||
|
||||
test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreurs", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const journal = await analysedRace(engine, 3, 3);
|
||||
const summary = summarizeAnalysis(journal);
|
||||
assert.ok(summary.toReview !== null, "la partie devrait avoir une erreur");
|
||||
const position = await summaryPosition(engine, journal);
|
||||
const prompt = summaryPrompt(templates, phrases, { journal, position });
|
||||
assertComplete(prompt, position);
|
||||
const { player, computer, counts } = summary;
|
||||
// Les chiffres de « Voir les chiffres du moteur » de l'analyse, et les
|
||||
// trois cases du bilan.
|
||||
assert.ok(
|
||||
prompt.includes(
|
||||
`Mon erreur par décision : ${signed(-player.errorRate.perDecision)} sur ${player.errorRate.decisions} décision`,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name} ;`),
|
||||
);
|
||||
assert.ok(
|
||||
prompt.includes(`celle de l'ordinateur : ${signed(-computer.errorRate.perDecision)} sur`),
|
||||
);
|
||||
assert.ok(
|
||||
prompt.includes(
|
||||
`La chance de mes dés par tour : ${signed(player.luck.perRoll)} (${diceWords(phrases, player.luck.rating)})`,
|
||||
),
|
||||
);
|
||||
assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(computer.luck.perRoll)}.`));
|
||||
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
|
||||
assert.ok(prompt.includes(`, ${counts.bad + counts.veryBad} erreur`), prompt);
|
||||
assert.ok(prompt.includes("Résultat : "), prompt);
|
||||
assert.ok(prompt.includes("contre l'ordinateur (niveau Intermédiaire)"), prompt);
|
||||
// Le coup à revoir : sa position, et sa ligne avec son écart.
|
||||
const worst = summary.toReview.advice;
|
||||
assert.equal(position.positionId, worst.position);
|
||||
assert.ok(
|
||||
prompt.includes(`Le coup à revoir, ma plus grosse erreur (coup ${summary.toReview.turn})`),
|
||||
);
|
||||
assert.ok(
|
||||
prompt.includes(
|
||||
`écart ${formatEquity(worst.equityLoss)} (${masterWords(phrases, worst).word})`,
|
||||
),
|
||||
prompt,
|
||||
);
|
||||
});
|
||||
|
||||
test("prompt du bilan sans erreur : la position finale", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Le joueur au trait : un pion sur 6, un sur 1 ; il joue 6/off 1/off et
|
||||
// gagne (scénario « Revoir ma partie »).
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "BQAACAIAAAAAAA", firstTurn: "player" },
|
||||
dice: () => 6,
|
||||
});
|
||||
await game.roll();
|
||||
const moves = await engine.legalMoves(game.state.position, game.state.roll ?? [6, 6]);
|
||||
for (const halfMove of moves[0]?.halfMoves ?? []) {
|
||||
await game.playHalfMove(halfMove);
|
||||
}
|
||||
await game.confirmMove();
|
||||
assert.equal(game.state.phase, "over");
|
||||
const journal = await analyseAll(engine, game.journal);
|
||||
const position = await summaryPosition(engine, journal);
|
||||
const prompt = summaryPrompt(templates, phrases, { journal, position });
|
||||
assertComplete(prompt, position);
|
||||
assert.ok(prompt.includes("- aucune : j'ai joué comme GNU Backgammon."), prompt);
|
||||
assert.ok(prompt.includes("Voici la position finale"), prompt);
|
||||
assert.ok(prompt.includes("j'ai gagné une partie simple, 1 point"), prompt);
|
||||
});
|
||||
|
||||
test("prompt d'un concept : la position de départ, ou celle de la partie en cours", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const alone = await conceptExample(engine, null, 2);
|
||||
const prompt = conceptPrompt(templates, {
|
||||
term: "Videau",
|
||||
english: "Cube",
|
||||
brief: "le dé des enchères.",
|
||||
journal: null,
|
||||
example: alone,
|
||||
});
|
||||
assertComplete(prompt, alone.position);
|
||||
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)."), prompt);
|
||||
assert.ok(prompt.includes("voici la position de départ"), prompt);
|
||||
assert.ok(prompt.includes("précis. Je débute au backgammon.\n"), prompt);
|
||||
assert.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/);
|
||||
|
||||
const played = await playOpening(engine, [3, 1], "24/23 13/10");
|
||||
played.game.recordAdvice(await adviceOn(engine, played));
|
||||
const during = await conceptExample(engine, played.game.journal, 2);
|
||||
assert.ok(during.fromGame);
|
||||
const second = conceptPrompt(templates, {
|
||||
term: "Blot",
|
||||
english: "Blot",
|
||||
brief: "un pion seul sur une case.",
|
||||
journal: played.game.journal,
|
||||
example: during,
|
||||
});
|
||||
assertComplete(second, during.position);
|
||||
assert.ok(second.includes("le concept « Blot »."), second);
|
||||
assert.ok(second.includes("une position de ma partie en cours"), second);
|
||||
// Une seule décision analysée : pas de niveau, pas de taux d'erreur.
|
||||
assert.ok(second.includes("Je débute au backgammon."), second);
|
||||
assert.ok(!second.includes("erreur par décision"), second);
|
||||
});
|
||||
|
||||
test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const journals = [await analysedRace(engine, 3, 3), await analysedRace(engine, 7, 2)];
|
||||
const position = await exercisesPosition(engine, journals);
|
||||
assert.ok(position !== null);
|
||||
const prompt = exercisesPrompt(templates, phrases, { journals, position });
|
||||
assertComplete(prompt, position);
|
||||
assert.ok(prompt.includes("Voici mes 2 dernières parties"), prompt);
|
||||
assert.match(prompt, /- Partie [12], coup \d+/);
|
||||
assert.ok(prompt.includes("La plus coûteuse (partie"), prompt);
|
||||
assert.ok(prompt.includes("cinq exercices"), prompt);
|
||||
assert.equal(await exercisesPosition(engine, []), null);
|
||||
});
|
||||
@@ -8,12 +8,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
isAiRoute,
|
||||
isHelpScreen,
|
||||
parseRoute,
|
||||
type Route,
|
||||
routeHash,
|
||||
slug,
|
||||
stepsBack,
|
||||
withoutAi,
|
||||
} from "../src/ui/router.js";
|
||||
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
@@ -41,6 +43,18 @@ const TABLE: [string, Route][] = [
|
||||
["#/menu", { screen: "menu" }],
|
||||
["#/nouvelle-partie", { screen: "newGame" }],
|
||||
["#/explication/12", { screen: "explanation", event: "12" }],
|
||||
// La feuille du prompt pour une IA (amendement du 2026-11-10, 1f).
|
||||
["#/explication/12/ia", { screen: "explanation", event: "12", ai: true }],
|
||||
["#/glossaire/videau/ia", { screen: "glossary", term: "videau", ai: true }],
|
||||
["#/parties/mh2k3x-a1b2c3/bilan/ia", { screen: "summary", game: "mh2k3x-a1b2c3", ai: true }],
|
||||
[
|
||||
"#/parties/mh2k3x-a1b2c3/analyse/7/ia",
|
||||
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7, ai: true },
|
||||
],
|
||||
[
|
||||
"#/parties/mh2k3x-a1b2c3/analyse/ia",
|
||||
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: null, ai: true },
|
||||
],
|
||||
[
|
||||
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
|
||||
{ screen: "position", position: START, match: MATCH },
|
||||
@@ -55,6 +69,17 @@ test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversemen
|
||||
}
|
||||
});
|
||||
|
||||
test("la feuille du prompt pour une IA se pose sur son écran, et s'en retire", () => {
|
||||
const route = parseRoute("#/parties/ab/analyse/3/ia");
|
||||
assert.ok(isAiRoute(route));
|
||||
if (isAiRoute(route)) {
|
||||
assert.equal(routeHash(withoutAi(route)), "#/parties/ab/analyse/3");
|
||||
}
|
||||
assert.ok(!isAiRoute(parseRoute("#/parties/ab/analyse/3")));
|
||||
// Un terme nommé « ia » reste un terme.
|
||||
assert.deepEqual(parseRoute("#/glossaire/ia"), { screen: "glossary", term: "ia" });
|
||||
});
|
||||
|
||||
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
|
||||
assert.deepEqual(parseRoute(""), { screen: "game" });
|
||||
assert.deepEqual(parseRoute("#"), { screen: "game" });
|
||||
@@ -84,6 +109,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/parties/ab/analyse/x",
|
||||
"#/parties/ab/analyse/3/plus",
|
||||
"#/parties/AB/bilan",
|
||||
"#/aide/blot/ia",
|
||||
"#/menu/ia",
|
||||
"#/parties/ab/ia",
|
||||
"#/explication/12/ia/ia",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Étapes du scénario docs/scenarios/demander-a-une-ia.feature (ADR 0008) :
|
||||
// les décisions se jouent par le domaine sur le vrai moteur, leur analyse
|
||||
// par le domaine, et les prompts sont remplis par le domaine
|
||||
// (domain/prompts/) avec les gabarits de docs/prompts/, comme dans
|
||||
// l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
|
||||
import {
|
||||
missingAnalyses,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import { formatEquity, rankingRows } from "../../src/domain/analysis/numbers.js";
|
||||
import { diceWords } from "../../src/domain/analysis/phrases.js";
|
||||
import type { Advice } from "../../src/domain/game/advice.js";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import {
|
||||
type PromptPosition,
|
||||
PUBLIC_SITE,
|
||||
positionBefore,
|
||||
} from "../../src/domain/prompts/position.js";
|
||||
import {
|
||||
conceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
summaryPosition,
|
||||
summaryPrompt,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../../src/domain/prompts/templates.js";
|
||||
import { parseGlossaryEntry } from "../../src/ui/help-text.js";
|
||||
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { loadPhrases, playOpening } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
|
||||
const phrases = loadPhrases();
|
||||
const templates: PromptTemplates = {
|
||||
move: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.move}`)),
|
||||
cube: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.cube}`)),
|
||||
summary: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.summary}`)),
|
||||
concept: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.concept}`)),
|
||||
exercises: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.exercises}`)),
|
||||
};
|
||||
|
||||
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
|
||||
* l'ordinateur : un sur son point 1, un sur son point 2. */
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
let engine: Engine;
|
||||
let game: Game | null;
|
||||
let journal: GameJournal;
|
||||
let advice: Advice;
|
||||
let position: PromptPosition;
|
||||
let prompt: string;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Given(/^vous avez joué (.+) avec un (\d)-(\d) d'ouverture$/, async (move, a, b) => {
|
||||
const played = await playOpening(engine, [Number(a), Number(b)], move);
|
||||
const analysed = await analyseDecision(engine, played.game.journal, played.eventId);
|
||||
assert.ok(analysed !== null);
|
||||
// Le conseil rattaché au journal, comme dans l'application.
|
||||
played.game.recordAdvice(analysed);
|
||||
game = played.game;
|
||||
journal = played.game.journal;
|
||||
advice = analysed;
|
||||
});
|
||||
|
||||
When("vous demandez à une IA d'expliquer ce coup", async () => {
|
||||
position = await positionBefore(engine, journal, advice.eventId);
|
||||
prompt = decisionPrompt(templates, phrases, { journal, advice, position });
|
||||
});
|
||||
|
||||
Then("la question donne la position, avec son Position ID, son Match ID et son lien", () => {
|
||||
assert.equal(position.positionId, advice.position);
|
||||
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
|
||||
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), prompt);
|
||||
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), prompt);
|
||||
assert.ok(prompt.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication",
|
||||
() => {
|
||||
assert.equal(advice.kind, "move");
|
||||
if (advice.kind !== "move") {
|
||||
return;
|
||||
}
|
||||
// Les lignes de « Voir les chiffres du moteur » : coup et écart.
|
||||
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
|
||||
assert.ok(rows.length >= 3);
|
||||
for (const row of rows) {
|
||||
assert.ok(
|
||||
prompt.includes(`${row.rank}. ${row.move.notation}`) &&
|
||||
prompt.includes(`écart ${formatEquity(row.loss)}`),
|
||||
`${row.move.notation} absent :\n${prompt}`,
|
||||
);
|
||||
}
|
||||
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
|
||||
},
|
||||
);
|
||||
|
||||
Then("elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français", () => {
|
||||
assert.ok(prompt.includes("ne les recalcule pas et ne les contredis pas"), prompt);
|
||||
assert.ok(prompt.includes("Réponds en français"), prompt);
|
||||
assert.ok(!/[{}]/.test(prompt.replace(/```/g, "")), prompt);
|
||||
});
|
||||
|
||||
/** Toutes les analyses qui manquent, une à une, comme l'application en
|
||||
* arrière-plan. */
|
||||
async function analyseAll(start: GameJournal): Promise<GameJournal> {
|
||||
let current = start;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const next = missingAnalyses(current)[0];
|
||||
if (next === undefined) {
|
||||
return current;
|
||||
}
|
||||
current = await runAnalysis(engine, current, next);
|
||||
}
|
||||
throw new Error("l'analyse ne finit pas");
|
||||
}
|
||||
|
||||
Given(
|
||||
/^une partie finie où vous avez joué (.+) avec (\d)-(\d), puis passé le double de l'ordinateur$/,
|
||||
async (move: string, a: string, b: string) => {
|
||||
const played = Game.start(engine, {
|
||||
setup: { position: BEAROFF, firstTurn: "player" },
|
||||
level: "2ply",
|
||||
dice: fixedDice([Number(a), Number(b)]),
|
||||
});
|
||||
await played.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.ok(await played.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
|
||||
}
|
||||
await played.confirmMove();
|
||||
await played.playComputerTurn();
|
||||
assert.equal(played.state.phase, "doubled", "l'ordinateur ne double pas");
|
||||
await played.drop();
|
||||
assert.equal(played.state.phase, "over");
|
||||
journal = await analyseAll(played.journal);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous demandez à une IA le bilan de votre partie", async () => {
|
||||
position = await summaryPosition(engine, journal);
|
||||
prompt = summaryPrompt(templates, phrases, { journal, position });
|
||||
});
|
||||
|
||||
Then("la question donne vos décisions, votre niveau et la chance des dés, comme le bilan", () => {
|
||||
const summary = summarizeAnalysis(journal);
|
||||
const { counts, player } = summary;
|
||||
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
|
||||
assert.ok(prompt.includes(`${counts.bad + counts.veryBad} erreur`), prompt);
|
||||
assert.ok(
|
||||
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name}`),
|
||||
prompt,
|
||||
);
|
||||
assert.ok(prompt.includes(`(${diceWords(phrases, player.luck.rating)})`), prompt);
|
||||
assert.ok(prompt.includes("Résultat : l'ordinateur a gagné"), prompt);
|
||||
});
|
||||
|
||||
Then(/^elle cite votre coup (.+), le coup du maître (.+) et leur écart$/, (mine, best) => {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
assert.ok(toReview !== null);
|
||||
assert.ok(
|
||||
prompt.includes(
|
||||
`j'ai joué ${mine}, GNU Backgammon préférait ${best} ; écart ${formatEquity(toReview.advice.equityLoss)} (Grosse erreur).`,
|
||||
),
|
||||
prompt,
|
||||
);
|
||||
});
|
||||
|
||||
Then("elle donne la position du coup à revoir", () => {
|
||||
const toReview = summarizeAnalysis(journal).toReview;
|
||||
assert.equal(position.positionId, toReview?.advice.position);
|
||||
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
|
||||
assert.ok(prompt.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), prompt);
|
||||
});
|
||||
|
||||
Given("aucune partie n'est en cours", () => {
|
||||
game = null;
|
||||
});
|
||||
|
||||
When(/^vous demandez à une IA d'expliquer le mot « (.+) »$/, async (term: string) => {
|
||||
const entry = parseGlossaryEntry(read(`docs/glossaire/${term.toLowerCase()}.md`));
|
||||
assert.ok(entry !== null);
|
||||
const example = await conceptExample(engine, game?.journal ?? null, 2);
|
||||
position = example.position;
|
||||
prompt = conceptPrompt(templates, {
|
||||
term: entry.term,
|
||||
english: entry.english,
|
||||
brief: entry.brief,
|
||||
journal: game?.journal ?? null,
|
||||
example,
|
||||
});
|
||||
});
|
||||
|
||||
Then("la question reprend la définition de sa fiche", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/videau.md"));
|
||||
assert.ok(entry !== null && prompt.includes(entry.brief), prompt);
|
||||
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)"), prompt);
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon",
|
||||
() => {
|
||||
assert.ok(prompt.includes("voici la position de départ"), prompt);
|
||||
assert.ok(prompt.includes("Position ID : 4HPwATDgc/ABMA"), prompt);
|
||||
assert.match(prompt, /chances de gagner \d+ %/);
|
||||
},
|
||||
);
|
||||
@@ -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é.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Affiche des prompts remplis sur de vraies analyses du moteur, pour les
|
||||
// relire (étape 1f) : node build/web/tests/tools/prompt-sample.js
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
missingAnalyses,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { positionBefore } from "../../src/domain/prompts/position.js";
|
||||
import {
|
||||
decisionPrompt,
|
||||
summaryPosition,
|
||||
summaryPrompt,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../../src/domain/prompts/templates.js";
|
||||
import { adviceOn, loadPhrases, playOpening } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
const read = (file: string) =>
|
||||
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
|
||||
const templates: PromptTemplates = {
|
||||
move: read(PROMPT_FILES.move),
|
||||
cube: read(PROMPT_FILES.cube),
|
||||
summary: read(PROMPT_FILES.summary),
|
||||
concept: read(PROMPT_FILES.concept),
|
||||
exercises: read(PROMPT_FILES.exercises),
|
||||
};
|
||||
const phrases = loadPhrases();
|
||||
const engine: Engine = await loadNodeEngine();
|
||||
|
||||
const played = await playOpening(engine, [3, 1], "24/23 13/10");
|
||||
const advice = await adviceOn(engine, played);
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
console.log(decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position }));
|
||||
|
||||
async function analyseAll(journal: GameJournal): Promise<GameJournal> {
|
||||
let current = journal;
|
||||
for (;;) {
|
||||
const next = missingAnalyses(current)[0];
|
||||
if (next === undefined) {
|
||||
return current;
|
||||
}
|
||||
current = await runAnalysis(engine, current, next);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [seed, choice] of [
|
||||
[7, 1],
|
||||
[7, 2],
|
||||
[11, 2],
|
||||
[3, 3],
|
||||
]) {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed ?? 0),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
const journal = await analyseAll(game.journal);
|
||||
const summary = summarizeAnalysis(journal);
|
||||
console.log(
|
||||
`\n=== graine ${seed}, choix ${choice} : coup à revoir ${summary.toReview?.turn ?? "aucun"}`,
|
||||
);
|
||||
if (seed === 3) {
|
||||
const where = await summaryPosition(engine, journal);
|
||||
console.log(summaryPrompt(templates, phrases, { journal, position: where }));
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,8 @@ test("chaque position du tutoriel est valide pour le moteur", async () => {
|
||||
const s = board.sides[side];
|
||||
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
|
||||
};
|
||||
assert.equal(count("player"), 15, `${name} : pions ivoire`);
|
||||
assert.equal(count("computer"), 15, `${name} : pions noyer`);
|
||||
assert.equal(count("player"), 15, `${name} : pions blancs`);
|
||||
assert.equal(count("computer"), 15, `${name} : pions noirs`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -209,14 +209,14 @@ test("aide contextuelle : chaque question ouverte depuis la partie existe dans l
|
||||
});
|
||||
|
||||
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md"));
|
||||
assert.ok(entry !== null);
|
||||
assert.equal(entry.english, "Home board");
|
||||
assert.deepEqual(
|
||||
entry.seeAlso.map((s) => s.name),
|
||||
["sortie", "barre", "point"],
|
||||
["jan-exterieur", "sortie", "barre", "point"],
|
||||
);
|
||||
assert.ok(matchesSearch(entry, "camp"));
|
||||
assert.ok(matchesSearch(entry, "jan interieur"));
|
||||
assert.ok(matchesSearch(entry, "HOME"));
|
||||
assert.ok(matchesSearch(entry, "fleches sortir"));
|
||||
assert.ok(!matchesSearch(entry, "videau"));
|
||||
|
||||
@@ -108,3 +108,13 @@ avec Gherkin et l'exécution de bout en bout par l'interface.
|
||||
- **Perspective** : si les scénarios peuvent un jour être joués dans une
|
||||
instance réelle de l'application, la même exécution produira les captures
|
||||
d'écran de l'aide, toujours à jour avec la version installée.
|
||||
|
||||
### 2026-11-10 — Les prompts pour une IA, même principe
|
||||
|
||||
Avec « Demander à une IA » (étape 1f), les gabarits des prompts suivent la
|
||||
même règle que le glossaire, les scénarios, le tutoriel et les phrases du
|
||||
maître : une seule source, `docs/prompts/` (un fichier par gabarit, sous
|
||||
CC BY-SA 4.0), copiée dans le site par `make site` et lue par l'application
|
||||
à l'exécution ; jamais recopiée dans le code. `make test` remplit chaque
|
||||
gabarit avec de vraies analyses du moteur (`app/tests/prompts.test.ts`) et
|
||||
le scénario `docs/scenarios/demander-a-une-ia.feature` en fait l'aide.
|
||||
|
||||
@@ -60,3 +60,21 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
|
||||
en dessous de 32 px.
|
||||
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
|
||||
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-11 — Pions blancs et noirs, avec un contour
|
||||
|
||||
Retour des premiers joueurs, relayé par Pierre : les pions sombres se voyaient
|
||||
mal sur la barre, et les joueurs préféraient le blanc et le noir à l'ivoire et
|
||||
au noyer. Après comparaison sur le canevas (rangée « Retours des premiers
|
||||
joueurs »), Pierre retient le blanc et le noir, avec un contour.
|
||||
|
||||
- Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé
|
||||
(`#6E6E69`) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris
|
||||
clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions.
|
||||
- Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1
|
||||
en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1).
|
||||
- Le logo ne change pas : ses pions restent ivoire et noyer, qui sont la
|
||||
matière de la marque, distincte des pièces du jeu.
|
||||
- Valeurs dans `docs/identite/charte/tokens.json`.
|
||||
|
||||
@@ -159,3 +159,36 @@ non, lancer, jouer), à partir de 1, sur sa ligne courante.
|
||||
- Comme la feuille d'explication, ces adresses n'ont de sens que sur
|
||||
l'appareil qui a joué la partie. Une partie absente donne un message,
|
||||
jamais une page cassée.
|
||||
|
||||
### 2026-11-10 — Adresses de la feuille du prompt pour une IA
|
||||
|
||||
Ajoutées avec « Demander à une IA » (étape 1f), dans le même schéma. La
|
||||
feuille montre le prompt pour qu'on le relise avant de le copier : c'est un
|
||||
écran à part entière, elle a donc son adresse, celle de l'écran d'où on la
|
||||
demande suivie de `/ia` :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Prompt d'une décision, depuis la feuille d'explication du maître | `#/explication/<événement>/ia` |
|
||||
| Prompt du bilan d'une partie | `#/parties/<id>/bilan/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` |
|
||||
|
||||
- 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.
|
||||
- La confirmation de la copie (« Copié » sur le bouton, 2 s) n'a pas
|
||||
d'adresse.
|
||||
- Le prompt contient, lui, le lien public de la position
|
||||
(`https://ustagammon.sans.pub/#/position/<Position ID>/<Match ID>`),
|
||||
qui se partage.
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
|
||||
|
||||
@@ -40,7 +40,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Barre](barre.md) | Bar |
|
||||
| [Blot](blot.md) | Blot |
|
||||
| [Branche](branche.md) | Branch |
|
||||
| [Camp](camp.md) | Home board |
|
||||
| [Chance](chance.md) | Luck |
|
||||
| [Compte de points](compte-de-points.md) | Pip count |
|
||||
| [Conseil du maître](conseil-du-maitre.md) | Advice |
|
||||
@@ -54,11 +53,14 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Gammon](gammon.md) | Gammon |
|
||||
| [Identifiant de match](identifiant-de-match.md) | Match ID |
|
||||
| [Identifiant de position](identifiant-de-position.md) | Position ID |
|
||||
| [Jan extérieur](jan-exterieur.md) | Outer board |
|
||||
| [Jan intérieur](jan-interieur.md) | Home board |
|
||||
| [Jet](jet.md) | Roll |
|
||||
| [Lien de position](lien-de-position.md) | Position link |
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Notation des coups](notation-des-coups.md) | Move notation |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Partie enregistrée](partie-enregistree.md) | Saved game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
@@ -67,6 +69,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Pli](pli.md) | Ply |
|
||||
| [Point](point.md) | Point |
|
||||
| [Prendre](prendre.md) | Take |
|
||||
| [Prompt](prompt.md) | Prompt |
|
||||
| [Règle de Crawford](regle-de-crawford.md) | Crawford rule |
|
||||
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
|
||||
| [SGF](sgf.md) | SGF |
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
term: Camp
|
||||
english: Home board
|
||||
---
|
||||
# Camp
|
||||
|
||||
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
|
||||
|
||||
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
|
||||
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
|
||||
|
||||
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Jan extérieur
|
||||
english: Outer board
|
||||
code: OuterBoard
|
||||
---
|
||||
# Jan extérieur
|
||||
|
||||
**En bref** : les six flèches qui précèdent le jan intérieur d'un joueur : ses pions y passent avant d'y entrer.
|
||||
|
||||
Votre jan extérieur est en bas à gauche (vos flèches 7 à 12) ; celui de
|
||||
l'ordinateur, en haut à gauche (vos flèches 13 à 18). Vos pions partent du jan
|
||||
intérieur de l'ordinateur, traversent les deux jans extérieurs, puis entrent
|
||||
dans votre jan intérieur.
|
||||
|
||||
Voir aussi : [Jan intérieur](jan-interieur.md), [Point](point.md)
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
term: Jan intérieur
|
||||
english: Home board
|
||||
code: HomeBoard
|
||||
---
|
||||
# Jan intérieur
|
||||
|
||||
**En bref** : les six dernières flèches d'un joueur avant la sortie : il y rassemble ses quinze pions pour les sortir, et les pions adverses frappés y rentrent.
|
||||
|
||||
Le plateau se partage en quatre jans de six flèches. Votre jan intérieur est
|
||||
en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, en haut à droite
|
||||
(vos flèches 19 à 24) : c'est là que rentrent vos pions frappés. Un pion
|
||||
adverse resté dans votre jan intérieur à la fin de la partie en fait un
|
||||
backgammon.
|
||||
|
||||
Voir aussi : [Jan extérieur](jan-exterieur.md), [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
term: Notation des coups
|
||||
english: Move notation
|
||||
code: MoveNotation
|
||||
---
|
||||
# Notation des coups
|
||||
|
||||
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
|
||||
|
||||
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
|
||||
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
|
||||
plus proche de sa sortie) à 24 :
|
||||
|
||||
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
|
||||
dés compte en général deux déplacements, séparés par une espace :
|
||||
`13/7 8/7`.
|
||||
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
|
||||
seul de l'adversaire, qui part sur la barre.
|
||||
- `6/off` : le pion de la case 6 sort du plateau.
|
||||
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
|
||||
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
|
||||
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
|
||||
6 à 2.
|
||||
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
|
||||
de sa case de départ à sa case d'arrivée.
|
||||
|
||||
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
term: Prompt
|
||||
english: Prompt
|
||||
code: Prompt
|
||||
---
|
||||
# Prompt
|
||||
|
||||
**En bref** : la question toute prête que l'application copie pour une IA (« Demander à une IA ») : la position, les chiffres de GNU Backgammon, et ce qu'on demande ; il suffit de la coller dans l'IA de votre choix.
|
||||
|
||||
L'application n'appelle aucune IA et n'envoie rien : elle prépare le texte,
|
||||
vous le relisez, vous le copiez. Le moteur décide, l'IA explique : le prompt
|
||||
lui demande de ne pas contredire les évaluations de GNU Backgammon, qui
|
||||
restent celles que l'application affiche. Il contient aussi le lien de la
|
||||
position, pour la retrouver dans l'application.
|
||||
|
||||
Voir aussi : [Lien de position](lien-de-position.md), [Moteur](moteur.md), [Conseil du maître](conseil-du-maitre.md)
|
||||
@@ -76,9 +76,11 @@ interrompre.
|
||||
demande au moteur si le coup du joueur fait partie des coups permis attendus.
|
||||
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
|
||||
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
|
||||
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
|
||||
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
|
||||
veut dire « à faire »).
|
||||
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
|
||||
circulaires** : une claire pour vos pions, une sombre pour ceux de
|
||||
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
|
||||
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
|
||||
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
|
||||
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
|
||||
courte, « Continuer » devient l'action principale. Un geste faux : le pion
|
||||
revient, et une phrase dit pourquoi, sans le mot « erreur ».
|
||||
@@ -87,14 +89,14 @@ interrompre.
|
||||
|
||||
### Texte, première version
|
||||
|
||||
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
|
||||
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
|
||||
1. **Le but du jeu.** « Vous avez quinze pions blancs, l'ordinateur quinze
|
||||
pions noirs. Chacun fait faire le tour du plateau à ses pions, puis les
|
||||
sort. Le premier qui a sorti tous les siens gagne la partie. »
|
||||
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
|
||||
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
|
||||
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
|
||||
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
|
||||
l'ordinateur font le même chemin, en sens inverse. »
|
||||
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
|
||||
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
|
||||
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
|
||||
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
|
||||
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
|
||||
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
|
||||
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
|
||||
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
|
||||
@@ -106,16 +108,16 @@ interrompre.
|
||||
point 7 avec 6-1.
|
||||
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
|
||||
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
|
||||
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
|
||||
jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée :
|
||||
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
|
||||
passe. »
|
||||
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
|
||||
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
|
||||
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
|
||||
(flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la
|
||||
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
|
||||
droite. » *À vous* : sortir deux pions avec 6-3.
|
||||
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
|
||||
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
|
||||
reste en plus un pion sur la barre ou dans votre camp, c'est un
|
||||
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
|
||||
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
|
||||
que si le videau a été doublé : c'est la règle de Jacoby. »
|
||||
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
|
||||
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
|
||||
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
|
||||
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
|
||||
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
|
||||
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
|
||||
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
|
||||
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
|
||||
| Menu | `#/menu` |
|
||||
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
|
||||
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
|
||||
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
|
||||
sans retour possible.
|
||||
|
||||
## Vocabulaire : les jans
|
||||
|
||||
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
|
||||
explications (retour des premiers joueurs, 2026-11-11) :
|
||||
|
||||
| Flèches | Nom |
|
||||
| --- | --- |
|
||||
| 1 à 6 | votre jan intérieur |
|
||||
| 7 à 12 | votre jan extérieur |
|
||||
| 13 à 18 | le jan extérieur de l'ordinateur |
|
||||
| 19 à 24 | le jan intérieur de l'ordinateur |
|
||||
|
||||
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
|
||||
vers leur fiche du glossaire à la première apparition. Fiches à créer :
|
||||
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
|
||||
outer board). « Camp » n'est plus employé dans les textes.
|
||||
|
||||
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
|
||||
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
|
||||
« Une ligne après chacun de vos coups ».
|
||||
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
|
||||
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
|
||||
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
|
||||
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
|
||||
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
|
||||
@@ -206,9 +206,21 @@ Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
|
||||
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
|
||||
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
|
||||
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
|
||||
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
|
||||
puis le texte du prompt reste lisible dans une zone `surface-sunken`
|
||||
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
|
||||
glossaire. Le bouton ouvre la feuille du prompt : le texte y est lisible
|
||||
dans une zone `surface-sunken`, pour qui veut le relire avant de le copier
|
||||
(composant `AskAI`).
|
||||
|
||||
Recette de 1f :
|
||||
|
||||
- **Une seule feuille à la fois.** Depuis la feuille du maître, la feuille
|
||||
du prompt en remplace le contenu, à la même hauteur, avec « ‹ Explication »
|
||||
en haut à gauche pour revenir ; de même depuis le bilan (« ‹ Bilan »).
|
||||
Depuis l'analyse et le glossaire, elle monte seule (« ‹ Analyse »,
|
||||
« ‹ Glossaire »). Le retour du téléphone ramène à l'écran précédent.
|
||||
- **La copie se confirme sur le bouton**, sans bandeau : « Copier le
|
||||
prompt » devient « ✓ Copié » (coche, `verdict-good`) pendant 2 s, puis
|
||||
revient. Si le navigateur refuse la copie, le texte est sélectionné et
|
||||
une phrase invite à le copier avec le menu du téléphone.
|
||||
|
||||
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
|
||||
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
|
||||
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
|
||||
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
|
||||
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions blancs (`checker-player`), l'ordinateur les pions noirs (`checker-computer`), chacun avec un contour et un filet (`checker-player-edge`, `checker-computer-edge`) qui le détachent de tous les fonds ; les pions du logo restent ivoire et noyer (ADR 0010, amendement du 2026-11-11).
|
||||
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
|
||||
|
||||
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
|
||||
|
||||
@@ -8,6 +8,8 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
## Toutes tailles
|
||||
|
||||
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
|
||||
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. (Vérifié par `tests/browser/retours-joueurs.mjs`.)
|
||||
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. Règle automatique : là où sa couleur ne se détache pas de son fond (moins de 3:1), son contour s'en détache à 3:1 au moins (`checkerContrasts` de `tests/browser/recette.mjs`).
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
|
||||
@@ -216,96 +216,96 @@
|
||||
{
|
||||
"name": "checker-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Pions du joueur : ivoire, l'apprenti."
|
||||
"usage": "Pions du joueur : blancs."
|
||||
},
|
||||
{
|
||||
"name": "checker-player-edge",
|
||||
"value": {
|
||||
"light": "#8C7A62",
|
||||
"dark": "#8E7C64"
|
||||
"light": "#6E6E69",
|
||||
"dark": "#6E6E69"
|
||||
},
|
||||
"usage": "Contour et filet des pions du joueur."
|
||||
"usage": "Contour et filet des pions blancs : gris foncé, pour qu'ils se détachent du fond clair du plateau."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#141414"
|
||||
},
|
||||
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
|
||||
"usage": "Pions de l'ordinateur : noirs."
|
||||
},
|
||||
{
|
||||
"name": "checker-computer-edge",
|
||||
"value": {
|
||||
"light": "#120A05",
|
||||
"dark": "#A8957D"
|
||||
"light": "#B5B5B0",
|
||||
"dark": "#B5B5B0"
|
||||
},
|
||||
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
|
||||
"usage": "Contour et filet des pions noirs : gris clair dans les deux thèmes, pour qu'ils se détachent de la barre, des flèches sombres et du fond sombre."
|
||||
},
|
||||
{
|
||||
"name": "count-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions du joueur."
|
||||
"usage": "Nombre écrit sur une pile de pions blancs."
|
||||
},
|
||||
{
|
||||
"name": "count-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#F3EAD8"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#FAFAF7"
|
||||
},
|
||||
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
|
||||
"usage": "Nombre écrit sur une pile de pions noirs."
|
||||
},
|
||||
{
|
||||
"name": "die-player",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Dés du joueur."
|
||||
"usage": "Dés du joueur, blancs comme ses pions."
|
||||
},
|
||||
{
|
||||
"name": "pip-player",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Points des dés du joueur."
|
||||
},
|
||||
{
|
||||
"name": "die-computer",
|
||||
"value": {
|
||||
"light": "#2E1C12",
|
||||
"dark": "#140D09"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#141414"
|
||||
},
|
||||
"usage": "Dés de l'ordinateur."
|
||||
"usage": "Dés de l'ordinateur, noirs comme ses pions."
|
||||
},
|
||||
{
|
||||
"name": "pip-computer",
|
||||
"value": {
|
||||
"light": "#F3EAD8",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Points des dés de l'ordinateur."
|
||||
},
|
||||
{
|
||||
"name": "cube",
|
||||
"value": {
|
||||
"light": "#F7F0E2",
|
||||
"dark": "#EDE3CF"
|
||||
"light": "#FAFAF7",
|
||||
"dark": "#F2F2EF"
|
||||
},
|
||||
"usage": "Face du videau."
|
||||
},
|
||||
{
|
||||
"name": "cube-text",
|
||||
"value": {
|
||||
"light": "#2B1D15",
|
||||
"dark": "#2B1D15"
|
||||
"light": "#1C1C1C",
|
||||
"dark": "#1C1C1C"
|
||||
},
|
||||
"usage": "Valeur du videau (2, 4, 8…)."
|
||||
},
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Prompts à copier (1f)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1f (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
|
||||
- « pourquoi ce coup est meilleur » ;
|
||||
- « expliquer cette décision de videau » ;
|
||||
- « bilan de ma partie » ;
|
||||
- « expliquer ce concept » ;
|
||||
- « créer des exercices », à garder pour la progression.
|
||||
|
||||
Chaque gabarit contient :
|
||||
- le rôle et le niveau du joueur ;
|
||||
- le Position ID et le Match ID ;
|
||||
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
|
||||
- les chiffres du moteur, avec le niveau d'évaluation ;
|
||||
- la consigne de ne pas contredire ces chiffres ;
|
||||
- la langue.
|
||||
|
||||
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
|
||||
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
|
||||
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
|
||||
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Les prompts à copier pour une IA
|
||||
|
||||
Les textes que l'application copie dans le presse-papiers quand on touche
|
||||
« Demander à une IA » (étape 1f ; `docs/cadrage.md`, « IA générative en
|
||||
V1 »). L'application n'appelle aucune IA : elle prépare une question
|
||||
complète, que le joueur colle dans l'IA de son choix. Règle d'or : **le
|
||||
moteur décide, l'IA explique**.
|
||||
|
||||
Une seule source (ADR 0008) : `make site` copie ce dossier dans le site
|
||||
publié (`docs/prompts/`), l'application lit les gabarits à l'exécution
|
||||
(`app/src/domain/prompts/`), et `make test` remplit chacun d'eux avec de
|
||||
vraies analyses du moteur (`app/tests/prompts.test.ts`) : aucun repère
|
||||
laissé vide, et les chiffres du prompt sont ceux que l'application affiche.
|
||||
|
||||
Textes sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
|
||||
d'après le cadrage, **à relire** par Pierre.
|
||||
|
||||
## Les gabarits
|
||||
|
||||
| Fichier | Bouton | Où |
|
||||
| --- | --- | --- |
|
||||
| [coup.md](coup.md) | Pourquoi ce coup est meilleur | Feuille du maître, bilan, analyse |
|
||||
| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse |
|
||||
| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie |
|
||||
| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire |
|
||||
| [exercices.md](exercices.md) | Créer des exercices | Progression (étape 1h, pas encore de bouton) |
|
||||
|
||||
Chaque prompt contient :
|
||||
|
||||
- le rôle de l'IA (un coach) et, quand il est mesuré, le niveau du joueur ;
|
||||
- la position : Position ID et Match ID de GNU Backgammon, le lien qui
|
||||
l'ouvre dans l'application (`https://ustagammon.sans.pub/#/position/…`,
|
||||
ADR 0013) et un diagramme texte dessiné d'après le plateau décodé par le
|
||||
moteur ;
|
||||
- les chiffres du moteur, avec le niveau d'évaluation (en plis) ;
|
||||
- la consigne de ne pas recalculer ni contredire ces chiffres ;
|
||||
- la langue de la réponse.
|
||||
|
||||
## Format
|
||||
|
||||
Dans chaque fichier, le gabarit est le bloc de texte qui suit le titre
|
||||
`## Gabarit`. Le reste du fichier n'est pas lu. Les mots entre accolades sont
|
||||
remplis par l'application ; un repère inconnu ou une valeur vide est une
|
||||
erreur, vérifiée par les tests.
|
||||
|
||||
Repères communs à plusieurs gabarits :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{joueur}` | « Je suis un joueur de niveau Avancé. », le niveau que donne GNU Backgammon d'après l'erreur par décision, seulement à partir de 20 décisions analysées ; en dessous, « Je débute au backgammon. ». Le taux d'erreur lui-même n'apparaît que dans le bilan. |
|
||||
| `{position_id}`, `{match_id}` | les identifiants de GNU Backgammon de la position |
|
||||
| `{au_trait}` | « moi » ou « l'ordinateur » : celui dont les identifiants décrivent le point de vue |
|
||||
| `{lien}` | l'adresse de la position dans l'application publiée |
|
||||
| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation |
|
||||
| `{score}` | « partie libre » ou le score du match |
|
||||
| `{videau}` | sa valeur et qui le possède |
|
||||
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » |
|
||||
| `{langue}` | la langue de la réponse : « français » |
|
||||
@@ -0,0 +1,44 @@
|
||||
# Bilan de ma partie
|
||||
|
||||
Bouton « Demander à une IA » de la carte « Le coup à revoir » du bilan de
|
||||
fin de partie. Texte sous licence CC BY-SA 4.0 (voir
|
||||
[README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{ordinateur}` | le niveau de l'ordinateur, « Intermédiaire » |
|
||||
| `{resultat}` | le résultat de la partie, et le score du match s'il y en a un |
|
||||
| `{decomptes}` | vos décisions par appréciation, comme les trois cases du bilan |
|
||||
| `{taux}`, `{decisions}`, `{niveau_jeu}` | votre erreur par décision, le nombre de décisions comptées et le niveau correspondant, comme « Voir les chiffres du moteur » de l'analyse |
|
||||
| `{taux_ordinateur}` | la même mesure pour l'ordinateur |
|
||||
| `{chance}`, `{chance_mots}`, `{chance_ordinateur}` | la chance par tour de vos dés, en chiffres et en mots, et celle de l'ordinateur |
|
||||
| `{erreurs}` | vos imprécisions et erreurs, coup par coup, avec leur coût |
|
||||
| `{position_titre}` | ce que montre la position : le coup à revoir, ou la position finale s'il n'y en a pas |
|
||||
|
||||
## Gabarit
|
||||
|
||||
```text
|
||||
Tu es un coach de backgammon, bienveillant et précis. {joueur}
|
||||
|
||||
Je viens de finir une partie contre l'ordinateur (niveau {ordinateur}), analysée par GNU Backgammon ({niveau_eval}).
|
||||
Résultat : {resultat}.
|
||||
Mes décisions : {decomptes}.
|
||||
Mon erreur par décision : {taux} sur {decisions}, soit le niveau {niveau_jeu} ; celle de l'ordinateur : {taux_ordinateur}.
|
||||
La chance de mes dés par tour : {chance} ({chance_mots}) ; celle de l'ordinateur : {chance_ordinateur}.
|
||||
|
||||
Mes imprécisions et erreurs, avec leur coût en équité :
|
||||
{erreurs}
|
||||
|
||||
{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
{diagramme}
|
||||
|
||||
Score : {score}. Videau : {videau}.
|
||||
|
||||
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
|
||||
À partir de ce bilan, identifie trois thèmes que je devrais travailler en priorité, avec pour chacun un conseil concret.
|
||||
Réponds en {langue}, simplement, en moins de 300 mots.
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Expliquer ce concept
|
||||
|
||||
Bouton « Demander à une IA » sur la fiche d'un terme du glossaire (« Les
|
||||
mots du jeu »). La position d'exemple est celle de la partie en cours, ou la
|
||||
position de départ s'il n'y en a pas. Texte sous licence CC BY-SA 4.0 (voir
|
||||
[README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{concept}` | le terme, et son nom anglais s'il en a un |
|
||||
| `{definition}` | la phrase « En bref » de sa fiche |
|
||||
| `{position_titre}` | « une position de ma partie en cours » ou « la position de départ » |
|
||||
| `{evaluation}` | les chances de gagner et l'équité du joueur au trait, avant de lancer les dés |
|
||||
|
||||
## Gabarit
|
||||
|
||||
```text
|
||||
Tu es un coach de backgammon, bienveillant et précis. {joueur}
|
||||
|
||||
Explique-moi le concept {concept}.
|
||||
Mon application le définit ainsi : {definition}
|
||||
|
||||
Pour l'illustrer, voici {position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
{diagramme}
|
||||
|
||||
Score : {score}. Videau : {videau}.
|
||||
Évaluation de GNU Backgammon ({niveau_eval}), pour le joueur qui a la main ({au_trait}), avant de lancer les dés : {evaluation}.
|
||||
|
||||
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
|
||||
Explique ce concept simplement, avec une analogie de la vie courante, puis dis s'il joue un rôle dans cette position et comment.
|
||||
Réponds en {langue}, en moins de 250 mots.
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Pourquoi ce coup est meilleur
|
||||
|
||||
Bouton « Demander à une IA » sur un coup du joueur : feuille d'explication
|
||||
du maître, carte « Le coup à revoir » du bilan, visualiseur de l'analyse.
|
||||
Texte sous licence CC BY-SA 4.0 (voir [README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{des}` | le jet joué, « 3-1 » |
|
||||
| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon |
|
||||
| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » |
|
||||
| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » |
|
||||
| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
|
||||
|
||||
## Gabarit
|
||||
|
||||
```text
|
||||
Tu es un coach de backgammon, bienveillant et précis. {joueur}
|
||||
|
||||
Voici la position avant mon coup (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
{diagramme}
|
||||
|
||||
Score : {score}. Videau : {videau}. Dés : {des}.
|
||||
|
||||
J'ai joué {coup_joue} : {rang}, jugé « {appreciation} ».
|
||||
Classement de GNU Backgammon ({niveau_eval}), équités du point de vue du joueur qui joue :
|
||||
{classement}
|
||||
|
||||
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
|
||||
Explique-moi pourquoi le meilleur coup est supérieur au mien : quels concepts sont en jeu, et ce que je dois retenir pour la prochaine fois.
|
||||
Réponds en {langue}, simplement, en moins de 200 mots.
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Créer des exercices
|
||||
|
||||
Prévu pour l'écran de progression (étape 1h) : le gabarit est écrit et
|
||||
testé, sans bouton pour l'instant. Il rassemble les erreurs de vos
|
||||
dernières parties analysées. Texte sous licence CC BY-SA 4.0 (voir
|
||||
[README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{parties}` | le nombre de parties analysées |
|
||||
| `{erreurs}` | vos erreurs les plus coûteuses sur ces parties, avec leur coût |
|
||||
| `{position_titre}` | la plus coûteuse d'entre elles |
|
||||
|
||||
## Gabarit
|
||||
|
||||
```text
|
||||
Tu es un coach de backgammon, bienveillant et précis. {joueur}
|
||||
|
||||
Voici mes {parties} dernières parties contre l'ordinateur, analysées par GNU Backgammon ({niveau_eval}).
|
||||
|
||||
Mes erreurs les plus coûteuses, avec leur coût en équité :
|
||||
{erreurs}
|
||||
|
||||
{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
{diagramme}
|
||||
|
||||
Score : {score}. Videau : {videau}.
|
||||
|
||||
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
|
||||
Repère les thèmes qui reviennent dans ces erreurs, puis propose-moi cinq exercices ciblés : pour chacun, le thème, une position type décrite simplement, et ce que je dois y chercher.
|
||||
Réponds en {langue}, en moins de 350 mots.
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# Expliquer cette décision de videau
|
||||
|
||||
Bouton « Demander à une IA » sur une décision de videau du joueur (doubler,
|
||||
lancer sans doubler, prendre, passer) : feuille d'explication du maître,
|
||||
carte « Le coup à revoir » du bilan, visualiseur de l'analyse. Texte sous
|
||||
licence CC BY-SA 4.0 (voir [README.md](README.md)).
|
||||
|
||||
Repères propres à ce gabarit :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{decision}` | la question posée : « doubler ou lancer les dés sans doubler » ou « prendre ou passer le double de l'ordinateur » |
|
||||
| `{choix}` | votre décision, « passer » |
|
||||
| `{choix_moteur}` | la décision que préfère GNU Backgammon |
|
||||
| `{appreciation}` | le mot du maître : « Bonne décision », « Imprécision », « Erreur » ou « Grosse erreur » |
|
||||
| `{qui_double}` | « moi » ou « l'ordinateur » : celui qui double ou non |
|
||||
| `{equites}` | les équités sans double, double et prise, double et refus, comme dans « Voir les chiffres du moteur » |
|
||||
| `{ecart}` | l'équité perdue par votre décision |
|
||||
|
||||
## Gabarit
|
||||
|
||||
```text
|
||||
Tu es un coach de backgammon, bienveillant et précis. {joueur}
|
||||
|
||||
Voici la position au moment d'une décision de videau (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
|
||||
Elle s'ouvre aussi à cette adresse : {lien}
|
||||
|
||||
{diagramme}
|
||||
|
||||
Score : {score}. Videau : {videau}.
|
||||
|
||||
Il fallait choisir : {decision}. J'ai choisi de {choix}, jugé « {appreciation} » ; GNU Backgammon aurait choisi de {choix_moteur}.
|
||||
Équités calculées par GNU Backgammon ({niveau_eval}), du point de vue de celui qui double ou non ({qui_double}), pour un videau de 1 :
|
||||
{equites}
|
||||
Écart de ma décision : {ecart}.
|
||||
|
||||
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
|
||||
Explique-moi cette décision : pourquoi doubler ou non, prendre ou passer, quels repères de la position comptent, et ce que je dois retenir.
|
||||
Réponds en {langue}, simplement, en moins de 200 mots.
|
||||
```
|
||||
@@ -59,6 +59,7 @@ Règles d'écriture :
|
||||
| [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c |
|
||||
| [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d |
|
||||
| [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e |
|
||||
| [demander-a-une-ia.feature](demander-a-une-ia.feature) | Demander à une IA d'expliquer un coup, une partie ou un mot | Étape 1f |
|
||||
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
|
||||
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Demander à une IA d'expliquer un coup, une partie ou un mot
|
||||
|
||||
Le maître dit quel coup il aurait joué ; pour comprendre pourquoi, vous
|
||||
pouvez demander à une IA de votre choix. Touchez « Demander à une IA »
|
||||
dans l'explication du maître, sur le bilan, dans l'analyse ou sur la fiche
|
||||
d'un mot du jeu : l'application prépare une question complète, avec la
|
||||
position, son lien et les chiffres de GNU Backgammon. Relisez-la, copiez-la,
|
||||
puis collez-la dans l'IA. L'application n'appelle aucune IA et n'envoie
|
||||
rien : le moteur décide, l'IA explique, et la question lui demande de ne
|
||||
pas contredire les chiffres du moteur.
|
||||
|
||||
Scénario: Comment demander à une IA d'expliquer mon erreur ?
|
||||
Touchez « Demander à une IA » dans l'explication du maître : la question
|
||||
donne la position avant votre coup, dessinée en texte, ses identifiants
|
||||
de GNU Backgammon et son lien, puis les trois meilleurs coups du maître et
|
||||
le vôtre, avec les mêmes chiffres que l'explication du maître.
|
||||
|
||||
Étant donné que vous avez joué 24/23 13/10 avec un 3-1 d'ouverture
|
||||
Quand vous demandez à une IA d'expliquer ce coup
|
||||
Alors la question donne la position, avec son Position ID, son Match ID et son lien
|
||||
Et elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication
|
||||
Et elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français
|
||||
|
||||
Scénario: Comment demander à une IA le bilan de ma partie ?
|
||||
Sur le bilan, « Demander à une IA » prépare une question sur toute la
|
||||
partie : le résultat, vos décisions par appréciation, votre niveau, la
|
||||
chance des dés, vos erreurs et leur coût, et la position du coup à
|
||||
revoir. L'IA vous propose trois thèmes à travailler.
|
||||
|
||||
Étant donné une partie finie où vous avez joué 6/5 5/off avec 6-1, puis passé le double de l'ordinateur
|
||||
Quand vous demandez à une IA le bilan de votre partie
|
||||
Alors la question donne vos décisions, votre niveau et la chance des dés, comme le bilan
|
||||
Et elle cite votre coup 6/off, le coup du maître 6/off 1/off et leur écart
|
||||
Et elle donne la position du coup à revoir
|
||||
|
||||
Scénario: Puis-je demander à une IA d'expliquer un mot du jeu ?
|
||||
Oui : sur la fiche d'un mot du glossaire, la question reprend sa
|
||||
définition et l'illustre par la position de votre partie en cours, ou par
|
||||
la position de départ, avec l'évaluation de GNU Backgammon.
|
||||
|
||||
Étant donné qu'aucune partie n'est en cours
|
||||
Quand vous demandez à une IA d'expliquer le mot « Videau »
|
||||
Alors la question reprend la définition de sa fiche
|
||||
Et elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon
|
||||
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
|
||||
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
|
||||
plateau, même juste après le bilan d'une partie finie.
|
||||
|
||||
Scénario: Comment choisir le niveau de l'ordinateur ?
|
||||
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
|
||||
|
||||
@@ -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 votre jan intérieur (flèches 1 à 6), 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
|
||||
|
||||
@@ -51,7 +51,8 @@ Le texte de la fin du tutoriel.
|
||||
position (par défaut, vous) ;
|
||||
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
|
||||
joue tout seul (notation de gnubg) ;
|
||||
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
|
||||
- `chemin: oui` : le sens du jeu, deux flèches circulaires sur le plateau
|
||||
(claire pour vos pions, sombre pour ceux de l'ordinateur) ;
|
||||
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
|
||||
coups justes, en notation de gnubg, tels que le moteur les écrit ;
|
||||
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
|
||||
|
||||
+11
-9
@@ -1,13 +1,15 @@
|
||||
# Les règles du backgammon
|
||||
|
||||
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
|
||||
validé par Pierre le 9 novembre 2026. Format : voir README.md.
|
||||
validé par Pierre le 9 novembre 2026 ; pions blancs et noirs, flèches
|
||||
circulaires et jans repris de la charte le 11 novembre 2026
|
||||
(docs/identite/charte/accueil-tutoriel-aide.md). Format : voir README.md.
|
||||
|
||||
## 1. Le but du jeu
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
|
||||
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
|
||||
Vous avez quinze [pions](pion.md) blancs, l'ordinateur quinze pions noirs. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
|
||||
|
||||
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
|
||||
|
||||
@@ -16,9 +18,9 @@ Le premier qui a sorti tous les siens gagne la [partie](partie.md).
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- chemin: oui
|
||||
|
||||
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
|
||||
Vos pions suivent la **flèche claire** : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à votre [jan intérieur](jan-interieur.md), les flèches 1 à 6. C'est là que vous les rassemblez avant de les sortir.
|
||||
|
||||
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
|
||||
Les pions de l'ordinateur suivent la **flèche sombre**, en sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24.
|
||||
|
||||
## 3. Les dés et les doubles
|
||||
|
||||
@@ -60,10 +62,10 @@ Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez p
|
||||
- attendu: 24/23 13/7*
|
||||
- attendu: 24/18 8/7*
|
||||
- attendu: 8/7* 8/2
|
||||
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
|
||||
- juste: Touché : le pion noir part sur la barre, il devra refaire tout le chemin.
|
||||
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
|
||||
|
||||
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
|
||||
Vous avez 6 et 1. Le pion noir de la flèche 7 est seul : frappez-le.
|
||||
|
||||
## 5. Rentrer depuis la barre
|
||||
|
||||
@@ -71,7 +73,7 @@ Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
|
||||
- jet: 4-3
|
||||
- coup: bar/22 13/9
|
||||
|
||||
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
|
||||
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le jan intérieur de l'ordinateur, en haut à droite.
|
||||
|
||||
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
|
||||
|
||||
@@ -81,7 +83,7 @@ Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les d
|
||||
- jet: 5-2
|
||||
- coup: 5/off 2/off
|
||||
|
||||
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
|
||||
Quand vos quinze pions sont dans votre jan intérieur (flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
|
||||
|
||||
Touchez le pion, puis la sortie à droite.
|
||||
|
||||
@@ -101,7 +103,7 @@ Vous avez 6 et 3 : sortez deux pions.
|
||||
- jet: 2-1
|
||||
- coup: 2/off 1/off
|
||||
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
|
||||
|
||||
## 8. Le videau
|
||||
|
||||
|
||||
+36
-5
@@ -11,10 +11,17 @@
|
||||
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
|
||||
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
|
||||
# d'analyse (tests/browser/bilan.mjs), captures dans
|
||||
# build/browser-shots/bilan/.
|
||||
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
|
||||
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
|
||||
# partie finie et son bilan, par chaque entrée
|
||||
# (tests/browser/nouvelle-partie.mjs), captures dans
|
||||
# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs
|
||||
# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le
|
||||
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
|
||||
# build/browser-shots/retours-joueurs/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan)
|
||||
# restreignent le test.
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts, nouvelle-partie, retours-joueurs) restreignent le test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
@@ -27,7 +34,7 @@ cd "$(dirname "$0")/.."
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -65,7 +72,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/"
|
||||
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
|
||||
tests/browser/retours-joueurs.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
@@ -105,5 +113,28 @@ for browser in $browsers; do
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" prompts "* ]]; then
|
||||
# « Demander à une IA » : boutons, feuille du prompt, copie (relue
|
||||
# dans Chromium) ; captures dans build/browser-shots/prompts/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
|
||||
# « Nouvelle partie » après une partie finie et son bilan : la
|
||||
# nouvelle partie tout de suite ; captures dans
|
||||
# build/browser-shots/nouvelle-partie/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" retours-joueurs "* ]]; then
|
||||
# Retours des premiers joueurs : pions sur la barre, numéros sur le
|
||||
# cadre, flèches du tutoriel ; captures dans
|
||||
# build/browser-shots/retours-joueurs/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -268,17 +274,16 @@ async function playTouches(page, touches) {
|
||||
await tutorialReady(page, hash);
|
||||
}
|
||||
if (step === 2) {
|
||||
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
|
||||
"étape 2 : pas de chemin en pointillés");
|
||||
// Sous les pions et sous les numéros des flèches.
|
||||
check(await page.locator("#tutorial-board .tutorial-path .direction-line").count() === 2,
|
||||
"étape 2 : pas les deux flèches du sens du jeu");
|
||||
// Sous les pions (détail : retours-joueurs.mjs).
|
||||
const order = await page.evaluate(() => {
|
||||
const children = [...document.getElementById("tutorial-board").children];
|
||||
const at = (selector) => children.findIndex((c) => c.matches(selector));
|
||||
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
|
||||
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
|
||||
return { path: at(".tutorial-path"), pieces: at("g.pieces") };
|
||||
});
|
||||
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
|
||||
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
|
||||
check(order.path >= 0 && order.path < order.pieces,
|
||||
`étape 2 : les flèches passent au-dessus des pions ${JSON.stringify(order)}`);
|
||||
}
|
||||
await tutorialCapture(page, `tutoriel-${step}`);
|
||||
screens++;
|
||||
@@ -331,6 +336,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})` : ""}` : ""));
|
||||
|
||||
@@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine")
|
||||
await page.click("#explain-mine");
|
||||
const sheetLegend = await page.textContent("#explain-legend");
|
||||
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
|
||||
// Le coup y est un lien vers la fiche « Notation des coups ».
|
||||
const notationLink = await page.evaluate(() => {
|
||||
const a = document.querySelector("#explain-legend a.notation");
|
||||
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
|
||||
});
|
||||
check(
|
||||
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
|
||||
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
|
||||
);
|
||||
await page.click("#explain-done");
|
||||
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
|
||||
// Le joueur passe.
|
||||
|
||||
@@ -194,7 +194,7 @@ await page.click("#new-game-match");
|
||||
await page.click("#new-game-length-3");
|
||||
await page.click("#new-game-level-beginner");
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match",
|
||||
`match en 3 points : ${JSON.stringify(s)}`);
|
||||
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
|
||||
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
|
||||
@@ -254,7 +254,7 @@ await page.click("#menu-new-game");
|
||||
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden);
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match",
|
||||
`choix retrouvés : ${JSON.stringify(s)}`);
|
||||
await page.click("#new-game-start");
|
||||
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
|
||||
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
|
||||
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
|
||||
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
|
||||
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
|
||||
// l'image de production.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
|
||||
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
|
||||
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
|
||||
// une fin de course courte, partie d'un lien de position avec des dés
|
||||
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
|
||||
// (« Commencer la partie », « Commencer le match ») et que le coup de
|
||||
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
|
||||
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
|
||||
// en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/nouvelle-partie`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
|
||||
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
|
||||
window.__dice = [];
|
||||
let state = 7;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 120_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une capture en portrait et en paysage (Chromium seulement), après la
|
||||
* règle de la recette : rien d'interactif sous une barre d'action. */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(
|
||||
hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
|
||||
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
|
||||
* l'identifiant de la partie. */
|
||||
async function finishedGameWithSummary() {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until(
|
||||
"partie commencée",
|
||||
() =>
|
||||
location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
await page.evaluate(() => window.__dice.push(6, 1));
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
for (const [from, to] of [
|
||||
["p6", "p5"],
|
||||
["p5", "off-player"],
|
||||
]) {
|
||||
await page.click(`#board .hit[data-place="${from}"]`);
|
||||
await idle();
|
||||
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
|
||||
if ((await land.count()) > 0) {
|
||||
await land.click();
|
||||
await idle();
|
||||
}
|
||||
}
|
||||
await page.click("#confirm");
|
||||
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
|
||||
await page.click("#drop");
|
||||
await until(
|
||||
"bilan de la partie finie",
|
||||
() =>
|
||||
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
|
||||
document.getElementById("summary")?.dataset.ready === "true",
|
||||
);
|
||||
return page.evaluate(() => location.hash.split("/")[2]);
|
||||
}
|
||||
|
||||
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
|
||||
* du bouton, puis commence. */
|
||||
async function chooseAndStart(choice, shot) {
|
||||
await until(
|
||||
"écran « Nouvelle partie »",
|
||||
() =>
|
||||
location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden &&
|
||||
!document.getElementById("new-game-start").disabled,
|
||||
);
|
||||
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
|
||||
if (choice.match) {
|
||||
await page.click(`#new-game-length-${choice.length}`);
|
||||
}
|
||||
await page.click(`#new-game-level-${choice.level}`);
|
||||
const label = await page.textContent("#new-game-start");
|
||||
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
|
||||
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
|
||||
if (shot) {
|
||||
await capture("1-nouvelle-partie");
|
||||
}
|
||||
await page.click("#new-game-start");
|
||||
}
|
||||
|
||||
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
|
||||
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
|
||||
* peu plus tard. */
|
||||
async function checkNewGame(entry, oldId, choice) {
|
||||
await until(
|
||||
`${entry} : partie commencée`,
|
||||
() => location.hash === "#/" && !document.body.classList.contains("busy"),
|
||||
);
|
||||
// Le défaut apparaissait juste après : l'adresse passait au bilan de
|
||||
// l'ancienne partie. On regarde pendant deux secondes.
|
||||
for (let waited = 0; waited < 2000; waited += 100) {
|
||||
const s = await page.evaluate(() => ({
|
||||
hash: location.hash,
|
||||
summary: !document.getElementById("summary-veil").hidden,
|
||||
panel: !document.getElementById("panel-veil").hidden,
|
||||
board: !document.getElementById("game").hidden,
|
||||
newGame: !document.getElementById("new-game").hidden,
|
||||
}));
|
||||
check(
|
||||
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
|
||||
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
|
||||
);
|
||||
await sleep(100);
|
||||
}
|
||||
await idle();
|
||||
const shown = await page.evaluate(() => ({
|
||||
line: document.getElementById("match-line").textContent,
|
||||
lineShown: !document.getElementById("match-line").hidden,
|
||||
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
|
||||
over: document.getElementById("again").hidden === false,
|
||||
}));
|
||||
check(!shown.over, `${entry} : la partie à l'écran est finie`);
|
||||
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
|
||||
if (choice.match) {
|
||||
check(
|
||||
shown.lineShown && shown.line === `Match en ${choice.length} points`,
|
||||
`${entry} : ligne du match « ${shown.line} »`,
|
||||
);
|
||||
} else {
|
||||
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
|
||||
}
|
||||
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
|
||||
}
|
||||
|
||||
// --------------------------- 1. L'application rouverte, puis l'accueil
|
||||
|
||||
let oldId = await finishedGameWithSummary();
|
||||
// L'application rouverte : sans adresse, elle ouvre l'accueil.
|
||||
await page.goto(base);
|
||||
await until(
|
||||
"accueil",
|
||||
() =>
|
||||
location.hash === "#/accueil" &&
|
||||
!document.getElementById("home-new").hidden &&
|
||||
!document.getElementById("home-new").disabled,
|
||||
);
|
||||
await page.click("#home-new");
|
||||
let choice = { match: true, length: 3, level: "beginner" };
|
||||
await chooseAndStart(choice, true);
|
||||
await checkNewGame("accueil", oldId, choice);
|
||||
await capture("2-partie-demarree");
|
||||
|
||||
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
|
||||
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 200, "l'ordinateur ne joue pas de coup");
|
||||
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
|
||||
if (link !== null) {
|
||||
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
|
||||
break;
|
||||
}
|
||||
const what = await page.evaluate(() => {
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return `#${id}`;
|
||||
}
|
||||
}
|
||||
if (document.querySelector("#board .hit.can-land")) {
|
||||
return "#board .hit.can-land";
|
||||
}
|
||||
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
|
||||
});
|
||||
if (what === null) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
await page.locator(what).first().click();
|
||||
await idle();
|
||||
}
|
||||
await page.click("#info a.notation");
|
||||
await until(
|
||||
"fiche « Notation des coups »",
|
||||
() =>
|
||||
location.hash === "#/glossaire/notation-des-coups" &&
|
||||
document.getElementById("glossaire-notation-des-coups") !== null,
|
||||
);
|
||||
await page.goBack();
|
||||
await until("retour à la partie", () => location.hash === "#/");
|
||||
log("ligne de l'ordinateur : la notation mène à sa fiche");
|
||||
|
||||
// --------------------------------- 2. L'application rouverte sur le menu
|
||||
|
||||
oldId = await finishedGameWithSummary();
|
||||
await page.goto(`${base}#/menu`);
|
||||
await until("menu", () => !document.getElementById("menu-veil").hidden);
|
||||
await page.click("#menu-new-game");
|
||||
choice = { match: false, level: "amateur" };
|
||||
await chooseAndStart(choice, false);
|
||||
await checkNewGame("menu", oldId, choice);
|
||||
|
||||
// ------------------------------- 3. « Changer les réglages » du bilan
|
||||
|
||||
oldId = await finishedGameWithSummary();
|
||||
await page.click("#summary-settings");
|
||||
choice = { match: true, length: 5, level: "intermediate" };
|
||||
await chooseAndStart(choice, false);
|
||||
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`nouvelle partie : tout est conforme (${captures} captures)`);
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
// prompts.mjs -- « Demander à une IA » (étape 1f) par l'interface, dans un
|
||||
// vrai navigateur sans écran (Playwright), sur le site servi par l'image de
|
||||
// production (charte, ecrans-etape-1.md, « 1f. Demander à une IA »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node prompts.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. La fin de course courte de bilan.mjs : le
|
||||
// joueur a 6-1, deux pions sur 6 et 1, et joue 6/5/off (grosse erreur) ;
|
||||
// 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 ; 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";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node prompts.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/prompts`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const SIZES = [PORTRAIT, LANDSCAPE];
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
if (full) {
|
||||
// Chromium : le test relit le presse-papiers.
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
|
||||
origin: new URL(base).origin,
|
||||
});
|
||||
}
|
||||
await context.addInitScript(() => {
|
||||
window.__dice = [];
|
||||
let state = 5;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
// Aucun appel réseau hors du site : l'application n'appelle aucune IA.
|
||||
const origin = new URL(base).origin;
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:")) {
|
||||
problems.push(`requête hors du site : ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 120_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** 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 (« Copié » ne
|
||||
* dure que 2 s sur le bouton). */
|
||||
async function capture(name, before = null) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
// « 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);
|
||||
await before?.();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(
|
||||
hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
|
||||
);
|
||||
const tall = await tallButtons(page);
|
||||
check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
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 débute au backgammon.",
|
||||
"Position ID : ",
|
||||
"Match ID : ",
|
||||
"https://ustagammon.sans.pub/#/position/",
|
||||
"X : mes pions",
|
||||
"GNU Backgammon (analyse à 2 plis)",
|
||||
"ne les contredis pas",
|
||||
"Réponds en français",
|
||||
]) {
|
||||
check(text.includes(part), `${what} : « ${part} » absent`);
|
||||
}
|
||||
check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`);
|
||||
}
|
||||
|
||||
/** 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", 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.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.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
|
||||
log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`);
|
||||
return shown.text;
|
||||
}
|
||||
|
||||
/** « ‹ … » : 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 &&
|
||||
document.querySelector(".prompting") === null &&
|
||||
location.hash === below,
|
||||
hashBelow,
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------- Une erreur, et son explication
|
||||
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until(
|
||||
"partie commencée",
|
||||
() => location.hash === "#/" && !document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
await page.evaluate(() => window.__dice.push(6, 1));
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
for (const [from, to] of [[6, 5], [5, 0]]) {
|
||||
await page.click(`#board .hit[data-place="p${from}"]`);
|
||||
await idle();
|
||||
const target = to === 0 ? "off-player" : `p${to}`;
|
||||
const land = page.locator(`#board .hit.can-land[data-place="${target}"]`);
|
||||
if ((await land.count()) > 0) {
|
||||
await land.click();
|
||||
await idle();
|
||||
}
|
||||
}
|
||||
await page.click("#confirm");
|
||||
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
|
||||
await until("ligne du maître", () => !document.getElementById("advice-line").hidden);
|
||||
await page.click("#advice-line");
|
||||
await until("feuille du maître", () => document.getElementById("explain-ask-ai") !== null);
|
||||
const explainHash = await page.evaluate(() => location.hash);
|
||||
// Les écarts de « Voir les chiffres du moteur », pour les comparer au prompt.
|
||||
await page.click("#explain-numbers summary");
|
||||
const sheetNumbers = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#explain-numbers .explain-table span")].map((s) => s.textContent),
|
||||
);
|
||||
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é");
|
||||
check(moveText.includes("1. 6/off 1/off : équité"), "prompt du coup : meilleur coup");
|
||||
check(moveText.includes("« Grosse erreur »"), "prompt du coup : appréciation");
|
||||
// Chaque ligne du classement de la feuille (rang, coup, écart) est dans le
|
||||
// prompt, avec le même écart.
|
||||
for (let i = 0; i + 2 < sheetNumbers.length; i += 3) {
|
||||
const [rank, move, loss] = sheetNumbers.slice(i, i + 3);
|
||||
const notation = move.replace(" (vous)", "");
|
||||
if (Number(rank) <= 3 || move.includes("(vous)")) {
|
||||
check(
|
||||
moveText.includes(`${rank}. ${notation}`) && moveText.includes(`écart ${loss}`),
|
||||
`prompt du coup : ligne ${rank} ${notation} ${loss} absente`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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("« Copié »", copiedOnButton);
|
||||
});
|
||||
log(`prompt du coup copié (${copied.length} caractères)`);
|
||||
// Échap ramène à l'explication, qui n'est pas fermée.
|
||||
await page.keyboard.press("Escape");
|
||||
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-page")?.dataset.ready === "true",
|
||||
);
|
||||
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
|
||||
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
|
||||
|
||||
await page.click("#drop");
|
||||
await until(
|
||||
"bilan prêt",
|
||||
() =>
|
||||
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
|
||||
document.getElementById("summary")?.dataset.ready === "true" &&
|
||||
document.getElementById("summary-ask-ai") !== null,
|
||||
);
|
||||
await until("mini-plateau du coup à revoir", () =>
|
||||
document.querySelector("#summary-board .board-frame") !== null,
|
||||
);
|
||||
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(
|
||||
summaryText.includes("j'ai joué 6/off, GNU Backgammon préférait 6/off 1/off"),
|
||||
"prompt du bilan : erreur",
|
||||
);
|
||||
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");
|
||||
// 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",
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------- L'analyse
|
||||
|
||||
await page.click("#summary-revisit");
|
||||
await until(
|
||||
"analyse sur le coup à revoir",
|
||||
(game) =>
|
||||
location.hash === `#/parties/${game}/analyse/1` &&
|
||||
document.querySelector("#analysis-board .board-frame") !== null &&
|
||||
!document.getElementById("analysis-ask-ai").hidden,
|
||||
id,
|
||||
);
|
||||
await capture("6-bouton-analyse", () =>
|
||||
page.locator("#analysis-ask-ai").scrollIntoViewIfNeeded(),
|
||||
);
|
||||
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"));
|
||||
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 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`);
|
||||
await until("coup 2", () =>
|
||||
document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2") &&
|
||||
!document.getElementById("analysis-ask-ai").hidden,
|
||||
);
|
||||
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");
|
||||
// « Fermer » : sans feuille dessous, retour à l'analyse.
|
||||
await page.click("#prompt-done");
|
||||
await promptGone(`#/parties/${id}/analyse/2`);
|
||||
|
||||
// --------------------------------------------------------- Le glossaire
|
||||
|
||||
await page.goto(`${base}#/glossaire/videau`);
|
||||
await until(
|
||||
"fiche du videau",
|
||||
() => {
|
||||
const link = document.getElementById("glossary-ask-ai-videau");
|
||||
return link !== null && !link.hidden;
|
||||
},
|
||||
);
|
||||
check(
|
||||
(await page.locator(".term-card .ask-ai:not([hidden])").count()) === 1,
|
||||
"un seul bouton « Demander à une IA » dans le glossaire",
|
||||
);
|
||||
await capture("7-bouton-glossaire", () =>
|
||||
page.locator("#glossary-ask-ai-videau").scrollIntoViewIfNeeded(),
|
||||
);
|
||||
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");
|
||||
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("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", copyDone, null, 10_000);
|
||||
const fallback = await page.evaluate(async () => ({
|
||||
selected: window.getSelection().toString(),
|
||||
text: document.getElementById("prompt-text").textContent,
|
||||
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
|
||||
// (« 41 % ») : ce sont des espaces ordinaires dans le presse-papiers.
|
||||
const plain = (text) => text.replace(/ /g, " ").trim();
|
||||
check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné");
|
||||
check(
|
||||
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 backFromPrompt("#/glossaire/videau");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`demander à une IA : tout est conforme (${captures} captures)`);
|
||||
@@ -98,3 +98,165 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les contrastes des pions dessinés sur les plateaux visibles de la page
|
||||
* (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche,
|
||||
* le fond du plateau ou la sortie, trouvé au centre du pion (le dernier
|
||||
* dessiné qui le contient). Pour chaque pion : son camp, ce fond, le
|
||||
* contraste de son contour (stroke) et celui de sa couleur (fill) avec ce
|
||||
* fond. Les pions entourés de brique (jouable, sélectionné) sont laissés :
|
||||
* leur contour est celui du pion jouable, pas celui de son camp.
|
||||
*/
|
||||
export async function checkerContrasts(target) {
|
||||
return target.evaluate(() => {
|
||||
const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
|
||||
const luminance = (color) => {
|
||||
const [r, g, b] = rgb(color).map((v) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
const BACKGROUNDS = [
|
||||
["board-bar", "barre"],
|
||||
["board-tray", "sortie"],
|
||||
["point-a", "flèche impaire"],
|
||||
["point-b", "flèche paire"],
|
||||
["board-bed", "fond"],
|
||||
];
|
||||
const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", ");
|
||||
const found = [];
|
||||
for (const svg of document.querySelectorAll("svg.board")) {
|
||||
if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const shapes = [...svg.querySelectorAll(selector)].reverse();
|
||||
for (const checker of svg.querySelectorAll("circle.checker")) {
|
||||
if (checker.matches(".movable, .selected")) {
|
||||
continue;
|
||||
}
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = Number(checker.getAttribute("cx"));
|
||||
point.y = Number(checker.getAttribute("cy"));
|
||||
const under = shapes.find((s) => s.isPointInFill(point));
|
||||
if (under === undefined) {
|
||||
continue;
|
||||
}
|
||||
const ground = getComputedStyle(under).fill;
|
||||
const style = getComputedStyle(checker);
|
||||
found.push({
|
||||
board: svg.id || "plateau",
|
||||
side: checker.matches(".checker-computer") ? "computer" : "player",
|
||||
on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1],
|
||||
edge: ratio(style.stroke, ground),
|
||||
fill: ratio(style.fill, ground),
|
||||
});
|
||||
}
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles
|
||||
* partout ») : là où un pion de l'ordinateur ne se détache pas de son fond
|
||||
* par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond
|
||||
* sombre), son contour s'en détache à 3:1 au moins (guide de marque :
|
||||
* contours porteurs de sens à 3:1). contrasts : le résultat de
|
||||
* checkerContrasts. Renvoie la liste des écarts, décrits (vide si tout va
|
||||
* bien).
|
||||
*/
|
||||
export function checkerContrastProblems(contrasts) {
|
||||
const problems = new Set();
|
||||
for (const c of contrasts) {
|
||||
if (c.side === "computer" && c.fill < 3 && c.edge < 3) {
|
||||
problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
|
||||
}
|
||||
}
|
||||
return [...problems];
|
||||
}
|
||||
|
||||
/** Le plus faible contraste du contour, par camp et par fond, pour le
|
||||
* journal du test (« ordinateur sur barre 6.79:1, … »). */
|
||||
export function checkerContrastSummary(contrasts) {
|
||||
const lowest = new Map();
|
||||
for (const c of contrasts) {
|
||||
const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`;
|
||||
lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge));
|
||||
}
|
||||
return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", ");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// retours-joueurs.mjs -- les règles tirées des retours des premiers joueurs
|
||||
// (ADR 0010, amendement du 2026-11-11 ; recette-ux.md), dans un vrai
|
||||
// navigateur sans écran (Playwright), sur le site servi par l'image de
|
||||
// production, en thème clair et en thème sombre :
|
||||
//
|
||||
// - pions de l'ordinateur lisibles sur la barre : un pion de chaque camp sur
|
||||
// la barre, contraste du contour de chaque pion sur son fond
|
||||
// (recette.mjs, checkerContrasts) ;
|
||||
// - numéros des flèches sur le cadre, en portrait comme en paysage, de
|
||||
// 12 px au moins ;
|
||||
// - étape 2 du tutoriel : deux flèches circulaires, claire et sombre, sous
|
||||
// les pions, jamais en brique.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node retours-joueurs.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Captures (Chromium) dans
|
||||
// <dossier des captures>/retours-joueurs/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import {
|
||||
checkerContrastProblems,
|
||||
checkerContrastSummary,
|
||||
checkerContrasts,
|
||||
hiddenUnderActionBar,
|
||||
} from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node retours-joueurs.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/retours-joueurs`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const SIZES = [
|
||||
{ width: 412, height: 915 },
|
||||
{ width: 915, height: 412 },
|
||||
];
|
||||
// Positions de gnubg, chacun vu de lui-même : la position de départ, et la
|
||||
// même avec un pion de chaque camp de sa flèche 24 sur la barre.
|
||||
const OPENING = "4HPwATDgc/ABMA";
|
||||
const BOTH_ON_BAR = "4HPwAVDgc/ABUA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const contrasts = { light: [], dark: [] };
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
|
||||
/** Un contexte neuf dans un thème, tutoriel déjà vu (l'accueil ne propose
|
||||
* plus le premier lancement). */
|
||||
async function newPage(theme) {
|
||||
const context = await browser.newContext({
|
||||
viewport: SIZES[0],
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript((saved) => {
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
if (localStorage.getItem("ustagammon.settings") === null) {
|
||||
localStorage.setItem("ustagammon.settings", saved);
|
||||
}
|
||||
}, JSON.stringify({ theme, tutorialSeen: 1 }));
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(page, what, fn, arg, ms = 60_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg).catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une partie à partir d'une position, joueur au trait, dés à lancer. */
|
||||
async function startFrom(page, position) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
await until(page, "position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play", { timeout: 15_000 }).catch(async (error) => {
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw error;
|
||||
});
|
||||
await until(
|
||||
page,
|
||||
"partie commencée",
|
||||
() =>
|
||||
location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
}
|
||||
|
||||
/** Les numéros des flèches du plateau visible : tous sur le cadre (hors du
|
||||
* fond du plateau) et de 12 px au moins à l'écran. Renvoie les écarts. */
|
||||
async function numberProblems(page, selector) {
|
||||
return page.evaluate((selector) => {
|
||||
const svg = document.querySelector(selector);
|
||||
const box = svg.getBoundingClientRect();
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const scale = Math.min(box.width / vb.width, box.height / vb.height);
|
||||
const bed = svg.querySelector(".board-bed").getBBox();
|
||||
const found = [];
|
||||
const numbers = [...svg.querySelectorAll(".point-number")];
|
||||
if (numbers.length !== 24) {
|
||||
found.push(`${numbers.length} numéros`);
|
||||
}
|
||||
for (const text of numbers) {
|
||||
const b = text.getBBox();
|
||||
const px = Number(text.getAttribute("font-size")) * scale;
|
||||
if (b.y + b.height > bed.y + 0.5 && b.y < bed.y + bed.height - 0.5) {
|
||||
found.push(`flèche ${text.textContent} dans le fond du plateau`);
|
||||
}
|
||||
if (px < 12) {
|
||||
found.push(`flèche ${text.textContent} en ${px.toFixed(1)} px`);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/** Les règles sur l'écran courant, dans chaque taille, et sa capture
|
||||
* (Chromium). */
|
||||
async function checkScreen(page, theme, name, selector, settle = async () => {}) {
|
||||
// Le bandeau « Reprendre l'ancienne » (10 s) n'est pas le sujet.
|
||||
await until(page, "bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of SIZES) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await settle();
|
||||
const where = `${name} ${theme} ${size.width}x${size.height}`;
|
||||
const numbers = await numberProblems(page, selector);
|
||||
check(numbers.length === 0, `${where} : numéros : ${numbers.join(", ")}`);
|
||||
const measured = await checkerContrasts(page);
|
||||
check(measured.length > 0, `${where} : aucun pion mesuré`);
|
||||
const low = checkerContrastProblems(measured);
|
||||
check(low.length === 0, `${where} : ${low.join(", ")}`);
|
||||
contrasts[theme].push(...measured);
|
||||
if (full) {
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${theme}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(SIZES[0]);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const { context, page } = await newPage(theme);
|
||||
|
||||
// Un pion de chaque camp sur la barre.
|
||||
await startFrom(page, BOTH_ON_BAR);
|
||||
check(
|
||||
(await page.evaluate(() => document.documentElement.dataset.theme)) === theme,
|
||||
`thème ${theme} non appliqué`,
|
||||
);
|
||||
const onBar = await page.evaluate(() =>
|
||||
["player", "computer"].map(
|
||||
(side) => document.querySelectorAll(`#board [data-checker="bar-${side}"]`).length,
|
||||
),
|
||||
);
|
||||
check(onBar[0] === 1 && onBar[1] === 1, `pions sur la barre : ${onBar}`);
|
||||
await checkScreen(page, theme, "barre", "#board");
|
||||
const bar = contrasts[theme].filter((c) => c.on === "barre" && c.side === "computer");
|
||||
check(bar.length > 0, `${theme} : pion de l'ordinateur sur la barre non mesuré`);
|
||||
|
||||
// La position de départ : les numéros sur le cadre, en portrait aussi.
|
||||
await startFrom(page, OPENING);
|
||||
await checkScreen(page, theme, "numeros-sur-le-cadre", "#board");
|
||||
|
||||
// L'étape 2 du tutoriel : deux flèches circulaires sous les pions.
|
||||
await page.goto(`${base}#/tutoriel/2`);
|
||||
const ready = () =>
|
||||
until(page, "tutoriel, étape 2", () => {
|
||||
const section = document.getElementById("tutorial");
|
||||
return (
|
||||
location.hash === "#/tutoriel/2" &&
|
||||
section.dataset.ready !== undefined &&
|
||||
document.querySelectorAll("#tutorial-board .tutorial-path").length === 1
|
||||
);
|
||||
});
|
||||
await ready();
|
||||
const arrows = async () => {
|
||||
const drawn = await page.evaluate(() => {
|
||||
const svg = document.getElementById("tutorial-board");
|
||||
const children = [...svg.children];
|
||||
const path = svg.querySelector(".tutorial-path");
|
||||
const colors = (selector) =>
|
||||
[...path.querySelectorAll(selector)].map((el) => getComputedStyle(el).stroke);
|
||||
return {
|
||||
path: children.indexOf(path),
|
||||
pieces: children.findIndex((c) => c.matches("g.pieces")),
|
||||
player: colors(".direction-line.direction-player"),
|
||||
computer: colors(".direction-line.direction-computer"),
|
||||
heads: path.querySelectorAll(".direction-head").length,
|
||||
pieceColors: ["player", "computer"].map(
|
||||
(side) => getComputedStyle(svg.querySelector(`.checker-${side}`)).fill,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(drawn.path >= 0 && drawn.path < drawn.pieces, `étape 2 : flèches au-dessus des pions ${JSON.stringify(drawn)}`);
|
||||
check(drawn.player.length === 1 && drawn.computer.length === 1 && drawn.heads === 2,
|
||||
`étape 2 : flèches ${JSON.stringify(drawn)}`);
|
||||
// Aux couleurs des pions de chaque camp, donc jamais en brique.
|
||||
check(drawn.player[0] === drawn.pieceColors[0] && drawn.computer[0] === drawn.pieceColors[1],
|
||||
`étape 2 : flèches pas aux couleurs des pions ${JSON.stringify(drawn)}`);
|
||||
};
|
||||
await checkScreen(page, theme, "tutoriel-2", "#tutorial-board", async () => {
|
||||
await ready();
|
||||
await arrows();
|
||||
});
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
|
||||
await context.close();
|
||||
log(`thème ${theme} : contours ${checkerContrastSummary(contrasts[theme])}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (full) {
|
||||
// Les mesures, à côté des captures.
|
||||
writeFileSync(
|
||||
`${dir}/contrastes.txt`,
|
||||
["light", "dark"].map((theme) => `${theme}\t${checkerContrastSummary(contrasts[theme])}`).join("\n") + "\n",
|
||||
);
|
||||
}
|
||||
check(problems.length === 0, `problèmes relevés :\n ${problems.join("\n ")}`);
|
||||
log(
|
||||
"retours des joueurs : pions des deux camps sur la barre, numéros sur le cadre, " +
|
||||
`flèches circulaires du tutoriel, thèmes clair et sombre${full ? ` ; ${captures} captures` : ""}`,
|
||||
);
|
||||
@@ -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"
|
||||
|
||||
+7
-1
@@ -87,7 +87,13 @@ cmp -s <(curl -s "$base/docs/glossaire/videau.md") docs/glossaire/videau.md \
|
||||
check "docs/tutoriel/regles.md" "text/markdown"
|
||||
cmp -s <(curl -s "$base/docs/tutoriel/regles.md") docs/tutoriel/regles.md \
|
||||
|| report "/docs/tutoriel/regles.md diffère de la source"
|
||||
for feature in apprendre-les-regles decouvrir-l-application installer-l-application; do
|
||||
# Prompts pour une IA : copie de leur source unique (ADR 0008, étape 1f).
|
||||
for template in coup videau bilan concept exercices; do
|
||||
check "docs/prompts/$template.md" "text/markdown"
|
||||
cmp -s <(curl -s "$base/docs/prompts/$template.md") "docs/prompts/$template.md" \
|
||||
|| report "/docs/prompts/$template.md diffère de la source"
|
||||
done
|
||||
for feature in apprendre-les-regles decouvrir-l-application installer-l-application demander-a-une-ia; do
|
||||
check "docs/scenarios/$feature.feature" "text/plain"
|
||||
done
|
||||
# Le service worker attend l'accord du joueur avant de prendre la main.
|
||||
|
||||
+233
-60
@@ -81,29 +81,23 @@
|
||||
--point-a: #6b4430;
|
||||
--point-b: #c9ae88;
|
||||
--point-number: #f3ead8;
|
||||
--checker-player: #f7f0e2;
|
||||
--checker-player-edge: #8c7a62;
|
||||
--checker-computer: #2e1c12;
|
||||
--checker-computer-edge: #120a05;
|
||||
--count-player: #2b1d15;
|
||||
--count-computer: #f3ead8;
|
||||
--die-player: #f7f0e2;
|
||||
--pip-player: #2b1d15;
|
||||
--die-computer: #2e1c12;
|
||||
--pip-computer: #f3ead8;
|
||||
--cube: #f7f0e2;
|
||||
--cube-text: #2b1d15;
|
||||
--checker-player: #fafaf7;
|
||||
--checker-player-edge: #6e6e69;
|
||||
--checker-computer: #1c1c1c;
|
||||
--checker-computer-edge: #b5b5b0;
|
||||
--count-player: #1c1c1c;
|
||||
--count-computer: #fafaf7;
|
||||
--die-player: #fafaf7;
|
||||
--pip-player: #1c1c1c;
|
||||
--die-computer: #1c1c1c;
|
||||
--pip-computer: #fafaf7;
|
||||
--cube: #fafaf7;
|
||||
--cube-text: #1c1c1c;
|
||||
--highlight: #a8432b;
|
||||
--target: rgba(168, 67, 43, 0.32);
|
||||
/* Dernier coup joué (marques et traces) : le laiton, rien d'autre. */
|
||||
--last-move: var(--brand-laiton);
|
||||
|
||||
/* Numéros des flèches. La charte les pose sur le cadre (point-number,
|
||||
* ivoire) ; notre plateau les pose sur le fond, où l'ivoire ne se lirait
|
||||
* pas : texte secondaire en thème clair (4,7:1 sur bed), point-number en
|
||||
* thème sombre (6,3:1). */
|
||||
--point-label: var(--muted);
|
||||
|
||||
--shadow-raised: 0 1px 2px rgba(43, 29, 21, 0.1);
|
||||
|
||||
--font-sans: "Alegreya Sans", "Gill Sans", "Segoe UI", system-ui, sans-serif;
|
||||
@@ -167,23 +161,21 @@
|
||||
--point-a: #3a2519;
|
||||
--point-b: #7e6142;
|
||||
--point-number: #cdbfa8;
|
||||
--checker-player: #ede3cf;
|
||||
--checker-player-edge: #8e7c64;
|
||||
--checker-computer: #140d09;
|
||||
--checker-computer-edge: #a8957d;
|
||||
--count-player: #2b1d15;
|
||||
--count-computer: #f3ead8;
|
||||
--die-player: #ede3cf;
|
||||
--pip-player: #2b1d15;
|
||||
--die-computer: #140d09;
|
||||
--pip-computer: #ede3cf;
|
||||
--cube: #ede3cf;
|
||||
--cube-text: #2b1d15;
|
||||
--checker-player: #f2f2ef;
|
||||
--checker-player-edge: #6e6e69;
|
||||
--checker-computer: #141414;
|
||||
--checker-computer-edge: #b5b5b0;
|
||||
--count-player: #1c1c1c;
|
||||
--count-computer: #fafaf7;
|
||||
--die-player: #f2f2ef;
|
||||
--pip-player: #1c1c1c;
|
||||
--die-computer: #141414;
|
||||
--pip-computer: #f2f2ef;
|
||||
--cube: #f2f2ef;
|
||||
--cube-text: #1c1c1c;
|
||||
--highlight: #e07a5a;
|
||||
--target: rgba(224, 122, 90, 0.38);
|
||||
|
||||
--point-label: var(--point-number);
|
||||
|
||||
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--scrim: rgba(0, 0, 0, 0.6);
|
||||
color-scheme: dark;
|
||||
@@ -215,23 +207,21 @@
|
||||
--point-a: #3a2519;
|
||||
--point-b: #7e6142;
|
||||
--point-number: #cdbfa8;
|
||||
--checker-player: #ede3cf;
|
||||
--checker-player-edge: #8e7c64;
|
||||
--checker-computer: #140d09;
|
||||
--checker-computer-edge: #a8957d;
|
||||
--count-player: #2b1d15;
|
||||
--count-computer: #f3ead8;
|
||||
--die-player: #ede3cf;
|
||||
--pip-player: #2b1d15;
|
||||
--die-computer: #140d09;
|
||||
--pip-computer: #ede3cf;
|
||||
--cube: #ede3cf;
|
||||
--cube-text: #2b1d15;
|
||||
--checker-player: #f2f2ef;
|
||||
--checker-player-edge: #6e6e69;
|
||||
--checker-computer: #141414;
|
||||
--checker-computer-edge: #b5b5b0;
|
||||
--count-player: #1c1c1c;
|
||||
--count-computer: #fafaf7;
|
||||
--die-player: #f2f2ef;
|
||||
--pip-player: #1c1c1c;
|
||||
--die-computer: #141414;
|
||||
--pip-computer: #f2f2ef;
|
||||
--cube: #f2f2ef;
|
||||
--cube-text: #1c1c1c;
|
||||
--highlight: #e07a5a;
|
||||
--target: rgba(224, 122, 90, 0.38);
|
||||
|
||||
--point-label: var(--point-number);
|
||||
|
||||
--shadow-raised: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--scrim: rgba(0, 0, 0, 0.6);
|
||||
color-scheme: dark;
|
||||
@@ -295,6 +285,15 @@ a {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Les pages sont des éléments de la colonne flexible #screen. Leurs marges
|
||||
* automatiques les centrent, mais annulent leur étirement en largeur : sans
|
||||
* largeur explicite, une page au contenu court se réduit à la largeur de ce
|
||||
* contenu (le plateau de l'étape 6 « à vous » du tutoriel passait de 396 à
|
||||
* 300 px en 412 × 915). Leur max-width les borne toujours. */
|
||||
.screen > .page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -577,6 +576,19 @@ button:disabled {
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Un coup en notation du moteur, lien vers sa fiche du glossaire (ligne de
|
||||
* l'ordinateur, feuille du maître) : couleur du texte, soulignement en
|
||||
* pointillé discret. Le remplissage vertical d'un élément en ligne agrandit
|
||||
* la zone d'appui sans changer la hauteur des lignes. */
|
||||
a.notation {
|
||||
padding-block: 8px;
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: var(--muted);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.infoline .replay {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -962,9 +974,10 @@ button:disabled {
|
||||
.point-b { fill: var(--point-b); }
|
||||
|
||||
/* Numéros des flèches : leur taille est une mesure du dessin (attribut
|
||||
* font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts). */
|
||||
* font-size), calculée pour faire au moins 12 px à l'écran (geometry.ts).
|
||||
* Posés sur le cadre, en portrait comme en paysage. */
|
||||
.point-number {
|
||||
fill: var(--point-label);
|
||||
fill: var(--point-number);
|
||||
font-weight: 500;
|
||||
text-anchor: middle;
|
||||
}
|
||||
@@ -1096,10 +1109,8 @@ button:disabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Paysage : numéros sur le cadre (ivoire), comptes de points en pastilles
|
||||
* aux bouts de la barre, consigne dans la bande, commandes à gauche. */
|
||||
.point-number.on-frame { fill: var(--point-number); }
|
||||
|
||||
/* Paysage : comptes de points en pastilles aux bouts de la barre, consigne
|
||||
* dans la bande, commandes à gauche. */
|
||||
.pip-chip { stroke-width: 1.5; }
|
||||
.chip-player { fill: var(--checker-player); stroke: var(--checker-player-edge); }
|
||||
.chip-computer { fill: var(--checker-computer); stroke: var(--checker-computer-edge); }
|
||||
@@ -2024,9 +2035,10 @@ html.bare .topbar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Le sens du jeu : pointillés text sur un liseré ivoire, jamais en brique
|
||||
* (la brique veut dire « à faire »). Couleurs de marque, les mêmes dans les
|
||||
* deux thèmes : le liseré reste lisible sur le bois. */
|
||||
/* Le sens du jeu : deux flèches circulaires, claire pour le joueur, sombre
|
||||
* pour l'ordinateur, aux couleurs de leurs pions, chacune avec un liseré de
|
||||
* la couleur opposée pour rester lisible sur le bois ; jamais en brique (la
|
||||
* brique veut dire « à faire »). */
|
||||
.tutorial-path path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
@@ -2034,13 +2046,19 @@ html.bare .topbar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tutorial-path .path-edge {
|
||||
stroke: var(--brand-ivoire);
|
||||
.tutorial-path .direction-player.direction-line { stroke: var(--checker-player); }
|
||||
.tutorial-path .direction-player.direction-edge { stroke: var(--checker-computer); }
|
||||
.tutorial-path .direction-computer.direction-line { stroke: var(--checker-computer); }
|
||||
.tutorial-path .direction-computer.direction-edge { stroke: var(--checker-player); }
|
||||
|
||||
.tutorial-path .direction-head.direction-player {
|
||||
fill: var(--checker-player);
|
||||
stroke: var(--checker-computer);
|
||||
}
|
||||
|
||||
.tutorial-path .path-dots,
|
||||
.tutorial-path .path-head {
|
||||
stroke: var(--brand-noyer);
|
||||
.tutorial-path .direction-head.direction-computer {
|
||||
fill: var(--checker-computer);
|
||||
stroke: var(--checker-player);
|
||||
}
|
||||
|
||||
/* En paysage, comme la position partagée : le plateau à gauche sur toute
|
||||
@@ -3406,3 +3424,158 @@ html.landscape .analysis-board {
|
||||
.summary-board .point-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Demander à une IA (1f) */
|
||||
|
||||
/* Le bouton : secondaire partout, avec l'icône de copie (charte, 1f). */
|
||||
a.button.ask-ai .icon,
|
||||
#prompt-copy .icon {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.ask-ai-slot .ask-ai {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.explain-actions {
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.term-card .ask-ai {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Sa propre feuille, au-dessus de l'analyse et du glossaire. */
|
||||
.prompt-veil {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Le texte du prompt, à relire avant de le copier : une zone en creux qui
|
||||
* défile, en chasse fixe pour que le diagramme garde ses colonnes. */
|
||||
.prompt-text {
|
||||
min-height: 0;
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "DejaVu Sans Mono", "Roboto Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.prompt-status {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.prompt-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.prompt-actions button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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);
|
||||
}
|
||||
|
||||
html.landscape .prompting .prompt-text {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Le bouton ne se coupe pas : trop étroit à côté de « Revoir ce coup » ou
|
||||
* « Rejouer d'ici », il passe à la ligne. */
|
||||
.summary-review-actions,
|
||||
.viewer-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@
|
||||
construit par app/src/ui/summary-view.ts. -->
|
||||
<div id="summary-veil" class="veil summary-veil" hidden></div>
|
||||
|
||||
<!-- 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. -->
|
||||
<div id="install-veil" class="veil sheet-veil" hidden>
|
||||
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
|
||||
Reference in New Issue
Block a user