Compare commits
69
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 leçons, des phrases du
|
||||
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
|
||||
# tutoriel, les leçons, le conseil du maître et « Demander à une IA »
|
||||
# (ADR 0008 : jamais commitées
|
||||
# ailleurs) ;
|
||||
# - version.json (commit publié) ;
|
||||
@@ -142,7 +143,8 @@ 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/lecons 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
|
||||
|
||||
@@ -102,10 +102,9 @@ export async function analyseDecision(
|
||||
if (analysis === null) {
|
||||
return null;
|
||||
}
|
||||
const ranking =
|
||||
options.ranking === false
|
||||
? []
|
||||
: await engine.rankMoves(before.position, cube, before.roll, ply);
|
||||
// Le classement est celui de l'analyse même : mêmes coups, mêmes
|
||||
// niveaux et mêmes équités que le coup joué et le meilleur coup.
|
||||
const ranking = options.ranking === false ? [] : analysis.ranking;
|
||||
const start = await engine.board(before.position, cube);
|
||||
const facts = async (move: EvaluatedMove) =>
|
||||
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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";
|
||||
import type { Ply } from "../game/engine.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 de l'analyse, chacun avec son écart par rapport au
|
||||
* premier ; puis le coup joué, s'il n'y figure pas. Le coup joué a toujours
|
||||
* l'écart de l'analyse (equityLoss), celui que dit le maître.
|
||||
*/
|
||||
export function rankingRows(advice: MoveAdvice): RankingRow[] {
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const rows = advice.ranking.map((move, i) => {
|
||||
const played = move.notation === advice.played.notation;
|
||||
return {
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: played ? advice.equityLoss : move.equity - first,
|
||||
played,
|
||||
};
|
||||
});
|
||||
if (!rows.some((row) => row.played)) {
|
||||
rows.push({
|
||||
move: advice.played,
|
||||
rank: advice.rank,
|
||||
loss: advice.equityLoss,
|
||||
played: true,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le niveau du classement d'un conseil : celui auquel le moteur a évalué le
|
||||
* meilleur coup. C'est le niveau demandé (advice.ply), sauf quand gnubg
|
||||
* s'arrête avant, parce qu'un seul coup a passé ses filtres : tout le
|
||||
* classement reste alors au niveau inférieur.
|
||||
*/
|
||||
export function rankingPly(advice: MoveAdvice): Ply {
|
||||
return advice.best.ply;
|
||||
}
|
||||
|
||||
/** « 1 pli », « 2 plis » : gnubg compte les plis à partir de 0. */
|
||||
export function pliesText(ply: Ply): string {
|
||||
return `${ply} ${ply > 1 ? "plis" : "pli"}`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction, LuckRating } from "../game/engine.js";
|
||||
import type { GameSummary } from "./game-analysis.js";
|
||||
import type { ProgressionTrend } from "./progression.js";
|
||||
|
||||
/** Les gabarits, par clé de section (« coup.meilleur »…). */
|
||||
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
|
||||
@@ -103,6 +104,10 @@ export const PHRASE_KEYS: readonly string[] = [
|
||||
"bilan.des.contre",
|
||||
"bilan.des.tres-contre",
|
||||
"bilan.aucun-coup",
|
||||
"progression.baisse",
|
||||
"progression.hausse",
|
||||
"progression.stable",
|
||||
"progression.attente",
|
||||
];
|
||||
|
||||
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
|
||||
@@ -140,6 +145,26 @@ export function nothingToReview(phrases: MasterPhrases): string {
|
||||
return pick(phrases, "bilan.aucun-coup", "0");
|
||||
}
|
||||
|
||||
const TREND_KEYS: Readonly<Record<ProgressionTrend["kind"], string>> = {
|
||||
down: "progression.baisse",
|
||||
up: "progression.hausse",
|
||||
steady: "progression.stable",
|
||||
waiting: "progression.attente",
|
||||
};
|
||||
|
||||
/** La phrase de l'écran de progression (étape 1h) : la tendance du taux
|
||||
* d'erreur, en faits (nombre de parties, moyennes en millièmes). */
|
||||
export function progressionSentence(phrases: MasterPhrases, trend: ProgressionTrend): string {
|
||||
const template = pick(phrases, TREND_KEYS[trend.kind], "0");
|
||||
return trend.kind === "waiting"
|
||||
? template
|
||||
: fillIn(template, {
|
||||
parties: String(trend.games),
|
||||
debut: String(trend.from),
|
||||
fin: String(trend.to),
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce que le maître dit d'un conseil du moteur. */
|
||||
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
|
||||
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// La progression du joueur (étape 1h ; charte, ecrans-etape-1.md, « 1h ») :
|
||||
// le taux d'erreur de chaque partie terminée, tel que le bilan le calcule
|
||||
// (summarizeAnalysis), sa courbe partie après partie et la tendance que dit
|
||||
// la phrase du maître. Fonctions pures, testées sous Node
|
||||
// (app/tests/progression.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Rien n'est recalculé ici : le taux d'une partie vient des analyses
|
||||
// rangées dans son journal (1e). Une partie dont l'analyse n'est pas finie
|
||||
// n'a pas encore de taux ; l'application l'analyse en arrière-plan.
|
||||
|
||||
import type { Level } from "../game/engine.js";
|
||||
import type { GameJournal } from "../game/events.js";
|
||||
import type { GameResult } from "../game/state.js";
|
||||
import { type ErrorRate, readyForSummary, summarizeAnalysis } from "./game-analysis.js";
|
||||
|
||||
/** Les parties prises en compte au plus : les plus récentes. Au-delà, la
|
||||
* courbe devient illisible sur un téléphone, et il faudrait relire tous
|
||||
* les journaux à chaque visite. */
|
||||
export const PROGRESSION_MAX_GAMES = 20;
|
||||
|
||||
/** Parties analysées qu'il faut pour tracer la courbe (charte : « Votre
|
||||
* courbe apparaîtra après trois parties »). */
|
||||
export const CURVE_MIN_GAMES = 3;
|
||||
|
||||
/** Une partie terminée, telle que le stockage la donne. */
|
||||
export interface FinishedGame {
|
||||
readonly id: string;
|
||||
/** Début de la partie, en millisecondes depuis 1970. */
|
||||
readonly startedAt: number;
|
||||
/** Le niveau de l'ordinateur. */
|
||||
readonly level: Level;
|
||||
readonly result: GameResult | null;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
/** Une ligne de la progression : une partie terminée et son taux
|
||||
* d'erreur (null : analyse pas encore finie). */
|
||||
export interface ProgressionEntry {
|
||||
readonly id: string;
|
||||
readonly startedAt: number;
|
||||
readonly level: Level;
|
||||
readonly result: GameResult | null;
|
||||
readonly errorRate: ErrorRate | null;
|
||||
}
|
||||
|
||||
/** Les lignes de la progression, de la plus ancienne à la plus récente.
|
||||
* Le taux d'erreur est celui du bilan, sur les analyses déjà rangées. */
|
||||
export function progressionEntries(games: readonly FinishedGame[]): ProgressionEntry[] {
|
||||
return [...games]
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.map((game) => ({
|
||||
id: game.id,
|
||||
startedAt: game.startedAt,
|
||||
level: game.level,
|
||||
result: game.result,
|
||||
errorRate: readyForSummary(game.journal)
|
||||
? summarizeAnalysis(game.journal).player.errorRate
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Un point de la courbe : une partie analysée. */
|
||||
export interface ProgressionPoint {
|
||||
readonly id: string;
|
||||
/** Équité perdue par décision (ErrorRate.perDecision). */
|
||||
readonly perDecision: number;
|
||||
}
|
||||
|
||||
/** Les points de la courbe, de la plus ancienne partie analysée à la plus
|
||||
* récente. */
|
||||
export function progressionCurve(entries: readonly ProgressionEntry[]): ProgressionPoint[] {
|
||||
return entries.flatMap((entry) =>
|
||||
entry.errorRate === null
|
||||
? []
|
||||
: [{ id: entry.id, perDecision: entry.errorRate.perDecision }],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le taux d'erreur tel que l'écran l'écrit : en millièmes d'équité par
|
||||
* décision, arrondi (0,014 s'écrit 14), comme les « mEMG » de GNU
|
||||
* Backgammon. Le glossaire le dit (docs/glossaire/taux-d-erreur.md).
|
||||
*/
|
||||
export function errorRateMillis(perDecision: number): number {
|
||||
return Math.round(perDecision * 1000);
|
||||
}
|
||||
|
||||
/** Ce que dit la phrase de la progression. */
|
||||
export type ProgressionTrend =
|
||||
/** Moins de CURVE_MIN_GAMES parties analysées. */
|
||||
| { readonly kind: "waiting" }
|
||||
/** Le taux baisse, monte ou ne change pas : moyenne de la première
|
||||
* moitié des parties (from) et de la seconde (to), en millièmes. */
|
||||
| {
|
||||
readonly kind: "down" | "up" | "steady";
|
||||
readonly games: number;
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* La tendance de la courbe : la moyenne des taux de la plus ancienne moitié
|
||||
* des parties, comparée à celle de la plus récente (la partie du milieu,
|
||||
* quand leur nombre est impair, n'entre dans aucune). Les deux moyennes
|
||||
* sont arrondies en millièmes avant d'être comparées : la phrase ne dit
|
||||
* jamais « passé de 9 à 9 ».
|
||||
*/
|
||||
export function progressionTrend(points: readonly ProgressionPoint[]): ProgressionTrend {
|
||||
if (points.length < CURVE_MIN_GAMES) {
|
||||
return { kind: "waiting" };
|
||||
}
|
||||
const half = Math.floor(points.length / 2);
|
||||
const mean = (part: readonly ProgressionPoint[]) =>
|
||||
part.reduce((sum, p) => sum + p.perDecision, 0) / part.length;
|
||||
const from = errorRateMillis(mean(points.slice(0, half)));
|
||||
const to = errorRateMillis(mean(points.slice(points.length - half)));
|
||||
const kind = to < from ? "down" : to > from ? "up" : "steady";
|
||||
return { kind, games: points.length, from, to };
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
// 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";
|
||||
|
||||
/** Version du format d'un conseil enregistré. Un conseil d'une autre
|
||||
* version est ignoré à la reprise (il peut être recalculé). */
|
||||
export const ADVICE_VERSION = 1;
|
||||
* version est ignoré à la reprise (il peut être recalculé). Version 2 : le
|
||||
* classement des coups vient de l'analyse du coup joué, et non plus d'un
|
||||
* second classement comme la commande « hint » de gnubg. */
|
||||
export const ADVICE_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Le conseil du maître sur une décision du joueur : un coup ou une décision
|
||||
@@ -29,7 +31,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. */
|
||||
@@ -66,8 +68,8 @@ export interface MoveAdvice {
|
||||
readonly best: AdvisedMove;
|
||||
readonly playedFacts: MoveFacts;
|
||||
readonly bestFacts: MoveFacts;
|
||||
/** Les meilleurs coups du classement du moteur, du meilleur au moins
|
||||
* bon (cinq au plus). */
|
||||
/** Les meilleurs coups du classement de l'analyse, du meilleur au
|
||||
* moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */
|
||||
readonly ranking: readonly AdvisedMove[];
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
|
||||
@@ -80,17 +80,6 @@ export interface Engine {
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
|
||||
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
|
||||
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
|
||||
* ply). */
|
||||
rankMoves(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
ply: Ply,
|
||||
): Promise<EvaluatedMove[]>;
|
||||
|
||||
/** L'analyse d'une décision de videau, position vue du joueur au trait
|
||||
* (celui qui double ou non ; take et pass sont la réponse de son
|
||||
* adversaire). */
|
||||
@@ -222,6 +211,12 @@ export interface MoveAnalysis {
|
||||
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
/** Le classement de l'analyse, du meilleur au moins bon (dix coups au
|
||||
* plus) : la liste de l'analyse de partie de gnubg, chaque coup au
|
||||
* niveau où gnubg l'a évalué (ply). Les coups écartés par ses filtres
|
||||
* gardent l'évaluation d'un niveau inférieur ; le meilleur coup et le
|
||||
* coup joué y ont les évaluations de best et played. */
|
||||
readonly ranking: readonly EvaluatedMove[];
|
||||
}
|
||||
|
||||
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
|
||||
@@ -281,6 +276,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 +292,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 {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// La progression dans les leçons (ADR 0011, étape 1g) : où en est chaque
|
||||
// leçon, et laquelle proposer ensuite. Code pur, testé sous Node
|
||||
// (app/tests/lessons.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le contenu des leçons vit dans docs/lecons/ (ADR 0008) ; ici, une leçon
|
||||
// n'est que son numéro et les étapes qui portent un exercice « à vous ».
|
||||
// La progression est enregistrée telle quelle (ADR 0007, amendement du
|
||||
// 2026-11-12) : son format est durable, et readLessonProgress relit toute
|
||||
// version enregistrée.
|
||||
|
||||
/** Version du format de la progression enregistrée. */
|
||||
export const LESSON_PROGRESS_VERSION = 1;
|
||||
|
||||
/** reached quand l'écran de fin d'une leçon a été vu. */
|
||||
export const LESSON_END = 99;
|
||||
|
||||
/** Où en est une leçon. */
|
||||
export interface LessonRecord {
|
||||
/** La dernière étape vue (0 : aucune ; LESSON_END : la fin). */
|
||||
readonly last: number;
|
||||
/** L'étape la plus loin déjà vue : la barre de progression ne mène
|
||||
* qu'aux étapes vues. */
|
||||
readonly reached: number;
|
||||
/** Les étapes dont l'exercice « à vous » a été réussi, dans l'ordre. */
|
||||
readonly solved: readonly number[];
|
||||
}
|
||||
|
||||
/** La progression dans toutes les leçons, par numéro de leçon. */
|
||||
export interface LessonProgress {
|
||||
readonly version: typeof LESSON_PROGRESS_VERSION;
|
||||
readonly lessons: Readonly<Record<string, LessonRecord>>;
|
||||
}
|
||||
|
||||
export const NO_PROGRESS: LessonProgress = { version: LESSON_PROGRESS_VERSION, lessons: {} };
|
||||
|
||||
const NO_RECORD: LessonRecord = { last: 0, reached: 0, solved: [] };
|
||||
|
||||
/** Ce qu'une leçon offre à la progression : son numéro et les étapes qui
|
||||
* ont un exercice « à vous ». */
|
||||
export interface LessonOutline {
|
||||
readonly number: number;
|
||||
readonly exercises: readonly number[];
|
||||
}
|
||||
|
||||
function step(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= LESSON_END
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* La progression enregistrée, relue : absente (avant l'étape 1g) ou
|
||||
* illisible, elle est vide ; une leçon mal formée est oubliée, sans toucher
|
||||
* aux autres. Une version inconnue (enregistrée par une version future de
|
||||
* l'application) est lue comme vide plutôt que mal comprise.
|
||||
*/
|
||||
export function readLessonProgress(value: unknown): LessonProgress {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
const raw = value as { version?: unknown; lessons?: unknown };
|
||||
if (raw.version !== LESSON_PROGRESS_VERSION) {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
if (raw.lessons === null || typeof raw.lessons !== "object") {
|
||||
return NO_PROGRESS;
|
||||
}
|
||||
const lessons: Record<string, LessonRecord> = {};
|
||||
for (const [key, entry] of Object.entries(raw.lessons as Record<string, unknown>)) {
|
||||
if (!/^[1-9][0-9]?$/.test(key) || entry === null || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = entry as { last?: unknown; reached?: unknown; solved?: unknown };
|
||||
const last = step(record.last);
|
||||
const reached = step(record.reached);
|
||||
const solved = Array.isArray(record.solved)
|
||||
? record.solved.map(step).filter((s): s is number => s !== null && s > 0)
|
||||
: null;
|
||||
if (last === null || reached === null || solved === null) {
|
||||
continue;
|
||||
}
|
||||
lessons[key] = { last, reached, solved: [...new Set(solved)].sort((a, b) => a - b) };
|
||||
}
|
||||
return { version: LESSON_PROGRESS_VERSION, lessons };
|
||||
}
|
||||
|
||||
/** Où en est la leçon number (rien de fait si elle n'a jamais été ouverte). */
|
||||
export function lessonRecord(progress: LessonProgress, number: number): LessonRecord {
|
||||
return progress.lessons[String(number)] ?? NO_RECORD;
|
||||
}
|
||||
|
||||
function withRecord(
|
||||
progress: LessonProgress,
|
||||
number: number,
|
||||
change: (record: LessonRecord) => LessonRecord,
|
||||
): LessonProgress {
|
||||
return {
|
||||
version: LESSON_PROGRESS_VERSION,
|
||||
lessons: { ...progress.lessons, [String(number)]: change(lessonRecord(progress, number)) },
|
||||
};
|
||||
}
|
||||
|
||||
/** La progression après l'arrivée sur une étape d'une leçon (LESSON_END :
|
||||
* sa fin). */
|
||||
export function withVisit(progress: LessonProgress, number: number, at: number): LessonProgress {
|
||||
return withRecord(progress, number, (record) => ({
|
||||
...record,
|
||||
last: at,
|
||||
reached: Math.max(record.reached, at),
|
||||
}));
|
||||
}
|
||||
|
||||
/** La progression après la réussite de l'exercice d'une étape. */
|
||||
export function withSolved(progress: LessonProgress, number: number, at: number): LessonProgress {
|
||||
return withRecord(progress, number, (record) =>
|
||||
record.solved.includes(at)
|
||||
? record
|
||||
: { ...record, solved: [...record.solved, at].sort((a, b) => a - b) },
|
||||
);
|
||||
}
|
||||
|
||||
/** L'état d'une leçon, tel que l'affichent la liste et l'accueil : à
|
||||
* commencer, en cours (« 2 exercices sur 4 »), terminée (tous ses
|
||||
* exercices réussis). */
|
||||
export type LessonState =
|
||||
| { readonly kind: "toStart"; readonly total: number }
|
||||
| { readonly kind: "inProgress"; readonly solved: number; readonly total: number }
|
||||
| { readonly kind: "done"; readonly total: number };
|
||||
|
||||
export function lessonState(progress: LessonProgress, outline: LessonOutline): LessonState {
|
||||
const record = lessonRecord(progress, outline.number);
|
||||
const total = outline.exercises.length;
|
||||
const solved = outline.exercises.filter((s) => record.solved.includes(s)).length;
|
||||
if (solved === total && (total > 0 || record.reached === LESSON_END)) {
|
||||
return { kind: "done", total };
|
||||
}
|
||||
if (solved === 0 && record.reached === 0) {
|
||||
return { kind: "toStart", total };
|
||||
}
|
||||
return { kind: "inProgress", solved, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* La leçon à proposer ensuite (carte de l'accueil, « Continuer » de la
|
||||
* liste) : la première en cours, sinon la première à commencer, sinon
|
||||
* aucune (toutes terminées). L'ordre est conseillé, jamais imposé.
|
||||
*/
|
||||
export function nextLesson(
|
||||
progress: LessonProgress,
|
||||
outlines: readonly LessonOutline[],
|
||||
): LessonOutline | null {
|
||||
const states = outlines.map((outline) => ({ outline, state: lessonState(progress, outline) }));
|
||||
return (
|
||||
states.find((s) => s.state.kind === "inProgress")?.outline ??
|
||||
states.find((s) => s.state.kind === "toStart")?.outline ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'étape où reprendre une leçon : là où on l'a quittée ; après sa fin, le
|
||||
* premier exercice pas encore réussi ; sinon le début.
|
||||
*/
|
||||
export function resumeStep(progress: LessonProgress, outline: LessonOutline): number {
|
||||
const record = lessonRecord(progress, outline.number);
|
||||
if (record.last > 0 && record.last < LESSON_END) {
|
||||
return record.last;
|
||||
}
|
||||
if (record.last === LESSON_END) {
|
||||
return outline.exercises.find((s) => !record.solved.includes(s)) ?? 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Le nombre de leçons terminées. */
|
||||
export function finishedLessons(
|
||||
progress: LessonProgress,
|
||||
outlines: readonly LessonOutline[],
|
||||
): number {
|
||||
return outlines.filter((outline) => lessonState(progress, outline).kind === "done").length;
|
||||
}
|
||||
@@ -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,526 @@
|
||||
// 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,
|
||||
pliesText,
|
||||
rankingPly,
|
||||
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 à ${pliesText(ply)}`;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
// Le niveau d'un classement de coups : celui de son meilleur coup, que
|
||||
// le moteur a pu évaluer moins profondément que demandé.
|
||||
return data.advice.kind === "move"
|
||||
? fillPrompt(templates.move, {
|
||||
...common,
|
||||
niveau_eval: plyText(rankingPly(data.advice)),
|
||||
...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) => {
|
||||
// Un coup écarté par les filtres du moteur garde l'évaluation d'un
|
||||
// niveau inférieur à celui du classement : la ligne le dit.
|
||||
const ply =
|
||||
row.move.ply === rankingPly(advice) ? "" : `, évalué à ${pliesText(row.move.ply)}`;
|
||||
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 qui revient dans les imprécisions et erreurs de plusieurs parties,
|
||||
* compté sur les faits que le moteur donne de chaque coup (pions frappés,
|
||||
* points faits, lus sur les plateaux qu'il décode) : jamais une
|
||||
* explication stratégique. Une ligne par fait présent au moins une fois.
|
||||
*/
|
||||
export function mistakeThemes(mistakes: readonly { readonly advice: Advice }[]): string[] {
|
||||
const moves = mistakes.flatMap((m) => (m.advice.kind === "move" ? [m.advice] : []));
|
||||
const cubes = mistakes.length - moves.length;
|
||||
const times = (n: number) => `${n} fois`;
|
||||
const masterHits = moves.filter((a) => a.bestFacts.hits > 0 && a.playedFacts.hits === 0);
|
||||
const masterPoints = moves.filter((a) =>
|
||||
a.bestFacts.madePoints.some((p) => !a.playedFacts.madePoints.includes(p)),
|
||||
);
|
||||
const playerHits = moves.filter((a) => a.playedFacts.hits > 0 && a.bestFacts.hits === 0);
|
||||
const lines = [
|
||||
`- ${plural(moves.length, "coup", "coups")} et ${plural(cubes, "décision de videau", "décisions de videau")}`,
|
||||
];
|
||||
if (masterHits.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(masterHits.length)}, le coup de GNU Backgammon frappait un pion et le mien non`,
|
||||
);
|
||||
}
|
||||
if (masterPoints.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(masterPoints.length)}, le coup de GNU Backgammon faisait un point que le mien ne faisait pas`,
|
||||
);
|
||||
}
|
||||
if (playerHits.length > 0) {
|
||||
lines.push(
|
||||
`- ${times(playerHits.length)}, mon coup frappait un pion et celui de GNU Backgammon non`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 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"),
|
||||
themes: mistakeThemes(mistakes).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;
|
||||
});
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
best: CliEvaluatedMove | null;
|
||||
equity_loss: number;
|
||||
skill: CliSkill;
|
||||
ranking: CliEvaluatedMove[];
|
||||
};
|
||||
if (answer.rank === null || answer.played === null || answer.best === null) {
|
||||
return null;
|
||||
@@ -193,17 +194,10 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
best: toEvaluatedMove(answer.best),
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
ranking: answer.ranking.map(toEvaluatedMove),
|
||||
};
|
||||
},
|
||||
|
||||
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
|
||||
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as {
|
||||
moves: CliEvaluatedMove[];
|
||||
};
|
||||
return answer.moves.map(toEvaluatedMove);
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
|
||||
+21
-3
@@ -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, exercisesPosition, 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";
|
||||
@@ -63,7 +65,7 @@ const settings = createSettingsStore(localStorage ?? memoryStorage());
|
||||
// pendant que le moteur se charge : le plateau est dessiné sans les
|
||||
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
|
||||
const opening = (async () => {
|
||||
const { library } = await openGameLibrary({
|
||||
const { library, lessons } = await openGameLibrary({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
@@ -74,7 +76,7 @@ const opening = (async () => {
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
const saved = await session.restore();
|
||||
return { library, session, saved };
|
||||
return { library, session, saved, lessons };
|
||||
})();
|
||||
let stored: Awaited<typeof opening> | null = null;
|
||||
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
|
||||
@@ -152,6 +154,14 @@ startApp({
|
||||
MovePractice.start(engine, position, roll, expected),
|
||||
shouldTake: (position) => shouldTake(engine, position),
|
||||
},
|
||||
// La progression dans les leçons (étape 1g), dans le stockage des
|
||||
// parties : lue une fois ouvert, enregistrée sans être attendue.
|
||||
lessons: {
|
||||
load: async () => (await opening).lessons.load(),
|
||||
save: (progress) => {
|
||||
void opening.then((opened) => opened.lessons.save(progress));
|
||||
},
|
||||
},
|
||||
// Hors ligne : le service worker met tout le site en cache. Le navigateur
|
||||
// le refuse hors https (sauf localhost) : l'application marche alors sans.
|
||||
// Il est enregistré une fois le moteur chargé : si le Worker et le service
|
||||
@@ -164,6 +174,14 @@ 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),
|
||||
exercises: (journals) => exercisesPosition(engine, journals),
|
||||
},
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
@@ -59,6 +59,10 @@ export interface LibraryBackend {
|
||||
remove(id: string): Promise<void>;
|
||||
currentId(): Promise<string | undefined>;
|
||||
setCurrentId(id: string): Promise<void>;
|
||||
/** La progression dans les leçons (étape 1g), telle qu'enregistrée
|
||||
* (undefined : jamais enregistrée) ; relue par readLessonProgress. */
|
||||
lessonProgress(): Promise<unknown>;
|
||||
saveLessonProgress(progress: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// Base « ustagammon », trois magasins d'objets :
|
||||
// - games : les résumés des parties (SavedGameSummary), clé id ;
|
||||
// - journals : leurs journaux, { id, journal }, clé id ;
|
||||
// - settings : { key, value }, dont la partie en cours (key « current »).
|
||||
// - settings : { key, value }, dont la partie en cours (key « current ») et
|
||||
// la progression dans les leçons (key « lessons », étape 1g).
|
||||
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
|
||||
// L'API IndexedDB est utilisée directement, sans bibliothèque.
|
||||
|
||||
@@ -19,6 +20,9 @@ const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
/** Clé de la progression dans les leçons, dans le magasin settings (étape
|
||||
* 1g : nouvelle clé, même schéma, version 1 de la base). */
|
||||
const LESSONS = "lessons";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
@@ -77,5 +81,11 @@ export async function openIndexedDbBackend(factory: IDBFactory): Promise<Library
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
lessonProgress: async () => (await get<{ value: unknown }>(SETTINGS, LESSONS))?.value,
|
||||
saveLessonProgress: async (progress) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: LESSONS, value: progress });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface KeyValueStorage {
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
/** La progression dans les leçons (étape 1g). */
|
||||
const LESSONS = "ustagammon.lessons";
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
@@ -60,6 +62,8 @@ export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
lessonProgress: async () => read(storage, LESSONS),
|
||||
saveLessonProgress: async (progress) => storage.setItem(LESSONS, JSON.stringify(progress)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// La progression dans les leçons (étape 1g), dans le stockage des parties
|
||||
// (IndexedDB, ou localStorage en repli) : une seule valeur, relue par le
|
||||
// domaine (readLessonProgress), qui en connaît le format.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { type LessonProgress, readLessonProgress } from "../domain/learning/lessons.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
export interface LessonStore {
|
||||
/** La progression enregistrée (vide si rien n'est enregistré ou si le
|
||||
* stockage est illisible). */
|
||||
load(): Promise<LessonProgress>;
|
||||
/** Enregistre la progression ; les enregistrements se font dans l'ordre
|
||||
* des appels. Un stockage refusé ne fait pas échouer l'appel. */
|
||||
save(progress: LessonProgress): Promise<void>;
|
||||
}
|
||||
|
||||
export function createLessonStore(
|
||||
backend: Pick<LibraryBackend, "lessonProgress" | "saveLessonProgress">,
|
||||
): LessonStore {
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
const inTurn = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const run = queue.then(task, task);
|
||||
queue = run.catch(() => undefined);
|
||||
return run;
|
||||
};
|
||||
return {
|
||||
load: () =>
|
||||
inTurn(async () => {
|
||||
try {
|
||||
return readLessonProgress(await backend.lessonProgress());
|
||||
} catch {
|
||||
return readLessonProgress(undefined);
|
||||
}
|
||||
}),
|
||||
save: (progress) =>
|
||||
inTurn(async () => {
|
||||
try {
|
||||
await backend.saveLessonProgress(progress);
|
||||
} catch {
|
||||
// Stockage plein ou refusé : la progression reste en
|
||||
// mémoire pour cette visite.
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,19 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
|
||||
import {
|
||||
createGameLibrary,
|
||||
type GameLibrary,
|
||||
type LibraryBackend,
|
||||
type LibraryOptions,
|
||||
} from "./game-library.js";
|
||||
import { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
import { createLessonStore, type LessonStore } from "./lesson-store.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
@@ -23,6 +29,8 @@ export interface OpenedLibrary {
|
||||
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
|
||||
* mémoire de la page (rien n'est gardé après sa fermeture). */
|
||||
readonly kind: "indexeddb" | "localStorage" | "memory";
|
||||
/** La progression dans les leçons, dans le même stockage. */
|
||||
readonly lessons: LessonStore;
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
@@ -55,7 +63,7 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
return opened(backend, options, "indexeddb");
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
@@ -63,14 +71,21 @@ async function open(storage: BrowserStorage, options: LibraryOptions): Promise<O
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
return opened(createKeyValueBackend(storage.localStorage), options, "localStorage");
|
||||
}
|
||||
return opened(createKeyValueBackend(memoryStorage()), options, "memory");
|
||||
}
|
||||
|
||||
/** Les parties et la progression dans les leçons, sur le même stockage. */
|
||||
function opened(
|
||||
backend: LibraryBackend,
|
||||
options: LibraryOptions,
|
||||
kind: OpenedLibrary["kind"],
|
||||
): OpenedLibrary {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
library: createGameLibrary(backend, options),
|
||||
kind,
|
||||
lessons: createLessonStore(backend),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ export const ADVICE_TEXTS = {
|
||||
replay: "Rejouer le coup",
|
||||
numbers: "Voir les chiffres du moteur",
|
||||
you: "(vous)",
|
||||
moveNote: (ply: number) =>
|
||||
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
|
||||
moveNote: (plies: string) =>
|
||||
`Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
|
||||
cubeNote: (ply: number) =>
|
||||
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
|
||||
cubeRows: {
|
||||
@@ -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";
|
||||
|
||||
+26
-20
@@ -7,6 +7,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { pliesText, rankingPly, 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),
|
||||
@@ -299,7 +304,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(advice.ply),
|
||||
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
|
||||
});
|
||||
} else {
|
||||
// Les lignes de votre décision, en gras : doubler mène à l'une des
|
||||
@@ -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 = () => {
|
||||
|
||||
+627
-46
@@ -21,26 +21,49 @@
|
||||
// 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,
|
||||
masterWords,
|
||||
parsePhrases,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
type FinishedGame,
|
||||
PROGRESSION_MAX_GAMES,
|
||||
progressionEntries,
|
||||
} from "../domain/analysis/progression.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";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import {
|
||||
finishedLessons,
|
||||
LESSON_END,
|
||||
type LessonProgress,
|
||||
lessonRecord,
|
||||
NO_PROGRESS,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../domain/learning/lessons.js";
|
||||
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,
|
||||
@@ -73,11 +96,25 @@ import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { LESSON_COUNT, type Lesson, lessonSource, outlineOf, parseLesson } from "./lesson-text.js";
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import { lessonCards, nextLessonCard } from "./lessons-model.js";
|
||||
import { createLessonsView } from "./lessons-view.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 { createProgressionView } from "./progression-view.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,
|
||||
@@ -88,8 +125,21 @@ import {
|
||||
} from "./settings.js";
|
||||
import { createSummaryView } from "./summary-view.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
import {
|
||||
lessonPlaceOf,
|
||||
lessonPlaceRoute,
|
||||
seenAfter,
|
||||
placeOf as tutorialPlaceOf,
|
||||
placeRoute as tutorialPlaceRoute,
|
||||
} from "./tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
|
||||
import {
|
||||
type Course,
|
||||
cachedText,
|
||||
createTutorialView,
|
||||
TUTORIAL_SOURCE,
|
||||
type TutorialAccess,
|
||||
} from "./tutorial-view.js";
|
||||
import { betweenTurns, type Updates, updateBannerShown } from "./updates.js";
|
||||
|
||||
/** Glissement d'un pion de l'ordinateur, et pause entre deux pions
|
||||
@@ -129,8 +179,15 @@ export interface AppOptions {
|
||||
/** Les réglages, en JSON (null : jamais enregistrés). */
|
||||
readonly loadSettings: () => string | null;
|
||||
readonly saveSettings: (json: string) => void;
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur. */
|
||||
/** Le tutoriel : plateaux, coups illustrés, gestes jugés par le moteur ;
|
||||
* les leçons s'en servent aussi. */
|
||||
readonly tutorial: TutorialAccess;
|
||||
/** La progression dans les leçons (étape 1g), dans le stockage des
|
||||
* parties. */
|
||||
readonly lessons: {
|
||||
readonly load: () => Promise<LessonProgress>;
|
||||
readonly save: (progress: LessonProgress) => void;
|
||||
};
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
@@ -140,6 +197,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,7 +286,9 @@ 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"),
|
||||
progression: byId("progression"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
|
||||
@@ -287,8 +348,11 @@ export function startApp(options: AppOptions): void {
|
||||
resumeOld: byId<HTMLButtonElement>("resume-old"),
|
||||
home: byId("home"),
|
||||
tutorial: byId("tutorial"),
|
||||
lessons: byId("lessons"),
|
||||
lesson: byId("lesson"),
|
||||
menuHome: byId<HTMLAnchorElement>("menu-home"),
|
||||
menuTutorial: byId<HTMLAnchorElement>("menu-tutorial"),
|
||||
menuLessons: byId<HTMLAnchorElement>("menu-lessons"),
|
||||
menuInstall: byId<HTMLButtonElement>("menu-install"),
|
||||
menuUpdate: byId<HTMLButtonElement>("menu-update"),
|
||||
installVeil: byId("install-veil"),
|
||||
@@ -386,6 +450,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.resumeOld.textContent = TEXTS.resumeOld;
|
||||
label(ui.menuHome, HOME_TEXTS.homeLink, "home");
|
||||
label(ui.menuTutorial, HOME_TEXTS.tutorialMenu, "rules");
|
||||
label(ui.menuLessons, LESSON_TEXTS.menu, "lessons");
|
||||
label(ui.menuInstall, HOME_TEXTS.installMenu, "install");
|
||||
label(ui.menuUpdate, HOME_TEXTS.updateCheck, "replay");
|
||||
ui.installTitle.textContent = HOME_TEXTS.installTitle;
|
||||
@@ -424,6 +489,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 +696,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 +1091,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 +1371,124 @@ 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);
|
||||
case "progression": {
|
||||
// Les parties analysées de l'écran, de la plus ancienne à la
|
||||
// plus récente : rien n'est analysé pour le prompt.
|
||||
const games = progressionGames ?? (await loadProgression());
|
||||
const journals = [...games]
|
||||
.sort((a, b) => a.startedAt - b.startedAt)
|
||||
.map((g) => g.journal)
|
||||
.filter((journal) => readyForSummary(journal));
|
||||
return journals.length === 0
|
||||
? { message: ASK_AI_TEXTS.noAnalysedGame }
|
||||
: promptSource.exercises(journals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -1331,20 +1523,143 @@ export function startApp(options: AppOptions): void {
|
||||
if (missingAnalyses(journal).length === 0) {
|
||||
return;
|
||||
}
|
||||
reviewRunner.start(id, journal, (analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
showReview(id, false);
|
||||
render();
|
||||
});
|
||||
reviewRunner.start(
|
||||
id,
|
||||
journal,
|
||||
(analysed) => {
|
||||
const live = liveGame(id);
|
||||
if (live !== null) {
|
||||
live.recordAnalyses(analysed);
|
||||
save();
|
||||
} else {
|
||||
options.games.saveAnalyses(id, analysed);
|
||||
}
|
||||
if (reviewed?.id === id) {
|
||||
reviewed = { ...reviewed, journal: live?.journal ?? analysed };
|
||||
}
|
||||
progressionAnalysed(id, live?.journal ?? analysed);
|
||||
showReview(id, false);
|
||||
render();
|
||||
},
|
||||
// L'écran de progression attend peut-être cette analyse pour
|
||||
// lancer celle de la partie suivante.
|
||||
() => reviewProgression(),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Progression (1h)
|
||||
|
||||
// L'écran #/progression : les parties terminées (les plus récentes),
|
||||
// relues à chaque visite, avec leurs analyses rangées. Celles qui n'en
|
||||
// ont pas encore sont analysées en arrière-plan, une à une, la plus
|
||||
// récente d'abord ; rien n'est recalculé pour les autres.
|
||||
const progressionView = createProgressionView({ section: ui.progression });
|
||||
let progressionGames: FinishedGame[] | null = null;
|
||||
// Chaque lecture a son numéro : une lecture dépassée n'est pas dessinée.
|
||||
let progressionRequest = 0;
|
||||
// Les parties dont l'analyse a été lancée depuis l'ouverture de l'écran.
|
||||
const progressionTried = new Set<string>();
|
||||
|
||||
/** Les parties terminées les plus récentes, avec leur journal (celui
|
||||
* de la partie à l'écran, s'il s'agit d'elle). */
|
||||
async function loadProgression(): Promise<FinishedGame[]> {
|
||||
const list = await options.games.list();
|
||||
const over = list
|
||||
.filter((summary) => summary.status === "over")
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.slice(0, PROGRESSION_MAX_GAMES);
|
||||
const games = await Promise.all(
|
||||
over.map(async (summary) => {
|
||||
const journal =
|
||||
liveGame(summary.id)?.journal ?? (await options.games.journal(summary.id));
|
||||
return journal === null
|
||||
? null
|
||||
: {
|
||||
id: summary.id,
|
||||
startedAt: summary.startedAt,
|
||||
level: summary.level,
|
||||
result: summary.result,
|
||||
journal,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return games.filter((g): g is FinishedGame => g !== null);
|
||||
}
|
||||
|
||||
/** Ouvre l'écran : les parties relues à chaque visite (une partie a pu
|
||||
* finir entre-temps) ; d'ici là, l'écran dit qu'il les lit, sans
|
||||
* montrer la liste de la visite précédente comme si elle était à
|
||||
* jour. */
|
||||
function showProgression(): void {
|
||||
const request = ++progressionRequest;
|
||||
progressionTried.clear();
|
||||
progressionGames = null;
|
||||
renderProgression();
|
||||
options.engineReady
|
||||
.then(() => loadProgression())
|
||||
.then(
|
||||
(games) => {
|
||||
if (request !== progressionRequest) {
|
||||
return;
|
||||
}
|
||||
progressionGames = games;
|
||||
renderProgression();
|
||||
reviewProgression();
|
||||
},
|
||||
() => {
|
||||
if (request === progressionRequest && progressionGames === null) {
|
||||
progressionView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (phrases === null) {
|
||||
phrasesLoading.then(
|
||||
() => renderProgression(),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgression(): void {
|
||||
if (navigation.route.screen !== "progression") {
|
||||
return;
|
||||
}
|
||||
progressionView.render(
|
||||
progressionGames === null
|
||||
? null
|
||||
: { entries: progressionEntries(progressionGames), phrases },
|
||||
);
|
||||
}
|
||||
|
||||
/** Une analyse rangée : la partie, si l'écran la montre, se met à
|
||||
* jour. */
|
||||
function progressionAnalysed(id: string, journal: GameJournal): void {
|
||||
if (progressionGames?.some((g) => g.id === id)) {
|
||||
progressionGames = progressionGames.map((g) => (g.id === id ? { ...g, journal } : g));
|
||||
renderProgression();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lance l'analyse de la prochaine partie de l'écran qui n'est pas
|
||||
* analysée (rien si une analyse tourne déjà : à sa fin, elle rappelle
|
||||
* cette fonction). */
|
||||
function reviewProgression(): void {
|
||||
if (
|
||||
navigation.route.screen !== "progression" ||
|
||||
progressionGames === null ||
|
||||
reviewRunner.running !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Une partie dont l'analyse a échoué n'est pas relancée sans fin :
|
||||
// une fois par visite de l'écran.
|
||||
const next = progressionGames.find(
|
||||
(g) => !progressionTried.has(g.id) && missingAnalyses(g.journal).length > 0,
|
||||
);
|
||||
if (next !== undefined) {
|
||||
progressionTried.add(next.id);
|
||||
review(next.id, next.journal);
|
||||
}
|
||||
}
|
||||
|
||||
/** L'analyse en cours d'une partie qui n'est ni regardée ni à l'écran
|
||||
@@ -1363,6 +1678,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 +1719,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 }));
|
||||
}
|
||||
});
|
||||
@@ -1570,6 +1901,12 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave((r) => r.screen === "analysis");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
} else if (route.screen === "lesson" || route.screen === "lessonEnd") {
|
||||
navigation.leave((r) => r.screen === "lesson" || r.screen === "lessonEnd");
|
||||
} else if (route.screen === "lessons") {
|
||||
navigation.leave((r) => r.screen === "lessons");
|
||||
} else if (route.screen === "progression") {
|
||||
navigation.leave((r) => r.screen === "progression");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1675,6 +2012,7 @@ export function startApp(options: AppOptions): void {
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
replacingLink(ui.menuLessons, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
@@ -1711,6 +2049,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 {
|
||||
@@ -1724,9 +2064,21 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const lessonList = below.screen === "lessons";
|
||||
const lesson = below.screen === "lesson" || below.screen === "lessonEnd";
|
||||
const choosing = below.screen === "newGame";
|
||||
const analysing = below.screen === "analysis";
|
||||
const page = help || position || home || tutorial || choosing || analysing;
|
||||
const progression = below.screen === "progression";
|
||||
const page =
|
||||
help ||
|
||||
position ||
|
||||
home ||
|
||||
tutorial ||
|
||||
lessonList ||
|
||||
lesson ||
|
||||
choosing ||
|
||||
analysing ||
|
||||
progression;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1734,13 +2086,19 @@ export function startApp(options: AppOptions): void {
|
||||
ui.position.hidden = !position;
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
ui.lessons.hidden = !lessonList;
|
||||
ui.lesson.hidden = !lesson;
|
||||
ui.analysis.hidden = !analysing;
|
||||
ui.progression.hidden = !progression;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, les leçons, le premier lancement et l'écran « Nouvelle
|
||||
// partie » ont leur propre en-tête.
|
||||
ui.root.classList.toggle(
|
||||
"bare",
|
||||
tutorial || lesson || choosing || (home && isFirstLaunch(settings)),
|
||||
);
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
@@ -1756,9 +2114,29 @@ export function startApp(options: AppOptions): void {
|
||||
} else {
|
||||
tutorialView.hide();
|
||||
}
|
||||
if (lesson) {
|
||||
// Une leçon qui n'existe pas : la liste des leçons.
|
||||
if (below.lesson > LESSON_COUNT) {
|
||||
navigation.replace(routeHash({ screen: "lessons" }));
|
||||
return;
|
||||
}
|
||||
if (route.screen !== "install") {
|
||||
void lessonView.show(below);
|
||||
}
|
||||
} else {
|
||||
lessonView.hide();
|
||||
}
|
||||
if (lessonList) {
|
||||
renderLessons();
|
||||
}
|
||||
if (home) {
|
||||
homeProgress = null;
|
||||
}
|
||||
// La progression se relit à chaque arrivée sur l'écran (pas en
|
||||
// ouvrant ou en fermant la feuille « Créer des exercices »).
|
||||
if (progression && wasScreen !== "progression") {
|
||||
showProgression();
|
||||
}
|
||||
|
||||
// La marche à suivre pour installer : une feuille, comme le menu.
|
||||
if (route.screen === "install" && ui.installVeil.hidden) {
|
||||
@@ -1790,8 +2168,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
|
||||
@@ -1813,7 +2197,7 @@ export function startApp(options: AppOptions): void {
|
||||
() => openReview(id),
|
||||
() => undefined,
|
||||
);
|
||||
} else {
|
||||
} else if (route.screen !== "progression") {
|
||||
stopForeignReview();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
@@ -1845,6 +2229,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 +2262,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 +2274,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");
|
||||
@@ -1890,8 +2288,26 @@ export function startApp(options: AppOptions): void {
|
||||
sides: sides ?? options.cachedBoard()?.sides ?? null,
|
||||
tutorialSeen: settings.tutorialSeen,
|
||||
progress: homeProgress,
|
||||
lessons:
|
||||
lessonTexts === null || lessonProgress === null
|
||||
? null
|
||||
: {
|
||||
next: nextLessonCard(lessonTexts, lessonProgress),
|
||||
finished: finishedLessons(lessonProgress, lessonTexts.map(outlineOf)),
|
||||
total: lessonTexts.length,
|
||||
},
|
||||
busy: busy || game === null,
|
||||
});
|
||||
if (lessonTexts === null || lessonProgress === null) {
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
if (homeProgress === null && !progressLoading) {
|
||||
progressLoading = true;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -1914,17 +2330,158 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
const loadTutorial = cachedText(TUTORIAL_SOURCE, parseTutorial);
|
||||
const tutorialCourse: Course = {
|
||||
id: "tutorial",
|
||||
load: () => loadTutorial(),
|
||||
placeOf: tutorialPlaceOf,
|
||||
routeOf: (_, place) => tutorialPlaceRoute(place),
|
||||
seen: () => settings.tutorialSeen,
|
||||
visit: (place) => {
|
||||
visit: (_, place) => {
|
||||
const seen = seenAfter(settings.tutorialSeen, place);
|
||||
if (seen !== settings.tutorialSeen || !settings.welcomed) {
|
||||
saveSettings({ tutorialSeen: seen, ...welcomed() });
|
||||
}
|
||||
},
|
||||
solved: () => undefined,
|
||||
quitHref: () => "#/",
|
||||
firstBackHref: () => "#/accueil",
|
||||
endHref: () => "#/",
|
||||
texts: {
|
||||
progress: HOME_TEXTS.tutorialProgress,
|
||||
stepLink: HOME_TEXTS.tutorialStepLink,
|
||||
quit: HOME_TEXTS.tutorialSkip,
|
||||
quitLabel: HOME_TEXTS.tutorialSkipLabel,
|
||||
overline: (_, n, total, practice) => HOME_TEXTS.tutorialOverline(n, total, practice),
|
||||
endOverline: () => HOME_TEXTS.tutorialEndOverline,
|
||||
endTitle: () => HOME_TEXTS.tutorialEndTitle,
|
||||
loading: HOME_TEXTS.tutorialLoading,
|
||||
failed: HOME_TEXTS.tutorialFailed,
|
||||
},
|
||||
};
|
||||
const tutorialView = createTutorialView({
|
||||
section: ui.tutorial,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: tutorialCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
replace: (hash) => navigation.replace(hash),
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Leçons
|
||||
|
||||
// Les textes des leçons (docs/lecons/, copiés dans le site) et la
|
||||
// progression enregistrée, lus une fois.
|
||||
const lessonLoaders = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
cachedText(lessonSource(i + 1), (text) => parseLesson(i + 1, text)),
|
||||
);
|
||||
let lessonTexts: Lesson[] | null = null;
|
||||
let lessonProgress: LessonProgress | null = null;
|
||||
const lessonProgressLoading = options.lessons
|
||||
.load()
|
||||
.catch(() => NO_PROGRESS)
|
||||
.then((progress) => {
|
||||
lessonProgress = progress;
|
||||
return progress;
|
||||
});
|
||||
/** Les leçons et la progression, lues (rejetée si un texte manque). */
|
||||
async function lessonsReady(): Promise<{ texts: Lesson[]; progress: LessonProgress }> {
|
||||
const [texts, progress] = await Promise.all([
|
||||
Promise.all(lessonLoaders.map((load) => load())),
|
||||
lessonProgressLoading,
|
||||
]);
|
||||
lessonTexts = texts;
|
||||
return { texts, progress };
|
||||
}
|
||||
const progressNow = () => lessonProgress ?? NO_PROGRESS;
|
||||
function updateLessons(progress: LessonProgress): void {
|
||||
lessonProgress = progress;
|
||||
options.lessons.save(progress);
|
||||
}
|
||||
const lessonOf = (route: Route) =>
|
||||
route.screen === "lesson" || route.screen === "lessonEnd" ? route.lesson : 1;
|
||||
// Le texte que l'écran d'une leçon reçoit est une leçon (Lesson).
|
||||
const lessonNumberOf = (text: Tutorial) => (text as Partial<Lesson>).number ?? 1;
|
||||
const lessonTitleOf = (text: Tutorial) => (text as Partial<Lesson>).title ?? "";
|
||||
|
||||
const lessonsView = createLessonsView(ui.lessons);
|
||||
function renderLessons(): void {
|
||||
if (lessonTexts !== null && lessonProgress !== null) {
|
||||
lessonsView.render(lessonCards(lessonTexts, lessonProgress));
|
||||
return;
|
||||
}
|
||||
lessonsView.render(null);
|
||||
lessonsReady().then(
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
renderLessons();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (navigation.route.screen === "lessons") {
|
||||
lessonsView.render(null, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const lessonCourse: Course = {
|
||||
id: "lesson",
|
||||
load: async (route) => {
|
||||
await lessonProgressLoading;
|
||||
const load = lessonLoaders[lessonOf(route) - 1];
|
||||
if (load === undefined) {
|
||||
throw new Error(`leçon ${lessonOf(route)} absente`);
|
||||
}
|
||||
return load();
|
||||
},
|
||||
placeOf: lessonPlaceOf,
|
||||
routeOf: (route, place) => lessonPlaceRoute(lessonOf(route), place),
|
||||
seen: (route) => lessonRecord(progressNow(), lessonOf(route)).reached,
|
||||
visit: (route, place) => {
|
||||
const number = lessonOf(route);
|
||||
const at = place === "end" ? LESSON_END : place.step;
|
||||
const record = lessonRecord(progressNow(), number);
|
||||
if (record.last !== at || record.reached < at) {
|
||||
updateLessons(withVisit(progressNow(), number, at));
|
||||
}
|
||||
if (!settings.welcomed) {
|
||||
saveSettings(welcomed());
|
||||
}
|
||||
},
|
||||
solved: (route, place) => {
|
||||
if (place !== "end" && place.practice) {
|
||||
updateLessons(withSolved(progressNow(), lessonOf(route), place.step));
|
||||
}
|
||||
},
|
||||
quitHref: () => routeHash({ screen: "lessons" }),
|
||||
firstBackHref: () => routeHash({ screen: "lessons" }),
|
||||
endHref: (route) => {
|
||||
const number = lessonOf(route);
|
||||
return number < LESSON_COUNT
|
||||
? routeHash({ screen: "lesson", lesson: number + 1, step: 1, practice: false })
|
||||
: routeHash({ screen: "lessons" });
|
||||
},
|
||||
texts: {
|
||||
progress: LESSON_TEXTS.progress,
|
||||
stepLink: LESSON_TEXTS.stepLink,
|
||||
quit: LESSON_TEXTS.quit,
|
||||
quitLabel: LESSON_TEXTS.quitLabel,
|
||||
overline: (text, n, total, practice) =>
|
||||
LESSON_TEXTS.overline(lessonNumberOf(text), n, total, practice),
|
||||
endOverline: (text) => LESSON_TEXTS.endOverline(lessonNumberOf(text)),
|
||||
endTitle: (text) => LESSON_TEXTS.endTitle(lessonTitleOf(text)),
|
||||
loading: LESSON_TEXTS.loading,
|
||||
failed: LESSON_TEXTS.failed,
|
||||
},
|
||||
};
|
||||
const lessonView = createTutorialView({
|
||||
section: ui.lesson,
|
||||
access: options.tutorial,
|
||||
engineReady: options.engineReady,
|
||||
course: lessonCourse,
|
||||
placed: () => haptic("checkerPlaced"),
|
||||
reducedMotion,
|
||||
landscape: () => layout() === "landscape",
|
||||
@@ -2195,6 +2752,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (!ui.tutorial.hidden) {
|
||||
tutorialView.relayout();
|
||||
}
|
||||
if (!ui.lesson.hidden) {
|
||||
lessonView.relayout();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
@@ -2282,6 +2842,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 +2946,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 +3072,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,35 @@
|
||||
// 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",
|
||||
progression: "Progression",
|
||||
},
|
||||
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.",
|
||||
noAnalysedGame:
|
||||
"Pas encore d'exercices : le maître n'a analysé aucune partie terminée. Ils seront prêts avec la première.",
|
||||
noMistake:
|
||||
"Pas d'exercices à proposer : sur vos parties analysées, vous avez joué comme le maître.",
|
||||
} 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,
|
||||
|
||||
+18
-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,7 +33,10 @@ 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/suivre-une-lecon.feature",
|
||||
"docs/scenarios/suivre-ma-progression.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
@@ -124,7 +128,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 +220,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 +321,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) {
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface HomeModel {
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** La miniature du plateau et le compte de points : seulement pour une
|
||||
* partie à reprendre. Sans partie en cours, la carte ne montre que son
|
||||
* titre et « Nouvelle partie », jamais la position de la partie finie
|
||||
* (recette 1h). */
|
||||
readonly board: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
@@ -45,6 +50,7 @@ export function homeModel(
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
board: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ export const HOME_TEXTS = {
|
||||
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
|
||||
rulesAgain: "Revoir",
|
||||
progress: (played: number, won: number) =>
|
||||
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
played === 0
|
||||
? "aucune partie jouée"
|
||||
: `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
|
||||
/** La ligne de progression, lien vers l'écran de progression (1h). */
|
||||
progressLink: (line: string) => `Votre progression : ${line}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
@@ -94,14 +98,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
|
||||
@@ -118,7 +123,7 @@ export const HOME_TEXTS = {
|
||||
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
|
||||
aboutParagraphs: [
|
||||
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
|
||||
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"Les textes du tutoriel, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
|
||||
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
|
||||
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
|
||||
],
|
||||
|
||||
+80
-10
@@ -1,13 +1,13 @@
|
||||
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
|
||||
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
|
||||
// installer, les règles et la progression résumée
|
||||
// installer, la prochaine leçon (1g), les règles et la progression résumée
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
|
||||
// l'accueil n'affiche que ce qui existe.
|
||||
// La ligne de progression résumée mène à l'écran de progression
|
||||
// (#/progression, étape 1h).
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
@@ -16,6 +16,9 @@ import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import type { LessonCardModel } from "./lessons-model.js";
|
||||
import { fillLessonCard, lessonAction } from "./lessons-view.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
@@ -38,6 +41,13 @@ export interface HomeState {
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Les leçons : la prochaine (null : toutes terminées), et combien sont
|
||||
* terminées sur combien (null : pas encore lues). */
|
||||
readonly lessons: {
|
||||
readonly next: LessonCardModel | null;
|
||||
readonly finished: number;
|
||||
readonly total: number;
|
||||
} | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
@@ -151,7 +161,7 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
@@ -186,16 +196,37 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
later.title = HOME_TEXTS.installLater;
|
||||
later.addEventListener("click", () => options.dismissInstall());
|
||||
|
||||
// La prochaine leçon : une seule carte, « Continuer » en secondaire, et
|
||||
// le lien vers toutes les leçons.
|
||||
const lesson = element(main, "article", { id: "home-lesson", className: "card home-lesson" });
|
||||
element(lesson, "span", { className: "overline", text: LESSON_TEXTS.homeOverline });
|
||||
const lessonInfo = element(lesson, "div", { className: "home-lesson-info" });
|
||||
const lessonActions = element(lesson, "div", { className: "home-lesson-actions" });
|
||||
const lessonGo = element(lessonActions, "a", {
|
||||
id: "home-lesson-go",
|
||||
className: "button secondary",
|
||||
});
|
||||
const lessonAll = element(lessonActions, "a", {
|
||||
id: "home-lessons",
|
||||
className: "home-lessons-link",
|
||||
text: LESSON_TEXTS.allLessons,
|
||||
});
|
||||
lessonAll.href = "#/lecons";
|
||||
|
||||
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
|
||||
const rulesText = element(rules, "span", { className: "home-rules-text" });
|
||||
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
// La ligne de progression mène à l'écran de progression (1h).
|
||||
const progress = element(main, "a", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
progress.href = "#/progression";
|
||||
const progressText = element(progress, "span", { className: "home-progress-text" });
|
||||
progress.append(icon("chevron"));
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
@@ -212,7 +243,12 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
miniBox.hidden = !model.board;
|
||||
pips.hidden = !model.board;
|
||||
againstLine.hidden = !model.board;
|
||||
if (model.board) {
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
}
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
@@ -233,10 +269,44 @@ export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
: seen >= TUTORIAL_DONE
|
||||
? HOME_TEXTS.rulesAgain
|
||||
: HOME_TEXTS.rulesContinue(seen);
|
||||
progress.textContent =
|
||||
state.progress === null || state.progress.played === 0
|
||||
? ""
|
||||
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
|
||||
// La carte de la leçon, une fois les leçons lues.
|
||||
lesson.hidden = state.lessons === null;
|
||||
if (state.lessons !== null) {
|
||||
const card = state.lessons.next;
|
||||
if (card === null) {
|
||||
lessonInfo.replaceChildren();
|
||||
element(lessonInfo, "strong", {
|
||||
className: "lesson-card-title",
|
||||
text: LESSON_TEXTS.homeAllDone,
|
||||
});
|
||||
element(lessonInfo, "span", {
|
||||
className: "muted",
|
||||
text: LESSON_TEXTS.homeAllDoneDetail,
|
||||
});
|
||||
lessonGo.hidden = true;
|
||||
} else {
|
||||
fillLessonCard(lessonInfo, card);
|
||||
lessonGo.hidden = false;
|
||||
lessonGo.textContent = lessonAction(card);
|
||||
lessonGo.href = card.href;
|
||||
lessonGo.setAttribute(
|
||||
"aria-label",
|
||||
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (state.lessons !== null) {
|
||||
parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total));
|
||||
}
|
||||
if (state.progress !== null) {
|
||||
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
|
||||
}
|
||||
const line = parts.join(" · ");
|
||||
const shown = line.charAt(0).toUpperCase() + line.slice(1);
|
||||
progressText.textContent = shown;
|
||||
progress.hidden = parts.length === 0;
|
||||
progress.setAttribute("aria-label", HOME_TEXTS.progressLink(line));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ICON_PATHS = {
|
||||
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
|
||||
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
|
||||
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
|
||||
lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6",
|
||||
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
|
||||
verdictDoubtful:
|
||||
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Lecture du texte des leçons (docs/lecons/lecon-<n>.md, format décrit dans
|
||||
// docs/lecons/README.md), copié tel quel dans le site par make site
|
||||
// (ADR 0008 : une seule source). Même format que le tutoriel, avec un titre
|
||||
// en tête. Fonctions pures, testées sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { LessonOutline } from "../domain/learning/lessons.js";
|
||||
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
|
||||
|
||||
/** Le nombre de leçons (fichiers lecon-1.md à lecon-<n>.md). */
|
||||
export const LESSON_COUNT = 5;
|
||||
|
||||
/** Une leçon : son numéro, son titre, et ses écrans, comme le tutoriel. */
|
||||
export interface Lesson extends Tutorial {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/** L'adresse, dans le site, du texte de la leçon number. */
|
||||
export function lessonSource(number: number): string {
|
||||
return `docs/lecons/lecon-${number}.md`;
|
||||
}
|
||||
|
||||
/** Lit le texte d'une leçon. Erreur si le titre manque ou si les écrans
|
||||
* sont mal formés (parseTutorial). */
|
||||
export function parseLesson(number: number, text: string): Lesson {
|
||||
const title = /^# (.+)$/m.exec(text)?.[1]?.trim();
|
||||
if (title === undefined || title === "") {
|
||||
throw new Error(`leçon ${number} : titre absent`);
|
||||
}
|
||||
return { ...parseTutorial(text), number, title };
|
||||
}
|
||||
|
||||
/** Ce que la progression retient d'une leçon : les étapes de ses
|
||||
* exercices. */
|
||||
export function outlineOf(lesson: Lesson): LessonOutline {
|
||||
return {
|
||||
number: lesson.number,
|
||||
exercises: lesson.steps.filter((s) => s.practice !== null).map((s) => s.number),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Les textes de la liste des leçons, de leurs écrans et de la carte
|
||||
// « prochaine leçon » de l'accueil (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). Le texte des
|
||||
// leçons elles-mêmes n'est pas ici : il vit dans docs/lecons/.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { LessonState } from "../domain/learning/lessons.js";
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n > 1 ? many : one}`;
|
||||
}
|
||||
|
||||
export const LESSON_TEXTS = {
|
||||
// Liste
|
||||
menu: "Les leçons",
|
||||
listTitle: "Les leçons",
|
||||
listLead:
|
||||
"Cinq leçons courtes, plateau à l'appui, avec des exercices où vous jouez le coup du maître. L'ordre est conseillé, jamais imposé.",
|
||||
rulesReminder: "Les règles du jeu",
|
||||
rulesReview: "revoir",
|
||||
rulesReviewLabel: "Revoir les règles du jeu (le tutoriel)",
|
||||
number: (n: number) => `Leçon ${n}`,
|
||||
listLoading: "Chargement des leçons…",
|
||||
listFailed: "Les leçons n'ont pas pu se charger.",
|
||||
state: (state: LessonState) =>
|
||||
state.kind === "done"
|
||||
? "Terminée"
|
||||
: state.kind === "toStart"
|
||||
? `À commencer · ${plural(state.total, "exercice", "exercices")}`
|
||||
: `${plural(state.solved, "exercice", "exercices")} sur ${state.total}`,
|
||||
continue: "Continuer",
|
||||
start: "Commencer",
|
||||
review: "Revoir",
|
||||
openLabel: (n: number, title: string) => `Leçon ${n} : ${title}`,
|
||||
|
||||
// Écrans d'une leçon
|
||||
progress: "Étapes de la leçon",
|
||||
stepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
quit: "Quitter",
|
||||
quitLabel: "Quitter la leçon",
|
||||
overline: (lesson: number, n: number, total: number, practice: boolean) =>
|
||||
`Leçon ${lesson} · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
endOverline: (lesson: number) => `Leçon ${lesson} · fin`,
|
||||
endTitle: (title: string) => title,
|
||||
loading: "Chargement de la leçon…",
|
||||
failed: "La leçon n'a pas pu se charger.",
|
||||
|
||||
// Accueil
|
||||
homeOverline: "Prochaine leçon",
|
||||
homeAllDone: "Toutes les leçons sont terminées",
|
||||
homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.",
|
||||
allLessons: "Toutes les leçons",
|
||||
homeSummary: (finished: number, total: number) =>
|
||||
finished === 0
|
||||
? `Aucune leçon terminée sur ${total}`
|
||||
: `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`,
|
||||
} as const;
|
||||
@@ -0,0 +1,66 @@
|
||||
// Ce que montrent la liste des leçons et la carte « prochaine leçon » de
|
||||
// l'accueil : fonctions pures, testées sous Node
|
||||
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
type LessonProgress,
|
||||
type LessonState,
|
||||
lessonRecord,
|
||||
lessonState,
|
||||
nextLesson,
|
||||
resumeStep,
|
||||
} from "../domain/learning/lessons.js";
|
||||
import { type Lesson, outlineOf } from "./lesson-text.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Une leçon dans la liste, ou sur l'accueil. */
|
||||
export interface LessonCardModel {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly state: LessonState;
|
||||
/** Les exercices, dans l'ordre : réussi ou non (barre de progression). */
|
||||
readonly exercises: readonly boolean[];
|
||||
/** Où mène la carte : là où reprendre la leçon. */
|
||||
readonly href: string;
|
||||
/** La leçon proposée ensuite : carte bordée de brique, seule action
|
||||
* principale de l'écran. */
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** L'adresse où reprendre la leçon. */
|
||||
export function resumeHref(progress: LessonProgress, lesson: Lesson): string {
|
||||
const step = resumeStep(progress, outlineOf(lesson));
|
||||
return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false });
|
||||
}
|
||||
|
||||
/** Les cartes de la liste des leçons, dans l'ordre. */
|
||||
export function lessonCards(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel[] {
|
||||
const outlines = lessons.map(outlineOf);
|
||||
const next = nextLesson(progress, outlines)?.number ?? null;
|
||||
return lessons.map((lesson, i) => {
|
||||
const outline = outlines[i] ?? outlineOf(lesson);
|
||||
const solved = lessonRecord(progress, lesson.number).solved;
|
||||
return {
|
||||
number: lesson.number,
|
||||
title: lesson.title,
|
||||
state: lessonState(progress, outline),
|
||||
exercises: outline.exercises.map((step) => solved.includes(step)),
|
||||
href: resumeHref(progress, lesson),
|
||||
current: lesson.number === next,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */
|
||||
export function nextLessonCard(
|
||||
lessons: readonly Lesson[],
|
||||
progress: LessonProgress,
|
||||
): LessonCardModel | null {
|
||||
return lessonCards(lessons, progress).find((card) => card.current) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// La liste des leçons (#/lecons) et la carte d'une leçon, reprise par la
|
||||
// carte « prochaine leçon » de l'accueil
|
||||
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { LESSON_TEXTS } from "./lesson-texts.js";
|
||||
import type { LessonCardModel } from "./lessons-model.js";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** La pastille ronde au numéro de la leçon : verte une fois terminée,
|
||||
* brique pour la leçon proposée, au contour sinon. */
|
||||
function badge(parent: HTMLElement, card: LessonCardModel): void {
|
||||
const kind = card.state.kind === "done" ? "done" : card.current ? "current" : "todo";
|
||||
const span = element(parent, "span", {
|
||||
className: `lesson-badge ${kind}`,
|
||||
text: String(card.number),
|
||||
});
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
/** La barre de progression : un segment par exercice, en brique une fois
|
||||
* réussi. */
|
||||
function progressBar(parent: HTMLElement, card: LessonCardModel): void {
|
||||
const bar = element(parent, "span", { className: "lesson-progress" });
|
||||
bar.setAttribute("aria-hidden", "true");
|
||||
for (const done of card.exercises) {
|
||||
element(bar, "span", { className: done ? "segment done" : "segment" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Le contenu d'une carte de leçon : pastille, numéro, titre, état et
|
||||
* progression. */
|
||||
export function fillLessonCard(target: HTMLElement, card: LessonCardModel): void {
|
||||
target.replaceChildren();
|
||||
const top = element(target, "span", { className: "lesson-card-top" });
|
||||
badge(top, card);
|
||||
const body = element(top, "span", { className: "lesson-card-body" });
|
||||
element(body, "span", { className: "overline", text: LESSON_TEXTS.number(card.number) });
|
||||
element(body, "strong", { className: "lesson-card-title", text: card.title });
|
||||
element(body, "span", {
|
||||
className: `lesson-card-state ${card.state.kind}`,
|
||||
text: LESSON_TEXTS.state(card.state),
|
||||
});
|
||||
if (card.state.kind !== "toStart" && card.state.total > 0) {
|
||||
progressBar(body, card);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le libellé de l'action d'une leçon. */
|
||||
export function lessonAction(card: LessonCardModel): string {
|
||||
return card.state.kind === "toStart"
|
||||
? LESSON_TEXTS.start
|
||||
: card.state.kind === "done"
|
||||
? LESSON_TEXTS.review
|
||||
: LESSON_TEXTS.continue;
|
||||
}
|
||||
|
||||
export interface LessonsView {
|
||||
/** Les cartes (null : les leçons se chargent ; erreur : échec). */
|
||||
render(cards: readonly LessonCardModel[] | null, failed?: boolean): void;
|
||||
}
|
||||
|
||||
/** Crée la liste des leçons dans section (vide dans index.html). */
|
||||
export function createLessonsView(section: HTMLElement): LessonsView {
|
||||
section.replaceChildren();
|
||||
const head = element(section, "div", { className: "lessons-intro" });
|
||||
element(head, "h2", { text: LESSON_TEXTS.listTitle });
|
||||
element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead });
|
||||
// Le rappel des règles : toute la ligne est le lien, haute de 44 px.
|
||||
const review = element(section, "a", { id: "lessons-rules", className: "lessons-rules" });
|
||||
review.append(`${LESSON_TEXTS.rulesReminder} · `);
|
||||
element(review, "span", { text: LESSON_TEXTS.rulesReview });
|
||||
review.href = "#/tutoriel/1";
|
||||
review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel);
|
||||
const status = element(section, "p", { id: "lessons-status", className: "muted" });
|
||||
status.setAttribute("role", "status");
|
||||
const list = element(section, "ol", { id: "lesson-list", className: "lesson-list" });
|
||||
|
||||
return {
|
||||
render(cards, failed = false) {
|
||||
status.textContent =
|
||||
cards !== null ? "" : failed ? LESSON_TEXTS.listFailed : LESSON_TEXTS.listLoading;
|
||||
list.replaceChildren();
|
||||
for (const card of cards ?? []) {
|
||||
const item = element(list, "li");
|
||||
// La leçon proposée porte la seule action principale ; les
|
||||
// autres cartes sont des liens, toutes restent ouvertes.
|
||||
if (card.current) {
|
||||
const article = element(item, "article", {
|
||||
id: `lesson-card-${card.number}`,
|
||||
className: "card lesson-card current",
|
||||
});
|
||||
const info = element(article, "div");
|
||||
fillLessonCard(info, card);
|
||||
const action = element(article, "a", {
|
||||
className: "button lesson-card-action",
|
||||
text: lessonAction(card),
|
||||
});
|
||||
action.href = card.href;
|
||||
action.setAttribute(
|
||||
"aria-label",
|
||||
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
|
||||
);
|
||||
} else {
|
||||
const link = element(item, "a", {
|
||||
id: `lesson-card-${card.number}`,
|
||||
className: "card lesson-card",
|
||||
});
|
||||
link.href = card.href;
|
||||
fillLessonCard(link, card);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,50 @@
|
||||
// Les textes de l'écran de progression (étape 1h ; ADR 0007, point 5). La
|
||||
// phrase de synthèse, elle, est une phrase du maître : elle vit dans
|
||||
// docs/maitre/phrases.md (sections progression.…, une seule source,
|
||||
// ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameResult } from "../domain/game/state.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
|
||||
|
||||
const RESULT_KIND = {
|
||||
single: "",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
} as const;
|
||||
|
||||
export const PROGRESSION_TEXTS = {
|
||||
title: "Votre progression",
|
||||
loading: "Lecture de vos parties…",
|
||||
failed: "Vos parties n'ont pas pu être lues.",
|
||||
/** Aucune partie terminée. */
|
||||
empty: "Aucune partie terminée pour l'instant : votre progression commencera avec la première.",
|
||||
newGame: "Nouvelle partie",
|
||||
curveTitle: "Votre taux d'erreur · plus bas, c'est mieux",
|
||||
curveLabel: (rates: readonly number[]) =>
|
||||
`Votre taux d'erreur, partie après partie, de la plus ancienne à la plus récente : ${rates.join(", ")}.`,
|
||||
whatIsIt: "Qu'est-ce que le taux d'erreur ?",
|
||||
analysing: (n: number) =>
|
||||
`Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`,
|
||||
exercises: "Demander des exercices à une IA",
|
||||
listTitle: "Vos parties",
|
||||
/** Le résultat court : « Gagnée, 1 point », « Perdue, gammon, 2 points »
|
||||
* (jamais « partie simple »). */
|
||||
result: (result: GameResult | null) => {
|
||||
if (result === null) {
|
||||
return "Partie terminée";
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
const kind = result.kind === "single" ? "" : `${RESULT_KIND[result.kind]}, `;
|
||||
return `${who}, ${kind}${plural(result.points, "point", "points")}`;
|
||||
},
|
||||
level: (name: string) => `contre ${name}`,
|
||||
rateLabel: "taux d'erreur",
|
||||
rateLoading: "en analyse",
|
||||
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
|
||||
/** La date d'une partie : « 3 nov. 2026 ». */
|
||||
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
|
||||
} as const;
|
||||
@@ -0,0 +1,307 @@
|
||||
// L'écran de progression (#/progression, étape 1h ; charte,
|
||||
// ecrans-etape-1.md, « 1h ») : une phrase du maître, la courbe du taux
|
||||
// d'erreur partie après partie (SVG fait main), « Créer des exercices », et
|
||||
// la liste des parties terminées, chacune menant à son bilan et à son
|
||||
// analyse.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La vue ne connaît ni le stockage ni le moteur : l'écran de jeu (app.ts)
|
||||
// lui passe les parties et leur taux, lus dans les analyses rangées
|
||||
// (domain/analysis/progression.ts), et la redessine quand une analyse en
|
||||
// arrière-plan se termine.
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import { progressionSentence } from "../domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
errorRateMillis,
|
||||
type ProgressionEntry,
|
||||
progressionCurve,
|
||||
progressionTrend,
|
||||
} from "../domain/analysis/progression.js";
|
||||
import { computerLevelOf } from "../domain/game/level.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { PROGRESSION_TEXTS } from "./progression-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Ce que montre l'écran. */
|
||||
export interface ProgressionData {
|
||||
/** Les parties terminées, de la plus ancienne à la plus récente. */
|
||||
readonly entries: readonly ProgressionEntry[];
|
||||
/** Les phrases du maître (null : pas encore lues). */
|
||||
readonly phrases: MasterPhrases | null;
|
||||
}
|
||||
|
||||
export interface ProgressionViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ProgressionView {
|
||||
/** Dessine l'écran (null : les parties se lisent ; failed : échec). */
|
||||
render(data: ProgressionData | null, failed?: boolean): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus
|
||||
* des points, le dernier point plus gros). */
|
||||
const CURVE_HEIGHT = 168;
|
||||
const PAD = { left: 12, right: 12, top: 30, bottom: 12 };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attributes: Record<string, string | number>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const child = document.createElementNS(SVG_NS, tag);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
child.setAttribute(name, String(value));
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Le nom du niveau de l'ordinateur d'une partie. */
|
||||
function levelName(entry: ProgressionEntry): string {
|
||||
const level = computerLevelOf(entry.level);
|
||||
return level === null ? entry.level : MATCH_TEXTS.levels[level].name;
|
||||
}
|
||||
|
||||
export function createProgressionView(options: ProgressionViewOptions): ProgressionView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
// Pas de « Retour » (charte 1h) : la barre du haut et le retour du
|
||||
// téléphone suffisent, comme sur la liste des leçons.
|
||||
element(section, "h1", {
|
||||
id: "progression-title",
|
||||
className: "progression-title",
|
||||
text: PROGRESSION_TEXTS.title,
|
||||
});
|
||||
const summary = element(section, "p", {
|
||||
id: "progression-summary",
|
||||
className: "progression-summary",
|
||||
});
|
||||
summary.setAttribute("role", "status");
|
||||
const body = element(section, "div", { className: "progression-body" });
|
||||
// La courbe se redessine à la largeur de la page (rotation).
|
||||
let drawnCurve: (() => void) | null = null;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!section.hidden) {
|
||||
drawnCurve?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Ce qui est dessiné : chaque analyse rangée en arrière-plan redemande
|
||||
// l'écran, qui ne change que quand le taux d'une partie arrive.
|
||||
let drawn = "";
|
||||
|
||||
function render(data: ProgressionData | null, failed = false): void {
|
||||
const key = JSON.stringify(
|
||||
data === null
|
||||
? { failed }
|
||||
: {
|
||||
phrases: data.phrases !== null,
|
||||
entries: data.entries.map((e) => [
|
||||
e.id,
|
||||
e.result,
|
||||
e.level,
|
||||
e.errorRate?.perDecision ?? null,
|
||||
]),
|
||||
},
|
||||
);
|
||||
if (key === drawn) {
|
||||
return;
|
||||
}
|
||||
drawn = key;
|
||||
body.replaceChildren();
|
||||
drawnCurve = null;
|
||||
delete section.dataset.ready;
|
||||
if (data === null) {
|
||||
summary.textContent = failed ? PROGRESSION_TEXTS.failed : PROGRESSION_TEXTS.loading;
|
||||
return;
|
||||
}
|
||||
if (data.entries.length === 0) {
|
||||
summary.textContent = PROGRESSION_TEXTS.empty;
|
||||
const actions = element(body, "div", {
|
||||
className: "progression-actions progression-empty",
|
||||
});
|
||||
const start = element(actions, "a", {
|
||||
id: "progression-new-game",
|
||||
className: "button",
|
||||
text: PROGRESSION_TEXTS.newGame,
|
||||
});
|
||||
start.href = routeHash({ screen: "newGame" });
|
||||
section.dataset.ready = "true";
|
||||
return;
|
||||
}
|
||||
const points = progressionCurve(data.entries);
|
||||
const trend = progressionTrend(points);
|
||||
summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend);
|
||||
|
||||
if (points.length >= CURVE_MIN_GAMES) {
|
||||
const card = element(body, "section", {
|
||||
id: "progression-curve-card",
|
||||
className: "analysis-card",
|
||||
});
|
||||
element(card, "h3", { className: "overline", text: PROGRESSION_TEXTS.curveTitle });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "progression-curve";
|
||||
svg.setAttribute("class", "progression-curve");
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute(
|
||||
"aria-label",
|
||||
PROGRESSION_TEXTS.curveLabel(points.map((p) => errorRateMillis(p.perDecision))),
|
||||
);
|
||||
card.append(svg);
|
||||
const link = element(card, "a", {
|
||||
id: "progression-glossary",
|
||||
className: "link-button",
|
||||
text: PROGRESSION_TEXTS.whatIsIt,
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
|
||||
// Le vert dit « mieux », jamais « dernier » : seulement si le
|
||||
// taux baisse.
|
||||
const better = trend.kind === "down";
|
||||
drawnCurve = () => drawCurve(svg, points, better);
|
||||
drawnCurve();
|
||||
}
|
||||
|
||||
const waiting = data.entries.filter((e) => e.errorRate === null).length;
|
||||
if (waiting > 0) {
|
||||
element(body, "p", {
|
||||
id: "progression-analysing",
|
||||
className: "muted",
|
||||
text: PROGRESSION_TEXTS.analysing(waiting),
|
||||
}).setAttribute("role", "status");
|
||||
}
|
||||
|
||||
// « Demander des exercices à une IA » : la feuille du prompt, étape
|
||||
// 1f ; bouton secondaire avec l'icône de copie.
|
||||
if (points.length > 0) {
|
||||
const actions = element(body, "div", { className: "progression-actions" });
|
||||
const exercises = element(actions, "a", {
|
||||
id: "progression-exercises",
|
||||
className: "button secondary ask-ai",
|
||||
});
|
||||
exercises.href = routeHash({ screen: "progression", ai: true });
|
||||
exercises.append(icon("copy"), PROGRESSION_TEXTS.exercises);
|
||||
}
|
||||
|
||||
element(body, "h3", { className: "overline", text: PROGRESSION_TEXTS.listTitle });
|
||||
const list = element(body, "ol", { id: "progression-list", className: "progression-list" });
|
||||
// La plus récente d'abord, comme « Mes parties ».
|
||||
for (const entry of [...data.entries].reverse()) {
|
||||
list.append(item(entry));
|
||||
}
|
||||
section.dataset.ready = String(waiting === 0);
|
||||
}
|
||||
|
||||
function item(entry: ProgressionEntry): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "progression-item";
|
||||
li.dataset.id = entry.id;
|
||||
const date = PROGRESSION_TEXTS.date(entry.startedAt);
|
||||
const result = PROGRESSION_TEXTS.result(entry.result);
|
||||
// Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis
|
||||
// lui), chevron à droite.
|
||||
const game = element(li, "a", { className: "progression-game" });
|
||||
game.href = routeHash({ screen: "summary", game: entry.id });
|
||||
game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date));
|
||||
const text = element(game, "span", { className: "progression-text" });
|
||||
element(text, "strong", { text: result });
|
||||
element(text, "span", {
|
||||
className: "caption",
|
||||
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
|
||||
});
|
||||
const rate = element(game, "span", { className: "progression-rate" });
|
||||
if (entry.errorRate === null) {
|
||||
rate.classList.add("pending");
|
||||
rate.textContent = PROGRESSION_TEXTS.rateLoading;
|
||||
} else {
|
||||
rate.dataset.rate = String(entry.errorRate.perDecision);
|
||||
element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) });
|
||||
element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel });
|
||||
}
|
||||
game.append(icon("chevron"));
|
||||
return li;
|
||||
}
|
||||
|
||||
return { render };
|
||||
}
|
||||
|
||||
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
|
||||
* à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le
|
||||
* dernier point est plus gros, à l'encre du texte, en verdict-good si le
|
||||
* taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du
|
||||
* premier et du dernier point seulement (charte 1h). */
|
||||
function drawCurve(
|
||||
svg: SVGSVGElement,
|
||||
points: readonly { readonly id: string; readonly perDecision: number }[],
|
||||
better: boolean,
|
||||
): void {
|
||||
const width = Math.round(svg.getBoundingClientRect().width || 360);
|
||||
const height = CURVE_HEIGHT;
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
svg.replaceChildren();
|
||||
const millis = points.map((p) => errorRateMillis(p.perDecision));
|
||||
const top = Math.max(1, ...millis);
|
||||
const inner = width - PAD.left - PAD.right;
|
||||
const step = points.length > 1 ? inner / (points.length - 1) : 0;
|
||||
const x = (i: number) => PAD.left + i * step;
|
||||
const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom);
|
||||
svgElement(svg, "line", {
|
||||
x1: PAD.left,
|
||||
x2: width - PAD.right,
|
||||
y1: y(0),
|
||||
y2: y(0),
|
||||
class: "curve-axis",
|
||||
});
|
||||
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
|
||||
svgElement(svg, "polyline", { points: line, class: "curve-line" });
|
||||
const lastIndex = points.length - 1;
|
||||
points.forEach((point, i) => {
|
||||
const last = i === lastIndex;
|
||||
const cy = y(point.perDecision * 1000);
|
||||
const dot = svgElement(svg, "circle", {
|
||||
cx: x(i),
|
||||
cy,
|
||||
r: last ? 7 : 4,
|
||||
class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot",
|
||||
"data-id": point.id,
|
||||
"data-rate": String(point.perDecision),
|
||||
});
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
if (i === 0 || last) {
|
||||
const value = svgElement(svg, "text", {
|
||||
x: x(i),
|
||||
y: cy - (last ? 14 : 11),
|
||||
class: last
|
||||
? `progression-value last${better ? " better" : ""}`
|
||||
: "progression-value",
|
||||
"text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle",
|
||||
});
|
||||
value.textContent = String(millis[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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,
|
||||
exercisesPrompt,
|
||||
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>;
|
||||
/** La position de la plus coûteuse des erreurs de ces parties (null :
|
||||
* aucune imprécision ni erreur). */
|
||||
readonly exercises: (journals: readonly GameJournal[]) => Promise<PromptPosition | null>;
|
||||
}
|
||||
|
||||
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>;
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h), sur les
|
||||
* parties analysées, de la plus ancienne à la plus récente. */
|
||||
exercises(journals: readonly GameJournal[]): 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 exercises(journals) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.exercises(journals),
|
||||
]);
|
||||
if (position === null) {
|
||||
return { message: ASK_AI_TEXTS.noMistake };
|
||||
}
|
||||
return { text: exercisesPrompt(all, phrases, { journals, 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
|
||||
|
||||
+118
-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 » :
|
||||
@@ -23,23 +24,48 @@ export type Route =
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/lecons : la liste des leçons (étape 1g). */
|
||||
| { readonly screen: "lessons" }
|
||||
/** #/lecons/<n> (son premier écran), une étape de la leçon n :
|
||||
* #/lecons/<n>/<étape>, et son exercice « à vous » :
|
||||
* #/lecons/<n>/<étape>/a-vous. */
|
||||
| {
|
||||
readonly screen: "lesson";
|
||||
readonly lesson: number;
|
||||
readonly step: number | null;
|
||||
readonly practice: boolean;
|
||||
}
|
||||
/** #/lecons/<n>/fin : la fin de la leçon n. */
|
||||
| { readonly screen: "lessonEnd"; readonly lesson: number }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { 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;
|
||||
}
|
||||
/** #/progression : l'historique des parties et la courbe du taux
|
||||
* d'erreur (étape 1h) ; le prompt « Créer des exercices » :
|
||||
* #/progression/ia. */
|
||||
| { readonly screen: "progression"; 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,12 +97,20 @@ 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;
|
||||
}
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "lecons") {
|
||||
return lessonRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "parties" && rest.length > 1) {
|
||||
return gameReviewRoute(rest) ?? unknown;
|
||||
}
|
||||
@@ -100,6 +134,12 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "about" } : unknown;
|
||||
case "menu":
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "progression":
|
||||
return name === null
|
||||
? { screen: "progression" }
|
||||
: name === "ia"
|
||||
? { screen: "progression", ai: true }
|
||||
: unknown;
|
||||
case "nouvelle-partie":
|
||||
return name === null ? { screen: "newGame" } : unknown;
|
||||
case "explication":
|
||||
@@ -113,6 +153,38 @@ 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" | "progression" }
|
||||
>;
|
||||
|
||||
/** 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":
|
||||
case "progression":
|
||||
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;
|
||||
@@ -149,6 +221,29 @@ function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/** #/lecons, #/lecons/<n>, #/lecons/<n>/<étape>, #/lecons/<n>/<étape>/a-vous,
|
||||
* #/lecons/<n>/fin. */
|
||||
function lessonRoute(rest: readonly string[]): Route | null {
|
||||
const [number, name, part, ...more] = rest;
|
||||
if (number === undefined) {
|
||||
return { screen: "lessons" };
|
||||
}
|
||||
if (!/^[1-9]$/.test(number) || more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
const lesson = Number(number);
|
||||
if (name === undefined) {
|
||||
return { screen: "lesson", lesson, step: null, practice: false };
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "lessonEnd", lesson } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
|
||||
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
|
||||
@@ -179,6 +274,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 "#/";
|
||||
@@ -196,6 +296,14 @@ export function routeHash(route: Route): string {
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/fin";
|
||||
case "lessons":
|
||||
return "#/lecons";
|
||||
case "lesson":
|
||||
return route.step === null
|
||||
? `#/lecons/${route.lesson}`
|
||||
: `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "lessonEnd":
|
||||
return `#/lecons/${route.lesson}/fin`;
|
||||
case "install":
|
||||
return "#/installer";
|
||||
case "games":
|
||||
@@ -206,6 +314,8 @@ export function routeHash(route: Route): string {
|
||||
return route.turn === null
|
||||
? `#/parties/${route.game}/analyse`
|
||||
: `#/parties/${route.game}/analyse/${route.turn}`;
|
||||
case "progression":
|
||||
return "#/progression";
|
||||
case "menu":
|
||||
return "#/menu";
|
||||
case "newGame":
|
||||
|
||||
@@ -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 });
|
||||
@@ -222,6 +225,9 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
|
||||
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
|
||||
play.setAttribute("role", "img");
|
||||
// Le taux d'erreur du bilan, que la courbe de la progression (1h)
|
||||
// reprend tel quel (vérifié par tests/browser/progression.mjs).
|
||||
play.dataset.errorRate = String(summary.player.errorRate.perDecision);
|
||||
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
|
||||
const track = element(play, "span", { className: "gauge-track" });
|
||||
element(track, "span", {
|
||||
@@ -299,9 +305,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 +361,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 +369,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"
|
||||
}.`,
|
||||
|
||||
@@ -19,14 +19,19 @@ export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "tutorialEnd") {
|
||||
return "end";
|
||||
}
|
||||
if (route.screen !== "tutorial" || route.step === null) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
const step = tutorial.steps[route.step - 1];
|
||||
return route.screen === "tutorial"
|
||||
? existingPlace(tutorial, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première
|
||||
* étape (étape absente), ou l'explication (« à vous » absent). */
|
||||
function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace {
|
||||
const step = at === null ? undefined : tutorial.steps[at - 1];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
return { step: step.number, practice: practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
@@ -36,6 +41,24 @@ export function placeRoute(place: TutorialPlace): Route {
|
||||
: { screen: "tutorial", step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran d'une leçon que désigne une adresse (#/lecons/<n>/...), ramené à
|
||||
* un écran qui existe, comme placeOf pour le tutoriel. */
|
||||
export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace {
|
||||
if (route.screen === "lessonEnd") {
|
||||
return "end";
|
||||
}
|
||||
return route.screen === "lesson"
|
||||
? existingPlace(lesson, route.step, route.practice)
|
||||
: { step: 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran de la leçon number. */
|
||||
export function lessonPlaceRoute(number: number, place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "lessonEnd", lesson: number }
|
||||
: { screen: "lesson", lesson: number, step: place.step, practice: place.practice };
|
||||
}
|
||||
|
||||
/** L'écran suivant (la fin après la dernière étape). */
|
||||
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
|
||||
if (place === "end") {
|
||||
|
||||
@@ -24,8 +24,11 @@ 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;
|
||||
/** Les comptes de points de la position que cite le texte (vous,
|
||||
* l'ordinateur), vérifiés par les tests ; null s'il n'en cite pas. */
|
||||
readonly pips: readonly [number, number] | null;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
/** « À vous » de videau : la bonne réponse au double. */
|
||||
@@ -90,6 +93,17 @@ function rollOf(text: string | undefined, where: string): readonly [number, numb
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function pipsOf(text: string | undefined, where: string): readonly [number, number] | null {
|
||||
if (text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const match = /^(\d+)\/(\d+)$/.exec(text);
|
||||
if (match === null) {
|
||||
throw new Error(`${where} : compte illisible « ${text} »`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2])];
|
||||
}
|
||||
|
||||
function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
const one = (key: string) => raw.keys.get(key)?.[0];
|
||||
const position = one("position");
|
||||
@@ -110,6 +124,7 @@ function screenOf(raw: RawScreen, where: string): TutorialScreen {
|
||||
roll: rollOf(one("jet"), where),
|
||||
move: one("coup") ?? null,
|
||||
path: one("chemin") === "oui",
|
||||
pips: pipsOf(one("compte"), where),
|
||||
expected: raw.keys.get("attendu") ?? [],
|
||||
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
|
||||
right: parseInline(one("juste") ?? ""),
|
||||
|
||||
+199
-120
@@ -2,11 +2,14 @@
|
||||
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
|
||||
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
|
||||
// Les leçons (#/lecons/<n>/..., étape 1g) reprennent le même écran, avec
|
||||
// leur texte et leurs adresses (Course).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
|
||||
// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies du
|
||||
// site) ; les plateaux,
|
||||
// les coups illustrés et le jugement des gestes viennent du domaine, branché
|
||||
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
|
||||
// écrite ici.
|
||||
@@ -18,11 +21,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,
|
||||
@@ -36,20 +42,8 @@ import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geo
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { type Route, routeHash } from "./router.js";
|
||||
import {
|
||||
nextPlace,
|
||||
placeOf,
|
||||
placeRoute,
|
||||
previousPlace,
|
||||
progress,
|
||||
type TutorialPlace,
|
||||
} from "./tutorial-nav.js";
|
||||
import {
|
||||
type Paragraph,
|
||||
parseTutorial,
|
||||
type Tutorial,
|
||||
type TutorialScreen,
|
||||
} from "./tutorial-text.js";
|
||||
import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
|
||||
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
@@ -58,7 +52,32 @@ const PAUSE_MS = 120;
|
||||
/** Un pion refusé reste un instant à sa place avant de revenir. */
|
||||
const REFUSED_MS = 450;
|
||||
|
||||
const SOURCE = "docs/tutoriel/regles.md";
|
||||
/** Le texte du tutoriel, copié dans le site (make site). */
|
||||
export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md";
|
||||
|
||||
/**
|
||||
* Un texte du site, lu une fois puis gardé : la fonction renvoyée le
|
||||
* télécharge à son premier appel et le relit avec parse. Un échec n'est
|
||||
* pas gardé : l'appel suivant réessaie.
|
||||
*/
|
||||
export function cachedText<T>(source: string, parse: (text: string) => T): () => Promise<T> {
|
||||
let loaded: Promise<T> | null = null;
|
||||
return () => {
|
||||
loaded ??= fetch(source)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${source} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parse)
|
||||
.catch((error: unknown) => {
|
||||
loaded = null;
|
||||
throw error;
|
||||
});
|
||||
return loaded;
|
||||
};
|
||||
}
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
@@ -84,14 +103,53 @@ export interface TutorialAccess {
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape
|
||||
* 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les
|
||||
* distingue : leur texte, leurs adresses, leurs libellés, et ce que retient
|
||||
* la progression.
|
||||
*/
|
||||
export interface Course {
|
||||
/** Préfixe des identifiants des éléments de l'écran (« tutorial »,
|
||||
* « lesson ») : deux parcours peuvent coexister dans la page. */
|
||||
readonly id: string;
|
||||
/** Le texte du parcours que désigne l'adresse. */
|
||||
load(route: Route): Promise<Tutorial>;
|
||||
/** L'écran que désigne l'adresse, ramené à un écran qui existe. */
|
||||
placeOf(text: Tutorial, route: Route): TutorialPlace;
|
||||
/** L'adresse d'un écran du même parcours que route. */
|
||||
routeOf(route: Route, place: TutorialPlace): Route;
|
||||
/** La dernière étape déjà vue : la barre de progression n'en mène pas
|
||||
* plus loin. */
|
||||
seen(route: Route): number;
|
||||
/** L'arrivée sur un écran. */
|
||||
visit(route: Route, place: TutorialPlace): void;
|
||||
/** Un écran « à vous » réussi. */
|
||||
solved(route: Route, place: TutorialPlace): void;
|
||||
/** « Quitter » : où il mène. */
|
||||
quitHref(route: Route): string;
|
||||
/** « Retour » sur le premier écran. */
|
||||
firstBackHref(route: Route): string;
|
||||
/** L'action de l'écran de fin. */
|
||||
endHref(route: Route): string;
|
||||
readonly texts: {
|
||||
readonly progress: string;
|
||||
readonly stepLink: (n: number, title: string) => string;
|
||||
readonly quit: string;
|
||||
readonly quitLabel: string;
|
||||
readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string;
|
||||
readonly endOverline: (text: Tutorial) => string;
|
||||
readonly endTitle: (text: Tutorial) => string;
|
||||
readonly loading: string;
|
||||
readonly failed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TutorialViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly access: TutorialAccess;
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
|
||||
* écran. */
|
||||
readonly seen: () => number;
|
||||
readonly visit: (place: TutorialPlace) => void;
|
||||
readonly course: Course;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
@@ -149,79 +207,99 @@ 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). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
const { section, access, course } = options;
|
||||
const idOf = (name: string) => `${course.id}-${name}`;
|
||||
section.replaceChildren();
|
||||
const head = element(section, "header", { className: "tutorial-head" });
|
||||
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
|
||||
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
|
||||
const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" });
|
||||
bar.setAttribute("aria-label", course.texts.progress);
|
||||
const skip = element(head, "a", { id: idOf("skip"), className: "tutorial-skip" });
|
||||
skip.href = "#/";
|
||||
skip.textContent = HOME_TEXTS.tutorialSkip;
|
||||
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
|
||||
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
|
||||
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
|
||||
skip.textContent = course.texts.quit;
|
||||
skip.setAttribute("aria-label", course.texts.quitLabel);
|
||||
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
|
||||
// Le titre, et « Revoir » à sa droite (en portrait comme en paysage).
|
||||
const titleRow = element(section, "div", { className: "tutorial-title-row" });
|
||||
const title = element(titleRow, "h2", { id: idOf("title"), className: "tutorial-title" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-board";
|
||||
svg.id = idOf("board");
|
||||
svg.setAttribute("class", "board tutorial-board");
|
||||
svg.setAttribute("role", "img");
|
||||
boardBox.append(svg);
|
||||
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
|
||||
const replay = element(titleRow, "button", {
|
||||
id: idOf("replay"),
|
||||
className: "icon-button tutorial-replay",
|
||||
});
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
@@ -229,39 +307,43 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
|
||||
// partie qui défile si l'écran est trop bas (app.css).
|
||||
const body = element(section, "div", { className: "tutorial-body" });
|
||||
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
|
||||
const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" });
|
||||
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
|
||||
const feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
id: idOf("feedback"),
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
id: idOf("drop"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-take",
|
||||
id: idOf("take"),
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.take,
|
||||
});
|
||||
take.type = "button";
|
||||
const nav = element(section, "div", { className: "tutorial-nav" });
|
||||
const back = element(nav, "a", {
|
||||
id: "tutorial-back",
|
||||
id: idOf("back"),
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
id: idOf("next"),
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | null = null;
|
||||
// Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se
|
||||
// charge.
|
||||
let lastText: Tutorial | null = null;
|
||||
// L'adresse affichée.
|
||||
let shownRoute: Route | null = null;
|
||||
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
|
||||
// à l'écran (le joueur est déjà ailleurs).
|
||||
let shown = 0;
|
||||
@@ -285,22 +367,6 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
let selected: Place | null = null;
|
||||
let solved = false;
|
||||
|
||||
const load = () => {
|
||||
tutorial ??= fetch(SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${SOURCE} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(parseTutorial)
|
||||
.catch((error: unknown) => {
|
||||
tutorial = null;
|
||||
throw error;
|
||||
});
|
||||
return tutorial;
|
||||
};
|
||||
|
||||
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
|
||||
function geometry(): Geometry {
|
||||
@@ -359,7 +425,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +505,9 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
if (shownRoute !== null && place !== null) {
|
||||
course.solved(shownRoute, place);
|
||||
}
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
@@ -533,17 +602,21 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
|
||||
const steps = loaded.steps;
|
||||
const total = steps.length;
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
for (const segment of progress(total, at, course.seen(route))) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
|
||||
const label = course.texts.stepLink(segment.step, stepTitle);
|
||||
const item = segment.link
|
||||
? document.createElement("a")
|
||||
: document.createElement("span");
|
||||
item.className = `segment${segment.done ? " done" : ""}`;
|
||||
if (item instanceof HTMLAnchorElement) {
|
||||
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
|
||||
item.href = routeHash(
|
||||
course.routeOf(route, { step: segment.step, practice: false }),
|
||||
);
|
||||
item.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
@@ -556,8 +629,8 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
? course.texts.endOverline(loaded)
|
||||
: course.texts.overline(loaded, at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -586,42 +659,48 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
if (lastText === null) {
|
||||
title.textContent = course.texts.loading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
loaded = await course.load(route);
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
title.textContent = course.texts.failed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
lastText = loaded;
|
||||
const at = course.placeOf(loaded, route);
|
||||
const wanted = routeHash(course.routeOf(route, at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
shownRoute = route;
|
||||
course.visit(route, at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
renderProgress(route, at, loaded);
|
||||
skip.href = course.quitHref(route);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
back.href =
|
||||
before === null
|
||||
? course.firstBackHref(route)
|
||||
: routeHash(course.routeOf(route, before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
overline.textContent = course.texts.endOverline(loaded);
|
||||
title.textContent = course.texts.endTitle(loaded);
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.href = course.endHref(route);
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
@@ -631,7 +710,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
@@ -652,7 +731,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.href = routeHash(course.routeOf(route, after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -35,6 +35,35 @@ test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meil
|
||||
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
|
||||
);
|
||||
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
|
||||
// Le classement de l'analyse, comme gnubg de bureau
|
||||
// (tests/reference/analysis/move-opening-31-error.json) : le meilleur
|
||||
// coup et le coup joué, une erreur, réévalués à 2 plis ; les suivants
|
||||
// restés à 1 pli, puis à 0, écartés par les filtres.
|
||||
assert.deepEqual(analysis.ranking[0], analysis.best);
|
||||
assert.deepEqual(analysis.ranking[1], analysis.played);
|
||||
assert.deepEqual(
|
||||
analysis.ranking.map((move) => move.ply),
|
||||
[2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test("ouverture 3-1 jouée 8/5 6/5 : un seul coup passe le filtre, tout reste à 1 pli", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const analysis = await engine.analyseMove(
|
||||
START_POSITION,
|
||||
centered,
|
||||
[3, 1],
|
||||
"4HPwATCwZ/ABMA",
|
||||
2,
|
||||
);
|
||||
assert.ok(analysis !== null);
|
||||
assert.equal(analysis.rank, 1);
|
||||
assert.deepEqual(analysis.ranking[0], analysis.best);
|
||||
assert.deepEqual(analysis.best, analysis.played);
|
||||
assert.deepEqual(
|
||||
analysis.ranking.slice(0, 5).map((move) => move.ply),
|
||||
[1, 1, 1, 1, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("sans coup possible, il n'y a rien à analyser", async () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
};
|
||||
},
|
||||
analyseMove: async () => null,
|
||||
rankMoves: async () => [],
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { homeModel } from "../src/ui/home-model.js";
|
||||
import { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -45,6 +46,20 @@ test("réglages d'une version précédente : les nouveaux champs prennent leur v
|
||||
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
|
||||
});
|
||||
|
||||
test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => {
|
||||
const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 };
|
||||
// Partie en cours, ou moteur qui se charge : la miniature et le compte.
|
||||
assert.deepEqual(
|
||||
[homeModel(settings, true, false).board, homeModel(settings, true, false).resume],
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(homeModel(settings, null, false).board, true);
|
||||
// Partie finie : le titre et « Nouvelle partie » seulement (recette 1h).
|
||||
const over = homeModel(settings, false, false);
|
||||
assert.equal(over.board, false);
|
||||
assert.equal(over.resume, false);
|
||||
});
|
||||
|
||||
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
|
||||
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
|
||||
// La partie commencée d'office au chargement ne compte pas.
|
||||
|
||||
@@ -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,440 @@
|
||||
// Tests des leçons (étape 1g) : le texte des cinq leçons (docs/lecons/) se
|
||||
// lit ; chaque position est valide pour le vrai moteur ; chaque coup attendu
|
||||
// d'un exercice est le meilleur coup du moteur à 2 plis, avec plus de 0,04
|
||||
// d'avance sur le deuxième, et seul accepté ; chaque coup illustré est aussi
|
||||
// le meilleur coup ; chaque compte de points cité est celui du moteur. Puis
|
||||
// la progression : sa lecture (formats durables, ADR 0007), son
|
||||
// enregistrement, les états affichés et la leçon proposée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { Engine, EngineCube } from "../src/domain/game/engine.js";
|
||||
import type { Roll } from "../src/domain/game/roll.js";
|
||||
import {
|
||||
finishedLessons,
|
||||
LESSON_END,
|
||||
LESSON_PROGRESS_VERSION,
|
||||
type LessonOutline,
|
||||
lessonRecord,
|
||||
lessonState,
|
||||
NO_PROGRESS,
|
||||
nextLesson,
|
||||
readLessonProgress,
|
||||
resumeStep,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../src/domain/learning/lessons.js";
|
||||
import { MovePractice, tutorialBoard } from "../src/domain/learning/tutorial.js";
|
||||
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
|
||||
import { createLessonStore } from "../src/storage/lesson-store.js";
|
||||
import { memoryStorage } from "../src/storage/open-library.js";
|
||||
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../src/ui/lesson-text.js";
|
||||
import { LESSON_TEXTS } from "../src/ui/lesson-texts.js";
|
||||
import { lessonCards, nextLessonCard } from "../src/ui/lessons-model.js";
|
||||
import { lessonPlaceOf, lessonPlaceRoute } from "../src/ui/tutorial-nav.js";
|
||||
import type { TutorialScreen } from "../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const lessons: Lesson[] = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
|
||||
);
|
||||
|
||||
/** Niveau d'analyse des réponses attendues (décision de Pierre). */
|
||||
const PLY = 2;
|
||||
/** Avance minimale du coup attendu sur le deuxième : un exercice n'a
|
||||
* qu'une bonne réponse nette. */
|
||||
const MIN_GAP = 0.04;
|
||||
const CENTERED: EngineCube = { value: 1, owner: "centered" };
|
||||
|
||||
/** Les écrans d'une leçon, avec leur nom pour les messages. */
|
||||
function screens(lesson: Lesson): [string, TutorialScreen][] {
|
||||
return lesson.steps.flatMap((s) => [
|
||||
[`leçon ${lesson.number}, étape ${s.number}`, s.explain] as [string, TutorialScreen],
|
||||
...(s.practice === null
|
||||
? []
|
||||
: [
|
||||
[`leçon ${lesson.number}, étape ${s.number}, à vous`, s.practice] as [
|
||||
string,
|
||||
TutorialScreen,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
const allScreens = () => lessons.flatMap(screens);
|
||||
|
||||
/** Le classement du moteur à 2 plis pour un coup : son rang, et l'écart
|
||||
* avec le meilleur coup suivant (positif : le coup a cette avance). */
|
||||
async function rankOf(
|
||||
engine: Engine,
|
||||
position: string,
|
||||
roll: Roll,
|
||||
notation: string,
|
||||
): Promise<{ rank: number; best: string; gap: number; second: string }> {
|
||||
const legal = await engine.legalMoves(position, roll);
|
||||
const move = legal.find((m) => m.notation === notation);
|
||||
assert.ok(move !== undefined, `${notation} n'est pas un coup légal de ${roll.join("-")}`);
|
||||
const analysis = await engine.analyseMove(position, CENTERED, roll, move.position, PLY);
|
||||
assert.ok(analysis !== null, `${notation} : aucune analyse`);
|
||||
const second = analysis.ranking.find((m) => m.position !== analysis.best.position);
|
||||
assert.ok(second !== undefined, `${notation} : un seul coup possible, rien à choisir`);
|
||||
// Le deuxième coup analysé à son tour : l'analyse le réévalue au même
|
||||
// niveau que le meilleur, ce qui donne l'écart exact.
|
||||
const other = await engine.analyseMove(position, CENTERED, roll, second.position, PLY);
|
||||
assert.ok(other !== null);
|
||||
return {
|
||||
rank: analysis.rank,
|
||||
best: analysis.best.notation,
|
||||
gap: -other.equityLoss,
|
||||
second: second.notation,
|
||||
};
|
||||
}
|
||||
|
||||
test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fin", () => {
|
||||
assert.equal(lessons.length, 5);
|
||||
assert.deepEqual(
|
||||
readdirSync(new URL("docs/lecons/", root))
|
||||
.filter((f) => f.startsWith("lecon-"))
|
||||
.sort(),
|
||||
["lecon-1.md", "lecon-2.md", "lecon-3.md", "lecon-4.md", "lecon-5.md"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
lessons.map((l) => l.title),
|
||||
[
|
||||
"Les ouvertures",
|
||||
"Faire des points",
|
||||
"Frapper ou ne pas frapper",
|
||||
"La course et le compte de points",
|
||||
"Sortir sans se faire frapper",
|
||||
],
|
||||
);
|
||||
for (const lesson of lessons) {
|
||||
const exercises = outlineOf(lesson).exercises.length;
|
||||
assert.ok(
|
||||
exercises >= 2 && exercises <= 4,
|
||||
`leçon ${lesson.number} : ${exercises} exercices`,
|
||||
);
|
||||
assert.ok(lesson.end.action !== "" && lesson.end.paragraphs.length > 0);
|
||||
for (const step of lesson.steps) {
|
||||
assert.ok(
|
||||
step.explain.paragraphs.length > 0,
|
||||
`leçon ${lesson.number}, étape ${step.number} sans texte`,
|
||||
);
|
||||
const practice = step.practice;
|
||||
if (practice !== null) {
|
||||
assert.ok(
|
||||
practice.paragraphs.length > 0,
|
||||
`leçon ${lesson.number} : consigne absente`,
|
||||
);
|
||||
assert.ok(practice.right.length > 0 && practice.wrong.length > 0);
|
||||
assert.equal(practice.expected.length, 1, "un seul coup attendu par exercice");
|
||||
assert.ok(practice.roll !== null);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("le texte des leçons garde le ton du maître et les mots du glossaire", () => {
|
||||
for (const lesson of lessons) {
|
||||
const text = readFileSync(new URL(`docs/lecons/lecon-${lesson.number}.md`, root), "utf8");
|
||||
// Pas de point d'exclamation, pas de « camp » (remplacé par les
|
||||
// jans), pas de reproche.
|
||||
assert.ok(!text.includes("!"), `leçon ${lesson.number} : point d'exclamation`);
|
||||
assert.ok(!/\bcamp\b/i.test(text), `leçon ${lesson.number} : « camp »`);
|
||||
assert.ok(
|
||||
!/vous auriez dû|il fallait|erreur/i.test(text),
|
||||
`leçon ${lesson.number} : reproche`,
|
||||
);
|
||||
assert.ok(text.includes("CC BY-SA 4.0"), `leçon ${lesson.number} : licence`);
|
||||
const inlines = screens(lesson).flatMap(([, s]) => [
|
||||
...s.paragraphs.flat(),
|
||||
...s.right,
|
||||
...s.wrong,
|
||||
]);
|
||||
for (const term of inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]))) {
|
||||
assert.ok(
|
||||
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
|
||||
`fiche ${term}.md absente`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const terms = new Set(
|
||||
lessons
|
||||
.flatMap((l) => screens(l).flatMap(([, s]) => s.paragraphs.flat()))
|
||||
.flatMap((i) => (i.term === undefined ? [] : [i.term])),
|
||||
);
|
||||
for (const term of ["jan-interieur", "jan-exterieur", "point", "blot", "compte-de-points"]) {
|
||||
assert.ok(terms.has(term), `aucun lien vers ${term}.md`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque position des leçons est valide, et ses comptes de points sont ceux du moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
let counts = 0;
|
||||
for (const [name, screen] of allScreens()) {
|
||||
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
|
||||
for (const side of ["player", "computer"] as const) {
|
||||
const s = board.sides[side];
|
||||
assert.equal(
|
||||
s.points.reduce((a, b) => a + b, 0) + s.bar + s.off,
|
||||
15,
|
||||
`${name} : ${side}`,
|
||||
);
|
||||
}
|
||||
assert.equal(board.result, null, `${name} : partie finie`);
|
||||
if (screen.pips !== null) {
|
||||
counts++;
|
||||
assert.deepEqual(
|
||||
[board.sides.player.pipCount, board.sides.computer.pipCount],
|
||||
screen.pips,
|
||||
`${name} : comptes de points`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(counts >= 4, "la leçon 4 cite les comptes de points");
|
||||
});
|
||||
|
||||
test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const report: string[] = [];
|
||||
for (const [name, screen] of allScreens()) {
|
||||
const notation = screen.expected[0];
|
||||
if (notation === undefined || screen.roll === null) {
|
||||
continue;
|
||||
}
|
||||
const ranked = await rankOf(engine, screen.position, screen.roll, notation);
|
||||
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${notation}`);
|
||||
assert.ok(
|
||||
ranked.gap > MIN_GAP,
|
||||
`${name} : ${notation} devant ${ranked.second} de ${ranked.gap.toFixed(3)} seulement`,
|
||||
);
|
||||
report.push(`${name} : ${notation} (+${ranked.gap.toFixed(3)} sur ${ranked.second})`);
|
||||
}
|
||||
assert.equal(report.length, 16);
|
||||
});
|
||||
|
||||
// Couvre aussi le « nettement devant tous les autres » de la leçon 1 (étape
|
||||
// 1) : chaque coup illustré a plus de MIN_GAP d'avance sur le deuxième.
|
||||
test("chaque coup illustré est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
let illustrated = 0;
|
||||
for (const [name, screen] of allScreens()) {
|
||||
if (screen.move === null || screen.roll === null) {
|
||||
continue;
|
||||
}
|
||||
illustrated++;
|
||||
const ranked = await rankOf(engine, screen.position, screen.roll, screen.move);
|
||||
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${screen.move}`);
|
||||
assert.ok(
|
||||
ranked.gap > MIN_GAP,
|
||||
`${name} : ${screen.move} seulement +${ranked.gap.toFixed(3)}`,
|
||||
);
|
||||
}
|
||||
assert.ok(illustrated >= 5);
|
||||
});
|
||||
|
||||
test("chaque exercice accepte le coup du moteur et refuse tous les autres", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of allScreens()) {
|
||||
if (screen.expected.length === 0 || screen.roll === null) {
|
||||
continue;
|
||||
}
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
assert.ok(legal.length > 1, `${name} : rien à refuser`);
|
||||
for (const move of legal) {
|
||||
const practice = await MovePractice.start(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.expected,
|
||||
);
|
||||
let verdict = "continue";
|
||||
for (const halfMove of move.halfMoves) {
|
||||
verdict = await practice.play(halfMove);
|
||||
if (verdict !== "continue") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const right = screen.expected.includes(move.notation);
|
||||
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Progression
|
||||
|
||||
const outlines: LessonOutline[] = [
|
||||
{ number: 1, exercises: [1, 2, 3, 4] },
|
||||
{ number: 2, exercises: [1, 2, 3] },
|
||||
{ number: 3, exercises: [1, 2] },
|
||||
];
|
||||
|
||||
test("la progression enregistrée se relit, même absente, abîmée ou d'une autre version", () => {
|
||||
assert.deepEqual(readLessonProgress(undefined), NO_PROGRESS);
|
||||
assert.deepEqual(readLessonProgress("texte"), NO_PROGRESS);
|
||||
assert.deepEqual(readLessonProgress({ version: 99, lessons: {} }), NO_PROGRESS);
|
||||
assert.deepEqual(readLessonProgress({ version: 1, lessons: null }), NO_PROGRESS);
|
||||
const saved = {
|
||||
version: LESSON_PROGRESS_VERSION,
|
||||
lessons: {
|
||||
1: { last: 3, reached: 3, solved: [2, 1, 2, -1, 1.5] },
|
||||
2: { last: "x", reached: 1, solved: [] },
|
||||
x: { last: 1, reached: 1, solved: [] },
|
||||
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(readLessonProgress(JSON.parse(JSON.stringify(saved))), {
|
||||
version: 1,
|
||||
lessons: {
|
||||
1: { last: 3, reached: 3, solved: [1, 2] },
|
||||
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("les états d'une leçon : à commencer, « 2 exercices sur 4 », terminée", () => {
|
||||
let progress = NO_PROGRESS;
|
||||
const first = outlines[0] as LessonOutline;
|
||||
assert.deepEqual(lessonState(progress, first), { kind: "toStart", total: 4 });
|
||||
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "À commencer · 4 exercices");
|
||||
progress = withVisit(progress, 1, 1);
|
||||
assert.deepEqual(lessonState(progress, first), { kind: "inProgress", solved: 0, total: 4 });
|
||||
progress = withSolved(withSolved(withVisit(progress, 1, 2), 1, 1), 1, 2);
|
||||
progress = withSolved(progress, 1, 2);
|
||||
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "2 exercices sur 4");
|
||||
assert.deepEqual(lessonRecord(progress, 1), { last: 2, reached: 2, solved: [1, 2] });
|
||||
// Revenir en arrière garde l'étape la plus loin vue.
|
||||
progress = withVisit(progress, 1, 1);
|
||||
assert.deepEqual(lessonRecord(progress, 1), { last: 1, reached: 2, solved: [1, 2] });
|
||||
progress = withSolved(withSolved(progress, 1, 3), 1, 4);
|
||||
assert.deepEqual(lessonState(progress, first), { kind: "done", total: 4 });
|
||||
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "Terminée");
|
||||
assert.equal(
|
||||
LESSON_TEXTS.state({ kind: "inProgress", solved: 1, total: 3 }),
|
||||
"1 exercice sur 3",
|
||||
);
|
||||
});
|
||||
|
||||
test("la leçon proposée : la première en cours, sinon la première à commencer", () => {
|
||||
assert.equal(nextLesson(NO_PROGRESS, outlines)?.number, 1);
|
||||
let progress = withVisit(NO_PROGRESS, 2, 1);
|
||||
assert.equal(nextLesson(progress, outlines)?.number, 2);
|
||||
for (const step of [1, 2, 3]) {
|
||||
progress = withSolved(progress, 2, step);
|
||||
}
|
||||
assert.equal(nextLesson(progress, outlines)?.number, 1, "leçon 2 terminée");
|
||||
assert.equal(finishedLessons(progress, outlines), 1);
|
||||
for (const [number, steps] of [
|
||||
[1, [1, 2, 3, 4]],
|
||||
[3, [1, 2]],
|
||||
] as const) {
|
||||
for (const step of steps) {
|
||||
progress = withSolved(progress, number, step);
|
||||
}
|
||||
}
|
||||
assert.equal(nextLesson(progress, outlines), null, "toutes terminées");
|
||||
assert.equal(finishedLessons(progress, outlines), 3);
|
||||
});
|
||||
|
||||
test("« Continuer » reprend là où l'on a quitté la leçon", () => {
|
||||
const first = outlines[0] as LessonOutline;
|
||||
assert.equal(resumeStep(NO_PROGRESS, first), 1);
|
||||
let progress = withVisit(NO_PROGRESS, 1, 3);
|
||||
assert.equal(resumeStep(progress, first), 3);
|
||||
// Après la fin, le premier exercice pas encore réussi.
|
||||
progress = withSolved(withVisit(progress, 1, LESSON_END), 1, 1);
|
||||
assert.equal(resumeStep(progress, first), 2);
|
||||
});
|
||||
|
||||
test("la progression est enregistrée dans le stockage des parties et relue", async () => {
|
||||
const storage = memoryStorage();
|
||||
const store = createLessonStore(createKeyValueBackend(storage));
|
||||
assert.deepEqual(await store.load(), NO_PROGRESS);
|
||||
const progress = withSolved(withVisit(NO_PROGRESS, 3, 2), 3, 2);
|
||||
void store.save(progress);
|
||||
// Les enregistrements passent avant la lecture qui suit.
|
||||
assert.deepEqual(await store.load(), progress);
|
||||
assert.deepEqual(JSON.parse(storage.getItem("ustagammon.lessons") ?? "null"), progress);
|
||||
const again = createLessonStore(createKeyValueBackend(storage));
|
||||
assert.deepEqual(await again.load(), progress);
|
||||
// Une valeur illisible n'empêche rien.
|
||||
storage.setItem("ustagammon.lessons", "{abîmé");
|
||||
assert.deepEqual(await again.load(), NO_PROGRESS);
|
||||
// Un stockage qui refuse l'écriture non plus.
|
||||
const refused = createLessonStore({
|
||||
lessonProgress: async () => {
|
||||
throw new Error("refusé");
|
||||
},
|
||||
saveLessonProgress: async () => {
|
||||
throw new Error("refusé");
|
||||
},
|
||||
});
|
||||
await refused.save(progress);
|
||||
assert.deepEqual(await refused.load(), NO_PROGRESS);
|
||||
});
|
||||
|
||||
test("la liste des leçons et la carte de l'accueil", () => {
|
||||
const empty = lessonCards(lessons, NO_PROGRESS);
|
||||
assert.deepEqual(
|
||||
empty.map((c) => [c.number, c.state.kind, c.current, c.href]),
|
||||
[
|
||||
[1, "toStart", true, "#/lecons/1/1"],
|
||||
[2, "toStart", false, "#/lecons/2/1"],
|
||||
[3, "toStart", false, "#/lecons/3/1"],
|
||||
[4, "toStart", false, "#/lecons/4/1"],
|
||||
[5, "toStart", false, "#/lecons/5/1"],
|
||||
],
|
||||
);
|
||||
let progress = withSolved(withVisit(NO_PROGRESS, 2, 2), 2, 1);
|
||||
const card = nextLessonCard(lessons, progress);
|
||||
assert.equal(card?.number, 2);
|
||||
assert.equal(card?.href, "#/lecons/2/2");
|
||||
assert.deepEqual(card?.exercises, [true, false, false]);
|
||||
assert.equal(
|
||||
LESSON_TEXTS.state(card?.state ?? { kind: "toStart", total: 0 }),
|
||||
"1 exercice sur 3",
|
||||
);
|
||||
for (const lesson of lessons) {
|
||||
for (const step of outlineOf(lesson).exercises) {
|
||||
progress = withSolved(progress, lesson.number, step);
|
||||
}
|
||||
}
|
||||
assert.equal(nextLessonCard(lessons, progress), null);
|
||||
});
|
||||
|
||||
test("les écrans d'une leçon ont leur adresse, ramenée à un écran qui existe", () => {
|
||||
const lesson = lessons[0] as Lesson;
|
||||
assert.deepEqual(
|
||||
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 2, practice: true }),
|
||||
{
|
||||
step: 2,
|
||||
practice: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: null, practice: false }),
|
||||
{
|
||||
step: 1,
|
||||
practice: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 9, practice: false }),
|
||||
{
|
||||
step: 1,
|
||||
practice: false,
|
||||
},
|
||||
);
|
||||
assert.equal(lessonPlaceOf(lesson, { screen: "lessonEnd", lesson: 1 }), "end");
|
||||
assert.deepEqual(lessonPlaceRoute(3, { step: 2, practice: true }), {
|
||||
screen: "lesson",
|
||||
lesson: 3,
|
||||
step: 2,
|
||||
practice: true,
|
||||
});
|
||||
assert.deepEqual(lessonPlaceRoute(3, "end"), { screen: "lessonEnd", lesson: 3 });
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
// Tests de la progression (étape 1h) : le taux d'erreur de chaque partie
|
||||
// terminée est celui du bilan, lu dans les analyses rangées sans rien
|
||||
// recalculer ; la courbe, sa tendance et la phrase du maître ; le prompt
|
||||
// « Créer des exercices » sur plusieurs parties (thèmes des erreurs, niveau
|
||||
// du joueur à partir de 20 décisions cumulées).
|
||||
//
|
||||
// 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,
|
||||
readyForSummary,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { PHRASE_KEYS, progressionSentence } from "../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
errorRateMillis,
|
||||
type FinishedGame,
|
||||
progressionCurve,
|
||||
progressionEntries,
|
||||
progressionTrend,
|
||||
} from "../src/domain/analysis/progression.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import type { GameJournal } from "../src/domain/game/events.js";
|
||||
import { currentLine } from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { replay } from "../src/domain/game/state.js";
|
||||
import {
|
||||
exercisesPosition,
|
||||
exercisesPrompt,
|
||||
LEVEL_MIN_DECISIONS,
|
||||
LEVEL_NAMES,
|
||||
mistakeThemes,
|
||||
} from "../src/domain/prompts/prompts.js";
|
||||
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
|
||||
import { parseRoute, routeHash } from "../src/ui/router.js";
|
||||
import { loadPhrases } 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();
|
||||
const point = (id: string, perDecision: number) => ({ id, perDecision });
|
||||
|
||||
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 (le joueur prend le choice-ième
|
||||
* coup légal), non analysée. */
|
||||
async function race(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 game.journal;
|
||||
}
|
||||
|
||||
function finished(id: string, startedAt: number, journal: GameJournal): FinishedGame {
|
||||
return {
|
||||
id,
|
||||
startedAt,
|
||||
level: "intermediate",
|
||||
result: replay(currentLine(journal)).result,
|
||||
journal,
|
||||
};
|
||||
}
|
||||
|
||||
test("l'adresse #/progression et celle de « Créer des exercices »", () => {
|
||||
assert.deepEqual(parseRoute("#/progression"), { screen: "progression" });
|
||||
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
|
||||
});
|
||||
|
||||
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
|
||||
assert.equal(errorRateMillis(0.0142), 14);
|
||||
assert.equal(errorRateMillis(0.0075), 8);
|
||||
assert.equal(errorRateMillis(0), 0);
|
||||
});
|
||||
|
||||
test("la tendance : moyennes des deux moitiés, arrondies en millièmes", () => {
|
||||
assert.deepEqual(progressionTrend([]), { kind: "waiting" });
|
||||
assert.deepEqual(progressionTrend([point("a", 0.02), point("b", 0.01)]), { kind: "waiting" });
|
||||
assert.equal(CURVE_MIN_GAMES, 3);
|
||||
// Trois parties : la première contre la dernière, celle du milieu ne
|
||||
// compte pas.
|
||||
assert.deepEqual(progressionTrend([point("a", 0.014), point("b", 0.05), point("c", 0.007)]), {
|
||||
kind: "down",
|
||||
games: 3,
|
||||
from: 14,
|
||||
to: 7,
|
||||
});
|
||||
assert.deepEqual(
|
||||
progressionTrend([
|
||||
point("a", 0.01),
|
||||
point("b", 0.012),
|
||||
point("c", 0.02),
|
||||
point("d", 0.016),
|
||||
]),
|
||||
{ kind: "up", games: 4, from: 11, to: 18 },
|
||||
);
|
||||
// 0,0101 et 0,0104 s'écrivent tous deux 10 : stable.
|
||||
assert.equal(
|
||||
progressionTrend([point("a", 0.0101), point("b", 0.03), point("c", 0.0104)]).kind,
|
||||
"steady",
|
||||
);
|
||||
});
|
||||
|
||||
test("la phrase du maître : des faits, sans point d'exclamation, sous 140 caractères", () => {
|
||||
for (const key of ["progression.baisse", "progression.hausse", "progression.stable"]) {
|
||||
assert.ok(PHRASE_KEYS.includes(key), key);
|
||||
}
|
||||
assert.equal(
|
||||
progressionSentence(phrases, { kind: "down", games: 8, from: 14, to: 7 }),
|
||||
"Sur vos 8 dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7.",
|
||||
);
|
||||
assert.equal(
|
||||
progressionSentence(phrases, { kind: "waiting" }),
|
||||
"Votre courbe apparaîtra après trois parties.",
|
||||
);
|
||||
for (const trend of [
|
||||
{ kind: "down", games: 20, from: 140, to: 70 },
|
||||
{ kind: "up", games: 20, from: 70, to: 140 },
|
||||
{ kind: "steady", games: 20, from: 12, to: 12 },
|
||||
] as const) {
|
||||
const sentence = progressionSentence(phrases, trend);
|
||||
assert.ok(sentence.length < 140, sentence);
|
||||
assert.ok(!sentence.includes("!"), sentence);
|
||||
assert.ok(sentence.includes(String(trend.to)), sentence);
|
||||
}
|
||||
assert.ok(
|
||||
progressionSentence(phrases, { kind: "up", games: 5, from: 7, to: 9 }).includes(
|
||||
"Les leçons sont là",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("la progression lit le taux du bilan, sans rien recalculer ; une partie non analysée attend", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const raw = [await race(engine, 3, 3), await race(engine, 7, 2), await race(engine, 11, 1)];
|
||||
const analysed = await Promise.all(raw.map((j) => analyseAll(engine, j)));
|
||||
// Données dans le désordre : la progression les range par date.
|
||||
const games = [
|
||||
finished("c", 3000, analysed[2] as GameJournal),
|
||||
finished("a", 1000, analysed[0] as GameJournal),
|
||||
finished("b", 2000, analysed[1] as GameJournal),
|
||||
finished("d", 4000, raw[0] as GameJournal),
|
||||
];
|
||||
const entries = progressionEntries(games);
|
||||
assert.deepEqual(
|
||||
entries.map((e) => e.id),
|
||||
["a", "b", "c", "d"],
|
||||
);
|
||||
for (const [i, entry] of entries.slice(0, 3).entries()) {
|
||||
assert.deepEqual(
|
||||
entry.errorRate,
|
||||
summarizeAnalysis(analysed[i] as GameJournal).player.errorRate,
|
||||
);
|
||||
}
|
||||
assert.equal(readyForSummary(raw[0] as GameJournal), false);
|
||||
assert.equal(entries[3]?.errorRate, null, "partie non analysée : pas de taux");
|
||||
const curve = progressionCurve(entries);
|
||||
assert.deepEqual(
|
||||
curve.map((p) => p.id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
assert.equal(progressionTrend(curve).kind === "waiting", false);
|
||||
});
|
||||
|
||||
test("« Créer des exercices » : erreurs, thèmes comptés sur les faits du moteur, niveau à 20 décisions cumulées", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const template = parsePromptTemplate(
|
||||
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
|
||||
);
|
||||
const templates = { move: "", cube: "", summary: "", concept: "", exercises: template };
|
||||
const journals: GameJournal[] = [];
|
||||
for (const [seed, choice] of [
|
||||
[3, 3],
|
||||
[7, 2],
|
||||
[11, 1],
|
||||
[5, 4],
|
||||
[9, 3],
|
||||
] as const) {
|
||||
journals.push(await analyseAll(engine, await race(engine, seed, choice)));
|
||||
}
|
||||
const decisions = journals.map((j) => summarizeAnalysis(j).player.errorRate.decisions);
|
||||
assert.ok((decisions[0] ?? 0) < LEVEL_MIN_DECISIONS, `décisions : ${decisions.join()}`);
|
||||
let cumulated = 0;
|
||||
for (let n = 1; n <= journals.length; n++) {
|
||||
const some = journals.slice(0, n);
|
||||
cumulated += decisions[n - 1] ?? 0;
|
||||
const position = await exercisesPosition(engine, some);
|
||||
if (position === null) {
|
||||
continue;
|
||||
}
|
||||
const prompt = exercisesPrompt(templates, phrases, { journals: some, position });
|
||||
assert.ok(!/[{}]/.test(prompt), prompt);
|
||||
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
|
||||
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
|
||||
if (cumulated >= LEVEL_MIN_DECISIONS) {
|
||||
assert.match(prompt, /Je suis un joueur de niveau /);
|
||||
assert.ok(Object.values(LEVEL_NAMES).some((name) => prompt.includes(name)));
|
||||
} else {
|
||||
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
|
||||
}
|
||||
}
|
||||
assert.ok(cumulated >= LEVEL_MIN_DECISIONS, `décisions cumulées : ${cumulated}`);
|
||||
});
|
||||
|
||||
test("les thèmes des erreurs : faits du moteur seulement, une ligne par fait présent", () => {
|
||||
const facts = (hits: number, madePoints: number[]) => ({ hits, madePoints });
|
||||
const move = (best: ReturnType<typeof facts>, played: ReturnType<typeof facts>) => ({
|
||||
advice: { kind: "move", bestFacts: best, playedFacts: played } as never,
|
||||
});
|
||||
const cube = { advice: { kind: "cube" } as never };
|
||||
assert.deepEqual(
|
||||
mistakeThemes([
|
||||
move(facts(1, []), facts(0, [])),
|
||||
move(facts(0, [5]), facts(0, [])),
|
||||
move(facts(0, []), facts(1, [])),
|
||||
cube,
|
||||
]),
|
||||
[
|
||||
"- 3 coups et 1 décision de videau",
|
||||
"- 1 fois, le coup de GNU Backgammon frappait un pion et le mien non",
|
||||
"- 1 fois, le coup de GNU Backgammon faisait un point que le mien ne faisait pas",
|
||||
"- 1 fois, mon coup frappait un pion et celui de GNU Backgammon non",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(mistakeThemes([cube]), ["- 0 coup et 1 décision de videau"]);
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
// 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.match(prompt, /\(analyse à [12] plis?\)/);
|
||||
}
|
||||
});
|
||||
|
||||
/** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau :
|
||||
* à l'arrondi près, plus la tolérance des positions de référence (0,002 ;
|
||||
* tests/reference/README.md), le moteur WebAssembly ne calculant pas au
|
||||
* bit près comme gnubg de bureau (ADR 0005). */
|
||||
function assertNear(text: string, value: number, message: string): void {
|
||||
const written = Number(text.replace("−", "-").replace("+", "").replace(",", "."));
|
||||
assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`);
|
||||
}
|
||||
|
||||
/** Le niveau du titre du classement, et chaque ligne du classement : coup,
|
||||
* équité, écart, niveau propre (null : celui du titre). */
|
||||
function rankingOfPrompt(prompt: string) {
|
||||
const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt);
|
||||
assert.ok(title, prompt);
|
||||
const lines = [
|
||||
...prompt.matchAll(
|
||||
/^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm,
|
||||
),
|
||||
].map((m) => ({
|
||||
rank: Number(m[1]),
|
||||
notation: m[2],
|
||||
mine: m[3] !== undefined,
|
||||
equity: m[4],
|
||||
loss: m[5],
|
||||
ply: m[6] === undefined ? null : Number(m[6]),
|
||||
}));
|
||||
return { ply: Number(title[1]), lines };
|
||||
}
|
||||
|
||||
test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/).
|
||||
const reference = (id: string) =>
|
||||
JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8"))
|
||||
.plies["2"];
|
||||
for (const [dice, move, id] of [
|
||||
// Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ;
|
||||
// les autres restent au niveau où ses filtres les ont laissés.
|
||||
[[3, 1], "24/23 13/10", "move-opening-31-error"],
|
||||
// Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg
|
||||
// s'arrête là, tout le classement est à 1 pli.
|
||||
[[3, 1], "8/5 6/5", "move-opening-31-best"],
|
||||
[[6, 5], "13/8 13/7", "move-opening-65-error"],
|
||||
] as const) {
|
||||
const played = await playOpening(engine, dice, move);
|
||||
const advice = await adviceOn(engine, played);
|
||||
assert.ok(advice.kind === "move");
|
||||
played.game.recordAdvice(advice);
|
||||
const position = await positionBefore(engine, played.game.journal, played.eventId);
|
||||
const prompt = decisionPrompt(templates, phrases, {
|
||||
journal: played.game.journal,
|
||||
advice,
|
||||
position,
|
||||
});
|
||||
const { ply, lines } = rankingOfPrompt(prompt);
|
||||
const expected = reference(id);
|
||||
|
||||
// Le titre dit le niveau du meilleur coup, celui que gnubg de bureau
|
||||
// donne ; une ligne sans niveau propre est à ce niveau, une ligne
|
||||
// avec un niveau propre, à un niveau inférieur.
|
||||
assert.equal(ply, expected.ranking[0].ply, prompt);
|
||||
assert.equal(ply, advice.best.ply);
|
||||
assert.ok(lines.length >= 3, prompt);
|
||||
for (const line of lines) {
|
||||
const reference = expected.ranking.find(
|
||||
(m: { move: string }) => m.move === line.notation,
|
||||
);
|
||||
assert.ok(reference, `${line.notation} absent du classement de gnubg`);
|
||||
assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`);
|
||||
assert.ok(line.ply === null || line.ply < ply, prompt);
|
||||
assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`);
|
||||
}
|
||||
|
||||
// Les équités et l'écart du meilleur coup et du vôtre sont ceux de
|
||||
// l'analyse, que disent la feuille du maître et l'écran d'analyse.
|
||||
const best = lines[0];
|
||||
const mine = lines.find((line) => line.mine);
|
||||
assert.ok(best && mine, prompt);
|
||||
assert.equal(best.notation, advice.best.notation);
|
||||
assert.equal(best.equity, formatEquity(advice.best.equity));
|
||||
assert.equal(best.loss, formatEquity(0));
|
||||
assert.equal(mine.notation, advice.played.notation);
|
||||
assert.equal(mine.rank, advice.rank);
|
||||
assert.equal(mine.equity, formatEquity(advice.played.equity));
|
||||
assert.equal(mine.loss, formatEquity(advice.equityLoss));
|
||||
assertNear(mine.loss, expected.equity_loss, prompt);
|
||||
assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`));
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
@@ -32,15 +34,36 @@ const TABLE: [string, Route][] = [
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
|
||||
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
|
||||
["#/tutoriel/fin", { screen: "tutorialEnd" }],
|
||||
// Les leçons (amendement du 2026-11-12, 1g).
|
||||
["#/lecons", { screen: "lessons" }],
|
||||
["#/lecons/2", { screen: "lesson", lesson: 2, step: null, practice: false }],
|
||||
["#/lecons/2/3", { screen: "lesson", lesson: 2, step: 3, practice: false }],
|
||||
["#/lecons/2/3/a-vous", { screen: "lesson", lesson: 2, step: 3, practice: true }],
|
||||
["#/lecons/2/fin", { screen: "lessonEnd", lesson: 2 }],
|
||||
["#/installer", { screen: "install" }],
|
||||
["#/parties", { screen: "games", game: null }],
|
||||
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
|
||||
["#/parties/mh2k3x-a1b2c3/bilan", { screen: "summary", game: "mh2k3x-a1b2c3" }],
|
||||
["#/parties/mh2k3x-a1b2c3/analyse", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: null }],
|
||||
["#/parties/mh2k3x-a1b2c3/analyse/7", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7 }],
|
||||
// La progression (étape 1h) et son prompt « Créer des exercices ».
|
||||
["#/progression", { screen: "progression" }],
|
||||
["#/progression/ia", { screen: "progression", ai: true }],
|
||||
["#/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 +78,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" });
|
||||
@@ -74,6 +108,14 @@ test("une adresse inconnue est reconnue comme telle", () => {
|
||||
"#/tutoriel/3/b",
|
||||
"#/tutoriel/3/a-vous/x",
|
||||
"#/tutoriel/fin/a-vous",
|
||||
"#/lecons/0",
|
||||
"#/lecons/12",
|
||||
"#/lecons/x",
|
||||
"#/lecons/2/0",
|
||||
"#/lecons/2/x",
|
||||
"#/lecons/2/3/b",
|
||||
"#/lecons/2/3/a-vous/x",
|
||||
"#/lecons/2/fin/a-vous",
|
||||
"#/installer/x",
|
||||
"#/a-propos/x",
|
||||
"#/position",
|
||||
@@ -84,6 +126,12 @@ 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",
|
||||
"#/progression/x",
|
||||
"#/progression/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,243 @@
|
||||
// Étapes du scénario docs/scenarios/suivre-ma-progression.feature (ADR
|
||||
// 0008) : des courses courtes jouées par le domaine sur le vrai moteur, avec
|
||||
// des dés reproductibles, enregistrées dans le stockage des parties (sur un
|
||||
// localStorage simulé), analysées par l'analyse en arrière-plan de
|
||||
// l'interface (review-runner.ts, sans DOM) ; l'écran de progression lit
|
||||
// ensuite le stockage comme app.ts (domain/analysis/progression.ts).
|
||||
//
|
||||
// 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 { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import {
|
||||
readyForSummary,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import { progressionSentence } from "../../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
CURVE_MIN_GAMES,
|
||||
type FinishedGame,
|
||||
type ProgressionEntry,
|
||||
progressionCurve,
|
||||
progressionEntries,
|
||||
progressionTrend,
|
||||
} from "../../src/domain/analysis/progression.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 { levelOf } from "../../src/domain/game/saved-game.js";
|
||||
import {
|
||||
exercisesPosition,
|
||||
exercisesPrompt,
|
||||
LEVEL_MIN_DECISIONS,
|
||||
} from "../../src/domain/prompts/prompts.js";
|
||||
import { PROMPT_FILES, parsePromptTemplate } from "../../src/domain/prompts/templates.js";
|
||||
import type { GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
|
||||
import { createReviewRunner } from "../../src/ui/review-runner.js";
|
||||
import { loadPhrases } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
const phrases = loadPhrases();
|
||||
/** Les courses jouées : graine des dés et choix du joueur. */
|
||||
const RACES = [
|
||||
[3, 3],
|
||||
[7, 2],
|
||||
[11, 1],
|
||||
] as const;
|
||||
|
||||
let engine: Engine;
|
||||
let library: GameLibrary;
|
||||
/** Les identifiants des parties enregistrées, dans l'ordre où elles ont
|
||||
* été jouées. */
|
||||
let ids: string[];
|
||||
let entries: ProgressionEntry[];
|
||||
/** Les analyses demandées au moteur, par partie. */
|
||||
let analyses: Map<string, number>;
|
||||
let prompt: string;
|
||||
let clock: number;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Before(async () => {
|
||||
clock = 1_000_000;
|
||||
library = (await openGameLibrary({ localStorage: memoryStorage() }, { now: () => clock }))
|
||||
.library;
|
||||
ids = [];
|
||||
analyses = new Map();
|
||||
});
|
||||
|
||||
/** Analyse en arrière-plan ce qui manque à la partie id, comme
|
||||
* l'application, et l'enregistre. */
|
||||
async function analyse(id: string): Promise<void> {
|
||||
const saved = await library.load(id);
|
||||
assert.ok(saved !== null);
|
||||
const runner = createReviewRunner((journal, task) => {
|
||||
analyses.set(id, (analyses.get(id) ?? 0) + 1);
|
||||
return runAnalysis(engine, journal, task);
|
||||
});
|
||||
let latest = saved.journal;
|
||||
await new Promise<void>((resolve) => {
|
||||
runner.start(id, saved.journal, (j) => (latest = j), resolve);
|
||||
});
|
||||
await library.saveAnalyses(id, latest, { level: levelOf(latest) });
|
||||
}
|
||||
|
||||
/** Ce que lit l'écran de progression : les parties terminées et leur
|
||||
* journal enregistré. */
|
||||
async function openProgression(): Promise<void> {
|
||||
const games: FinishedGame[] = [];
|
||||
for (const summary of await library.list()) {
|
||||
const saved = await library.load(summary.id);
|
||||
if (summary.status === "over" && saved !== null) {
|
||||
games.push({ ...summary, journal: saved.journal });
|
||||
}
|
||||
}
|
||||
entries = progressionEntries(games);
|
||||
}
|
||||
|
||||
Given("vous avez terminé {word} parties contre l'ordinateur", async (count: string) => {
|
||||
const n = { deux: 2, trois: 3 }[count] ?? Number(count);
|
||||
for (const [seed, choice] of RACES.slice(0, n)) {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "intermediate",
|
||||
dice: seededDice(seed),
|
||||
});
|
||||
await playToTheEnd(game, engine, choice);
|
||||
clock += 60_000;
|
||||
const summary = await library.save(`partie-${seed}`, game.journal, {
|
||||
level: "intermediate",
|
||||
});
|
||||
assert.equal(summary.status, "over");
|
||||
ids.push(summary.id);
|
||||
}
|
||||
});
|
||||
|
||||
Given("le maître les a analysées", async () => {
|
||||
for (const id of ids) {
|
||||
await analyse(id);
|
||||
}
|
||||
});
|
||||
|
||||
Given("le maître n'a analysé que les deux premières", async () => {
|
||||
for (const id of ids.slice(0, 2)) {
|
||||
await analyse(id);
|
||||
}
|
||||
analyses.clear();
|
||||
});
|
||||
|
||||
When("vous ouvrez votre progression", async () => {
|
||||
await openProgression();
|
||||
});
|
||||
|
||||
Then("la courbe a trois points, chacun égal au taux d'erreur du bilan de sa partie", async () => {
|
||||
const curve = progressionCurve(entries);
|
||||
assert.equal(curve.length, 3);
|
||||
// De la plus ancienne partie à la plus récente.
|
||||
assert.deepEqual(
|
||||
curve.map((p) => p.id),
|
||||
ids,
|
||||
);
|
||||
for (const point of curve) {
|
||||
const saved = await library.load(point.id);
|
||||
assert.ok(saved !== null && readyForSummary(saved.journal));
|
||||
assert.equal(
|
||||
point.perDecision,
|
||||
summarizeAnalysis(saved.journal).player.errorRate.perDecision,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la phrase du maître compare vos premières parties aux dernières", () => {
|
||||
const trend = progressionTrend(progressionCurve(entries));
|
||||
assert.notEqual(trend.kind, "waiting");
|
||||
const sentence = progressionSentence(phrases, trend);
|
||||
assert.ok(sentence.startsWith("Sur vos 3 dernières parties"), sentence);
|
||||
assert.ok(!sentence.includes("!"), sentence);
|
||||
});
|
||||
|
||||
Then("il n'y a pas encore de courbe", () => {
|
||||
assert.equal(entries.length, 2);
|
||||
assert.ok(
|
||||
entries.every((e) => e.errorRate !== null),
|
||||
"les deux parties ont leur taux",
|
||||
);
|
||||
assert.ok(progressionCurve(entries).length < CURVE_MIN_GAMES);
|
||||
});
|
||||
|
||||
Then(/^la phrase de votre progression est « (.+) »$/, (sentence: string) => {
|
||||
assert.equal(
|
||||
progressionSentence(phrases, progressionTrend(progressionCurve(entries))),
|
||||
sentence,
|
||||
);
|
||||
});
|
||||
|
||||
Then("la troisième partie attend son analyse, sans taux d'erreur", () => {
|
||||
assert.equal(entries.length, 3);
|
||||
assert.equal(entries[2]?.id, ids[2]);
|
||||
assert.equal(entries[2]?.errorRate, null);
|
||||
assert.equal(progressionCurve(entries).length, 2);
|
||||
});
|
||||
|
||||
When("son analyse en arrière-plan est finie", async () => {
|
||||
// Comme l'écran : les parties qui en ont besoin, une à une.
|
||||
for (const entry of entries) {
|
||||
if (entry.errorRate === null) {
|
||||
await analyse(entry.id);
|
||||
}
|
||||
}
|
||||
await openProgression();
|
||||
});
|
||||
|
||||
Then("les deux premières parties n'ont pas été analysées de nouveau", () => {
|
||||
assert.deepEqual([...analyses.keys()], [ids[2]]);
|
||||
});
|
||||
|
||||
When("vous demandez des exercices", async () => {
|
||||
await openProgression();
|
||||
const journals: GameJournal[] = [];
|
||||
for (const entry of entries) {
|
||||
const saved = await library.load(entry.id);
|
||||
assert.ok(saved !== null);
|
||||
journals.push(saved.journal);
|
||||
}
|
||||
const position = await exercisesPosition(engine, journals);
|
||||
assert.ok(position !== null, "aucune erreur dans ces parties");
|
||||
const exercises = parsePromptTemplate(
|
||||
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
|
||||
);
|
||||
prompt = exercisesPrompt({ move: "", cube: "", summary: "", concept: "", exercises }, phrases, {
|
||||
journals,
|
||||
position,
|
||||
});
|
||||
});
|
||||
|
||||
Then("la question cite vos erreurs avec leur coût et ce qui y revient", () => {
|
||||
assert.match(prompt, /- Partie \d, coup \d+.*écart −\d,\d{3}/);
|
||||
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
|
||||
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
|
||||
assert.ok(prompt.includes("Voici mes 3 dernières parties"), prompt);
|
||||
});
|
||||
|
||||
Then(
|
||||
"elle ne donne votre niveau que si vos parties comptent au moins {int} décisions",
|
||||
(min: number) => {
|
||||
assert.equal(min, LEVEL_MIN_DECISIONS);
|
||||
const decisions = entries.reduce((sum, e) => sum + (e.errorRate?.decisions ?? 0), 0);
|
||||
if (decisions >= min) {
|
||||
assert.match(prompt, /Je suis un joueur de niveau /);
|
||||
} else {
|
||||
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
// Étapes du scénario docs/scenarios/suivre-une-lecon.feature (ADR 0008) :
|
||||
// le texte des leçons est lu dans sa source (docs/lecons/), chaque geste est
|
||||
// jugé par le domaine branché sur le vrai moteur, et la progression passe
|
||||
// par le stockage des parties, 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, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import {
|
||||
type LessonProgress,
|
||||
lessonState,
|
||||
NO_PROGRESS,
|
||||
withSolved,
|
||||
withVisit,
|
||||
} from "../../src/domain/learning/lessons.js";
|
||||
import {
|
||||
MovePractice,
|
||||
type PracticeVerdict,
|
||||
tutorialBoard,
|
||||
} from "../../src/domain/learning/tutorial.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
} from "../../src/storage/key-value-backend.js";
|
||||
import { createLessonStore, type LessonStore } from "../../src/storage/lesson-store.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../../src/ui/lesson-text.js";
|
||||
import { LESSON_TEXTS } from "../../src/ui/lesson-texts.js";
|
||||
import { lessonCards, nextLessonCard } from "../../src/ui/lessons-model.js";
|
||||
import type { TutorialScreen } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let lessons: Lesson[];
|
||||
let storage: KeyValueStorage;
|
||||
let store: LessonStore;
|
||||
let progress: LessonProgress = NO_PROGRESS;
|
||||
let screen: TutorialScreen;
|
||||
let practice: MovePractice | null = null;
|
||||
let verdict: PracticeVerdict = "continue";
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
lessons = Array.from({ length: LESSON_COUNT }, (_, i) =>
|
||||
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
|
||||
);
|
||||
});
|
||||
|
||||
function lesson(n: number): Lesson {
|
||||
const found = lessons[n - 1];
|
||||
assert.ok(found !== undefined, `leçon ${n} absente`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Enregistre la progression, comme l'application à chaque écran. */
|
||||
async function save(next: LessonProgress): Promise<void> {
|
||||
progress = next;
|
||||
await store.save(progress);
|
||||
}
|
||||
|
||||
Given("vous ouvrez les leçons pour la première fois", async () => {
|
||||
storage = memoryStorage();
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = await store.load();
|
||||
assert.deepEqual(progress, NO_PROGRESS);
|
||||
});
|
||||
|
||||
Then("il y en a cinq, chacune avec deux à quatre exercices", () => {
|
||||
assert.equal(lessons.length, 5);
|
||||
for (const card of lessonCards(lessons, progress)) {
|
||||
assert.equal(card.state.kind, "toStart");
|
||||
assert.ok(card.state.total >= 2 && card.state.total <= 4);
|
||||
}
|
||||
});
|
||||
|
||||
Then("chaque position des leçons se lit sur le plateau", async () => {
|
||||
for (const current of lessons.flatMap((l) => l.steps.flatMap((s) => [s.explain, s.practice]))) {
|
||||
if (current !== null) {
|
||||
const board = await tutorialBoard(engine, current.position, current.onRoll);
|
||||
assert.equal(board.sides.player.points.length, 24);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose la leçon {int}", (n: number) => {
|
||||
assert.equal(nextLessonCard(lessons, progress)?.number, n);
|
||||
});
|
||||
|
||||
When("vous réussissez le premier exercice de la leçon {int}", async (n: number) => {
|
||||
const first = outlineOf(lesson(n)).exercises[0];
|
||||
assert.ok(first !== undefined);
|
||||
await save(withSolved(withVisit(progress, n, first), n, first));
|
||||
});
|
||||
|
||||
Then(
|
||||
"la leçon {int} en est à « {int} exercice sur {int} »",
|
||||
(n: number, solved: number, total: number) => {
|
||||
const state = lessonState(progress, outlineOf(lesson(n)));
|
||||
assert.deepEqual(state, { kind: "inProgress", solved, total });
|
||||
assert.equal(LESSON_TEXTS.state(state), `${solved} exercice sur ${total}`);
|
||||
},
|
||||
);
|
||||
|
||||
Then("l'accueil vous propose de continuer la leçon {int}", (n: number) => {
|
||||
const card = nextLessonCard(lessons, progress);
|
||||
assert.equal(card?.number, n);
|
||||
assert.equal(card?.state.kind, "inProgress");
|
||||
});
|
||||
|
||||
Given("vous êtes au premier exercice de la leçon {int}", async (n: number) => {
|
||||
const step = lesson(n).steps.find((s) => s.practice !== null);
|
||||
assert.ok(step?.practice != null && step.practice.roll !== null);
|
||||
screen = step.practice;
|
||||
practice = await MovePractice.start(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll as [number, number],
|
||||
screen.expected,
|
||||
);
|
||||
verdict = "continue";
|
||||
});
|
||||
|
||||
When(
|
||||
"vous avancez un pion de la flèche {int} à la flèche {int}",
|
||||
async (from: number, to: number) => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from, to });
|
||||
if (verdict === "wrong") {
|
||||
practice.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Then("le pion revient, et le maître vous met sur la voie", () => {
|
||||
assert.equal(verdict, "wrong");
|
||||
assert.deepEqual(practice?.pendingHalfMoves, []);
|
||||
assert.match(screen.wrong.map((part) => part.text).join(""), /^Ce coup est permis, mais/);
|
||||
});
|
||||
|
||||
When("vous faites le point {int}", async (point: number) => {
|
||||
assert.ok(practice !== null && screen.roll !== null);
|
||||
// Le coup du maître de cet exercice, demi-coup par demi-coup : il
|
||||
// amène bien deux pions sur la flèche demandée.
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
const move = legal.find((m) => m.notation === screen.expected[0]);
|
||||
assert.ok(move !== undefined);
|
||||
assert.equal(move.halfMoves.filter((h) => h.to === point).length, 2);
|
||||
for (const halfMove of move.halfMoves) {
|
||||
verdict = await practice.play(halfMove);
|
||||
}
|
||||
});
|
||||
|
||||
Then("le maître salue votre coup", () => {
|
||||
assert.equal(verdict, "right");
|
||||
assert.match(screen.right.map((part) => part.text).join(""), /^C'est (aussi )?mon coup/);
|
||||
});
|
||||
|
||||
Then("c'est le meilleur coup du moteur", async () => {
|
||||
assert.ok(screen.roll !== null);
|
||||
const best = await engine.bestMove(
|
||||
screen.position,
|
||||
{ value: 1, owner: "centered" },
|
||||
screen.roll,
|
||||
2,
|
||||
);
|
||||
assert.equal(best?.notation, screen.expected[0]);
|
||||
});
|
||||
|
||||
Given("vous avez quitté la leçon {int} à sa deuxième étape", async (n: number) => {
|
||||
storage = memoryStorage();
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = NO_PROGRESS;
|
||||
await save(withVisit(withVisit(progress, n, 1), n, 2));
|
||||
});
|
||||
|
||||
When("vous revenez plus tard aux leçons", async () => {
|
||||
store = createLessonStore(createKeyValueBackend(storage));
|
||||
progress = await store.load();
|
||||
});
|
||||
|
||||
Then("« Continuer » vous ramène à la deuxième étape de la leçon {int}", (n: number) => {
|
||||
const card = lessonCards(lessons, progress).find((c) => c.number === n);
|
||||
assert.equal(card?.href, `#/lecons/${n}/2`);
|
||||
assert.equal(nextLessonCard(lessons, progress)?.number, n);
|
||||
});
|
||||
|
||||
When("vous réussissez tous les exercices de la leçon {int}", async (n: number) => {
|
||||
let next = progress;
|
||||
for (const step of outlineOf(lesson(n)).exercises) {
|
||||
next = withSolved(withVisit(next, n, step), n, step);
|
||||
}
|
||||
await save(next);
|
||||
});
|
||||
|
||||
Then("la leçon {int} est terminée", (n: number) => {
|
||||
const state = lessonState(progress, outlineOf(lesson(n)));
|
||||
assert.equal(state.kind, "done");
|
||||
assert.equal(LESSON_TEXTS.state(state), "Terminée");
|
||||
});
|
||||
@@ -222,6 +222,57 @@ test("les identifiants de partie sont distincts", () => {
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
// Régression du test navigateur « mes parties » sous Firefox (disque lent) :
|
||||
// la nouvelle partie n'est pas perdue quand son enregistrement attend
|
||||
// derrière ceux, lents, de l'analyse de la partie finie ; seule une lecture
|
||||
// directe du stockage, hors de la file, peut la précéder.
|
||||
test("stockage lent : la nouvelle partie est enregistrée après l'analyse de l'ancienne", async () => {
|
||||
const storage = memoryStorage();
|
||||
const raw = createKeyValueBackend(storage);
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const slow = {
|
||||
...raw,
|
||||
write: async (...args: Parameters<typeof raw.write>) => {
|
||||
await pause();
|
||||
await raw.write(...args);
|
||||
},
|
||||
setCurrentId: async (id: string) => {
|
||||
await pause();
|
||||
await raw.setCurrentId(id);
|
||||
},
|
||||
};
|
||||
const games = createGameLibrary(slow, { now: clock() });
|
||||
const engine = {} as Engine;
|
||||
const session = createGameSession(games, {
|
||||
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
|
||||
resume: (j) => Game.resume(engine, j),
|
||||
});
|
||||
const finished = session.newGame();
|
||||
const finishedId = session.shownId as string;
|
||||
session.save(finished.journal);
|
||||
assert.equal((await games.current())?.summary.id, finishedId);
|
||||
|
||||
// L'analyse de la partie finie s'enregistre en arrière-plan, puis
|
||||
// « Rejouer » : nouvelle partie, enregistrée sans attendre.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
void games.saveAnalyses(finishedId, finished.journal, { level: "2ply" });
|
||||
}
|
||||
const next = session.newGame();
|
||||
session.save(next.journal);
|
||||
const nextId = session.shownId as string;
|
||||
assert.notEqual(nextId, finishedId);
|
||||
|
||||
// Lue directement, tout de suite : encore l'ancienne partie.
|
||||
assert.equal(await raw.currentId(), finishedId);
|
||||
// Lue par la bibliothèque, après les écritures en attente : la nouvelle,
|
||||
// avec son journal ; l'ancienne reste là.
|
||||
const current = await games.current();
|
||||
assert.equal(current?.summary.id, nextId);
|
||||
assert.deepEqual(current?.journal, next.journal);
|
||||
assert.equal(await raw.currentId(), nextId);
|
||||
assert.deepEqual((await games.load(finishedId))?.journal, finished.journal);
|
||||
});
|
||||
|
||||
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
|
||||
const games = library();
|
||||
// Game.start et Game.resume n'interrogent pas le moteur.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -184,3 +184,46 @@ a plus besoin (`app/src/ui/review-runner.ts`).
|
||||
La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé
|
||||
d'une partie sans en faire la partie en cours ni changer la date de sa
|
||||
dernière action (l'ordre de « Mes parties »).
|
||||
|
||||
### 2026-11-11 — Version 2 des conseils : le classement de l'analyse
|
||||
|
||||
Le classement des coups d'un conseil (`ranking`) venait d'un second appel au
|
||||
moteur, comme la commande « hint » de gnubg, alors que le coup joué et le
|
||||
meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent :
|
||||
quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le
|
||||
coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup
|
||||
passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à
|
||||
2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient
|
||||
désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme
|
||||
le panneau d'analyse de gnubg de bureau.
|
||||
|
||||
- `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la
|
||||
règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié
|
||||
à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur
|
||||
(`review.computer`), du même format, aussi.
|
||||
- Ni les événements (version 2) ni le schéma IndexedDB ne changent.
|
||||
|
||||
### 2026-11-12 — La progression dans les leçons
|
||||
|
||||
Avec les leçons (étape 1g), la progression rejoint les formats durables. Elle
|
||||
est rangée dans le stockage des parties (étape 1b), à côté de la partie en
|
||||
cours :
|
||||
|
||||
- IndexedDB : une nouvelle clé `lessons` dans le magasin `settings`
|
||||
existant. Le schéma ne change pas (base « ustagammon », version 1, mêmes
|
||||
magasins) : aucune montée de version n'est nécessaire, et une base
|
||||
enregistrée avant l'étape 1g se lit telle quelle, sans progression.
|
||||
- Repli localStorage (et mémoire) : la clé `ustagammon.lessons`.
|
||||
- La valeur, en JSON, porte sa version (`LESSON_PROGRESS_VERSION`, 1) et,
|
||||
par numéro de leçon, la dernière étape vue (`last`), la plus lointaine
|
||||
(`reached`, 99 pour la fin) et les étapes dont l'exercice est réussi
|
||||
(`solved`) (`app/src/domain/learning/lessons.ts`).
|
||||
- `readLessonProgress` relit toute valeur enregistrée : absente ou
|
||||
illisible, la progression est vide ; une leçon mal formée est oubliée sans
|
||||
toucher aux autres ; une version inconnue est lue comme vide plutôt que
|
||||
mal comprise. Toute évolution du format passera par une nouvelle version
|
||||
et sa migration dans cette fonction, testées dans
|
||||
`app/tests/lessons.test.ts` comme ici.
|
||||
- Une leçon est terminée quand tous ses exercices sont réussis ; ce n'est
|
||||
pas enregistré à part, mais déduit des exercices réussis et du texte de la
|
||||
leçon : ajouter un exercice à une leçon la fait repasser « en cours ».
|
||||
|
||||
@@ -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,22 @@ 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é
|
||||
(`#55554F` en thème clair, pour 3:1 sur les flèches en chêne ; `#6E6E69` en
|
||||
sombre) ; 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`.
|
||||
|
||||
@@ -57,3 +57,30 @@ aide » et « Fin de partie » du canevas ci-dessus.
|
||||
- Les leçons sont des contenus sous CC BY-SA 4.0, à écrire et à faire relire
|
||||
par un joueur fort (cadrage, risque « contenus longs à produire ») ; leur
|
||||
progression est stockée localement.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-12 — Les cinq premières leçons, sans relecture préalable
|
||||
|
||||
Décision de Pierre du 2026-11-09 (`docs/etape-1.md`, « Décisions de
|
||||
Pierre ») : les leçons ne sont pas relues par un joueur fort avant d'être
|
||||
livrées ; elles le sont telles quelles et améliorées sur retours d'experts.
|
||||
La relecture prévue ci-dessus est remplacée par des garde-fous vérifiés par
|
||||
`make test` (`app/tests/lessons.test.ts`) :
|
||||
|
||||
- chaque exercice « à vous » attend un seul coup, le meilleur coup du
|
||||
moteur à 2 plis, avec plus de 0,04 d'équité d'avance sur le deuxième ; il
|
||||
accepte ce coup et refuse tous les autres ;
|
||||
- chaque coup illustré est aussi le meilleur coup du moteur à 2 plis ;
|
||||
- chaque compte de points cité par le texte est celui du moteur ;
|
||||
- le texte n'affirme rien que le moteur ne confirme : les coups, les points
|
||||
faits, les pions frappés ou laissés seuls, lisibles dans la position ;
|
||||
aucune explication stratégique inventée.
|
||||
|
||||
Les cinq leçons (étape 1g) vivent dans `docs/lecons/` (format et
|
||||
garde-fous : `docs/lecons/README.md`), au format du tutoriel, et
|
||||
réutilisent son écran. L'état d'une leçon est « à commencer », « 2
|
||||
exercices sur 4 » ou « terminée » (tous ses exercices réussis) ; toutes
|
||||
restent ouvertes, l'ordre est conseillé. La progression est enregistrée
|
||||
avec les parties (ADR 0007, amendement du 2026-11-12) ; leurs adresses sont
|
||||
dans l'ADR 0013 (amendement du 2026-11-12).
|
||||
|
||||
@@ -159,3 +159,88 @@ 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.
|
||||
|
||||
### 2026-11-12 — Adresses des leçons
|
||||
|
||||
Ajoutées avec les cinq premières leçons (étape 1g), dans le même schéma,
|
||||
sur le modèle du tutoriel :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Liste des leçons | `#/lecons` |
|
||||
| Une étape d'une leçon (son écran d'explication) | `#/lecons/<n>/<étape>` |
|
||||
| L'exercice « à vous » d'une étape | `#/lecons/<n>/<étape>/a-vous` |
|
||||
| Fin d'une leçon | `#/lecons/<n>/fin` |
|
||||
|
||||
- `<n>` est le numéro de la leçon (1 à 5 pour l'instant), `<étape>` celui
|
||||
d'une étape de la leçon, à partir de 1 ; `#/lecons/<n>` mène à sa
|
||||
première étape. Le `<écran>` du plan de l'étape 1g se décline ainsi en
|
||||
`<étape>`, `<étape>/a-vous` ou `fin`, comme pour le tutoriel.
|
||||
- Une leçon qui n'existe pas mène à la liste (`#/lecons`), une étape qui
|
||||
n'existe pas à la première étape de la leçon, un exercice absent à
|
||||
l'explication de son étape : jamais une page cassée.
|
||||
- « Continuer » (la liste, la carte de l'accueil) mène à l'étape où l'on a
|
||||
quitté la leçon ; « Quitter » et « Retour » sur la première étape
|
||||
ramènent à la liste ; l'action de la fin mène à la leçon suivante, ou à la
|
||||
liste après la dernière. Le menu a une ligne « Les leçons ».
|
||||
- Ces adresses se partagent : le contenu des leçons est le même pour tous ;
|
||||
seule la progression (barre, état) dépend de l'appareil.
|
||||
|
||||
### 2026-11-12 — Adresses de la progression
|
||||
|
||||
Ajoutées avec l'écran de progression (étape 1h), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Progression : courbe du taux d'erreur et parties terminées | `#/progression` |
|
||||
| Prompt « Demander des exercices à une IA », depuis la progression | `#/progression/ia` |
|
||||
|
||||
- La ligne de progression de l'accueil est un lien vers `#/progression`.
|
||||
L'écran est une page, comme la liste des leçons : pas de bouton
|
||||
« Retour », la barre du haut, Échap et le retour du téléphone suffisent.
|
||||
Échap revient à l'écran d'avant, l'accueil s'il a été ouvert par son
|
||||
adresse.
|
||||
- Chaque partie de la liste est un lien vers son bilan
|
||||
(`#/parties/<id>/bilan`), d'où s'ouvre son analyse. Fermer le bilan
|
||||
ramène à la progression.
|
||||
- « Demander des exercices à une IA » suit la règle des prompts (amendement du
|
||||
2026-11-10) : le bouton ouvre la feuille du prompt, dans sa propre
|
||||
feuille au-dessus de la progression (« ‹ Progression »), où le texte se
|
||||
relit avant d'être copié. Rechargée, l'adresse recalcule le prompt à
|
||||
partir des analyses enregistrées ; sans partie analysée, ou sans erreur,
|
||||
la feuille le dit, jamais une page cassée.
|
||||
- Comme « Mes parties », ces adresses n'ont de sens que sur l'appareil qui
|
||||
a joué les parties : ce ne sont pas des liens à partager.
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@ 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 |
|
||||
| 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 |
|
||||
| 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 (fait) | 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 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a |
|
||||
| 1h. Progression (fait) | 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 |
|
||||
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -27,6 +27,10 @@ les noms des niveaux de l'ordinateur, dans le même ordre :
|
||||
| 0,020 à 0,032 | Intermediate | Amateur |
|
||||
| 0,032 et plus | Beginner | Débutant |
|
||||
|
||||
L'écran de progression l'écrit en millièmes, comme GNU Backgammon (« mEMG ») :
|
||||
14 veut dire 0,014 d'équité perdue par décision. Sa courbe montre ce taux
|
||||
partie après partie ; plus elle descend, mieux vous jouez.
|
||||
|
||||
Le bilan montre à côté la [chance](chance.md) de vos dés sur la partie : par
|
||||
tour, la chance de vos jets ; « un peu » pour vous ou contre vous au-delà de
|
||||
0,03 par tour, « nettement » au-delà de 0,06, comme GNU Backgammon. La
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
@@ -224,8 +236,18 @@ l'ordre, chacune avec une pastille ronde à son numéro :
|
||||
|
||||
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
|
||||
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
|
||||
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur).
|
||||
« Quitter », « Retour », « Continuer », écrans « à vous » contrôlés par le
|
||||
moteur), aux adresses `#/lecons/<n>/<écran>`, `…/a-vous` et `…/fin`.
|
||||
|
||||
- Une leçon est **terminée** quand tous ses exercices sont réussis.
|
||||
- « Retour » et « Continuer » restent **en bas de l'écran**, sous le pouce,
|
||||
comme dans le tutoriel, quelle que soit la longueur du texte.
|
||||
- « Revoir » (rejouer le coup illustré) se place sur la ligne du titre, à
|
||||
droite, en portrait comme en paysage : le texte garde toute la largeur.
|
||||
- En paysage, la colonne de droite garde la ligne `caption` « Leçon 1 · 1 sur
|
||||
4 · À vous » au-dessus du titre.
|
||||
- Les titres et les textes n'emploient que les mots du glossaire : « point »
|
||||
et « flèche », jamais « case ».
|
||||
|
||||
## 1h. Progression (`#/progression`)
|
||||
|
||||
@@ -233,14 +255,28 @@ Maquette « Progression ».
|
||||
|
||||
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
|
||||
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
|
||||
tendance monte, la phrase reste encourageante et sans reproche (« Votre
|
||||
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
|
||||
pour reprendre un point. »).
|
||||
tendance monte, la phrase reste encourageante et sans reproche, sans
|
||||
minimiser ni dramatiser (« Sur vos 8 dernières parties, votre taux
|
||||
d'erreur remonte, de 7 à 12. Les leçons sont là pour reprendre un
|
||||
point. »).
|
||||
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
|
||||
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
|
||||
vers la fiche « taux d'erreur ».
|
||||
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
|
||||
en chiffre gras aligné à droite), chacune ouvrant son bilan.
|
||||
c'est mieux » dans son titre et un lien vers la fiche « taux d'erreur ».
|
||||
Le dernier point est plus gros, à l'encre du texte ; il ne passe en
|
||||
`verdict-good` que si la tendance baisse (le vert dit « mieux », jamais
|
||||
« dernier »). Les valeurs s'écrivent au-dessus du premier et du dernier
|
||||
point seulement ; l'axe ne porte que la ligne du 0, sans chiffres qui
|
||||
doublent ceux des points.
|
||||
- La liste des parties : chaque ligne est un seul lien vers son bilan
|
||||
(chevron à droite), sans bouton par ligne ; l'analyse s'ouvre depuis le
|
||||
bilan. Sur la ligne : le résultat court (« Gagnée, 1 point », « Perdue,
|
||||
gammon, 2 points », jamais « partie simple »), puis en `caption`
|
||||
`text-muted` la date et le niveau (« 12 nov. 2026 · contre
|
||||
Intermédiaire », le niveau sans couleur), et à droite le taux d'erreur en
|
||||
chiffre gras.
|
||||
- Sans partie terminée : une phrase et « Nouvelle partie » en action
|
||||
principale, pleine largeur en portrait.
|
||||
- Pas de bouton « Retour » dans l'en-tête : la barre du haut et le retour du
|
||||
téléphone suffisent, comme sur la liste des leçons.
|
||||
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
|
||||
courbe apparaîtra après trois parties. ».
|
||||
|
||||
|
||||
@@ -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,9 @@ 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 du joueur lisibles partout : en thème clair, contour `checker-player-edge` à 3:1 au moins sur les flèches en chêne et sur le fond. Règle automatique : `checkerContrastProblems` de `tests/browser/recette.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": "#55554F",
|
||||
"dark": "#6E6E69"
|
||||
},
|
||||
"usage": "Contour et filet des pions du joueur."
|
||||
"usage": "Contour des pions blancs. Thème clair : #55554F, pour 3:1 au moins sur les flèches en chêne (3,5:1) et le fond (5,5:1). Thème sombre : le pion blanc se détache déjà par sa couleur (plus de 5:1)."
|
||||
},
|
||||
{
|
||||
"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…)."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Leçons
|
||||
|
||||
Les cinq premières leçons d'Ustagammon (ADR 0011, étape 1g), une leçon par
|
||||
fichier, en une seule source (ADR 0008) : `lecon-1.md` à `lecon-5.md`.
|
||||
`make site` copie ce dossier dans le site publié (`docs/lecons/`) ;
|
||||
l'application le lit à l'exécution (`app/src/ui/lesson-text.ts`), et
|
||||
`make test` vérifie chaque position, chaque coup et chaque compte de points
|
||||
avec le vrai moteur (`app/tests/lessons.test.ts`).
|
||||
|
||||
Textes sous licence **CC BY-SA 4.0**. Première version, rédigée par Claude
|
||||
Code ; décision de Pierre du 2026-11-09 : pas de relecture préalable par un
|
||||
joueur fort, les leçons sont livrées telles quelles et améliorées sur
|
||||
retours d'experts.
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Chaque coup attendu d'un exercice « à vous » est le meilleur coup du
|
||||
moteur à 2 plis, avec plus de 0,04 d'équité d'avance sur le deuxième :
|
||||
un exercice n'a qu'une bonne réponse nette. `make test` le vérifie.
|
||||
- Chaque coup illustré d'un écran d'explication est aussi le meilleur coup
|
||||
du moteur à 2 plis.
|
||||
- Le texte n'affirme rien que le moteur ne confirme : les coups du maître,
|
||||
les points faits, les pions frappés ou laissés seuls (lisibles dans la
|
||||
position), les comptes de points (`compte`, vérifiés par le test). Aucune
|
||||
explication stratégique inventée.
|
||||
- Le vocabulaire est celui du glossaire (`docs/glossaire/`) : flèche, point,
|
||||
jan intérieur, jan extérieur, blot, barre ; vos pions sont blancs, ceux de
|
||||
l'ordinateur noirs.
|
||||
- Le ton est celui du maître (`docs/maitre/phrases.md`) : première
|
||||
personne, vouvoiement, sobre, sans point d'exclamation ni reproche.
|
||||
|
||||
## Format
|
||||
|
||||
Celui du tutoriel (`docs/tutoriel/README.md`), avec deux ajouts :
|
||||
|
||||
- la première ligne, `# <titre>`, donne le titre de la leçon ; la notice qui
|
||||
suit, jusqu'à la première étape, n'est pas affichée ;
|
||||
- `compte: <vous>/<ordinateur>`, en tête d'un écran, donne les comptes de
|
||||
points de sa position, que le texte cite ; le test les compare à ceux du
|
||||
moteur.
|
||||
|
||||
Chaque leçon a de deux à quatre exercices « à vous » (`### À vous`), et un
|
||||
écran de fin (`## Fin`) dont l'action mène à la leçon suivante, ou à la
|
||||
liste des leçons après la dernière.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Les ouvertures
|
||||
|
||||
Leçon 1 d'Ustagammon : bien jouer ses premiers dés. Texte sous licence
|
||||
CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Un point dès le premier coup
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-1
|
||||
- coup: 8/5 6/5
|
||||
|
||||
Au premier coup, avec 3 et 1, je joue 8/5 6/5 : deux pions blancs se posent sur la flèche 5. Ce [point](point.md) est fait, l'ordinateur ne peut plus s'y poser.
|
||||
|
||||
La flèche 5 est dans votre [jan intérieur](jan-interieur.md), là où vous rassemblerez vos pions avant de les sortir. Le moteur classe ce coup premier, nettement devant tous les autres.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 4-2
|
||||
- attendu: 8/4 6/4
|
||||
- juste: C'est aussi mon coup : 8/4 6/4 fait le point 4.
|
||||
- refus: Ce coup est permis, mais je préfère faire un point. Cherchez la flèche de votre jan intérieur où le 4 et le 2 amènent chacun un pion.
|
||||
|
||||
Vous avez 4 et 2. Faites un point dans votre jan intérieur.
|
||||
|
||||
## 2. La flèche 7
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
|
||||
La flèche 7 ouvre votre [jan extérieur](jan-exterieur.md), juste à côté de la barre. Fermée, elle formerait avec la flèche 6, où sont déjà vos cinq pions, deux points côte à côte.
|
||||
|
||||
Les deux pions noirs de votre flèche 1 doivent passer par là : depuis la flèche 1, un 6 les mène sur la flèche 7, et un 5 est déjà bloqué par votre point 6.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 6-1
|
||||
- attendu: 13/7 8/7
|
||||
- juste: C'est mon coup : 13/7 8/7 fait le point 7.
|
||||
- refus: Ce coup est permis, mais je préfère fermer la flèche 7 : un pion y arrive avec le 6, un autre avec le 1.
|
||||
|
||||
Vous avez 6 et 1. Fermez une flèche de votre jan extérieur.
|
||||
|
||||
## 3. Répondre avec un double
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
|
||||
L'ordinateur a ouvert avec 3-1 : il a fait le point 5 de son jan intérieur, votre flèche 20. À vous de lancer les dés.
|
||||
|
||||
Un double se joue quatre fois. Dans la notation des coups, « (2) » veut dire que deux pions font le même trajet.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
- jet: 6-6
|
||||
- attendu: 24/18(2) 13/7(2)
|
||||
- juste: C'est mon coup : 24/18(2) 13/7(2) fait deux points, sur la flèche 18 et sur la flèche 7.
|
||||
- refus: Ce coup est permis, mais je préfère faire deux points : un avec les deux pions de la flèche 24, un autre avec deux pions de la flèche 13.
|
||||
|
||||
Vous avez 6-6, quatre fois 6. Faites deux points avec ce jet.
|
||||
|
||||
## 4. Un double qui ne passe pas partout
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
|
||||
Même position. Les pions de votre flèche 24 ne peuvent pas jouer de 5 : la flèche 19 est fermée par quatre pions noirs.
|
||||
|
||||
Avec 5-5, il faut donc jouer les quatre 5 avec vos autres pions.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: sGfwATDgc/ABMA
|
||||
- jet: 5-5
|
||||
- attendu: 13/3(2)
|
||||
- juste: C'est mon coup : 13/3(2) amène deux pions de la flèche 13 jusqu'à la flèche 3, où ils font un point.
|
||||
- refus: Ce coup est permis, mais je préfère amener deux pions de la flèche 13 jusque dans votre jan intérieur, où ils font un point ensemble.
|
||||
|
||||
Vous avez 5-5. Faites un point dans votre jan intérieur.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Avec 3-1, 4-2 et 6-1, le moteur fait un point dès le premier coup. Pour les autres jets, je vous dirai en partie ce que j'aurais joué.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Faire des points
|
||||
|
||||
Leçon 2 d'Ustagammon : construire son jeu en faisant des points. Texte sous
|
||||
licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Deux pions font un point
|
||||
|
||||
- position: 4HPwATDQc+IBMA
|
||||
- jet: 6-4
|
||||
- coup: 10/4 8/4
|
||||
|
||||
Une flèche où vous avez deux pions ou plus est un [point](point.md) fait : un pion noir ne peut pas s'y poser. Un pion noir frappé doit rentrer dans votre [jan intérieur](jan-interieur.md) : chaque point que vous y fermez lui retire une flèche d'entrée.
|
||||
|
||||
Avec 6 et 4, je joue 10/4 8/4 : les deux pions arrivent ensemble sur la flèche 4.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDQc+IBMA
|
||||
- jet: 6-2
|
||||
- attendu: 13/5
|
||||
- juste: C'est mon coup : 13/5 rejoint le pion de la flèche 5 et fait le point.
|
||||
- refus: Ce coup est permis, mais je préfère couvrir le pion seul de la flèche 5 : un pion de la flèche 13 y arrive avec le 6 et le 2.
|
||||
|
||||
Même position, vous avez 6 et 2. Votre pion de la flèche 5 est seul : couvrez-le.
|
||||
|
||||
## 2. Des pions prêts à construire
|
||||
|
||||
- position: 4HPwATDQZ+EBMA
|
||||
|
||||
Un pion posé sur les flèches 7 à 12, dans votre [jan extérieur](jan-exterieur.md), peut entrer dans votre jan intérieur avec un seul dé.
|
||||
|
||||
Ici, un pion blanc est seul sur la flèche 5, un autre sur la flèche 9.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDQZ+EBMA
|
||||
- jet: 4-1
|
||||
- attendu: 13/9 6/5
|
||||
- juste: C'est mon coup : 6/5 fait le point 5, et 13/9 amène un deuxième pion sur la flèche 9.
|
||||
- refus: Ce coup est permis, mais ce n'est pas le mien. Je couvre la flèche 5 avec le 1, depuis la flèche 6, et je joue le 4 depuis la flèche 13.
|
||||
|
||||
Vous avez 4 et 1. Couvrez votre pion de la flèche 5 et jouez l'autre dé au mieux.
|
||||
|
||||
## 3. Les doubles construisent vite
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 2-2
|
||||
- coup: 13/11(2) 6/4(2)
|
||||
|
||||
Un double se joue quatre fois : de quoi faire deux points d'un coup. Au départ, avec 2-2, je joue 13/11(2) 6/4(2) : un point sur la flèche 4, un autre sur la flèche 11.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- jet: 3-3
|
||||
- attendu: 8/5(2) 6/3(2)
|
||||
- juste: C'est mon coup : 8/5(2) 6/3(2) fait deux points dans votre jan intérieur, sur les flèches 5 et 3.
|
||||
- refus: Ce coup est permis, mais je préfère faire deux points dans votre jan intérieur : deux pions de la flèche 8 et deux pions de la flèche 6 avancent de 3.
|
||||
|
||||
Vous avez 3-3. Faites deux points dans votre jan intérieur.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Un point se fait à deux pions, et un double peut en faire deux d'un coup. Dans la leçon suivante : frapper un pion noir, ou ne pas le frapper.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Frapper ou ne pas frapper
|
||||
|
||||
Leçon 3 d'Ustagammon : frapper ou ne pas frapper, sécurité et risque. Texte
|
||||
sous licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Frapper en faisant un point
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
- jet: 6-1
|
||||
- coup: 13/7* 8/7
|
||||
|
||||
Un pion noir est seul sur votre flèche 7 : c'est un [blot](blot.md). Avec 6 et 1, je joue 13/7* 8/7. L'astérisque marque le pion frappé : il part sur la [barre](barre.md).
|
||||
|
||||
Les deux pions blancs restent ensemble sur la flèche 7 : le point est fait, aucun pion noir ne peut plus s'y poser.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5* 6/5
|
||||
- juste: C'est mon coup : 8/5* 6/5 frappe le pion noir et fait le point 5.
|
||||
- refus: Ce coup est permis, mais je préfère frapper le pion noir de la flèche 5 avec deux pions à la fois : l'un avec le 3, l'autre avec le 1.
|
||||
|
||||
Cette fois, le pion noir est seul sur votre flèche 5. Vous avez 3 et 1.
|
||||
|
||||
## 2. Un point plutôt qu'un coup risqué
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
|
||||
Même position. Un pion blanc qui frappe seul devient lui-même un blot : si un pion noir se pose dessus, c'est lui qui part sur la barre.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwASHgc/ABMA
|
||||
- jet: 6-1
|
||||
- attendu: 13/7 8/7
|
||||
- juste: C'est mon coup : 13/7 8/7 fait le point 7, sans frapper. Le moteur classe tous les coups qui frappent derrière celui-ci.
|
||||
- refus: Ce coup est permis, mais ce n'est pas le mien. Avec ce jet, je préfère faire un point plutôt que frapper : cherchez la flèche où le 6 et le 1 se rejoignent.
|
||||
|
||||
Vous avez 6 et 1. Le 1 frapperait depuis la flèche 6, mais je joue autrement. Trouvez mon coup.
|
||||
|
||||
## 3. Ne pas frapper avec un seul pion
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
|
||||
Retour au pion noir de votre flèche 7, avec un autre jet : 3 et 1. Le 1 le frapperait depuis la flèche 8, mais le pion qui frappe resterait seul sur la flèche 7.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 4HPwQSDgc/ABMA
|
||||
- jet: 3-1
|
||||
- attendu: 8/5 6/5
|
||||
- juste: C'est mon coup : 8/5 6/5 fait le point 5, sans frapper.
|
||||
- refus: Ce coup est permis, mais je préfère ne pas frapper ici et faire un point dans votre jan intérieur, comme à l'ouverture.
|
||||
|
||||
Vous avez 3 et 1. Frappez-vous ?
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Dans ces positions, je frappe quand je fais un point en même temps, et je préfère faire un point quand le pion qui frappe resterait seul. En partie, je vous dirai ce que j'aurais joué.
|
||||
@@ -0,0 +1,76 @@
|
||||
# La course et le compte de points
|
||||
|
||||
Leçon 4 d'Ustagammon : la course et le compte de points. Texte sous licence
|
||||
CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Compter ses points
|
||||
|
||||
- position: 4HPwATDgc/ABMA
|
||||
- compte: 167/167
|
||||
- jet: 6-1
|
||||
- coup: 13/7 8/7
|
||||
|
||||
Votre [compte de points](compte-de-points.md) est le nombre de flèches que vos pions doivent encore parcourir pour sortir : un pion sur la flèche 13 compte pour 13, un pion sur la barre pour 25. Au départ, chacun en a 167.
|
||||
|
||||
Chaque coup le fait baisser de la somme des dés : après 6-1, le vôtre passe à 160. Le plus petit compte est en avance dans la [course](course.md).
|
||||
|
||||
## 2. En avance, j'avance
|
||||
|
||||
- position: cM8NBwC49xkAIA
|
||||
- compte: 102/120
|
||||
|
||||
Ici, vos pions ont 102 flèches à parcourir, ceux de l'ordinateur 120 : vous avez 18 points d'avance.
|
||||
|
||||
Il vous reste un pion dans le jan intérieur de l'ordinateur, sur votre flèche 24.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: cM8NBwC49xkAIA
|
||||
- jet: 6-5
|
||||
- attendu: 24/13
|
||||
- juste: C'est mon coup : 24/13. Avec 18 points d'avance, mon dernier pion quitte le jan intérieur de l'ordinateur.
|
||||
- refus: Ce coup est permis, mais je préfère faire avancer le pion de la flèche 24 avec les deux dés : il quitte le jan intérieur de l'ordinateur.
|
||||
|
||||
Vous avez 6 et 5. Que faites-vous de votre dernier pion ?
|
||||
|
||||
## 3. Encore en avance
|
||||
|
||||
- position: cM8ZBwC49xkAEA
|
||||
- compte: 101/122
|
||||
|
||||
Autre position : 101 contre 122, vous avez 21 points d'avance. Un de vos pions est encore sur la flèche 23.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: cM8ZBwC49xkAEA
|
||||
- jet: 5-2
|
||||
- attendu: 23/16
|
||||
- juste: C'est mon coup : 23/16. Le pion de la flèche 23 avance avec les deux dés.
|
||||
- refus: Ce coup est permis, mais je préfère faire avancer le pion de la flèche 23 avec les deux dés.
|
||||
|
||||
Vous avez 5 et 2.
|
||||
|
||||
## 4. En retard, je frappe
|
||||
|
||||
- position: 3L0HIADYPQcHAA
|
||||
- compte: 110/81
|
||||
|
||||
Ici, les pions de l'ordinateur ont 81 flèches à parcourir, les vôtres 110 : il a 29 points d'avance. Son dernier pion est seul sur votre flèche 9.
|
||||
|
||||
Frappé, ce pion part sur la barre : pour l'ordinateur, il comptait 16 et compte maintenant 25, soit 9 de plus.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: 3L0HIADYPQcHAA
|
||||
- jet: 4-3
|
||||
- attendu: 13/10 13/9*
|
||||
- juste: C'est mon coup : 13/10 13/9* frappe le dernier pion noir. Sur la barre, il compte 25 : le compte de l'ordinateur passe de 81 à 90.
|
||||
- refus: Ce coup est permis, mais je préfère frapper le pion noir seul de la flèche 9 : un pion de la flèche 13 y arrive avec le 4.
|
||||
|
||||
Vous avez 4 et 3.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Leçon suivante
|
||||
|
||||
Comptez avant de choisir. Dans ces positions, en avance, je fais avancer mon dernier pion ; en retard, je frappe celui de l'ordinateur.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Sortir sans se faire frapper
|
||||
|
||||
Leçon 5 d'Ustagammon : sortir ses pions sans se faire frapper. Texte sous
|
||||
licence CC BY-SA 4.0. Format : voir README.md.
|
||||
|
||||
## 1. Un pion noir encore chez vous
|
||||
|
||||
- position: trsHACC2uwMAAA
|
||||
- jet: 6-3
|
||||
- coup: 6/off 5/2
|
||||
|
||||
Tous vos pions sont dans votre [jan intérieur](jan-interieur.md) : vous pouvez les [sortir](sortie.md). Mais un pion noir est encore sur votre flèche 1, et il peut frapper un pion blanc laissé seul.
|
||||
|
||||
Avec 6 et 3, je joue 6/off 5/2 : un pion sort, et aucun pion blanc ne reste seul. 6/off 3/off en sortirait deux, mais laisserait un pion seul sur la flèche 3.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: trsHACC2uwMAAA
|
||||
- jet: 6-4
|
||||
- attendu: 6/off 4/off
|
||||
- juste: C'est mon coup : 6/off 4/off sort deux pions et ne laisse aucun pion seul.
|
||||
- refus: Ce coup est permis, mais il laisse un pion blanc seul, ou sort moins de pions que le mien. Cherchez à sortir deux pions sans laisser de pion seul.
|
||||
|
||||
Même position, vous avez 6 et 4.
|
||||
|
||||
## 2. Un pion noir sur la barre
|
||||
|
||||
- position: trsHAEBtdw8AAA
|
||||
|
||||
Ici, le pion noir est sur la [barre](barre.md) : il doit rentrer dans votre jan intérieur, et s'il se pose sur un pion blanc seul, il le frappe.
|
||||
|
||||
Votre pion de la flèche 1 est seul.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: trsHAEBtdw8AAA
|
||||
- jet: 6-1
|
||||
- attendu: 6/off 1/off
|
||||
- juste: C'est mon coup : 6/off 1/off sort deux pions, dont le pion seul de la flèche 1.
|
||||
- refus: Ce coup est permis, mais je préfère sortir deux pions, dont celui qui est seul sur la flèche 1.
|
||||
|
||||
Vous avez 6 et 1.
|
||||
|
||||
## 3. Deux pions noirs en embuscade
|
||||
|
||||
- position: uHsOADC23QMAAA
|
||||
|
||||
Deux pions noirs tiennent votre flèche 1 : ils peuvent frapper tout pion blanc laissé seul à leur portée.
|
||||
|
||||
### À vous
|
||||
|
||||
- position: uHsOADC23QMAAA
|
||||
- jet: 5-2
|
||||
- attendu: 6/4 5/off
|
||||
- juste: C'est mon coup : 6/4 5/off sort un pion et ne laisse aucun pion seul.
|
||||
- refus: Ce coup est permis, mais il laisse un pion blanc seul, ou ne sort aucun pion. Le mien sort un pion sans en laisser un seul.
|
||||
|
||||
Vous avez 5 et 2.
|
||||
|
||||
## Fin
|
||||
|
||||
- action: Toutes les leçons
|
||||
|
||||
Dans ces positions, avec des pions noirs encore dans votre jan intérieur, je sors mes pions sans en laisser un seul. Les leçons suivantes viendront avec les prochaines versions.
|
||||
+33
-2
@@ -1,7 +1,7 @@
|
||||
# Les phrases du maître
|
||||
|
||||
Les mots et les phrases du conseil du maître (étape 1d) et du bilan de fin
|
||||
de partie (étape 1e), en une seule source (ADR 0008) : `make site` copie ce
|
||||
Les mots et les phrases du conseil du maître (étape 1d), du bilan de fin
|
||||
de partie (étape 1e) et de l'écran de progression (étape 1h), en une seule source (ADR 0008) : `make site` copie ce
|
||||
dossier dans le site publié
|
||||
(`docs/maitre/`), l'application le lit à l'exécution
|
||||
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
|
||||
@@ -39,6 +39,9 @@ mots entre accolades sont remplis avec les données du moteur :
|
||||
| `{roll}` | le jet, « 3-1 » |
|
||||
| `{hits}` | le nombre de pions frappés, en chiffres |
|
||||
| `{dice}` | la chance de vos dés sur la partie, en mots (bilan, étape 1e) |
|
||||
| `{parties}` | le nombre de parties analysées sur la courbe (progression, étape 1h) |
|
||||
| `{debut}` | le taux d'erreur moyen de la plus ancienne moitié de ces parties, en millièmes (14 pour 0,014) |
|
||||
| `{fin}` | le même, sur la plus récente moitié |
|
||||
|
||||
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
|
||||
sous 140 caractères.
|
||||
@@ -281,3 +284,31 @@ erreurs.
|
||||
## bilan.aucun-coup
|
||||
|
||||
- Aucun coup à revoir : vous avez joué comme je l'aurais fait.
|
||||
|
||||
## progression.baisse
|
||||
|
||||
Écran de progression (étape 1h), au moins trois parties analysées : le
|
||||
taux d'erreur moyen de la plus récente moitié des parties est plus bas que
|
||||
celui de la plus ancienne (moyennes arrondies en millièmes ; la partie du
|
||||
milieu, quand leur nombre est impair, ne compte dans aucune).
|
||||
|
||||
- Sur vos {parties} dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de {debut} à {fin}.
|
||||
|
||||
## progression.hausse
|
||||
|
||||
Le taux moyen remonte : la phrase reste encourageante, sans reproche, sans
|
||||
minimiser ni dramatiser.
|
||||
|
||||
- Sur vos {parties} dernières parties, votre taux d'erreur remonte, de {debut} à {fin}. Les leçons sont là pour reprendre un point.
|
||||
|
||||
## progression.stable
|
||||
|
||||
Les deux moyennes, arrondies, sont égales.
|
||||
|
||||
- Sur vos {parties} dernières parties, votre taux d'erreur reste à {fin}.
|
||||
|
||||
## progression.attente
|
||||
|
||||
Moins de trois parties analysées : pas encore de courbe.
|
||||
|
||||
- Votre courbe apparaîtra après trois parties.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Cinq premières leçons et accueil complet (1g)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1g (`docs/etape-1.md`) ; le tutoriel des règles est traité en 0e.5
|
||||
- **Statut** : prévu (après 0e.5 et 1d)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque leçon est faite d'écrans courts : une explication, une position animée, puis « à vous de jouer ». La progression est stockée localement, et l'accueil propose « Continuer la leçon ».
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Contenus.** Ils vivent dans une source unique sous `docs/lecons/`, sous licence CC BY-SA 4.0, au même format que le tutoriel de 0e.5 (réutiliser son moteur d'écrans). Cinq leçons du parcours 2 du cadrage, au niveau débutant :
|
||||
1. Les ouvertures : bien jouer ses premiers dés.
|
||||
2. Construire ses cases et faire des points.
|
||||
3. Frapper ou ne pas frapper : sécurité et risque.
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
5. **Tests.**
|
||||
- Pour chaque exercice, la position est valide et la réponse attendue est bien le meilleur coup du moteur.
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser`, avec captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Les cinq leçons se suivent jusqu'au bout. Chaque exercice accepte le coup du moteur et commente les autres avec bienveillance. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Progression (1h)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1h (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1e)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Mettre en place la progression du cadrage pour la V1 : l'historique des parties et la courbe du taux d'erreur.
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Écran `#/progression`.** Il est conforme à la charte et s'ajoute au tableau de l'ADR 0013. Il montre :
|
||||
- la courbe du taux d'erreur par partie terminée, en SVG à la main, sans dépendance ;
|
||||
- la liste des parties, reliée à leur bilan et à leur analyse (1e) ;
|
||||
- un résumé en une phrase.
|
||||
2. **Accueil.** Il reçoit la « ligne de progression » de la charte.
|
||||
3. **Données.** Elles proviennent des analyses enregistrées : rien n'est recalculé si l'analyse existe. Une partie non analysée est analysée en arrière-plan.
|
||||
4. **Tests.**
|
||||
- Scénarios Gherkin.
|
||||
- `make lint`, `make test`, `make test-browser` avec trois parties jouées, et captures.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après trois parties, la courbe a trois points égaux aux taux d'erreur des bilans. Les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -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) | Demander des exercices à une IA | Écran de progression (étape 1h) |
|
||||
|
||||
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 » ; pour un classement de coups, celui auquel le moteur a évalué le meilleur coup (voir [coup.md](coup.md)) |
|
||||
| `{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.
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user