Compare commits
87
Commits
@@ -127,8 +127,10 @@ 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 et du tutoriel sous docs/, lues par
|
||||
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
|
||||
# - 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é) ;
|
||||
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
|
||||
@@ -141,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 $(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
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// L'analyse des décisions du joueur par le moteur, pour le conseil du
|
||||
// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité
|
||||
// perdue, appréciation), le classement des coups, l'analyse de la décision
|
||||
// de videau, et les plateaux décodés, où se lisent les pions frappés et les
|
||||
// points faits. L'application lance ces analyses sur un second moteur, en
|
||||
// arrière-plan : elles ne retardent jamais la partie.
|
||||
|
||||
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
|
||||
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
|
||||
import { engineCube, STANDARD_PLY } from "../game/game.js";
|
||||
import { otherSide, type Side } from "../game/side.js";
|
||||
import { replay } from "../game/state.js";
|
||||
|
||||
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
|
||||
* défaut, et le niveau « standard » (ADR 0009). */
|
||||
export const ANALYSIS_PLY: Ply = STANDARD_PLY;
|
||||
|
||||
/** Nombre de coups gardés du classement du moteur, pour « Voir les
|
||||
* chiffres du moteur ». */
|
||||
export const RANKING_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Les décisions du joueur à analyser parmi des événements : ses coups (pas
|
||||
* un tour passé faute de coup possible), ses jets (il aurait pu doubler
|
||||
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
|
||||
*/
|
||||
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
|
||||
return decisionsOf(events, "player");
|
||||
}
|
||||
|
||||
/** Les décisions de side parmi des événements, comme playerDecisions. */
|
||||
export function decisionsOf(events: readonly GameEvent[], side: Side): GameEvent[] {
|
||||
return events.filter((event) => {
|
||||
switch (event.type) {
|
||||
case "MovePlayed":
|
||||
return event.side === side && event.halfMoves.length > 0;
|
||||
case "DiceRolled":
|
||||
case "CubeOffered":
|
||||
case "CubeTaken":
|
||||
case "CubeDropped":
|
||||
return event.side === side;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce qu'on demande à l'analyse d'une décision. */
|
||||
export interface DecisionOptions {
|
||||
/** Niveau de l'analyse ; ANALYSIS_PLY par défaut. */
|
||||
readonly ply?: Ply;
|
||||
/** Le camp qui décide ; le joueur par défaut. Le conseil est alors vu
|
||||
* de ce camp (sa position, ses coups). */
|
||||
readonly side?: Side;
|
||||
/** Garder le classement des coups (une seconde analyse) ; oui par
|
||||
* défaut. L'analyse des coups de l'ordinateur s'en passe. */
|
||||
readonly ranking?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le conseil du moteur sur la décision eventId du joueur (ou du camp
|
||||
* options.side) ; null s'il n'y a rien à analyser (pas une décision de ce
|
||||
* camp, aucun coup possible, videau inaccessible ou position de départ pour
|
||||
* gnubg).
|
||||
*/
|
||||
export async function analyseDecision(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
eventId: string,
|
||||
options: DecisionOptions = {},
|
||||
): Promise<Advice | null> {
|
||||
const ply = options.ply ?? ANALYSIS_PLY;
|
||||
const side = options.side ?? "player";
|
||||
const line = currentLine({ ...journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event === undefined || decisionsOf([event], side).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const earlier = line.slice(0, -1);
|
||||
const before = replay(earlier);
|
||||
if (event.type === "MovePlayed") {
|
||||
if (before.roll === null) {
|
||||
return null;
|
||||
}
|
||||
const cube = engineCube(before, side);
|
||||
// Le coup joué est enregistré vu de l'adversaire : le moteur donne
|
||||
// la même position vue de celui qui l'a joué.
|
||||
const after = await engine.board(event.position, engineCube(before, otherSide(side)));
|
||||
const analysis = await engine.analyseMove(
|
||||
before.position,
|
||||
cube,
|
||||
before.roll,
|
||||
after.swappedPosition,
|
||||
ply,
|
||||
);
|
||||
if (analysis === null) {
|
||||
return null;
|
||||
}
|
||||
// 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));
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "move",
|
||||
eventId,
|
||||
ply,
|
||||
position: before.position,
|
||||
roll: before.roll,
|
||||
opening: !earlier.some((e) => e.type === "MovePlayed"),
|
||||
moveCount: analysis.moveCount,
|
||||
rank: analysis.rank,
|
||||
played: advised(analysis.played),
|
||||
best: advised(analysis.best),
|
||||
playedFacts: await facts(analysis.played),
|
||||
bestFacts: await facts(analysis.best),
|
||||
ranking: ranking.slice(0, RANKING_SIZE).map(advised),
|
||||
equityLoss: analysis.equityLoss,
|
||||
skill: analysis.skill,
|
||||
};
|
||||
}
|
||||
let action: CubeAction;
|
||||
let decider: Side = side;
|
||||
if (event.type === "DiceRolled") {
|
||||
if (before.phase !== "rolling") {
|
||||
return null;
|
||||
}
|
||||
action = "noDouble";
|
||||
} else if (event.type === "CubeOffered") {
|
||||
action = "double";
|
||||
} else {
|
||||
action = event.type === "CubeTaken" ? "take" : "pass";
|
||||
decider = otherSide(side);
|
||||
}
|
||||
const analysis = await engine.analyseCube(
|
||||
before.position,
|
||||
engineCube(before, decider),
|
||||
action,
|
||||
ply,
|
||||
);
|
||||
if (!analysis.analysed || analysis.equities === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "cube",
|
||||
eventId,
|
||||
ply,
|
||||
action,
|
||||
position: before.position,
|
||||
equities: analysis.equities,
|
||||
equityLoss: analysis.equityLoss,
|
||||
skill: analysis.skill,
|
||||
};
|
||||
}
|
||||
|
||||
function advised(move: EvaluatedMove): AdvisedMove {
|
||||
return {
|
||||
notation: move.notation,
|
||||
halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })),
|
||||
equity: move.equity,
|
||||
ply: move.ply,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur
|
||||
* (vus du joueur) : pions adverses envoyés sur la barre, cases où le
|
||||
* joueur a désormais deux pions ou plus. */
|
||||
function factsOf(
|
||||
mine: BoardSide,
|
||||
theirs: BoardSide,
|
||||
after: { readonly onRoll: BoardSide; readonly opponent: BoardSide },
|
||||
): MoveFacts {
|
||||
const madePoints: number[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) {
|
||||
madePoints.push(i + 1);
|
||||
}
|
||||
}
|
||||
return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints };
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// L'analyse d'une partie (étape 1e) : ce qui manque à analyser, le calcul
|
||||
// de chaque analyse par le moteur, et les chiffres du bilan à la manière de
|
||||
// GNU Backgammon (taux d'erreur, chance, résultat corrigé de la chance).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Tout ce qui juge le jeu vient du moteur : l'analyse de chaque décision
|
||||
// (équité perdue, appréciation), la chance de chaque jet, l'évaluation des
|
||||
// positions. Ce module ne fait que les ranger et les additionner, comme le
|
||||
// fait gnubg dans ses statistiques de partie. Sources, dans gnubg 1.08.003
|
||||
// (commit 14b67984, ADR 0005), fichiers analysis.c et formatgs.c :
|
||||
// - updateStatcontext (analysis.c, l. 304-596) : les erreurs de chaque
|
||||
// décision et leur nombre ;
|
||||
// - getMWCFromError (analysis.c, l. 1432-1472) : le taux d'erreur ;
|
||||
// - GetRating et arThrsRating (analysis.c, l. 83-101) : le classement ;
|
||||
// - formatgs.c, l. 348-374 et getLuckRating (analysis.c, l. 1757) : la
|
||||
// chance par coup et son appréciation ;
|
||||
// - arLuckAdj (analysis.c, l. 304-330 et 405-414) : le résultat corrigé de
|
||||
// la chance.
|
||||
// Les tâches d'analyse sont indépendantes : l'application les lance une à
|
||||
// une sur le second moteur, en arrière-plan, et peut s'arrêter entre deux.
|
||||
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
type Verdict,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
import type { Engine, LuckRating } from "../game/engine.js";
|
||||
import { LUCK_THRESHOLDS } from "../game/engine.js";
|
||||
import {
|
||||
currentLine,
|
||||
type GameEnded,
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
type MovePlayed,
|
||||
withAdvice,
|
||||
withReview,
|
||||
} from "../game/events.js";
|
||||
import { engineCube } from "../game/game.js";
|
||||
import { COMPUTER_LEVELS, type ComputerLevel } from "../game/level.js";
|
||||
import { emptyReview, type GameReview } from "../game/review.js";
|
||||
import type { Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
import { applyEvent, replay } from "../game/state.js";
|
||||
import { ANALYSIS_PLY, analyseDecision, decisionsOf } from "./analyse.js";
|
||||
|
||||
// ------------------------------------------------------------ Les tours
|
||||
|
||||
/**
|
||||
* Un tour de la partie : ce qu'un camp a fait quand il avait la main
|
||||
* (doubler ou non, lancer, jouer), avec la réponse de l'adversaire à son
|
||||
* double. Les tours sont numérotés à partir de 1 ; c'est le « coup » de
|
||||
* l'écran d'analyse (#/parties/<id>/analyse/<n>).
|
||||
*/
|
||||
export interface Turn {
|
||||
readonly number: number;
|
||||
/** Le camp qui avait la main. */
|
||||
readonly side: Side;
|
||||
/** Le jet joué (null : le tour s'est fini sur un double passé). */
|
||||
readonly roll: Roll | null;
|
||||
/** Le coup joué (null : double passé). */
|
||||
readonly move: MovePlayed | null;
|
||||
/** Les événements du tour, dans l'ordre (sans la fin de partie). */
|
||||
readonly events: readonly GameEvent[];
|
||||
/** La fin de partie, si elle suit ce tour. */
|
||||
readonly end: GameEnded | null;
|
||||
}
|
||||
|
||||
/** Les tours d'une ligne de jeu (événements du début à la fin). */
|
||||
export function turnsOf(line: readonly GameEvent[]): Turn[] {
|
||||
const turns: Turn[] = [];
|
||||
let bucket: GameEvent[] = [];
|
||||
let state = replay([]);
|
||||
for (const event of line) {
|
||||
const before = state;
|
||||
state = applyEvent(state, event);
|
||||
if (event.type === "GameStarted") {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "GameEnded") {
|
||||
const last = turns.at(-1);
|
||||
if (bucket.length === 0 && last !== undefined) {
|
||||
turns[turns.length - 1] = { ...last, end: event };
|
||||
} else if (bucket.length > 0) {
|
||||
const offer = bucket.find((e) => e.type === "CubeOffered");
|
||||
const side = offer?.type === "CubeOffered" ? offer.side : (before.turn ?? "player");
|
||||
turns.push(makeTurn(turns.length + 1, side, bucket, null, event));
|
||||
}
|
||||
bucket = [];
|
||||
continue;
|
||||
}
|
||||
bucket.push(event);
|
||||
if (event.type === "MovePlayed") {
|
||||
turns.push(makeTurn(turns.length + 1, event.side, bucket, event, null));
|
||||
bucket = [];
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function makeTurn(
|
||||
number: number,
|
||||
side: Side,
|
||||
events: readonly GameEvent[],
|
||||
move: MovePlayed | null,
|
||||
end: GameEnded | null,
|
||||
): Turn {
|
||||
const rolled = move === null ? undefined : events.findLast((e) => e.type === "DiceRolled");
|
||||
return {
|
||||
number,
|
||||
side,
|
||||
roll: rolled?.type === "DiceRolled" ? rolled.roll : null,
|
||||
move,
|
||||
events,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
/** Les tours de la ligne courante d'un journal. */
|
||||
export function gameTurns(journal: GameJournal): Turn[] {
|
||||
return turnsOf(currentLine(journal));
|
||||
}
|
||||
|
||||
// --------------------------------------------- Ce qui reste à analyser
|
||||
|
||||
/** Une analyse à demander au moteur. */
|
||||
export type AnalysisTask =
|
||||
/** Une décision (coup ou videau) d'un camp. */
|
||||
| { readonly kind: "decision"; readonly side: Side; readonly eventId: string }
|
||||
/** La chance d'un jet. */
|
||||
| { readonly kind: "luck"; readonly eventId: string }
|
||||
/** Les chances de gagner du joueur après un coup. */
|
||||
| { readonly kind: "chance"; readonly eventId: string };
|
||||
|
||||
/** L'analyse de partie du journal, au niveau d'analyse ; vide s'il n'y en
|
||||
* a pas encore (ou à un autre niveau). */
|
||||
export function reviewOf(journal: GameJournal): GameReview {
|
||||
const review = journal.review;
|
||||
return review !== undefined && review.ply === ANALYSIS_PLY ? review : emptyReview(ANALYSIS_PLY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui reste à analyser sur la ligne courante, dans l'ordre où le bilan
|
||||
* en a besoin : les décisions du joueur (celles d'avant l'étape 1d, ou
|
||||
* dont l'analyse est arrivée trop tard), la chance de chaque jet, puis,
|
||||
* pour l'écran d'analyse, les décisions de l'ordinateur et les chances de
|
||||
* gagner après chaque coup.
|
||||
*/
|
||||
export function missingAnalyses(journal: GameJournal): AnalysisTask[] {
|
||||
const line = currentLine(journal);
|
||||
const review = reviewOf(journal);
|
||||
const skipped = new Set(review.unanalysed);
|
||||
const advice = journal.advice ?? {};
|
||||
const tasks: AnalysisTask[] = [];
|
||||
for (const event of decisionsOf(line, "player")) {
|
||||
if (advice[event.id] === undefined && !skipped.has(event.id)) {
|
||||
tasks.push({ kind: "decision", side: "player", eventId: event.id });
|
||||
}
|
||||
}
|
||||
for (const event of luckyRolls(line)) {
|
||||
if (review.luck[event.id] === undefined) {
|
||||
tasks.push({ kind: "luck", eventId: event.id });
|
||||
}
|
||||
}
|
||||
for (const event of decisionsOf(line, "computer")) {
|
||||
if (review.computer[event.id] === undefined && !skipped.has(event.id)) {
|
||||
tasks.push({ kind: "decision", side: "computer", eventId: event.id });
|
||||
}
|
||||
}
|
||||
for (const event of line) {
|
||||
if (event.type === "MovePlayed" && review.winChances[event.id] === undefined) {
|
||||
tasks.push({ kind: "chance", eventId: event.id });
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/** Ce qu'il faut au bilan : toutes les décisions du joueur et la chance
|
||||
* de tous les jets. */
|
||||
export function readyForSummary(journal: GameJournal): boolean {
|
||||
return missingAnalyses(journal).every(
|
||||
(task) => task.kind === "chance" || (task.kind === "decision" && task.side === "computer"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Les jets qui ont une chance : tous ceux d'un camp, et le jet
|
||||
* d'ouverture qui désigne le premier joueur (pas une égalité, qui se
|
||||
* relance). */
|
||||
function luckyRolls(line: readonly GameEvent[]): GameEvent[] {
|
||||
const rolls: GameEvent[] = [];
|
||||
let state = replay([]);
|
||||
for (const event of line) {
|
||||
const before = state;
|
||||
state = applyEvent(state, event);
|
||||
if (event.type !== "DiceRolled") {
|
||||
continue;
|
||||
}
|
||||
if (event.side !== null || (before.phase === "opening" && state.phase === "moving")) {
|
||||
rolls.push(event);
|
||||
}
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fait une analyse avec le moteur et la range dans le journal, qui est
|
||||
* renvoyé (le journal reçu ne change pas). Une décision que le moteur
|
||||
* n'analyse pas est notée comme telle, pour ne plus la demander.
|
||||
*/
|
||||
export async function runAnalysis(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
task: AnalysisTask,
|
||||
): Promise<GameJournal> {
|
||||
const review = reviewOf(journal);
|
||||
switch (task.kind) {
|
||||
case "decision": {
|
||||
const advice = await analyseDecision(engine, journal, task.eventId, {
|
||||
side: task.side,
|
||||
ranking: task.side === "player",
|
||||
});
|
||||
if (advice === null) {
|
||||
return withReview(journal, {
|
||||
...review,
|
||||
unanalysed: [...review.unanalysed, task.eventId],
|
||||
});
|
||||
}
|
||||
return task.side === "player"
|
||||
? withAdvice(journal, advice)
|
||||
: withReview(journal, {
|
||||
...review,
|
||||
computer: { ...review.computer, [task.eventId]: advice },
|
||||
});
|
||||
}
|
||||
case "luck": {
|
||||
const line = currentLine({ ...journal, head: task.eventId });
|
||||
const event = line.at(-1);
|
||||
if (event?.type !== "DiceRolled") {
|
||||
throw new Error(`pas un jet : ${task.eventId}`);
|
||||
}
|
||||
const before = replay(line.slice(0, -1));
|
||||
// Le camp qui joue le jet ; au jet d'ouverture, celui qu'il
|
||||
// désigne. La position de départ est la même pour les deux.
|
||||
const roller = event.side ?? replay(line).turn ?? "player";
|
||||
const luck = await engine.luck(before.position, engineCube(before, roller), event.roll);
|
||||
return withReview(journal, { ...review, luck: { ...review.luck, [event.id]: luck } });
|
||||
}
|
||||
case "chance": {
|
||||
const chance = await chanceAfter(engine, journal, task.eventId);
|
||||
return withReview(journal, {
|
||||
...review,
|
||||
winChances: { ...review.winChances, [task.eventId]: chance },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Les chances de gagner du joueur après le coup moveId : le résultat si
|
||||
* la partie finit là, sinon l'évaluation du moteur, vue de celui qui a
|
||||
* alors la main. */
|
||||
async function chanceAfter(engine: Engine, journal: GameJournal, moveId: string): Promise<number> {
|
||||
const line = currentLine(journal);
|
||||
const index = line.findIndex((e) => e.id === moveId);
|
||||
const upTo = index >= 0 ? line.slice(0, index + 1) : currentLine({ ...journal, head: moveId });
|
||||
const next = index >= 0 ? line[index + 1] : undefined;
|
||||
if (next?.type === "GameEnded") {
|
||||
return next.winner === "player" ? 1 : 0;
|
||||
}
|
||||
const after = replay(upTo);
|
||||
const onRoll = after.turn ?? "player";
|
||||
const evaluation = await engine.evaluate(
|
||||
after.position,
|
||||
engineCube(after, onRoll),
|
||||
ANALYSIS_PLY,
|
||||
);
|
||||
return onRoll === "player" ? evaluation.win : 1 - evaluation.win;
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Les chiffres du bilan
|
||||
|
||||
/**
|
||||
* Classement de gnubg selon le taux d'erreur par décision (GetRating et
|
||||
* arThrsRating, analysis.c l. 83-101) : en dessous de 0,005, « Super
|
||||
* Grandmaster » ; puis 0,008 « Grandmaster », 0,013 « Master », 0,020
|
||||
* « Advanced », 0,032 « Intermediate », au-delà « Beginner ». Les six
|
||||
* classements portent les noms des six niveaux de l'ordinateur, dans le
|
||||
* même ordre (correspondance dans docs/glossaire/taux-d-erreur.md).
|
||||
*/
|
||||
export const RATING_THRESHOLDS: readonly number[] = [0.032, 0.02, 0.013, 0.008, 0.005];
|
||||
|
||||
/**
|
||||
* Le taux d'erreur d'un camp, à la manière de gnubg (getMWCFromError,
|
||||
* analysis.c) : l'équité perdue sur toutes ses décisions, divisée par le
|
||||
* nombre de coups non forcés et de décisions de videau « serrées ».
|
||||
*
|
||||
* Glossaire : docs/glossaire/taux-d-erreur.md
|
||||
*/
|
||||
export interface ErrorRate {
|
||||
/** Équité perdue au total, pour un videau de 1 (positive ou nulle). */
|
||||
readonly total: number;
|
||||
/** Nombre de décisions comptées. */
|
||||
readonly decisions: number;
|
||||
/** Équité perdue par décision (0 sans décision). */
|
||||
readonly perDecision: number;
|
||||
/** Le niveau correspondant, du nom des niveaux de l'ordinateur. */
|
||||
readonly level: ComputerLevel;
|
||||
}
|
||||
|
||||
/** La chance d'un camp sur la partie, à la manière de gnubg (formatgs.c,
|
||||
* « Luck total », « Luck rate », « Luck rating »). */
|
||||
export interface LuckSummary {
|
||||
/** Somme de la chance de ses jets, pour un videau de 1. */
|
||||
readonly total: number;
|
||||
/** Nombre de ses tours avec un jet (anTotalMoves de gnubg). */
|
||||
readonly rolls: number;
|
||||
/** Chance par tour (0 sans tour). */
|
||||
readonly perRoll: number;
|
||||
/** getLuckRating : la chance par tour, multipliée par 10, comparée aux
|
||||
* seuils d'un jet (LUCK_THRESHOLDS). */
|
||||
readonly rating: LuckRating;
|
||||
/** La chance comptée en points : chaque jet multiplié par la valeur
|
||||
* du videau à ce moment (arLuck[…][1] de gnubg en partie libre). */
|
||||
readonly points: number;
|
||||
}
|
||||
|
||||
/** Ce que le bilan dit d'un camp. */
|
||||
export interface SideSummary {
|
||||
readonly errorRate: ErrorRate;
|
||||
readonly luck: LuckSummary;
|
||||
}
|
||||
|
||||
/** Une décision du joueur et son conseil, dans son tour. */
|
||||
export interface ReviewedDecision {
|
||||
readonly turn: number;
|
||||
readonly advice: Advice;
|
||||
}
|
||||
|
||||
/** Le bilan d'une partie (ligne courante). */
|
||||
export interface GameSummary {
|
||||
readonly player: SideSummary;
|
||||
readonly computer: SideSummary;
|
||||
/** Les décisions du joueur comptées, par appréciation : ses coups, ses
|
||||
* doubles et ses réponses aux doubles, et ses jets sans doubler
|
||||
* seulement quand doubler était mieux. */
|
||||
readonly counts: Readonly<Record<Verdict, number>>;
|
||||
/** La plus grosse erreur du joueur (null : aucune imprécision ni
|
||||
* erreur). */
|
||||
readonly toReview: ReviewedDecision | null;
|
||||
/** Le résultat de la partie en points pour le joueur (0 si elle n'est
|
||||
* pas finie), et ce résultat corrigé de la chance des deux camps
|
||||
* (arLuckAdj de gnubg) : ce qu'il serait sans les dés. */
|
||||
readonly result: number;
|
||||
readonly luckAdjustedResult: number;
|
||||
}
|
||||
|
||||
/** Le niveau de gnubg pour un taux d'erreur par décision. */
|
||||
export function levelOfErrorRate(perDecision: number): ComputerLevel {
|
||||
let index = 0;
|
||||
while (index < RATING_THRESHOLDS.length && perDecision < (RATING_THRESHOLDS[index] ?? 0)) {
|
||||
index++;
|
||||
}
|
||||
return COMPUTER_LEVELS[index] ?? "beginner";
|
||||
}
|
||||
|
||||
/** getLuckRating de gnubg : Luck(chance par tour × 10). */
|
||||
export function luckRatingOf(perRoll: number): LuckRating {
|
||||
const r = perRoll * 10;
|
||||
if (r > LUCK_THRESHOLDS.veryLucky) {
|
||||
return "veryLucky";
|
||||
}
|
||||
if (r > LUCK_THRESHOLDS.lucky) {
|
||||
return "lucky";
|
||||
}
|
||||
if (r < -LUCK_THRESHOLDS.veryLucky) {
|
||||
return "veryUnlucky";
|
||||
}
|
||||
if (r < -LUCK_THRESHOLDS.lucky) {
|
||||
return "unlucky";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Une décision de videau « serrée » pour gnubg (isCloseCubedecision,
|
||||
* eval.c l. 5107) : doubler coûte moins de 0,16 par rapport à l'optimum.
|
||||
* Un jet sans doubler n'est compté dans le taux d'erreur que dans ce cas ;
|
||||
* un double, une prise ou un refus le sont toujours.
|
||||
*/
|
||||
function closeCube(advice: CubeAdvice): boolean {
|
||||
const e = advice.equities;
|
||||
return e.optimal - Math.min(e.doubleTake, 1) < 0.16;
|
||||
}
|
||||
|
||||
/** L'erreur d'une décision (équité perdue, positive) et si elle compte
|
||||
* dans le nombre de décisions (updateStatcontext). */
|
||||
function errorOf(advice: Advice): { error: number; counted: boolean } {
|
||||
const error = -Math.min(0, advice.equityLoss);
|
||||
if (advice.kind === "move") {
|
||||
// Un coup forcé ne compte pas (anUnforcedMoves).
|
||||
return { error: advice.moveCount > 1 ? error : 0, counted: advice.moveCount > 1 };
|
||||
}
|
||||
return { error, counted: advice.action !== "noDouble" || closeCube(advice) };
|
||||
}
|
||||
|
||||
function errorRateOf(advices: readonly Advice[]): ErrorRate {
|
||||
let total = 0;
|
||||
let decisions = 0;
|
||||
for (const advice of advices) {
|
||||
const { error, counted } = errorOf(advice);
|
||||
total += error;
|
||||
decisions += counted ? 1 : 0;
|
||||
}
|
||||
const perDecision = decisions > 0 ? total / decisions : 0;
|
||||
return { total, decisions, perDecision, level: levelOfErrorRate(perDecision) };
|
||||
}
|
||||
|
||||
/** Le bilan de la ligne courante, avec les analyses déjà faites (les
|
||||
* manquantes ne comptent pas : voir readyForSummary). */
|
||||
export function summarizeAnalysis(journal: GameJournal): GameSummary {
|
||||
const line = currentLine(journal);
|
||||
const review = reviewOf(journal);
|
||||
const advice = journal.advice ?? {};
|
||||
const turns = turnsOf(line);
|
||||
const turnOf = new Map<string, number>();
|
||||
for (const turn of turns) {
|
||||
for (const event of turn.events) {
|
||||
turnOf.set(event.id, turn.number);
|
||||
}
|
||||
}
|
||||
|
||||
const decided = (side: Side): Advice[] =>
|
||||
decisionsOf(line, side)
|
||||
.map((e) => (side === "player" ? advice[e.id] : review.computer[e.id]))
|
||||
.filter((a): a is Advice => a !== undefined);
|
||||
const mine = decided("player");
|
||||
|
||||
// La chance de chaque camp, et la valeur du videau à chaque jet.
|
||||
const luck: Record<Side, { total: number; points: number; rolls: number }> = {
|
||||
player: { total: 0, points: 0, rolls: 0 },
|
||||
computer: { total: 0, points: 0, rolls: 0 },
|
||||
};
|
||||
let state = replay([]);
|
||||
for (const event of line) {
|
||||
const before = state;
|
||||
state = applyEvent(state, event);
|
||||
if (event.type === "MovePlayed") {
|
||||
luck[event.side].rolls++;
|
||||
}
|
||||
const value = event.type === "DiceRolled" ? review.luck[event.id] : undefined;
|
||||
if (value !== undefined && event.type === "DiceRolled") {
|
||||
const roller = event.side ?? state.turn ?? "player";
|
||||
luck[roller].total += value.value;
|
||||
luck[roller].points += value.value * before.cube.value;
|
||||
}
|
||||
}
|
||||
const luckSummary = (side: Side): LuckSummary => {
|
||||
const { total, points, rolls } = luck[side];
|
||||
const perRoll = rolls > 0 ? total / rolls : 0;
|
||||
return { total, rolls, perRoll, rating: luckRatingOf(perRoll), points };
|
||||
};
|
||||
|
||||
const counts: Record<Verdict, number> = { good: 0, doubtful: 0, bad: 0, veryBad: 0 };
|
||||
let toReview: ReviewedDecision | null = null;
|
||||
for (const a of mine) {
|
||||
const verdict = verdictOf(a.skill);
|
||||
// Un jet sans doubler n'est une décision visible que s'il était
|
||||
// une faute.
|
||||
if (!(a.kind === "cube" && a.action === "noDouble" && verdict === "good")) {
|
||||
counts[verdict]++;
|
||||
}
|
||||
if (
|
||||
verdict !== "good" &&
|
||||
(toReview === null || a.equityLoss < toReview.advice.equityLoss)
|
||||
) {
|
||||
toReview = { turn: turnOf.get(a.eventId) ?? 0, advice: a };
|
||||
}
|
||||
}
|
||||
|
||||
const ended = line.findLast((e) => e.type === "GameEnded");
|
||||
const result =
|
||||
ended?.type === "GameEnded" ? (ended.winner === "player" ? 1 : -1) * ended.points : 0;
|
||||
const player = luckSummary("player");
|
||||
const computer = luckSummary("computer");
|
||||
return {
|
||||
player: { errorRate: errorRateOf(mine), luck: player },
|
||||
computer: { errorRate: errorRateOf(decided("computer")), luck: computer },
|
||||
counts,
|
||||
toReview,
|
||||
result,
|
||||
luckAdjustedResult: result - player.points + computer.points,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Coup par coup
|
||||
|
||||
/** Un point de la courbe des chances de gagner : un tour. */
|
||||
export interface ChancePoint {
|
||||
readonly turn: number;
|
||||
readonly side: Side;
|
||||
/** Chances de gagner du joueur après le tour, de 0 à 1 (null : pas
|
||||
* encore analysé). */
|
||||
readonly chance: number | null;
|
||||
/** L'appréciation de la décision du joueur la plus coûteuse du tour
|
||||
* (null : aucune décision du joueur analysée). */
|
||||
readonly verdict: Verdict | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* La courbe des chances de gagner du joueur, tour après tour : la position
|
||||
* après chaque coup évaluée par le moteur, et le résultat après le dernier.
|
||||
*
|
||||
* Glossaire : docs/glossaire/courbe-des-chances.md
|
||||
*/
|
||||
export type WinChanceCurve = readonly ChancePoint[];
|
||||
|
||||
/** La décision du joueur à revoir dans un tour : la plus coûteuse, le coup
|
||||
* à égalité (null : aucune décision du joueur analysée dans ce tour). */
|
||||
export function mainAdvice(journal: GameJournal, turn: Turn): Advice | null {
|
||||
const advice = journal.advice ?? {};
|
||||
let main: Advice | null = null;
|
||||
for (const event of decisionsOf(turn.events, "player")) {
|
||||
const a = advice[event.id];
|
||||
if (a === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
main === null ||
|
||||
a.equityLoss < main.equityLoss ||
|
||||
(a.equityLoss === main.equityLoss && a.kind === "move")
|
||||
) {
|
||||
main = a;
|
||||
}
|
||||
}
|
||||
return main;
|
||||
}
|
||||
|
||||
/** Le conseil sur le coup de l'ordinateur d'un tour (null sinon). */
|
||||
export function computerMoveAdvice(journal: GameJournal, turn: Turn): MoveAdvice | null {
|
||||
if (turn.side !== "computer" || turn.move === null) {
|
||||
return null;
|
||||
}
|
||||
const advice = reviewOf(journal).computer[turn.move.id];
|
||||
return advice?.kind === "move" ? advice : null;
|
||||
}
|
||||
|
||||
export function chanceCurve(journal: GameJournal): WinChanceCurve {
|
||||
const review = reviewOf(journal);
|
||||
return gameTurns(journal).map((turn) => {
|
||||
const main = mainAdvice(journal, turn);
|
||||
let chance: number | null = null;
|
||||
if (turn.move !== null) {
|
||||
chance = review.winChances[turn.move.id] ?? null;
|
||||
}
|
||||
if (turn.end !== null && (turn.move === null || chance === null)) {
|
||||
chance = turn.end.winner === "player" ? 1 : 0;
|
||||
}
|
||||
return {
|
||||
turn: turn.number,
|
||||
side: turn.side,
|
||||
chance,
|
||||
verdict: main === null ? null : verdictOf(main.skill),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* « Rejouer d'ici » sur un tour : l'événement où revenir pour rejouer la
|
||||
* décision à revoir du joueur (juste avant elle : même jet pour un coup,
|
||||
* même moment pour un videau). null pour un tour sans décision du joueur.
|
||||
*/
|
||||
export function replayPoint(journal: GameJournal, turn: Turn): string | null {
|
||||
const main = mainAdvice(journal, turn);
|
||||
const decisions = decisionsOf(turn.events, "player");
|
||||
const event = main === null ? decisions.at(-1) : decisions.find((e) => e.id === main.eventId);
|
||||
return event?.parent ?? null;
|
||||
}
|
||||
@@ -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"}`;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Les mots et les phrases du maître : lecture de leur source
|
||||
// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des
|
||||
// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node
|
||||
// sur de vraies analyses (app/tests/advice.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import {
|
||||
type Advice,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
type MoveFacts,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
verdictOf,
|
||||
} from "../game/advice.js";
|
||||
import type { CubeAction, LuckRating } from "../game/engine.js";
|
||||
import type { GameSummary } from "./game-analysis.js";
|
||||
|
||||
/** Les gabarits, par clé de section (« coup.meilleur »…). */
|
||||
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
|
||||
|
||||
/** Longueur maximale de la ligne du maître (mot et phrase), en caractères
|
||||
* (charte, « Les phrases du maître ») : elle doit rester en dessous. */
|
||||
export const LINE_LIMIT = 140;
|
||||
|
||||
/** Ce que le maître dit d'une décision. */
|
||||
export interface MasterWords {
|
||||
readonly verdict: Verdict;
|
||||
/** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */
|
||||
readonly word: string;
|
||||
/** La phrase du maître. */
|
||||
readonly phrase: string;
|
||||
/** Le titre de la feuille d'explication. */
|
||||
readonly title: string;
|
||||
/** Le contexte de la décision : « votre 3-1 d'ouverture »… */
|
||||
readonly context: string;
|
||||
}
|
||||
|
||||
/** Lit la source des phrases : les sections « ## clé » à partir de la
|
||||
* première « ## mot.… », un gabarit par ligne « - … ». */
|
||||
export function parsePhrases(text: string): MasterPhrases {
|
||||
const phrases = new Map<string, string[]>();
|
||||
let key: string | null = null;
|
||||
let started = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const heading = /^## (\S+)$/.exec(line);
|
||||
if (heading !== null) {
|
||||
const name = heading[1] as string;
|
||||
started ||= name.startsWith("mot.");
|
||||
key = started ? name : null;
|
||||
if (key !== null) {
|
||||
phrases.set(key, []);
|
||||
}
|
||||
} else if (key !== null && line.startsWith("- ")) {
|
||||
phrases.get(key)?.push(line.slice(2).trim());
|
||||
}
|
||||
}
|
||||
return phrases;
|
||||
}
|
||||
|
||||
/** Les clés que la source doit contenir (vérifié par les tests). */
|
||||
export const PHRASE_KEYS: readonly string[] = [
|
||||
"mot.coup.bon",
|
||||
"mot.videau.bon",
|
||||
"mot.douteux",
|
||||
"mot.mauvais",
|
||||
"mot.tres-mauvais",
|
||||
"coup.meilleur",
|
||||
"coup.seul",
|
||||
"coup.proche",
|
||||
"coup.douteux",
|
||||
"coup.mauvais",
|
||||
"coup.tres-mauvais",
|
||||
"coup.loin",
|
||||
"fait.frappe",
|
||||
"fait.frappe-plusieurs",
|
||||
"fait.point",
|
||||
"fait.frappe-et-point",
|
||||
"fait.maitre-frappe",
|
||||
"fait.maitre-point",
|
||||
"fait.joueur-frappe",
|
||||
...["bon", "proche", "autre"].flatMap((group) =>
|
||||
["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`),
|
||||
),
|
||||
"titre.coup.meme",
|
||||
"titre.coup.autre",
|
||||
"titre.videau.meme",
|
||||
...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`),
|
||||
"contexte.coup",
|
||||
"contexte.coup.ouverture",
|
||||
"contexte.videau.double",
|
||||
"contexte.videau.pas-de-double",
|
||||
"contexte.videau.reponse",
|
||||
"bilan.jeu.gagne",
|
||||
"bilan.jeu.perdu",
|
||||
"bilan.des",
|
||||
"bilan.des.tres-pour",
|
||||
"bilan.des.pour",
|
||||
"bilan.des.neutres",
|
||||
"bilan.des.contre",
|
||||
"bilan.des.tres-contre",
|
||||
"bilan.aucun-coup",
|
||||
];
|
||||
|
||||
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
|
||||
veryLucky: "bilan.des.tres-pour",
|
||||
lucky: "bilan.des.pour",
|
||||
none: "bilan.des.neutres",
|
||||
unlucky: "bilan.des.contre",
|
||||
veryUnlucky: "bilan.des.tres-contre",
|
||||
};
|
||||
|
||||
/** La chance de vos dés sur la partie, en mots : « un peu contre vous »… */
|
||||
export function diceWords(phrases: MasterPhrases, rating: LuckRating): string {
|
||||
return pick(phrases, DICE_KEYS[rating], "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* La lecture du bilan d'une partie finie : ce qui a fait la différence,
|
||||
* selon le résultat corrigé de la chance (gnubg) ; et vos dés, en mots.
|
||||
* Partie gagnée : votre jeu, si le résultat corrigé de la chance reste
|
||||
* positif, sinon les dés ; partie perdue : le jeu de l'ordinateur, si le
|
||||
* résultat corrigé reste négatif, sinon les dés.
|
||||
*/
|
||||
export function summaryReading(phrases: MasterPhrases, summary: GameSummary): string {
|
||||
const dice = diceWords(phrases, summary.player.luck.rating);
|
||||
const won = summary.result > 0;
|
||||
const playDecided = won ? summary.luckAdjustedResult > 0 : summary.luckAdjustedResult < 0;
|
||||
if (!playDecided) {
|
||||
return pick(phrases, "bilan.des", "0");
|
||||
}
|
||||
return fillIn(pick(phrases, won ? "bilan.jeu.gagne" : "bilan.jeu.perdu", "0"), { dice });
|
||||
}
|
||||
|
||||
/** « Aucun coup à revoir… » : la carte du coup à revoir sans erreur. */
|
||||
export function nothingToReview(phrases: MasterPhrases): string {
|
||||
return pick(phrases, "bilan.aucun-coup", "0");
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */
|
||||
export function masterLine(words: MasterWords): string {
|
||||
return `${words.word}. ${words.phrase}`;
|
||||
}
|
||||
|
||||
const ACTION_KEYS: Readonly<Record<CubeAction, string>> = {
|
||||
double: "double",
|
||||
noDouble: "pas-de-double",
|
||||
take: "prendre",
|
||||
pass: "passer",
|
||||
};
|
||||
|
||||
function wordKey(verdict: Verdict, kind: Advice["kind"]): string {
|
||||
switch (verdict) {
|
||||
case "good":
|
||||
return kind === "move" ? "mot.coup.bon" : "mot.videau.bon";
|
||||
case "doubtful":
|
||||
return "mot.douteux";
|
||||
case "bad":
|
||||
return "mot.mauvais";
|
||||
case "veryBad":
|
||||
return "mot.tres-mauvais";
|
||||
}
|
||||
}
|
||||
|
||||
function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
|
||||
const verdict = verdictOf(advice.skill);
|
||||
const word = pick(phrases, wordKey(verdict, "move"), advice.eventId);
|
||||
const values = {
|
||||
best: advice.best.notation,
|
||||
played: advice.played.notation,
|
||||
rank: String(advice.rank),
|
||||
roll: `${advice.roll[0]}-${advice.roll[1]}`,
|
||||
};
|
||||
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
|
||||
fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra });
|
||||
let base: string;
|
||||
let fact: string | null;
|
||||
if (advice.moveCount <= 1) {
|
||||
base = fill("coup.seul");
|
||||
fact = null;
|
||||
} else if (advice.rank === 1) {
|
||||
base = fill("coup.meilleur");
|
||||
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
|
||||
} else {
|
||||
// Le rang n'est cité que pour un bon coup non meilleur ou une
|
||||
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
|
||||
// erreur, et le classement est dans les chiffres du moteur.
|
||||
const near = advice.rank <= 3;
|
||||
const key = {
|
||||
good: near ? "coup.proche" : "coup.loin",
|
||||
doubtful: near ? "coup.douteux" : "coup.loin",
|
||||
bad: "coup.mauvais",
|
||||
veryBad: "coup.tres-mauvais",
|
||||
}[verdict];
|
||||
base = fill(key);
|
||||
fact = differenceFact(phrases, advice);
|
||||
}
|
||||
const withFact = fact === null ? base : `${base} ${fact}`;
|
||||
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
|
||||
return {
|
||||
verdict,
|
||||
word,
|
||||
phrase,
|
||||
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
|
||||
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ce que fait le coup lui-même : pions frappés, points faits. */
|
||||
function selfFact(
|
||||
phrases: MasterPhrases,
|
||||
id: string,
|
||||
move: string,
|
||||
facts: MoveFacts,
|
||||
): string | null {
|
||||
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
|
||||
const made = facts.madePoints.length > 0;
|
||||
if (facts.hits === 1 && made) {
|
||||
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
|
||||
}
|
||||
if (facts.hits > 1) {
|
||||
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
|
||||
}
|
||||
if (facts.hits === 1) {
|
||||
return fillIn(pick(phrases, "fait.frappe", id), values);
|
||||
}
|
||||
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
|
||||
}
|
||||
|
||||
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
|
||||
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
|
||||
const best = advice.bestFacts;
|
||||
const played = advice.playedFacts;
|
||||
if (best.hits > 0 && played.hits === 0) {
|
||||
return pick(phrases, "fait.maitre-frappe", advice.eventId);
|
||||
}
|
||||
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
|
||||
if (onlyBest.length > 0) {
|
||||
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
|
||||
points: pointsText(onlyBest),
|
||||
});
|
||||
}
|
||||
if (played.hits > 0 && best.hits === 0) {
|
||||
return pick(phrases, "fait.joueur-frappe", advice.eventId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
|
||||
const verdict = verdictOf(advice.skill);
|
||||
const master = masterCubeAction(advice);
|
||||
const same = master === advice.action;
|
||||
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
|
||||
const shown = group === "bon" ? advice.action : master;
|
||||
const context =
|
||||
advice.action === "take" || advice.action === "pass"
|
||||
? "contexte.videau.reponse"
|
||||
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
|
||||
return {
|
||||
verdict,
|
||||
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
|
||||
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
|
||||
title: pick(
|
||||
phrases,
|
||||
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
|
||||
advice.eventId,
|
||||
),
|
||||
context: pick(phrases, context, advice.eventId),
|
||||
};
|
||||
}
|
||||
|
||||
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
|
||||
function pointsText(points: readonly number[]): string {
|
||||
const sorted = [...points].sort((a, b) => a - b).map(String);
|
||||
if (sorted.length <= 1) {
|
||||
return `votre point ${sorted[0] ?? ""}`;
|
||||
}
|
||||
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
|
||||
}
|
||||
|
||||
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
|
||||
* de l'événement. Erreur si la section manque. */
|
||||
function pick(phrases: MasterPhrases, key: string, id: string): string {
|
||||
const choices = phrases.get(key) ?? [];
|
||||
if (choices.length === 0) {
|
||||
throw new Error(`phrases du maître : section « ${key} » absente`);
|
||||
}
|
||||
const n = Number.parseInt(id, 10);
|
||||
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
|
||||
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
|
||||
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
|
||||
return choices[index] as string;
|
||||
}
|
||||
|
||||
/** Remplace les {repères} ; erreur sur un repère inconnu. */
|
||||
function fillIn(template: string, values: Readonly<Record<string, string>>): string {
|
||||
return template.replace(/\{([a-z]+)\}/g, (_, name: string) => {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`phrases du maître : repère {${name}} inconnu`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Le conseil du maître sur une décision du joueur : ce que le moteur en dit,
|
||||
// rattaché à l'événement de la décision dans le journal (étape 1d).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
|
||||
// moteur (analyse du coup, classement des coups, analyse de la décision de
|
||||
// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le
|
||||
// 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, 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é). 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
|
||||
* de videau, avec l'appréciation du moteur.
|
||||
*
|
||||
* Glossaire : docs/glossaire/conseil-du-maitre.md
|
||||
*/
|
||||
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: MoveNotation;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
|
||||
* le moteur l'a évaluée. */
|
||||
readonly equity: number;
|
||||
readonly ply: Ply;
|
||||
}
|
||||
|
||||
/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après
|
||||
* un coup : de quoi écrire une phrase sans rien inventer. */
|
||||
export interface MoveFacts {
|
||||
/** Pions adverses envoyés sur la barre. */
|
||||
readonly hits: number;
|
||||
/** Cases (numérotation du joueur) où il n'avait pas deux pions avant
|
||||
* le coup et en a deux ou plus après : les points qu'il fait. */
|
||||
readonly madePoints: readonly number[];
|
||||
}
|
||||
|
||||
export interface MoveAdvice {
|
||||
readonly version: number;
|
||||
readonly kind: "move";
|
||||
/** L'événement MovePlayed du coup. */
|
||||
readonly eventId: string;
|
||||
/** Niveau de l'analyse, en plis. */
|
||||
readonly ply: Ply;
|
||||
/** La position avant le coup, vue du joueur, et son jet. */
|
||||
readonly position: PositionId;
|
||||
readonly roll: Roll;
|
||||
/** Le premier coup de la partie (après le jet d'ouverture). */
|
||||
readonly opening: boolean;
|
||||
/** Nombre de coups légaux et rang du coup joué (à partir de 1). */
|
||||
readonly moveCount: number;
|
||||
readonly rank: number;
|
||||
readonly played: AdvisedMove;
|
||||
readonly best: AdvisedMove;
|
||||
readonly playedFacts: MoveFacts;
|
||||
readonly bestFacts: MoveFacts;
|
||||
/** 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;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
export interface CubeAdvice {
|
||||
readonly version: number;
|
||||
readonly kind: "cube";
|
||||
/** L'événement de la décision : DiceRolled (le joueur lance sans
|
||||
* doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il
|
||||
* répond au double de l'ordinateur). */
|
||||
readonly eventId: string;
|
||||
readonly ply: Ply;
|
||||
/** La décision du joueur. */
|
||||
readonly action: CubeAction;
|
||||
/** La position, vue de celui qui double ou non (l'ordinateur quand le
|
||||
* joueur prend ou passe). */
|
||||
readonly position: PositionId;
|
||||
/** Équités de celui qui double ou non, pour un videau de 1. */
|
||||
readonly equities: CubeEquities;
|
||||
/** Équité perdue par le joueur : 0 ou négative. */
|
||||
readonly equityLoss: number;
|
||||
readonly skill: Skill;
|
||||
}
|
||||
|
||||
/** L'appréciation dite au joueur : celle du moteur, « rien à signaler »
|
||||
* devenant « bon ». */
|
||||
export type Verdict = "good" | "doubtful" | "bad" | "veryBad";
|
||||
|
||||
export function verdictOf(skill: Skill): Verdict {
|
||||
return skill === "none" ? "good" : skill;
|
||||
}
|
||||
|
||||
/** L'autre choix d'une décision de videau. */
|
||||
export function otherCubeAction(action: CubeAction): CubeAction {
|
||||
switch (action) {
|
||||
case "double":
|
||||
return "noDouble";
|
||||
case "noDouble":
|
||||
return "double";
|
||||
case "take":
|
||||
return "pass";
|
||||
case "pass":
|
||||
return "take";
|
||||
}
|
||||
}
|
||||
|
||||
/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu
|
||||
* aucune équité, l'autre sinon (une décision de videau n'a que deux
|
||||
* choix). */
|
||||
export function masterCubeAction(advice: CubeAdvice): CubeAction {
|
||||
return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action;
|
||||
}
|
||||
@@ -93,6 +93,11 @@ export interface Engine {
|
||||
/** La chance d'un jet pour le joueur au trait. */
|
||||
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
|
||||
|
||||
/** L'évaluation d'une position, joueur au trait avant de lancer les
|
||||
* dés, au niveau ply : ses chances de gain (sans videau) et son équité
|
||||
* avec videau. */
|
||||
evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation>;
|
||||
|
||||
/** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui
|
||||
* (partie libre sans match), sans dés. */
|
||||
encodeMatch(cube: EngineCube): Promise<MatchId>;
|
||||
@@ -178,6 +183,16 @@ export interface Luck {
|
||||
readonly rating: LuckRating;
|
||||
}
|
||||
|
||||
/** Une position évaluée par le moteur, du point de vue du joueur au
|
||||
* trait. */
|
||||
export interface Evaluation {
|
||||
/** Chances de gagner la partie, de 0 à 1, sans tenir compte du videau
|
||||
* (première probabilité de gnubg). */
|
||||
readonly win: number;
|
||||
/** Équité avec videau, normalisée pour un videau de 1. */
|
||||
readonly equity: number;
|
||||
}
|
||||
|
||||
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
|
||||
export interface EvaluatedMove extends Move {
|
||||
readonly equity: number;
|
||||
@@ -196,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
|
||||
@@ -255,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.
|
||||
*
|
||||
@@ -262,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. */
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
|
||||
// le moteur : l'état se recalcule par une simple boucle (state.ts).
|
||||
|
||||
import { ADVICE_VERSION, type Advice } from "./advice.js";
|
||||
import type { Cube } from "./cube.js";
|
||||
import type { Level, ResultKind } from "./engine.js";
|
||||
import type { Match, MatchAfterGame } from "./match.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import { type GameReview, REVIEW_VERSION } from "./review.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
import type { Side } from "./side.js";
|
||||
|
||||
@@ -38,7 +40,8 @@ const VERSION_1_LEVEL: Level = "2ply";
|
||||
* une partie de la version 1 étant une partie libre. Un événement d'une
|
||||
* version inconnue est laissé tel quel (Game.resume le refuse).
|
||||
*/
|
||||
export function migrateJournal(journal: GameJournal): GameJournal {
|
||||
export function migrateJournal(saved: GameJournal): GameJournal {
|
||||
const journal = keepKnownReview(keepKnownAdvice(saved));
|
||||
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
|
||||
return journal;
|
||||
}
|
||||
@@ -55,7 +58,84 @@ export function migrateJournal(journal: GameJournal): GameJournal {
|
||||
}
|
||||
return Object.freeze({ ...event, version: EVENT_VERSION });
|
||||
});
|
||||
return { events, head: journal.head };
|
||||
return { ...journal, events };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les conseils du maître d'un journal enregistré : seuls restent ceux de la
|
||||
* version courante (ADVICE_VERSION), rattachés à un événement du journal.
|
||||
* Les autres sont oubliés : ce ne sont que des analyses du moteur, qui
|
||||
* peuvent être refaites. Un journal sans conseils (enregistré avant l'étape
|
||||
* 1d) reste tel quel.
|
||||
*/
|
||||
function keepKnownAdvice(journal: GameJournal): GameJournal {
|
||||
const advice = journal.advice;
|
||||
if (advice === undefined) {
|
||||
return journal;
|
||||
}
|
||||
const ids = new Set(journal.events.map((event) => event.id));
|
||||
const kept = Object.entries(advice).filter(
|
||||
([id, a]) =>
|
||||
typeof a === "object" &&
|
||||
a !== null &&
|
||||
a.version === ADVICE_VERSION &&
|
||||
a.eventId === id &&
|
||||
ids.has(id),
|
||||
);
|
||||
if (kept.length === Object.keys(advice).length) {
|
||||
return journal;
|
||||
}
|
||||
return { ...journal, advice: Object.fromEntries(kept) };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'analyse de partie d'un journal enregistré (étape 1e) : oubliée si elle
|
||||
* est d'une autre version (REVIEW_VERSION) ou mal formée ; sinon, seules
|
||||
* restent ses entrées rattachées à un événement du journal. Ce n'est
|
||||
* qu'une analyse du moteur, qui peut être refaite. Un journal sans analyse
|
||||
* (enregistré avant l'étape 1e) reste tel quel.
|
||||
*/
|
||||
function keepKnownReview(journal: GameJournal): GameJournal {
|
||||
const review = journal.review;
|
||||
if (review === undefined) {
|
||||
return journal;
|
||||
}
|
||||
const tables = [review.luck, review.winChances, review.computer];
|
||||
const wellFormed =
|
||||
typeof review === "object" &&
|
||||
review !== null &&
|
||||
review.version === REVIEW_VERSION &&
|
||||
typeof review.ply === "number" &&
|
||||
tables.every((table) => typeof table === "object" && table !== null) &&
|
||||
Array.isArray(review.unanalysed);
|
||||
if (!wellFormed) {
|
||||
const { review: _, ...rest } = journal;
|
||||
return rest;
|
||||
}
|
||||
const ids = new Set(journal.events.map((event) => event.id));
|
||||
const known = <T>(table: Readonly<Record<string, T>>) =>
|
||||
Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(id)));
|
||||
const computer = Object.fromEntries(
|
||||
Object.entries(review.computer).filter(
|
||||
([id, a]) =>
|
||||
ids.has(id) &&
|
||||
typeof a === "object" &&
|
||||
a !== null &&
|
||||
a.version === ADVICE_VERSION &&
|
||||
a.eventId === id,
|
||||
),
|
||||
);
|
||||
const kept: GameReview = {
|
||||
...review,
|
||||
luck: known(review.luck),
|
||||
winChances: known(review.winChances),
|
||||
computer,
|
||||
unanalysed: review.unanalysed.filter((id) => ids.has(id)),
|
||||
};
|
||||
// Le nombre d'entrées de chaque table : rien d'oublié, rien ne change.
|
||||
const sizes = (r: GameReview) =>
|
||||
[r.luck, r.winChances, r.computer, r.unanalysed].map((t) => Object.keys(t).length).join();
|
||||
return sizes(kept) === sizes(review) ? journal : { ...journal, review: kept };
|
||||
}
|
||||
|
||||
interface EventBase {
|
||||
@@ -150,10 +230,17 @@ export type NewGameEvent = WithoutBase<GameEvent>;
|
||||
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
|
||||
|
||||
/** Tous les événements d'une partie, branches comprises, et l'événement
|
||||
* courant (head) : la partie en est là. */
|
||||
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
|
||||
* conseil du maître sur les décisions du joueur, par identifiant de
|
||||
* l'événement de la décision. Ce n'est pas un événement : il arrive après
|
||||
* coup, quand l'analyse du moteur est prête, et ne change pas la partie.
|
||||
* review (étape 1e, facultatif) : le reste de l'analyse de la partie (chance
|
||||
* des jets, chances de gagner, décisions de l'ordinateur), pour le bilan. */
|
||||
export interface GameJournal {
|
||||
readonly events: readonly GameEvent[];
|
||||
readonly head: string | null;
|
||||
readonly advice?: Readonly<Record<string, Advice>>;
|
||||
readonly review?: GameReview;
|
||||
}
|
||||
|
||||
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
|
||||
@@ -166,7 +253,35 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
|
||||
parent: journal.head,
|
||||
version: EVENT_VERSION,
|
||||
}) as GameEvent;
|
||||
return { events: [...journal.events, added], head: added.id };
|
||||
return { ...journal, events: [...journal.events, added], head: added.id };
|
||||
}
|
||||
|
||||
/** Rattache un conseil du maître à son événement (il remplace un conseil
|
||||
* déjà rattaché). Erreur si l'événement n'est pas dans le journal. */
|
||||
export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
|
||||
if (!journal.events.some((event) => event.id === advice.eventId)) {
|
||||
throw new Error(`événement introuvable : ${advice.eventId}`);
|
||||
}
|
||||
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
|
||||
}
|
||||
|
||||
/** Remplace l'analyse de partie du journal (elle ne change pas la
|
||||
* partie). */
|
||||
export function withReview(journal: GameJournal, review: GameReview): GameJournal {
|
||||
return { ...journal, review };
|
||||
}
|
||||
|
||||
/** Le journal avec les analyses d'une copie de lui-même, faite en
|
||||
* arrière-plan : ses conseils s'ajoutent, son analyse de partie remplace
|
||||
* celle du journal. Les événements ne changent pas. */
|
||||
export function withAnalysesOf(journal: GameJournal, analysed: GameJournal): GameJournal {
|
||||
const ids = new Set(journal.events.map((event) => event.id));
|
||||
const advice = Object.entries(analysed.advice ?? {}).filter(([id]) => ids.has(id));
|
||||
return {
|
||||
...journal,
|
||||
advice: { ...Object.fromEntries(advice), ...journal.advice },
|
||||
...(analysed.review === undefined ? {} : { review: analysed.review }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
|
||||
@@ -190,5 +305,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal {
|
||||
if (!journal.events.some((event) => event.id === id)) {
|
||||
throw new Error(`événement introuvable : ${id}`);
|
||||
}
|
||||
return { events: journal.events, head: id };
|
||||
return { ...journal, head: id };
|
||||
}
|
||||
|
||||
+113
-30
@@ -9,9 +9,18 @@
|
||||
// l'ordinateur, videau (Crawford compris), fin de partie, points et score du
|
||||
// match.
|
||||
|
||||
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,
|
||||
@@ -22,9 +31,18 @@ import {
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
withAdvice,
|
||||
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";
|
||||
@@ -183,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;
|
||||
}
|
||||
@@ -227,36 +248,88 @@ export class Game {
|
||||
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
|
||||
*/
|
||||
async lastMoveSteps(): Promise<MoveSteps | null> {
|
||||
const events = this.events;
|
||||
const index = events.findLastIndex((e) => e.type === "MovePlayed");
|
||||
const played = events[index];
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return null;
|
||||
}
|
||||
// L'état juste avant le coup : la position vue de celui qui joue, et
|
||||
// son jet.
|
||||
const before = replay(events.slice(0, index));
|
||||
const roll = rollToPlay(before);
|
||||
const played = this.events.findLast((e) => e.type === "MovePlayed");
|
||||
return played?.type === "MovePlayed"
|
||||
? this.stepsBefore(played.id, played.side, played.halfMoves)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup de side joué demi-coup par demi-coup à partir de la position
|
||||
* d'avant l'événement eventId (le coup réellement joué, ou un autre coup
|
||||
* du même jet : celui du maître). Les plateaux viennent du moteur ;
|
||||
* erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient
|
||||
* que la position d'avant l'événement.
|
||||
*/
|
||||
async stepsBefore(
|
||||
eventId: string,
|
||||
side: Side,
|
||||
halfMoves: readonly HalfMove[],
|
||||
): Promise<MoveSteps> {
|
||||
const line = currentLine({ ...this.#journal, head: eventId });
|
||||
// L'état juste avant l'événement : la position vue de celui qui a la
|
||||
// main, et son jet.
|
||||
const before = replay(line.slice(0, -1));
|
||||
const positions = [before.position];
|
||||
for (let i = 1; i <= played.halfMoves.length; i++) {
|
||||
for (let i = 1; i <= halfMoves.length; i++) {
|
||||
const check = await this.#engine.checkHalfMoves(
|
||||
before.position,
|
||||
roll,
|
||||
played.halfMoves.slice(0, i),
|
||||
rollToPlay(before),
|
||||
halfMoves.slice(0, i),
|
||||
);
|
||||
if (check.position === null) {
|
||||
throw new Error("coup enregistré refusé par le moteur");
|
||||
throw new Error("coup refusé par le moteur");
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(
|
||||
positions.map((p) => this.#sideBoards(p, played.side, before)),
|
||||
);
|
||||
const opponent = otherSide(played.side);
|
||||
const hits = played.halfMoves.map(
|
||||
const onRoll = before.turn ?? side;
|
||||
const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before)));
|
||||
const opponent = otherSide(side);
|
||||
const hits = halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
|
||||
);
|
||||
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
|
||||
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>> {
|
||||
return this.#journal.advice ?? {};
|
||||
}
|
||||
|
||||
/** Rattache le conseil du maître à sa décision dans le journal (il sera
|
||||
* enregistré avec la partie). Erreur si l'événement n'existe pas. */
|
||||
recordAdvice(advice: Advice): void {
|
||||
this.#journal = withAdvice(this.#journal, advice);
|
||||
}
|
||||
|
||||
/** Range dans le journal les analyses faites en arrière-plan sur une
|
||||
* copie de celui-ci (étape 1e : conseils, analyse de partie). */
|
||||
recordAnalyses(analysed: GameJournal): void {
|
||||
this.#journal = withAnalysesOf(this.#journal, analysed);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,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);
|
||||
@@ -338,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), [
|
||||
@@ -361,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,55 @@
|
||||
// L'analyse de la partie par le moteur, rangée dans le journal à côté du
|
||||
// conseil du maître (étape 1e) : la chance de chaque jet, les chances de
|
||||
// gagner après chaque coup, et les décisions de l'ordinateur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
|
||||
// moteur. Le calcul est dans domain/analysis/game-analysis.ts. Comme le
|
||||
// conseil (advice.ts), ce n'est pas un événement : l'analyse arrive après
|
||||
// coup et ne change pas la partie. Le format est durable (ADR 0007) : il
|
||||
// porte sa version (REVIEW_VERSION), et une analyse d'une autre version
|
||||
// est oubliée à la reprise (elle peut être refaite).
|
||||
|
||||
import type { Advice } from "./advice.js";
|
||||
import type { Luck, Ply } from "./engine.js";
|
||||
|
||||
/** Version du format de l'analyse de partie enregistrée. */
|
||||
export const REVIEW_VERSION = 1;
|
||||
|
||||
/**
|
||||
* L'analyse de la partie, en plus des conseils du maître : de quoi
|
||||
* calculer le bilan (taux d'erreur, chance) et tracer la courbe des chances
|
||||
* de gagner. Chaque table est indexée par l'identifiant d'un événement du
|
||||
* journal ; une table incomplète est complétée en arrière-plan.
|
||||
*/
|
||||
export interface GameReview {
|
||||
readonly version: number;
|
||||
/** Niveau des analyses et des évaluations, en plis. */
|
||||
readonly ply: Ply;
|
||||
/** Pour chaque jet (DiceRolled, ouverture comprise), sa chance pour le
|
||||
* camp qui le joue, selon le moteur. */
|
||||
readonly luck: Readonly<Record<string, Luck>>;
|
||||
/** Pour chaque coup (MovePlayed), les chances de gagner du joueur dans
|
||||
* la position d'après, de 0 à 1, selon le moteur. */
|
||||
readonly winChances: Readonly<Record<string, number>>;
|
||||
/** Les décisions de l'ordinateur, analysées comme celles du joueur (le
|
||||
* conseil d'un coup de l'ordinateur est vu de lui). */
|
||||
readonly computer: Readonly<Record<string, Advice>>;
|
||||
/** Les décisions (des deux camps) que le moteur n'analyse pas : videau
|
||||
* inaccessible, position de départ. Elles ne sont plus demandées. */
|
||||
readonly unanalysed: readonly string[];
|
||||
}
|
||||
|
||||
/** Une analyse vide, au niveau ply. */
|
||||
export function emptyReview(ply: Ply): GameReview {
|
||||
return {
|
||||
version: REVIEW_VERSION,
|
||||
ply,
|
||||
luck: {},
|
||||
winChances: {},
|
||||
computer: {},
|
||||
unanalysed: [],
|
||||
};
|
||||
}
|
||||
@@ -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,489 @@
|
||||
// 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 que demande le prompt des exercices. */
|
||||
export interface ExercisesPromptData {
|
||||
/** Les parties analysées, de la plus ancienne à la plus récente. */
|
||||
readonly journals: readonly GameJournal[];
|
||||
/** exercisesPosition (non null). */
|
||||
readonly position: PromptPosition;
|
||||
}
|
||||
|
||||
/** Le prompt « Créer des exercices » (progression, étape 1h). Erreur si
|
||||
* les parties n'ont aucune imprécision ni erreur. */
|
||||
export function exercisesPrompt(
|
||||
templates: PromptTemplates,
|
||||
phrases: MasterPhrases,
|
||||
data: ExercisesPromptData,
|
||||
): Prompt {
|
||||
const mistakes = costliest(data.journals);
|
||||
const worst = mistakes[0];
|
||||
if (worst === undefined) {
|
||||
throw new Error("exercices : aucune erreur dans ces parties");
|
||||
}
|
||||
// Le niveau, sur toutes les décisions de ces parties.
|
||||
let total = 0;
|
||||
let decisions = 0;
|
||||
for (const journal of data.journals) {
|
||||
const rate = summarizeAnalysis(journal).player.errorRate;
|
||||
total += rate.total;
|
||||
decisions += rate.decisions;
|
||||
}
|
||||
const level = levelOfErrorRate(decisions > 0 ? total / decisions : 0);
|
||||
const listed = mistakes
|
||||
.slice(0, MAX_ERRORS)
|
||||
.map((m) => mistakeLine(phrases, m, `Partie ${m.game}, coup ${m.turn}`));
|
||||
return fillPrompt(templates.exercises, {
|
||||
...positionValues(data.position),
|
||||
joueur: playerSentence({ decisions, level }),
|
||||
niveau_eval: plyText(worst.advice.ply),
|
||||
parties: String(data.journals.length),
|
||||
erreurs: listed.join("\n"),
|
||||
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Les gabarits des prompts pour une IA (étape 1f) : lecture de leur source
|
||||
// (docs/prompts/, ADR 0008 : une seule source) et remplissage des repères.
|
||||
// Fonctions pures, testées sous Node sur de vraies analyses du moteur
|
||||
// (app/tests/prompts.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Un prompt : le texte que l'application prépare pour une IA, rempli avec
|
||||
* la position et les chiffres du moteur, et que le joueur copie.
|
||||
*
|
||||
* Glossaire : docs/glossaire/prompt.md
|
||||
*/
|
||||
export type Prompt = string;
|
||||
|
||||
/** Les cinq gabarits du cadrage (« IA générative en V1 »). */
|
||||
export type PromptKind = "move" | "cube" | "summary" | "concept" | "exercises";
|
||||
|
||||
export const PROMPT_KINDS: readonly PromptKind[] = [
|
||||
"move",
|
||||
"cube",
|
||||
"summary",
|
||||
"concept",
|
||||
"exercises",
|
||||
];
|
||||
|
||||
/** Le fichier de chaque gabarit, dans docs/prompts/. */
|
||||
export const PROMPT_FILES: Readonly<Record<PromptKind, string>> = {
|
||||
move: "coup.md",
|
||||
cube: "videau.md",
|
||||
summary: "bilan.md",
|
||||
concept: "concept.md",
|
||||
exercises: "exercices.md",
|
||||
};
|
||||
|
||||
/** Les gabarits lus, par sorte. */
|
||||
export type PromptTemplates = Readonly<Record<PromptKind, string>>;
|
||||
|
||||
/** Le gabarit d'un fichier de docs/prompts/ : le bloc de texte qui suit le
|
||||
* titre « ## Gabarit ». Erreur s'il manque. */
|
||||
export function parsePromptTemplate(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
const heading = lines.findIndex((line) => line.trim() === "## Gabarit");
|
||||
const start = lines.findIndex((line, i) => i > heading && line.trim() === "```text");
|
||||
const end = lines.findIndex((line, i) => i > start && line.trim() === "```");
|
||||
if (heading < 0 || start < 0 || end < 0) {
|
||||
throw new Error("gabarit de prompt : bloc « ## Gabarit » absent");
|
||||
}
|
||||
return lines.slice(start + 1, end).join("\n");
|
||||
}
|
||||
|
||||
/** Les repères d'un gabarit, dans l'ordre, sans doublon. */
|
||||
export function placeholders(template: string): string[] {
|
||||
return [...new Set([...template.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1] as string))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplit les repères {nom} d'un gabarit. Erreur sur un repère inconnu ou
|
||||
* une valeur vide : un prompt n'a jamais de trou.
|
||||
*/
|
||||
export function fillPrompt(template: string, values: Readonly<Record<string, string>>): Prompt {
|
||||
return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} inconnu`);
|
||||
}
|
||||
if (value.trim() === "") {
|
||||
throw new Error(`gabarit de prompt : repère {${name}} vide`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
EngineCube,
|
||||
EngineMatch,
|
||||
EvaluatedMove,
|
||||
Evaluation,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Luck,
|
||||
@@ -181,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;
|
||||
@@ -192,6 +194,7 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
best: toEvaluatedMove(answer.best),
|
||||
equityLoss: answer.equity_loss,
|
||||
skill: toSkill(answer.skill),
|
||||
ranking: answer.ranking.map(toEvaluatedMove),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -252,6 +255,15 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
return { value: answer.luck, rating };
|
||||
},
|
||||
|
||||
async evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation> {
|
||||
const args = ["--ply", String(ply), "eval", position, await matchId(cube)];
|
||||
const answer = (await run(args)) as {
|
||||
probabilities: { win: number };
|
||||
cubeful_equity: number;
|
||||
};
|
||||
return { win: answer.probabilities.win, equity: answer.cubeful_equity };
|
||||
},
|
||||
|
||||
encodeMatch: matchId,
|
||||
|
||||
async decodeMatch(id: MatchId): Promise<MatchDescription> {
|
||||
|
||||
+67
-3
@@ -5,8 +5,11 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
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";
|
||||
import { levelOf } from "./domain/game/saved-game.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
import {
|
||||
illustratedMove,
|
||||
@@ -14,8 +17,10 @@ import {
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { positionBefore } from "./domain/prompts/position.js";
|
||||
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
@@ -27,6 +32,30 @@ import { startUpdates } from "./ui/updates.js";
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
|
||||
// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un
|
||||
// second moteur, dans son propre Web Worker. Le premier ne traite qu'une
|
||||
// commande à la fois : une analyse y retarderait le coup de l'ordinateur.
|
||||
// Le second démarre à la première analyse demandée, une fois le premier
|
||||
// chargé et le service worker installé (au plus 20 s d'attente) : si deux
|
||||
// téléchargements des données du moteur se croisent, Chromium fait échouer
|
||||
// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les
|
||||
// données viennent du cache.
|
||||
let analysisWorker: Promise<EngineWorker> | null = null;
|
||||
function startAnalysisWorker(): Promise<EngineWorker> {
|
||||
analysisWorker ??= worker.ready
|
||||
.then(() => {
|
||||
const installed =
|
||||
"serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve();
|
||||
return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]);
|
||||
})
|
||||
.then(() => {
|
||||
const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
return started.ready.then(() => started);
|
||||
});
|
||||
return analysisWorker;
|
||||
}
|
||||
const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args));
|
||||
|
||||
// Chaque accès au stockage du navigateur peut lever une exception (stockage
|
||||
// interdit) : on fait alors sans.
|
||||
const localStorage = attempt(() => window.localStorage);
|
||||
@@ -36,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,
|
||||
});
|
||||
@@ -47,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). */
|
||||
@@ -78,6 +107,22 @@ startApp({
|
||||
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
|
||||
shownId: () => storage().session.shownId,
|
||||
open: (id, branchHead) => storage().session.open(id, branchHead),
|
||||
view: async (id) => {
|
||||
const saved = await storage().library.load(id);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, saved.journal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
saveAnalyses: (id, journal) => {
|
||||
storage()
|
||||
.library.saveAnalyses(id, journal, { level: levelOf(journal) })
|
||||
.catch(() => {});
|
||||
},
|
||||
remove: (id) => storage().library.remove(id),
|
||||
exportSgf: async (id) => {
|
||||
const game = await storage().library.load(id);
|
||||
@@ -109,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
|
||||
@@ -117,6 +170,17 @@ startApp({
|
||||
// network error »), et l'application n'est pas disponible hors ligne.
|
||||
// Cela évite aussi de télécharger deux fois ces données à la première
|
||||
// visite.
|
||||
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
|
||||
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
|
||||
// manquent, une à une, sur le même moteur à part.
|
||||
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
|
||||
// Les prompts pour une IA (étape 1f) : la position à décrire, lue par
|
||||
// le moteur, et l'évaluation de la position d'exemple d'un concept.
|
||||
prompts: {
|
||||
before: (journal, eventId) => positionBefore(engine, journal, eventId),
|
||||
summary: (journal) => summaryPosition(engine, journal),
|
||||
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
|
||||
},
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface GameLibrary {
|
||||
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
|
||||
* cours. Renvoie son résumé. */
|
||||
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
|
||||
/** Enregistre le journal d'une partie analysée en arrière-plan (étape
|
||||
* 1e) : ni la partie en cours ni la date de la dernière action ne
|
||||
* changent. Rien si la partie n'existe plus. */
|
||||
saveAnalyses(id: string, journal: GameJournal, details: SavedGameDetails): Promise<void>;
|
||||
/** Supprime une partie (rien si elle n'existe pas). */
|
||||
remove(id: string): Promise<void>;
|
||||
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
|
||||
@@ -55,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 {
|
||||
@@ -118,6 +126,15 @@ export function createGameLibrary(
|
||||
await backend.setCurrentId(id);
|
||||
return summary;
|
||||
}),
|
||||
saveAnalyses: (id, journal, details) =>
|
||||
enqueue(async () => {
|
||||
const known = await backend.summary(id);
|
||||
if (known === undefined) {
|
||||
return;
|
||||
}
|
||||
const times = { startedAt: known.startedAt, updatedAt: known.updatedAt };
|
||||
await backend.write(summarizeGame(id, journal, details, times), journal);
|
||||
}),
|
||||
remove: (id) => enqueue(() => backend.remove(id)),
|
||||
current: () =>
|
||||
enqueue(async () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Les textes de l'interface du conseil du maître (ligne, feuille
|
||||
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
|
||||
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { CubeAction } from "../domain/game/engine.js";
|
||||
|
||||
export const ADVICE_TEXTS = {
|
||||
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
|
||||
menuSwitch: "Conseil du maître",
|
||||
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
|
||||
/** Paysage : la pastille du mot, au lecteur d'écran. */
|
||||
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
|
||||
sheetLabel: "Explication du maître",
|
||||
yourMove: "Votre coup",
|
||||
masterMove: "Le coup du maître",
|
||||
choiceLabel: "Coup montré sur le plateau",
|
||||
legendMine: (move: string) =>
|
||||
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
|
||||
legendBest: (move: string) =>
|
||||
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
|
||||
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
|
||||
legendCube: "La position au moment de la décision.",
|
||||
replay: "Rejouer le coup",
|
||||
numbers: "Voir les chiffres du moteur",
|
||||
you: "(vous)",
|
||||
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: {
|
||||
noDouble: "Pas de double",
|
||||
doubleTake: "Double, prend",
|
||||
doublePass: "Double, passe",
|
||||
},
|
||||
glossary: (term: string) => `En savoir plus : « ${term} »`,
|
||||
close: "Fermer",
|
||||
missing: "Le maître n'a pas d'explication pour ce coup.",
|
||||
} as const;
|
||||
|
||||
/** La fiche du glossaire d'une décision de videau, et son titre. */
|
||||
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
|
||||
double: { file: "doubler", term: "Doubler" },
|
||||
noDouble: { file: "doubler", term: "Doubler" },
|
||||
take: { file: "prendre", term: "Prendre" },
|
||||
pass: { file: "passer", term: "Passer" },
|
||||
};
|
||||
|
||||
/** 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";
|
||||
@@ -0,0 +1,152 @@
|
||||
// Le conseil du maître pendant la partie : quelles décisions du joueur
|
||||
// analyser, quand montrer la ligne, quand l'effacer (charte, 1d « La ligne
|
||||
// du maître »). Sans DOM : testé sous Node (app/tests/advice-tracker.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les analyses partent dès que la décision est jouée, sans être attendues :
|
||||
// l'application les confie à un second moteur (main.ts), et le tour de
|
||||
// l'ordinateur continue sur le premier. Chaque résultat est rattaché à sa
|
||||
// décision dans le journal, pour le bilan (1e). La ligne apparaît quand
|
||||
// l'analyse attendue est prête, si le joueur n'a rien fait entre-temps ;
|
||||
// sinon elle n'apparaît pas, et le coup reste commenté dans le journal.
|
||||
|
||||
import { playerDecisions } from "../domain/analysis/analyse.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { GameEvent, GameJournal } from "../domain/game/events.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
|
||||
/** Analyse une décision du joueur (null : rien à en dire). */
|
||||
export type Analyse = (journal: GameJournal, eventId: string) => Promise<Advice | null>;
|
||||
|
||||
export interface AdviceTracker {
|
||||
/** Le conseil à montrer maintenant (null : rien). */
|
||||
readonly shown: Advice | null;
|
||||
/** Après une action du joueur sur game : before est la ligne de la
|
||||
* partie avant l'action. Les décisions ajoutées sont analysées. */
|
||||
track(game: Game, before: readonly GameEvent[]): void;
|
||||
/** L'action suivante du joueur : la ligne s'efface, et une analyse
|
||||
* encore attendue ne s'affichera pas. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface AdviceTrackerOptions {
|
||||
readonly analyse: Analyse;
|
||||
/** Un conseil a été rattaché au journal de game (à enregistrer). */
|
||||
readonly recorded: (game: Game) => void;
|
||||
/** Le conseil à montrer a changé. */
|
||||
readonly changed: () => void;
|
||||
}
|
||||
|
||||
export function createAdviceTracker(options: AdviceTrackerOptions): AdviceTracker {
|
||||
let shown: Advice | null = null;
|
||||
// Chaque attente de ligne a son jeton : clear() l'annule.
|
||||
let waiting: object | null = null;
|
||||
let tracked: Game | null = null;
|
||||
let pending = new Map<string, Promise<Advice | null>>();
|
||||
|
||||
/** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */
|
||||
function adviceOf(game: Game, id: string): Promise<Advice | null> {
|
||||
const known = game.advice[id];
|
||||
if (known !== undefined) {
|
||||
return Promise.resolve(known);
|
||||
}
|
||||
let running = pending.get(id);
|
||||
if (running === undefined) {
|
||||
running = options
|
||||
.analyse(game.journal, id)
|
||||
.catch(() => null)
|
||||
.then((advice) => {
|
||||
if (advice !== null && game.journal.events.some((e) => e.id === id)) {
|
||||
game.recordAdvice(advice);
|
||||
options.recorded(game);
|
||||
}
|
||||
return advice;
|
||||
});
|
||||
pending.set(id, running);
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
return {
|
||||
get shown() {
|
||||
return shown;
|
||||
},
|
||||
track(game, before) {
|
||||
if (game !== tracked) {
|
||||
tracked = game;
|
||||
pending = new Map();
|
||||
}
|
||||
const known = new Set(before.map((e) => e.id));
|
||||
const line = game.events;
|
||||
const added = line.filter((e) => !known.has(e.id));
|
||||
for (const decision of playerDecisions(added)) {
|
||||
void adviceOf(game, decision.id);
|
||||
}
|
||||
const candidates = toShow(line, added);
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
const token = {};
|
||||
waiting = token;
|
||||
void Promise.all(candidates.map((id) => adviceOf(game, id))).then((results) => {
|
||||
if (waiting !== token || tracked !== game) {
|
||||
return; // trop tard : le joueur a déjà agi
|
||||
}
|
||||
waiting = null;
|
||||
shown = choose(results);
|
||||
if (shown !== null) {
|
||||
options.changed();
|
||||
}
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
waiting = null;
|
||||
if (shown !== null) {
|
||||
shown = null;
|
||||
options.changed();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les décisions dont la ligne peut parler après une action : le coup du
|
||||
* joueur et, juste avant lui, son jet sans doubler (il aurait pu doubler) ;
|
||||
* ou son double, ou sa réponse au double de l'ordinateur. Un jet seul ne
|
||||
* montre rien : le joueur réfléchit alors à son coup.
|
||||
*/
|
||||
function toShow(line: readonly GameEvent[], added: readonly GameEvent[]): string[] {
|
||||
const cube = added.find(
|
||||
(e) =>
|
||||
(e.type === "CubeOffered" || e.type === "CubeTaken" || e.type === "CubeDropped") &&
|
||||
e.side === "player",
|
||||
);
|
||||
if (cube !== undefined) {
|
||||
return [cube.id];
|
||||
}
|
||||
const move = added.find((e) => e.type === "MovePlayed" && e.side === "player");
|
||||
if (move?.type !== "MovePlayed") {
|
||||
return [];
|
||||
}
|
||||
const index = line.findIndex((e) => e.id === move.id);
|
||||
const roll = line
|
||||
.slice(0, index)
|
||||
.findLast((e) => e.type === "DiceRolled" && e.side === "player");
|
||||
const ids = move.halfMoves.length > 0 ? [move.id] : [];
|
||||
return roll === undefined ? ids : [roll.id, ...ids];
|
||||
}
|
||||
|
||||
/** Ce que la ligne montre : un double manqué avant le jet s'il était une
|
||||
* faute, sinon le conseil sur le coup ou sur la décision de videau. */
|
||||
function choose(results: readonly (Advice | null)[]): Advice | null {
|
||||
const advices = results.filter((a): a is Advice => a !== null);
|
||||
const missedDouble = advices.find(
|
||||
(a) => a.kind === "cube" && a.action === "noDouble" && a.skill !== "none",
|
||||
);
|
||||
if (missedDouble !== undefined) {
|
||||
return missedDouble;
|
||||
}
|
||||
return advices.find((a) => !(a.kind === "cube" && a.action === "noDouble")) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// La feuille d'explication du maître (#/explication/<événement>), qui monte
|
||||
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
|
||||
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
|
||||
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
|
||||
// position d'avant, puis les chiffres du moteur, repliés.
|
||||
//
|
||||
// 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,
|
||||
type CubeAdvice,
|
||||
type MoveAdvice,
|
||||
masterCubeAction,
|
||||
type Verdict,
|
||||
} 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. */
|
||||
const PREFIX = "explain-";
|
||||
|
||||
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
|
||||
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
|
||||
good: "verdictGood",
|
||||
doubtful: "verdictDoubtful",
|
||||
bad: "verdictBad",
|
||||
veryBad: "verdictBad",
|
||||
};
|
||||
|
||||
export interface AdviceViewOptions {
|
||||
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
|
||||
readonly veil: HTMLElement;
|
||||
readonly game: () => Game | null;
|
||||
readonly phrases: () => Promise<MasterPhrases>;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** Fermer : quitter l'adresse de la feuille. */
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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 createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const { veil } = options;
|
||||
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
|
||||
sheet.setAttribute("role", "dialog");
|
||||
sheet.setAttribute("aria-modal", "true");
|
||||
sheet.setAttribute("aria-labelledby", "explain-title");
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.close();
|
||||
}
|
||||
});
|
||||
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
|
||||
// fermeture, arrête l'animation en cours.
|
||||
let opened = 0;
|
||||
// Le plateau de la feuille joue un coup à la fois.
|
||||
let player: MovePlayer | null = null;
|
||||
|
||||
async function show(eventId: string): Promise<void> {
|
||||
const id = ++opened;
|
||||
const game = options.game();
|
||||
const advice = game?.advice[eventId] ?? null;
|
||||
clearSheet(sheet);
|
||||
veil.hidden = false;
|
||||
const head = element(sheet, "div", { className: "sheet-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
const verdictLine = element(head, "p", {
|
||||
id: "explain-verdict",
|
||||
className: "explain-verdict",
|
||||
});
|
||||
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
|
||||
close.type = "button";
|
||||
close.append(icon("close"));
|
||||
close.setAttribute("aria-label", ADVICE_TEXTS.close);
|
||||
close.onclick = () => options.close();
|
||||
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
|
||||
if (game === null || advice === null) {
|
||||
title.textContent = ADVICE_TEXTS.sheetLabel;
|
||||
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
|
||||
closeButton();
|
||||
close.focus();
|
||||
return;
|
||||
}
|
||||
const words = masterWords(await options.phrases(), advice);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
verdictLine.dataset.verdict = words.verdict;
|
||||
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
|
||||
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
|
||||
title.textContent = words.title;
|
||||
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
|
||||
// (et disparaît si elle ne disait que lui).
|
||||
const rest = words.phrase.startsWith(`${words.title}.`)
|
||||
? words.phrase.slice(words.title.length + 1).trim()
|
||||
: words.phrase;
|
||||
if (rest !== "") {
|
||||
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
|
||||
}
|
||||
|
||||
const body = element(sheet, "div", { className: "explain-body" });
|
||||
const boardBox = element(body, "div", { className: "explain-board-box" });
|
||||
if (advice.kind === "move") {
|
||||
await showMoves(id, game, advice, body, boardBox);
|
||||
} else {
|
||||
await showCube(id, game, advice, boardBox);
|
||||
}
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
numbers(advice);
|
||||
if (advice.kind === "cube") {
|
||||
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
|
||||
const link = element(sheet, "a", {
|
||||
id: "explain-glossary",
|
||||
className: "link-button explain-glossary",
|
||||
text: ADVICE_TEXTS.glossary(entry.term),
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: entry.file });
|
||||
}
|
||||
closeButton(advice.eventId);
|
||||
close.focus();
|
||||
}
|
||||
|
||||
/** « 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",
|
||||
text: ADVICE_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
}
|
||||
|
||||
/** Le plateau de la feuille, dans son cadre, et ce qui y joue les
|
||||
* coups. */
|
||||
function sheetBoard(box: HTMLElement): SVGSVGElement {
|
||||
const svg = boardSvg(box, "explain-board", "explain-board");
|
||||
player = createMovePlayer(svg, PREFIX, options.reducedMotion);
|
||||
return svg;
|
||||
}
|
||||
|
||||
async function showMoves(
|
||||
id: number,
|
||||
game: Game,
|
||||
advice: MoveAdvice,
|
||||
body: HTMLElement,
|
||||
boardBox: HTMLElement,
|
||||
): Promise<void> {
|
||||
const same = advice.rank === 1;
|
||||
const [mine, best] = await Promise.all([
|
||||
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
|
||||
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
|
||||
]);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
|
||||
const svg = sheetBoard(boardBox);
|
||||
if (best === null) {
|
||||
svg.dataset.move = "mine";
|
||||
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
showWithNotation(legend, text, advice.played.notation);
|
||||
void player?.play(mine);
|
||||
return;
|
||||
}
|
||||
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
|
||||
choice.setAttribute("role", "radiogroup");
|
||||
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
|
||||
body.prepend(choice);
|
||||
const buttons: Record<Shown, HTMLButtonElement> = {
|
||||
mine: element(choice, "button", {
|
||||
id: "explain-mine",
|
||||
className: "segment",
|
||||
text: ADVICE_TEXTS.yourMove,
|
||||
}),
|
||||
best: element(choice, "button", {
|
||||
id: "explain-best",
|
||||
className: "segment",
|
||||
text: ADVICE_TEXTS.masterMove,
|
||||
}),
|
||||
};
|
||||
const select = (shown: Shown) => {
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
buttons[key].setAttribute("aria-checked", String(key === shown));
|
||||
}
|
||||
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
|
||||
const text =
|
||||
shown === "mine"
|
||||
? ADVICE_TEXTS.legendMine(notation)
|
||||
: ADVICE_TEXTS.legendBest(notation);
|
||||
showWithNotation(legend, text, notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
svg.dataset.move = shown;
|
||||
void player?.play(shown === "mine" ? mine : best);
|
||||
};
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
const button = buttons[key];
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "radio");
|
||||
button.onclick = () => select(key);
|
||||
}
|
||||
// Flèches gauche et droite entre les deux segments.
|
||||
choice.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
|
||||
select(next);
|
||||
buttons[next].focus();
|
||||
}
|
||||
});
|
||||
// Par défaut, le coup du maître (maquette).
|
||||
select("best");
|
||||
}
|
||||
|
||||
async function showCube(
|
||||
id: number,
|
||||
game: Game,
|
||||
advice: CubeAdvice,
|
||||
boardBox: HTMLElement,
|
||||
): Promise<void> {
|
||||
const steps = await game.stepsBefore(advice.eventId, "player", []);
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
const svg = boardSvg(boardBox, "explain-board", "explain-board");
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
|
||||
drawSteps(svg, steps, 0, false, PREFIX);
|
||||
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
|
||||
}
|
||||
|
||||
/** « Voir les chiffres du moteur », replié par défaut. */
|
||||
function numbers(advice: Advice): void {
|
||||
const details = element(sheet, "details", {
|
||||
id: "explain-numbers",
|
||||
className: "explain-numbers",
|
||||
});
|
||||
element(details, "summary", { text: ADVICE_TEXTS.numbers });
|
||||
const table = element(details, "div", { className: "explain-table" });
|
||||
const row = (cells: string[], strong: boolean) => {
|
||||
for (const text of cells) {
|
||||
element(table, "span", { className: strong ? "strong" : "", text });
|
||||
}
|
||||
};
|
||||
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é (les mêmes lignes que le prompt).
|
||||
for (const { move, rank, loss, played: mine } of rankingRows(advice)) {
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
|
||||
formatEquity(loss),
|
||||
],
|
||||
mine,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
|
||||
});
|
||||
} else {
|
||||
// Les lignes de votre décision, en gras : doubler mène à l'une des
|
||||
// deux lignes « Double », selon la réponse de l'ordinateur.
|
||||
const mine = {
|
||||
noDouble: ["noDouble"],
|
||||
double: ["doubleTake", "doublePass"],
|
||||
take: ["doubleTake"],
|
||||
pass: ["doublePass"],
|
||||
}[advice.action];
|
||||
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
|
||||
const bold = mine.includes(key);
|
||||
const name = ADVICE_TEXTS.cubeRows[key];
|
||||
row(
|
||||
[
|
||||
"",
|
||||
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
|
||||
formatEquity(advice.equities[key]),
|
||||
],
|
||||
bold,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
sheet,
|
||||
hide() {
|
||||
opened++;
|
||||
player?.stop();
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
clearSheet(sheet);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
// L'écran d'analyse d'une partie (#/parties/<id>/analyse/<coup>, étape
|
||||
// 1e ; charte, ecrans-etape-1.md, « Analyse ») : une page qui défile, avec
|
||||
// la courbe de vos chances de gagner coup après coup, parcourue du doigt ;
|
||||
// le visualiseur du coup choisi (votre coup contre le coup du maître,
|
||||
// « Rejouer d'ici ») ; la liste des coups à revoir, filtrable ; et les
|
||||
// chiffres du moteur, repliés.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// La courbe est dessinée à la main en SVG (aucune bibliothèque). Chaque
|
||||
// coup y a une bande verticale sur toute la hauteur, cible du doigt : on
|
||||
// touche ou on glisse, et le coup sous le doigt est choisi. Le coup choisi
|
||||
// est dans l'adresse ; en changer remplace l'adresse (le retour du
|
||||
// téléphone quitte l'écran, il ne repasse pas par chaque coup).
|
||||
|
||||
import {
|
||||
chanceCurve,
|
||||
computerMoveAdvice,
|
||||
type GameSummary,
|
||||
gameTurns,
|
||||
mainAdvice,
|
||||
replayPoint,
|
||||
summarizeAnalysis,
|
||||
type Turn,
|
||||
type WinChanceCurve,
|
||||
} from "../domain/analysis/game-analysis.js";
|
||||
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
|
||||
import { type Advice, type Verdict, verdictOf } from "../domain/game/advice.js";
|
||||
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";
|
||||
import { percent, REVIEW_TEXTS, signed } from "./review-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Ce que montre l'écran. */
|
||||
export interface AnalysisData {
|
||||
readonly id: string;
|
||||
readonly journal: GameJournal;
|
||||
/** La partie, pour rejouer les coups sur le plateau. */
|
||||
readonly game: Game;
|
||||
/** Le coup choisi (null : le coup à revoir, ou le premier). */
|
||||
readonly turn: number | null;
|
||||
readonly phrases: MasterPhrases | null;
|
||||
}
|
||||
|
||||
export interface AnalysisViewOptions {
|
||||
/** La page (index.html) ; son contenu est construit ici. */
|
||||
readonly section: HTMLElement;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** Choisir un autre coup : remplacer l'adresse. */
|
||||
readonly select: (turn: number) => void;
|
||||
/** « Rejouer d'ici » : revenir à cet événement de la partie. */
|
||||
readonly replay: (id: string, eventId: string) => void;
|
||||
/** Le lien « Retour » (quitter l'écran sans empiler d'adresse). */
|
||||
readonly back: (link: HTMLAnchorElement) => void;
|
||||
}
|
||||
|
||||
export interface AnalysisView {
|
||||
show(data: AnalysisData | null): void;
|
||||
hide(): void;
|
||||
/** ← et → : le coup d'avant ou d'après. */
|
||||
step(delta: number): void;
|
||||
}
|
||||
|
||||
type Filter = "all" | "errors" | "doubtful";
|
||||
type Shown = "mine" | "best";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** Hauteur de la courbe, et marges du tracé (repère en pixels). */
|
||||
const CURVE_HEIGHT = 168;
|
||||
const PAD = { left: 40, right: 10, top: 10, bottom: 10 };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Les verdicts que montre chaque filtre. */
|
||||
const FILTERS: Readonly<Record<Filter, readonly Verdict[]>> = {
|
||||
all: ["good", "doubtful", "bad", "veryBad"],
|
||||
errors: ["bad", "veryBad"],
|
||||
doubtful: ["doubtful"],
|
||||
};
|
||||
|
||||
export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
|
||||
const { section } = options;
|
||||
let data: AnalysisData | null = null;
|
||||
let turns: Turn[] = [];
|
||||
let curve: WinChanceCurve = [];
|
||||
let selected = 1;
|
||||
let filter: Filter = "all";
|
||||
// Ce qui est dessiné dans le visualiseur, pour ne pas rejouer le coup à
|
||||
// chaque analyse rangée.
|
||||
let drawnTurn = "";
|
||||
let player: MovePlayer | null = null;
|
||||
// Chaque changement de coup a son numéro : un plateau qui arrive trop
|
||||
// tard n'est pas dessiné.
|
||||
let request = 0;
|
||||
|
||||
// La page : en-tête, courbe, visualiseur, liste, chiffres.
|
||||
const head = element(section, "div", { className: "analysis-head" });
|
||||
const back = element(head, "a", {
|
||||
id: "analysis-back",
|
||||
className: "button secondary",
|
||||
text: REVIEW_TEXTS.back,
|
||||
});
|
||||
back.href = "#/";
|
||||
options.back(back);
|
||||
element(head, "h2", {
|
||||
id: "analysis-title",
|
||||
className: "analysis-title",
|
||||
text: REVIEW_TEXTS.analysisTitle,
|
||||
});
|
||||
const message = element(section, "p", {
|
||||
id: "analysis-message",
|
||||
className: "analysis-message",
|
||||
});
|
||||
message.setAttribute("role", "status");
|
||||
|
||||
const curveCard = element(section, "section", { className: "analysis-card curve-card" });
|
||||
element(curveCard, "h3", { className: "overline", text: REVIEW_TEXTS.curveTitle });
|
||||
const curveSvg = document.createElementNS(SVG_NS, "svg");
|
||||
curveSvg.id = "analysis-curve";
|
||||
curveSvg.setAttribute("class", "analysis-curve");
|
||||
curveSvg.setAttribute("role", "slider");
|
||||
curveSvg.setAttribute("tabindex", "0");
|
||||
curveCard.append(curveSvg);
|
||||
|
||||
const viewer = element(section, "section", {
|
||||
id: "analysis-viewer",
|
||||
className: "analysis-card viewer",
|
||||
});
|
||||
const nav = element(viewer, "div", { className: "viewer-nav" });
|
||||
const prev = element(nav, "a", { id: "analysis-prev", className: "icon-button viewer-step" });
|
||||
prev.append(icon("chevron"));
|
||||
prev.setAttribute("aria-label", REVIEW_TEXTS.previous);
|
||||
const turnTitle = element(nav, "p", { id: "analysis-turn", className: "viewer-turn" });
|
||||
turnTitle.setAttribute("aria-live", "polite");
|
||||
const next = element(nav, "a", { id: "analysis-next", className: "icon-button viewer-step" });
|
||||
next.append(icon("chevron"));
|
||||
next.setAttribute("aria-label", REVIEW_TEXTS.next);
|
||||
for (const [link, delta] of [
|
||||
[prev, -1],
|
||||
[next, 1],
|
||||
] as const) {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
step(delta);
|
||||
});
|
||||
}
|
||||
const viewerBody = element(viewer, "div", { className: "viewer-body" });
|
||||
const boardBox = element(viewerBody, "div", { className: "viewer-board-box" });
|
||||
const board = boardSvg(boardBox, "analysis-board", "analysis-board");
|
||||
player = createMovePlayer(board, "analysis-", options.reducedMotion);
|
||||
const viewerText = element(viewerBody, "div", { className: "viewer-text" });
|
||||
const phrase = element(viewerText, "p", { id: "analysis-phrase", className: "viewer-phrase" });
|
||||
const choice = element(viewerText, "div", { id: "analysis-choice", className: "segmented" });
|
||||
choice.setAttribute("role", "radiogroup");
|
||||
choice.setAttribute("aria-label", REVIEW_TEXTS.choiceLabel);
|
||||
const segments: Record<Shown, HTMLButtonElement> = {
|
||||
mine: element(choice, "button", { id: "analysis-mine", className: "segment" }),
|
||||
best: element(choice, "button", {
|
||||
id: "analysis-best",
|
||||
className: "segment",
|
||||
text: REVIEW_TEXTS.masterMove,
|
||||
}),
|
||||
};
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
segments[key].type = "button";
|
||||
segments[key].setAttribute("role", "radio");
|
||||
}
|
||||
const legend = element(viewerText, "p", { id: "analysis-legend", className: "viewer-legend" });
|
||||
const chanceLine = element(viewerText, "p", {
|
||||
id: "analysis-chance",
|
||||
className: "viewer-chance",
|
||||
});
|
||||
const viewerActions = element(viewerText, "div", { className: "viewer-actions" });
|
||||
const replayButton = element(viewerActions, "button", {
|
||||
id: "analysis-replay",
|
||||
text: REVIEW_TEXTS.replayFromHere,
|
||||
});
|
||||
replayButton.type = "button";
|
||||
// « 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,
|
||||
});
|
||||
|
||||
const listCard = element(section, "section", { className: "analysis-card list-card" });
|
||||
element(listCard, "h3", { className: "overline", text: REVIEW_TEXTS.listTitle });
|
||||
const filters = element(listCard, "div", { id: "analysis-filters", className: "filters" });
|
||||
filters.setAttribute("role", "group");
|
||||
filters.setAttribute("aria-label", REVIEW_TEXTS.filterLabel);
|
||||
const filterButtons: Record<Filter, HTMLButtonElement> = {
|
||||
all: element(filters, "button", { className: "filter" }),
|
||||
errors: element(filters, "button", { className: "filter" }),
|
||||
doubtful: element(filters, "button", { className: "filter" }),
|
||||
};
|
||||
for (const key of ["all", "errors", "doubtful"] as const) {
|
||||
const button = filterButtons[key];
|
||||
button.type = "button";
|
||||
button.dataset.filter = key;
|
||||
button.onclick = () => {
|
||||
filter = key;
|
||||
renderList();
|
||||
};
|
||||
}
|
||||
const list = element(listCard, "ul", { id: "analysis-list", className: "analysis-list" });
|
||||
|
||||
const numbers = element(section, "details", {
|
||||
id: "analysis-numbers",
|
||||
className: "analysis-card explain-numbers",
|
||||
});
|
||||
element(numbers, "summary", { text: REVIEW_TEXTS.numbers });
|
||||
const table = element(numbers, "div", { className: "explain-table analysis-table" });
|
||||
element(numbers, "p", { className: "explain-note", text: REVIEW_TEXTS.numbersNote });
|
||||
|
||||
// ------------------------------------------------------------ Courbe
|
||||
|
||||
/** Le coup sous l'abscisse x (pixels du tracé). */
|
||||
function turnAt(x: number): number {
|
||||
const width = curveSvg.getBoundingClientRect().width;
|
||||
const inner = Math.max(1, width - PAD.left - PAD.right);
|
||||
const index = Math.floor(((x - PAD.left) / inner) * turns.length);
|
||||
return Math.min(turns.length, Math.max(1, index + 1));
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
curveSvg.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
curveSvg.setPointerCapture?.(event.pointerId);
|
||||
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
|
||||
});
|
||||
curveSvg.addEventListener("pointermove", (event) => {
|
||||
if (dragging) {
|
||||
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
|
||||
}
|
||||
});
|
||||
for (const end of ["pointerup", "pointercancel"] as const) {
|
||||
curveSvg.addEventListener(end, () => {
|
||||
dragging = false;
|
||||
});
|
||||
}
|
||||
curveSvg.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
step(event.key === "ArrowLeft" ? -1 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
function renderCurve(): void {
|
||||
const width = Math.round(curveSvg.getBoundingClientRect().width || 360);
|
||||
const height = CURVE_HEIGHT;
|
||||
curveSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
curveSvg.replaceChildren();
|
||||
const inner = width - PAD.left - PAD.right;
|
||||
const band = turns.length > 0 ? inner / turns.length : inner;
|
||||
const x = (turn: number) => PAD.left + (turn - 0.5) * band;
|
||||
const y = (chance: number) => PAD.top + (1 - chance) * (height - PAD.top - PAD.bottom);
|
||||
// Axe : 100 %, 50 % (pointillé), 0 %.
|
||||
for (const level of [1, 0.5, 0]) {
|
||||
svgElement(curveSvg, "line", {
|
||||
x1: PAD.left,
|
||||
x2: width - PAD.right,
|
||||
y1: y(level),
|
||||
y2: y(level),
|
||||
class: level === 0.5 ? "curve-half" : "curve-axis",
|
||||
});
|
||||
const label = svgElement(curveSvg, "text", {
|
||||
x: PAD.left - 6,
|
||||
y: y(level) + 4,
|
||||
class: "curve-label",
|
||||
"text-anchor": "end",
|
||||
});
|
||||
label.textContent = percent(level);
|
||||
}
|
||||
const known = curve.filter((p) => p.chance !== null) as {
|
||||
turn: number;
|
||||
chance: number;
|
||||
verdict: Verdict | null;
|
||||
side: string;
|
||||
}[];
|
||||
if (known.length > 0) {
|
||||
const line = known.map((p) => `${x(p.turn)},${y(p.chance)}`).join(" ");
|
||||
const first = known[0] as { turn: number };
|
||||
const last = known.at(-1) as { turn: number };
|
||||
svgElement(curveSvg, "polygon", {
|
||||
points: `${x(first.turn)},${y(0)} ${line} ${x(last.turn)},${y(0)}`,
|
||||
class: "curve-area",
|
||||
});
|
||||
svgElement(curveSvg, "polyline", { points: line, class: "curve-line" });
|
||||
}
|
||||
// Le coup choisi : un trait vertical brique.
|
||||
svgElement(curveSvg, "line", {
|
||||
x1: x(selected),
|
||||
x2: x(selected),
|
||||
y1: PAD.top,
|
||||
y2: height - PAD.bottom,
|
||||
class: "curve-cursor",
|
||||
});
|
||||
for (const point of known) {
|
||||
const commented = point.verdict !== null;
|
||||
svgElement(curveSvg, "circle", {
|
||||
cx: x(point.turn),
|
||||
cy: y(point.chance),
|
||||
r: commented ? 4 : 2,
|
||||
class: commented ? `curve-dot verdict-${point.verdict}` : "curve-dot other",
|
||||
});
|
||||
}
|
||||
const chosen = curve[selected - 1];
|
||||
if (chosen?.chance != null) {
|
||||
svgElement(curveSvg, "circle", {
|
||||
cx: x(selected),
|
||||
cy: y(chosen.chance),
|
||||
r: 7,
|
||||
class: "curve-ring",
|
||||
});
|
||||
}
|
||||
// Les bandes, sur toute la hauteur : cibles du doigt.
|
||||
for (const turn of turns) {
|
||||
const rect = svgElement(curveSvg, "rect", {
|
||||
x: PAD.left + (turn.number - 1) * band,
|
||||
y: 0,
|
||||
width: band,
|
||||
height,
|
||||
class: turn.number === selected ? "curve-band selected" : "curve-band",
|
||||
});
|
||||
rect.dataset.turn = String(turn.number);
|
||||
}
|
||||
const chance = chosen?.chance;
|
||||
curveSvg.setAttribute("aria-valuemin", "1");
|
||||
curveSvg.setAttribute("aria-valuemax", String(turns.length));
|
||||
curveSvg.setAttribute("aria-valuenow", String(selected));
|
||||
const spoken =
|
||||
chance === null || chance === undefined ? REVIEW_TEXTS.curveUnknown : percent(chance);
|
||||
curveSvg.setAttribute("aria-valuetext", spoken);
|
||||
curveSvg.setAttribute(
|
||||
"aria-label",
|
||||
REVIEW_TEXTS.curveLabel(selected, turns.length, spoken),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- Visualiseur
|
||||
|
||||
function renderViewer(): void {
|
||||
const current = data;
|
||||
const turn = turns[selected - 1];
|
||||
if (current === null || turn === undefined) {
|
||||
return;
|
||||
}
|
||||
prev.href = routeHash({
|
||||
screen: "analysis",
|
||||
game: current.id,
|
||||
turn: Math.max(1, selected - 1),
|
||||
});
|
||||
next.href = routeHash({
|
||||
screen: "analysis",
|
||||
game: current.id,
|
||||
turn: Math.min(turns.length, selected + 1),
|
||||
});
|
||||
prev.setAttribute("aria-disabled", String(selected <= 1));
|
||||
next.setAttribute("aria-disabled", String(selected >= turns.length));
|
||||
const roll = turn.roll === null ? "" : ` · ${REVIEW_TEXTS.roll(turn.roll)}`;
|
||||
turnTitle.textContent = `${REVIEW_TEXTS.turnOf(selected, turns.length)}${roll}`;
|
||||
const chance = curve[selected - 1]?.chance;
|
||||
chanceLine.textContent =
|
||||
chance === null || chance === undefined
|
||||
? ""
|
||||
: REVIEW_TEXTS.chanceAfter(percent(chance));
|
||||
|
||||
const main = mainAdvice(current.journal, turn);
|
||||
const theirs = computerMoveAdvice(current.journal, turn);
|
||||
const key = [
|
||||
current.id,
|
||||
selected,
|
||||
main?.eventId ?? "",
|
||||
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 = () => {
|
||||
const point = replayPoint(current.journal, turn);
|
||||
if (point !== null) {
|
||||
options.replay(current.id, point);
|
||||
}
|
||||
};
|
||||
if (key === drawnTurn) {
|
||||
return;
|
||||
}
|
||||
drawnTurn = key;
|
||||
player?.stop();
|
||||
phrase.replaceChildren();
|
||||
delete phrase.dataset.verdict;
|
||||
legend.textContent = "";
|
||||
const id = ++request;
|
||||
|
||||
// Ce qu'en dit le maître : la décision du joueur à revoir, ou ce
|
||||
// qu'a fait l'ordinateur.
|
||||
if (main !== null && current.phrases !== null) {
|
||||
const words = masterWords(current.phrases, main);
|
||||
phrase.dataset.verdict = words.verdict;
|
||||
const word = document.createElement("strong");
|
||||
word.append(icon(VERDICT_ICONS[words.verdict]), `${words.word}.`);
|
||||
const said = document.createElement("em");
|
||||
said.textContent = words.phrase;
|
||||
phrase.append(word, " ", said);
|
||||
} else if (turn.side === "computer") {
|
||||
phrase.textContent =
|
||||
turn.move === null
|
||||
? REVIEW_TEXTS.computerCube
|
||||
: turn.move.halfMoves.length === 0
|
||||
? REVIEW_TEXTS.computerCannotMove
|
||||
: REVIEW_TEXTS.computerPlayed(
|
||||
theirs?.played.notation ?? formatHalfMoves(turn.move.halfMoves),
|
||||
);
|
||||
} else if (turn.move !== null && turn.move.halfMoves.length === 0) {
|
||||
phrase.textContent = REVIEW_TEXTS.playerCannotMove;
|
||||
}
|
||||
|
||||
// Le plateau : le coup joué et, s'il diffère, le meilleur coup.
|
||||
void (async () => {
|
||||
const side = turn.side;
|
||||
let mine: MoveSteps | null = null;
|
||||
let best: MoveSteps | null = null;
|
||||
// Les coups s'écrivent dans la notation du moteur, comme dans le
|
||||
// bilan et la feuille du maître (« 6/off », pas « 6/5 5/off ») ;
|
||||
// à défaut d'analyse, à partir des demi-coups.
|
||||
let names = { mine: "", best: "" };
|
||||
const moveAdvice: Advice | null = side === "player" ? main : theirs;
|
||||
try {
|
||||
if (moveAdvice?.kind === "move") {
|
||||
mine = await current.game.stepsBefore(
|
||||
moveAdvice.eventId,
|
||||
side,
|
||||
moveAdvice.played.halfMoves,
|
||||
);
|
||||
names = {
|
||||
mine: moveAdvice.played.notation,
|
||||
best: moveAdvice.best.notation,
|
||||
};
|
||||
if (moveAdvice.rank > 1) {
|
||||
best = await current.game.stepsBefore(
|
||||
moveAdvice.eventId,
|
||||
side,
|
||||
moveAdvice.best.halfMoves,
|
||||
);
|
||||
}
|
||||
} else if (moveAdvice?.kind === "cube") {
|
||||
mine = await current.game.stepsBefore(moveAdvice.eventId, "player", []);
|
||||
} else if (turn.move !== null) {
|
||||
mine = await current.game.stepsBefore(turn.move.id, side, turn.move.halfMoves);
|
||||
names = { mine: formatHalfMoves(turn.move.halfMoves), best: "" };
|
||||
} else {
|
||||
const first = turn.events[0];
|
||||
mine =
|
||||
first === undefined
|
||||
? null
|
||||
: await current.game.stepsBefore(first.id, side, []);
|
||||
}
|
||||
} catch {
|
||||
mine = null;
|
||||
}
|
||||
if (id !== request || mine === null) {
|
||||
return;
|
||||
}
|
||||
showMoves(mine, best, side, names);
|
||||
})();
|
||||
}
|
||||
|
||||
function showMoves(
|
||||
mine: MoveSteps,
|
||||
best: MoveSteps | null,
|
||||
side: "player" | "computer",
|
||||
names: { readonly mine: string; readonly best: string },
|
||||
): void {
|
||||
const who = side === "player" ? REVIEW_TEXTS.yourMoveSegment : REVIEW_TEXTS.theirMove;
|
||||
segments.mine.textContent = who;
|
||||
choice.hidden = best === null;
|
||||
const select = (shown: Shown) => {
|
||||
const steps = shown === "best" && best !== null ? best : mine;
|
||||
for (const key of ["mine", "best"] as const) {
|
||||
segments[key].setAttribute("aria-checked", String(key === shown));
|
||||
}
|
||||
board.dataset.move = shown;
|
||||
if (steps.halfMoves.length === 0) {
|
||||
legend.textContent = REVIEW_TEXTS.legendPosition;
|
||||
board.setAttribute("aria-label", REVIEW_TEXTS.legendPosition);
|
||||
drawSteps(board, steps, 0, false, "analysis-");
|
||||
return;
|
||||
}
|
||||
const text = REVIEW_TEXTS.legendMove(
|
||||
shown === "best" ? REVIEW_TEXTS.masterMove : who,
|
||||
shown === "best" ? names.best : names.mine,
|
||||
);
|
||||
legend.textContent = text;
|
||||
board.setAttribute("aria-label", text);
|
||||
void player?.play(steps);
|
||||
};
|
||||
segments.mine.onclick = () => select("mine");
|
||||
segments.best.onclick = () => select("best");
|
||||
choice.onkeydown = (event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextShown =
|
||||
segments.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
|
||||
select(nextShown);
|
||||
segments[nextShown].focus();
|
||||
}
|
||||
};
|
||||
select("mine");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Liste
|
||||
|
||||
function renderList(): void {
|
||||
const current = data;
|
||||
if (current === null) {
|
||||
return;
|
||||
}
|
||||
const rows = turns
|
||||
.map((turn) => ({ turn, advice: mainAdvice(current.journal, turn) }))
|
||||
.filter((row): row is { turn: Turn; advice: Advice } => row.advice !== null);
|
||||
const count = (f: Filter) =>
|
||||
rows.filter((r) => FILTERS[f].includes(verdictOf(r.advice.skill))).length;
|
||||
for (const key of ["all", "errors", "doubtful"] as const) {
|
||||
filterButtons[key].textContent = `${REVIEW_TEXTS.filters[key]} · ${count(key)}`;
|
||||
filterButtons[key].setAttribute("aria-pressed", String(key === filter));
|
||||
}
|
||||
list.replaceChildren();
|
||||
const shown = rows.filter((r) => FILTERS[filter].includes(verdictOf(r.advice.skill)));
|
||||
if (shown.length === 0) {
|
||||
element(list, "li", { className: "analysis-empty", text: REVIEW_TEXTS.emptyFilter });
|
||||
return;
|
||||
}
|
||||
for (const { turn, advice } of shown) {
|
||||
const item = element(list, "li");
|
||||
const link = element(item, "a", { className: "analysis-row" });
|
||||
link.href = routeHash({ screen: "analysis", game: current.id, turn: turn.number });
|
||||
link.dataset.turn = String(turn.number);
|
||||
if (turn.number === selected) {
|
||||
link.setAttribute("aria-current", "true");
|
||||
}
|
||||
const verdict = verdictOf(advice.skill);
|
||||
const word = current.phrases === null ? "" : masterWords(current.phrases, advice).word;
|
||||
const roll = turn.roll === null ? "" : REVIEW_TEXTS.roll(turn.roll);
|
||||
const move =
|
||||
advice.kind === "move"
|
||||
? advice.played.notation
|
||||
: REVIEW_TEXTS.cubeActions[advice.action];
|
||||
link.setAttribute("aria-label", REVIEW_TEXTS.rowLabel(turn.number, roll, move, word));
|
||||
element(link, "span", { className: "row-number", text: String(turn.number) });
|
||||
element(link, "span", { className: "row-roll", text: roll });
|
||||
element(link, "span", { className: "row-move", text: move });
|
||||
const mark = element(link, "span", { className: "row-word" });
|
||||
mark.dataset.verdict = verdict;
|
||||
mark.append(icon(VERDICT_ICONS[verdict]), word);
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
choose(turn.number);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Chiffres du moteur
|
||||
|
||||
function renderNumbers(summary: GameSummary): void {
|
||||
table.replaceChildren();
|
||||
const cell = (text: string, className = "") => element(table, "span", { className, text });
|
||||
cell("");
|
||||
cell(REVIEW_TEXTS.numbersHead.you, "strong");
|
||||
cell(REVIEW_TEXTS.numbersHead.computer, "strong");
|
||||
const rows: [string, (s: GameSummary["player"]) => string][] = [
|
||||
[REVIEW_TEXTS.numbersRows.errorRate, (s) => signed(-s.errorRate.perDecision)],
|
||||
[REVIEW_TEXTS.numbersRows.decisions, (s) => String(s.errorRate.decisions)],
|
||||
[REVIEW_TEXTS.numbersRows.level, (s) => MATCH_TEXTS.levels[s.errorRate.level].name],
|
||||
[REVIEW_TEXTS.numbersRows.luck, (s) => signed(s.luck.perRoll)],
|
||||
];
|
||||
for (const [name, value] of rows) {
|
||||
cell(name);
|
||||
cell(value(summary.player));
|
||||
cell(value(summary.computer));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Ensemble
|
||||
|
||||
function choose(turn: number): void {
|
||||
if (turn === selected || turn < 1 || turn > turns.length) {
|
||||
return;
|
||||
}
|
||||
selected = turn;
|
||||
renderCurve();
|
||||
renderViewer();
|
||||
renderList();
|
||||
options.select(turn);
|
||||
}
|
||||
|
||||
function step(delta: number): void {
|
||||
choose(selected + delta);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!section.hidden && data !== null) {
|
||||
renderCurve();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
show(next) {
|
||||
data = next;
|
||||
if (next === null) {
|
||||
message.textContent = REVIEW_TEXTS.missingGame;
|
||||
curveCard.hidden = true;
|
||||
viewer.hidden = true;
|
||||
listCard.hidden = true;
|
||||
numbers.hidden = true;
|
||||
return;
|
||||
}
|
||||
turns = gameTurns(next.journal);
|
||||
curve = chanceCurve(next.journal);
|
||||
const summary = summarizeAnalysis(next.journal);
|
||||
const fallback = summary.toReview?.turn ?? 1;
|
||||
selected = Math.min(Math.max(1, next.turn ?? fallback), Math.max(1, turns.length));
|
||||
message.textContent = turns.length === 0 ? REVIEW_TEXTS.emptyFilter : "";
|
||||
curveCard.hidden = turns.length === 0;
|
||||
viewer.hidden = turns.length === 0;
|
||||
listCard.hidden = turns.length === 0;
|
||||
numbers.hidden = turns.length === 0;
|
||||
renderCurve();
|
||||
renderViewer();
|
||||
renderList();
|
||||
renderNumbers(summary);
|
||||
},
|
||||
hide() {
|
||||
data = null;
|
||||
drawnTurn = "";
|
||||
player?.stop();
|
||||
request++;
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
+851
-31
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
// Les textes de l'interface de « Demander à une IA » (étape 1f). Les
|
||||
// prompts eux-mêmes sont dans docs/prompts/ (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export const ASK_AI_TEXTS = {
|
||||
button: "Demander à une IA",
|
||||
title: "Demander à une IA",
|
||||
lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.",
|
||||
textLabel: "Le prompt, à relire avant de le copier",
|
||||
copy: "Copier le prompt",
|
||||
copied: "Copié",
|
||||
notCopied:
|
||||
"Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.",
|
||||
preparing: "Préparation du prompt…",
|
||||
close: "Fermer",
|
||||
/** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */
|
||||
back: {
|
||||
explanation: "Explication",
|
||||
summary: "Bilan",
|
||||
analysis: "Analyse",
|
||||
glossary: "Glossaire",
|
||||
},
|
||||
failed: "Le prompt n'a pas pu être préparé.",
|
||||
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
|
||||
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
missingTerm: "Ce terme n'est pas dans le glossaire.",
|
||||
} as const;
|
||||
@@ -0,0 +1,310 @@
|
||||
// « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») :
|
||||
// le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt
|
||||
// (…/ia, ADR 0013), où le texte se relit avant d'être copié.
|
||||
//
|
||||
// Une seule feuille à la fois (recette de 1f) : ouverte depuis la feuille du
|
||||
// maître ou le bilan, la page du prompt prend la place de leur contenu, à la
|
||||
// même hauteur, avec « ‹ Explication » (ou « ‹ Bilan ») pour y revenir ;
|
||||
// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte
|
||||
// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut,
|
||||
// le texte est sélectionné dans la feuille et copié par le navigateur, ou
|
||||
// laissé sélectionné pour être copié à la main. Le bouton la confirme
|
||||
// (« ✓ Copié », 2 s).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// L'application n'appelle aucune IA et n'envoie rien : elle copie un texte.
|
||||
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
|
||||
/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */
|
||||
export const COPIED_MS = 2000;
|
||||
|
||||
/** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */
|
||||
export type PromptResult = { readonly text: string } | { readonly message: string };
|
||||
|
||||
/** Classe de la page du prompt, posée dans une autre feuille. */
|
||||
const PAGE_CLASS = "prompt-page";
|
||||
|
||||
/** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */
|
||||
export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement {
|
||||
const link = document.createElement("a");
|
||||
link.id = id;
|
||||
link.className = "button secondary ask-ai";
|
||||
link.href = href;
|
||||
link.append(icon("copy"), ASK_AI_TEXTS.button);
|
||||
parent.append(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */
|
||||
export function clearSheet(sheet: HTMLElement): void {
|
||||
for (const child of [...sheet.children]) {
|
||||
if (!child.classList.contains(PAGE_CLASS)) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** D'où l'on vient : la feuille dont la page prend la place (null : sa
|
||||
* propre feuille) et le nom de l'écran, pour « ‹ Explication ». */
|
||||
export interface PromptPlace {
|
||||
readonly host: HTMLElement | null;
|
||||
readonly back: string;
|
||||
/** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */
|
||||
readonly backHref: string;
|
||||
}
|
||||
|
||||
export interface PromptViewOptions {
|
||||
/** Le voile de sa propre feuille (index.html) ; elle est construite ici. */
|
||||
readonly veil: HTMLElement;
|
||||
/** « ‹ Explication » : revenir à l'écran d'où l'on vient. */
|
||||
readonly back: () => void;
|
||||
/** La croix et « Fermer » : fermer la feuille. */
|
||||
readonly close: () => void;
|
||||
}
|
||||
|
||||
export interface PromptView {
|
||||
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
|
||||
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
/** Le prompt est affiché (pas un message). */
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
options: { id?: string; className?: string; text?: string } = {},
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const child = document.createElement(tag);
|
||||
if (options.id !== undefined) {
|
||||
child.id = options.id;
|
||||
}
|
||||
if (options.className !== undefined) {
|
||||
child.className = options.className;
|
||||
}
|
||||
if (options.text !== undefined) {
|
||||
child.textContent = options.text;
|
||||
}
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
export function createPromptView(options: PromptViewOptions): PromptView {
|
||||
const { veil } = options;
|
||||
// Sa propre feuille, pour l'analyse et le glossaire.
|
||||
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.close();
|
||||
}
|
||||
});
|
||||
// La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient.
|
||||
const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS });
|
||||
page.setAttribute("role", "dialog");
|
||||
page.setAttribute("aria-modal", "true");
|
||||
page.setAttribute("aria-labelledby", "prompt-title");
|
||||
const head = element(page, "div", { className: "sheet-head prompt-head" });
|
||||
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
|
||||
const back = element(head, "a", { id: "prompt-back", className: "prompt-back" });
|
||||
back.addEventListener("click", (event) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
options.back();
|
||||
});
|
||||
const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" });
|
||||
closeIcon.type = "button";
|
||||
closeIcon.append(icon("close"));
|
||||
closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close);
|
||||
closeIcon.onclick = () => options.close();
|
||||
element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
|
||||
element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
|
||||
const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" });
|
||||
text.tabIndex = 0;
|
||||
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
|
||||
const status = element(page, "p", { id: "prompt-status", className: "prompt-status" });
|
||||
status.setAttribute("role", "status");
|
||||
const actions = element(page, "div", { className: "prompt-actions" });
|
||||
const done = element(actions, "button", {
|
||||
id: "prompt-done",
|
||||
className: "secondary",
|
||||
text: ASK_AI_TEXTS.close,
|
||||
});
|
||||
done.type = "button";
|
||||
done.onclick = () => options.close();
|
||||
const copy = element(actions, "button", { id: "prompt-copy" });
|
||||
copy.type = "button";
|
||||
// Le libellé du bouton est annoncé quand il passe à « Copié ».
|
||||
const copyLabel = document.createElement("span");
|
||||
copyLabel.setAttribute("aria-live", "polite");
|
||||
|
||||
// Chaque ouverture a son numéro : un prompt qui arrive après une autre
|
||||
// ouverture, ou après la fermeture, n'est pas montré.
|
||||
let opened = 0;
|
||||
let ready = false;
|
||||
let open = false;
|
||||
// La feuille d'où l'on vient, dont la page a pris la place.
|
||||
let host: HTMLElement | null = null;
|
||||
let copiedTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */
|
||||
function showCopied(copied: boolean): void {
|
||||
if (copiedTimeout !== null) {
|
||||
clearTimeout(copiedTimeout);
|
||||
copiedTimeout = null;
|
||||
}
|
||||
copy.classList.toggle("copied", copied);
|
||||
copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy;
|
||||
copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel);
|
||||
if (copied) {
|
||||
copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS);
|
||||
}
|
||||
}
|
||||
showCopied(false);
|
||||
|
||||
/** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */
|
||||
function place(next: HTMLElement | null): void {
|
||||
if (host !== next) {
|
||||
host?.classList.remove("prompting");
|
||||
host = next;
|
||||
}
|
||||
if (next === null) {
|
||||
if (page.parentElement !== sheet) {
|
||||
sheet.append(page);
|
||||
}
|
||||
veil.hidden = false;
|
||||
return;
|
||||
}
|
||||
veil.hidden = true;
|
||||
// La page couvre la feuille telle qu'elle s'affiche, défilée en haut.
|
||||
next.scrollTop = 0;
|
||||
next.classList.add("prompting");
|
||||
if (page.parentElement !== next) {
|
||||
next.append(page);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le texte de la feuille, sélectionné : le repli quand le
|
||||
* presse-papiers n'est pas accessible. */
|
||||
function selectText(): void {
|
||||
const selection = window.getSelection();
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
async function copyText(): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.textContent ?? "");
|
||||
return true;
|
||||
} catch {
|
||||
// Hors contexte sûr (http sur le réseau local), ou refusé :
|
||||
// repli ci-dessous.
|
||||
}
|
||||
selectText();
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
copy.onclick = async () => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
const id = opened;
|
||||
const copied = await copyText();
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if (copied) {
|
||||
status.textContent = "";
|
||||
showCopied(true);
|
||||
} else {
|
||||
selectText();
|
||||
showCopied(false);
|
||||
status.textContent = ASK_AI_TEXTS.notCopied;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
show(prompt, where) {
|
||||
const id = ++opened;
|
||||
const opening = !open || host !== where.host;
|
||||
open = true;
|
||||
back.textContent = `‹ ${where.back}`;
|
||||
back.href = where.backHref;
|
||||
place(where.host);
|
||||
if (opening) {
|
||||
ready = false;
|
||||
text.textContent = "";
|
||||
text.hidden = true;
|
||||
copy.disabled = true;
|
||||
showCopied(false);
|
||||
status.textContent = ASK_AI_TEXTS.preparing;
|
||||
delete page.dataset.ready;
|
||||
back.focus();
|
||||
}
|
||||
prompt.then(
|
||||
(result) => {
|
||||
if (id !== opened) {
|
||||
return;
|
||||
}
|
||||
if ("text" in result) {
|
||||
ready = true;
|
||||
text.textContent = result.text;
|
||||
text.hidden = false;
|
||||
copy.disabled = false;
|
||||
status.textContent = "";
|
||||
page.dataset.ready = "true";
|
||||
} else {
|
||||
ready = false;
|
||||
text.hidden = true;
|
||||
copy.disabled = true;
|
||||
status.textContent = result.message;
|
||||
delete page.dataset.ready;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (id === opened) {
|
||||
ready = false;
|
||||
copy.disabled = true;
|
||||
status.textContent = ASK_AI_TEXTS.failed;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
hide() {
|
||||
opened++;
|
||||
ready = false;
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
showCopied(false);
|
||||
host?.classList.remove("prompting");
|
||||
host = null;
|
||||
veil.hidden = true;
|
||||
sheet.append(page);
|
||||
text.textContent = "";
|
||||
delete page.dataset.ready;
|
||||
},
|
||||
get isOpen() {
|
||||
return open;
|
||||
},
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } {
|
||||
return { top: true, column: number - 13 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan intérieur d'un camp : ses six dernières flèches avant la sortie,
|
||||
* numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour
|
||||
* l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur
|
||||
* reste seul juge des règles qui en dépendent (sortie, rentrée).
|
||||
* Glossaire : docs/glossaire/jan-interieur.md
|
||||
*/
|
||||
export interface HomeBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le jan extérieur d'un camp : les six flèches qui précèdent son jan
|
||||
* intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens
|
||||
* de marche du camp.
|
||||
* Glossaire : docs/glossaire/jan-exterieur.md
|
||||
*/
|
||||
export interface OuterBoard {
|
||||
readonly side: Side;
|
||||
readonly points: readonly number[];
|
||||
}
|
||||
|
||||
/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre
|
||||
* numérotation, renumérotées pour le joueur. */
|
||||
function sixPoints(side: Side, first: number): number[] {
|
||||
return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i)));
|
||||
}
|
||||
|
||||
export function homeBoard(side: Side): HomeBoard {
|
||||
return { side, points: sixPoints(side, 6) };
|
||||
}
|
||||
|
||||
export function outerBoard(side: Side): OuterBoard {
|
||||
return { side, points: sixPoints(side, 12) };
|
||||
}
|
||||
|
||||
/** Le plateau de la position de départ, pour le dessiner avant que le
|
||||
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
|
||||
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
|
||||
|
||||
+87
-15
@@ -83,6 +83,21 @@ export interface BoardDecor {
|
||||
readonly band: string | null;
|
||||
/** Paysage : la colonne de commandes (null : pas de colonne). */
|
||||
readonly controls: BoardControls | null;
|
||||
/** Paysage : le conseil du maître, une pastille avant la consigne dans
|
||||
* la bande centrale (absent ou null : rien). */
|
||||
readonly advice?: BoardAdvice | null;
|
||||
}
|
||||
|
||||
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
|
||||
* la feuille d'explication. */
|
||||
export interface BoardAdvice {
|
||||
readonly href: string;
|
||||
/** Appréciation : la couleur et le pictogramme. */
|
||||
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
|
||||
readonly icon: IconName;
|
||||
readonly word: string;
|
||||
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
|
||||
@@ -188,8 +203,10 @@ export function renderBoard(
|
||||
}
|
||||
svg.append(pieces);
|
||||
drawLandings(svg, g, board, decor.targets);
|
||||
const advice = decor.band !== null ? (decor.advice ?? null) : null;
|
||||
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
|
||||
if (decor.band !== null) {
|
||||
drawBand(svg, g, decor.band);
|
||||
drawBand(svg, g, decor.band, chip);
|
||||
}
|
||||
drawDice(svg, g, decor.dice);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
@@ -215,6 +232,9 @@ export function renderBoard(
|
||||
if (decor.controls !== null) {
|
||||
drawControls(svg, g, hits, decor.controls);
|
||||
}
|
||||
if (advice !== null) {
|
||||
drawAdviceChip(svg, g, hits, advice);
|
||||
}
|
||||
// Les zones à toucher passent devant tout le reste.
|
||||
svg.append(hits);
|
||||
}
|
||||
@@ -283,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),
|
||||
@@ -496,12 +510,13 @@ function addRing(
|
||||
}
|
||||
|
||||
/** Paysage : la consigne courte, dans la moitié gauche de la bande
|
||||
* centrale, sur une pastille accent-soft. */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
* centrale, sur une pastille accent-soft ; elle laisse la place before à
|
||||
* gauche (la pastille du conseil du maître). */
|
||||
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
|
||||
const middle = g.height / 2;
|
||||
const width = 6 * g.pointWidth - 24;
|
||||
const width = 6 * g.pointWidth - 24 - before;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const x = g.x0 + 12;
|
||||
const x = g.x0 + 12 + before;
|
||||
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
|
||||
// la police se resserre, sans descendre sous 13 px (charte). Largeur
|
||||
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
|
||||
@@ -529,6 +544,63 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
|
||||
* par lettre, police Alegreya Sans) et marges. */
|
||||
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
|
||||
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le conseil du maître, une pastille au début de la bande
|
||||
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
|
||||
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
|
||||
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
|
||||
*/
|
||||
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
|
||||
const middle = g.height / 2;
|
||||
const height = Math.max(44, g.bandFont + 20);
|
||||
const width = adviceChipWidth(g, advice);
|
||||
const x = g.x0 + 12;
|
||||
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
|
||||
add(group, "rect", {
|
||||
class: "advice-chip-pill",
|
||||
x,
|
||||
y: middle - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
});
|
||||
const size = g.bandFont;
|
||||
const icon = add(group, "g", {
|
||||
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
|
||||
});
|
||||
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: "advice-chip-text",
|
||||
x: x + 16 + size + 6,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
},
|
||||
advice.word,
|
||||
);
|
||||
const link = add(hits, "a", {
|
||||
href: advice.href,
|
||||
id: "advice-chip",
|
||||
role: "link",
|
||||
"aria-label": advice.label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x,
|
||||
y: middle - Math.max(height, g.target) / 2,
|
||||
width,
|
||||
height: Math.max(height, g.target),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
|
||||
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
|
||||
|
||||
@@ -28,6 +28,7 @@ export const GAMES_TEXTS = {
|
||||
close: "Fermer",
|
||||
resume: "Reprendre",
|
||||
showBranches: "Branches",
|
||||
summary: "Bilan",
|
||||
resumeBranch: "Reprendre cette branche",
|
||||
exportSgf: "Exporter (SGF)",
|
||||
remove: "Supprimer",
|
||||
|
||||
@@ -123,6 +123,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
"resume",
|
||||
),
|
||||
branchesLink(game.id),
|
||||
...(game.status === "over" ? [summaryLink(game.id)] : []),
|
||||
button(
|
||||
GAMES_TEXTS.exportSgf,
|
||||
run(async () => download(await options.exportSgf(game.id))),
|
||||
@@ -171,6 +172,15 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
return link;
|
||||
}
|
||||
|
||||
/** « Bilan » : le lien vers le bilan d'une partie finie (étape 1e). */
|
||||
function summaryLink(id: string): HTMLAnchorElement {
|
||||
const link = element("a", "button secondary");
|
||||
link.textContent = GAMES_TEXTS.summary;
|
||||
link.dataset.action = "summary";
|
||||
link.href = routeHash({ screen: "summary", game: id });
|
||||
return link;
|
||||
}
|
||||
|
||||
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
|
||||
const journal = await options.journal(id);
|
||||
const all = journal === null ? [] : branches(journal);
|
||||
|
||||
+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,
|
||||
|
||||
+19
-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,
|
||||
@@ -30,7 +31,11 @@ import { TEXTS } from "./texts.js";
|
||||
export const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"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/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
@@ -122,7 +127,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;
|
||||
@@ -211,7 +219,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;
|
||||
@@ -305,7 +320,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) {
|
||||
|
||||
@@ -41,7 +41,9 @@ 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")}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
@@ -94,14 +96,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 +121,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.",
|
||||
],
|
||||
|
||||
+65
-7
@@ -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 progression détaillée (1h) n'existe pas encore : l'accueil n'affiche
|
||||
// que ce qui existe.
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -186,6 +196,23 @@ 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 });
|
||||
@@ -233,10 +260,41 @@ 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(" · ");
|
||||
progress.textContent = line.charAt(0).toUpperCase() + line.slice(1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Un petit plateau qui rejoue un coup pion par pion, sur la position
|
||||
// d'avant le coup, puis laisse ses traces : la feuille d'explication du
|
||||
// maître (1d), le coup à revoir du bilan et le visualiseur de l'analyse
|
||||
// (1e).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MoveSteps } from "../domain/game/game.js";
|
||||
import { boardModel, moveTrace, placeOf } from "./board-model.js";
|
||||
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
|
||||
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
|
||||
/** Un plateau SVG vide, dans son cadre. */
|
||||
export function boardSvg(box: HTMLElement, id: string, className: string): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.id = id;
|
||||
svg.setAttribute("class", `board ${className}`);
|
||||
svg.setAttribute("role", "img");
|
||||
box.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dessine le plateau d'un coup après index demi-coups (0 : avant le
|
||||
* coup), avec ses traces si traced. Les flèches des traces renvoient à leur
|
||||
* pointe par identifiant : prefix les distingue de celles du plateau de jeu.
|
||||
*/
|
||||
export function drawSteps(
|
||||
svg: SVGSVGElement,
|
||||
steps: MoveSteps,
|
||||
index: number,
|
||||
traced: boolean,
|
||||
prefix: string,
|
||||
): void {
|
||||
const boards = steps.boards[index];
|
||||
if (boards === undefined) {
|
||||
return;
|
||||
}
|
||||
const width = svg.getBoundingClientRect().width || 358;
|
||||
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(boards), {
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: null,
|
||||
band: null,
|
||||
controls: null,
|
||||
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
|
||||
});
|
||||
for (const marker of svg.querySelectorAll("marker[id]")) {
|
||||
marker.id = `${prefix}${marker.id}`;
|
||||
}
|
||||
for (const path of svg.querySelectorAll("[marker-end]")) {
|
||||
const ref = path.getAttribute("marker-end") ?? "";
|
||||
path.setAttribute("marker-end", ref.replace("url(#", `url(#${prefix}`));
|
||||
}
|
||||
}
|
||||
|
||||
/** Un plateau qui joue des coups ; un coup demandé arrête le précédent. */
|
||||
export interface MovePlayer {
|
||||
/** Joue le coup pion par pion (sans mouvement si le système le
|
||||
* demande), puis laisse ses traces. */
|
||||
play(steps: MoveSteps): Promise<void>;
|
||||
/** Arrête le coup en cours. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function createMovePlayer(
|
||||
svg: SVGSVGElement,
|
||||
prefix: string,
|
||||
reducedMotion: MediaQueryList,
|
||||
): MovePlayer {
|
||||
let playing = 0;
|
||||
return {
|
||||
async play(steps) {
|
||||
const id = ++playing;
|
||||
drawSteps(svg, steps, 0, false, prefix);
|
||||
if (!reducedMotion.matches) {
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
if (id !== playing) {
|
||||
return;
|
||||
}
|
||||
if (i > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
}
|
||||
const from = placeOf(steps.side, halfMove.from);
|
||||
const to = placeOf(steps.side, halfMove.to);
|
||||
drawSteps(svg, steps, i, false, prefix);
|
||||
const start = checkerSpot(svg, from);
|
||||
const bar = steps.side === "player" ? "bar-computer" : "bar-player";
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
drawSteps(svg, steps, i + 1, false, prefix);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, bar, victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (id === playing) {
|
||||
drawSteps(svg, steps, steps.halfMoves.length, true, prefix);
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
playing++;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,8 +26,9 @@ export interface Navigation {
|
||||
replace(hash: string): void;
|
||||
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
|
||||
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
|
||||
* nouvel onglet), remplace l'adresse par celle de la partie. */
|
||||
leave(stay: (route: Route) => boolean): void;
|
||||
* nouvel onglet), remplace l'adresse par fallback (celle de la partie,
|
||||
* par défaut). */
|
||||
leave(stay: (route: Route) => boolean, fallback?: string): void;
|
||||
}
|
||||
|
||||
interface TrailState {
|
||||
@@ -123,13 +124,13 @@ export function startNavigation(onChange: (route: Route) => void): Navigation {
|
||||
},
|
||||
go,
|
||||
replace,
|
||||
leave(stay) {
|
||||
leave(stay, fallback = "#/") {
|
||||
if (leaving !== null) {
|
||||
return; // déjà en route
|
||||
}
|
||||
const steps = stepsBack(trail, stay);
|
||||
if (steps === null) {
|
||||
replace("#/");
|
||||
replace(fallback);
|
||||
return;
|
||||
}
|
||||
// Au cas où le navigateur ne reviendrait pas (historique
|
||||
|
||||
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
|
||||
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
|
||||
// veulent dire l'étoile, « off », « bar » et « (2) ».
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
|
||||
export const NOTATION_TERM = "notation-des-coups";
|
||||
|
||||
/**
|
||||
* Remplace le contenu de parent par text, où la première occurrence de
|
||||
* notation devient un lien vers la fiche « Notation des coups ». Sans
|
||||
* notation dans le texte, le texte seul.
|
||||
*/
|
||||
export function showWithNotation(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
notation: MoveNotation | null,
|
||||
): void {
|
||||
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
|
||||
if (notation === null || at < 0) {
|
||||
parent.textContent = text;
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.className = "notation";
|
||||
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
|
||||
link.textContent = notation;
|
||||
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Les prompts pour une IA tels que les demande l'interface (étape 1f) :
|
||||
// les gabarits, lus dans la copie de docs/prompts/ faite par make site
|
||||
// (ADR 0008), les phrases du maître, et les positions que le domaine lit
|
||||
// avec le moteur (PromptsAccess, branché par main.ts). Le remplissage
|
||||
// lui-même est dans le domaine (domain/prompts/prompts.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MasterPhrases } from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { PromptPosition } from "../domain/prompts/position.js";
|
||||
import {
|
||||
type ConceptExample,
|
||||
conceptPrompt,
|
||||
decisionPrompt,
|
||||
summaryPrompt,
|
||||
} from "../domain/prompts/prompts.js";
|
||||
import {
|
||||
PROMPT_FILES,
|
||||
PROMPT_KINDS,
|
||||
type PromptTemplates,
|
||||
parsePromptTemplate,
|
||||
} from "../domain/prompts/templates.js";
|
||||
import type { PromptResult } from "./ask-ai.js";
|
||||
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
|
||||
import type { GlossaryEntry } from "./help-text.js";
|
||||
|
||||
/** La copie des gabarits dans le site (make site). */
|
||||
const PROMPTS_DIR = "docs/prompts/";
|
||||
|
||||
/** Ce que les prompts demandent au domaine et au moteur : la position à
|
||||
* décrire. */
|
||||
export interface PromptsAccess {
|
||||
/** La position d'avant une décision de la partie. */
|
||||
readonly before: (journal: GameJournal, eventId: string) => Promise<PromptPosition>;
|
||||
/** La position que montre le prompt du bilan (coup à revoir, ou
|
||||
* position finale). */
|
||||
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
|
||||
/** La position d'exemple d'un concept et son évaluation. */
|
||||
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
|
||||
}
|
||||
|
||||
export interface PromptSourceOptions {
|
||||
readonly access: PromptsAccess;
|
||||
readonly phrases: () => Promise<MasterPhrases>;
|
||||
/** Une fiche du glossaire, par son nom de fichier. */
|
||||
readonly glossaryEntry: (file: string) => Promise<GlossaryEntry | null>;
|
||||
}
|
||||
|
||||
export interface PromptSource {
|
||||
/** Le prompt d'une décision du joueur (coup ou videau). */
|
||||
decision(journal: GameJournal, advice: Advice): Promise<PromptResult>;
|
||||
/** Le prompt du bilan d'une partie finie et analysée. */
|
||||
summary(journal: GameJournal): Promise<PromptResult>;
|
||||
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
|
||||
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export function createPromptSource(options: PromptSourceOptions): PromptSource {
|
||||
let loading: Promise<PromptTemplates> | null = null;
|
||||
|
||||
/** Les gabarits, lus une fois (une nouvelle tentative après un
|
||||
* échec). */
|
||||
function templates(): Promise<PromptTemplates> {
|
||||
loading ??= Promise.all(
|
||||
PROMPT_KINDS.map(async (kind) =>
|
||||
parsePromptTemplate(await fetchText(PROMPTS_DIR + PROMPT_FILES[kind])),
|
||||
),
|
||||
).then(
|
||||
([move, cube, summary, concept, exercises]) => ({
|
||||
move: move ?? "",
|
||||
cube: cube ?? "",
|
||||
summary: summary ?? "",
|
||||
concept: concept ?? "",
|
||||
exercises: exercises ?? "",
|
||||
}),
|
||||
(error: unknown) => {
|
||||
loading = null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
async decision(journal, advice) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.before(journal, advice.eventId),
|
||||
]);
|
||||
return { text: decisionPrompt(all, phrases, { journal, advice, position }) };
|
||||
},
|
||||
async summary(journal) {
|
||||
const [all, phrases, position] = await Promise.all([
|
||||
templates(),
|
||||
options.phrases(),
|
||||
options.access.summary(journal),
|
||||
]);
|
||||
return { text: summaryPrompt(all, phrases, { journal, position }) };
|
||||
},
|
||||
async concept(term, journal) {
|
||||
const [all, entry, example] = await Promise.all([
|
||||
templates(),
|
||||
options.glossaryEntry(`${term}.md`).catch(() => null),
|
||||
options.access.concept(journal),
|
||||
]);
|
||||
if (entry === null) {
|
||||
return { message: ASK_AI_TEXTS.missingTerm };
|
||||
}
|
||||
return {
|
||||
text: conceptPrompt(all, {
|
||||
term: entry.term,
|
||||
english: entry.english,
|
||||
brief: entry.brief,
|
||||
journal,
|
||||
example,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// L'analyse d'une partie en arrière-plan (étape 1e) : les analyses qui
|
||||
// manquent au bilan et à l'écran d'analyse sont demandées une à une au
|
||||
// second moteur, rangées au fur et à mesure, et l'ensemble s'arrête dès
|
||||
// qu'on n'en a plus besoin (une autre partie, une nouvelle partie). Sans
|
||||
// DOM : testé sous Node (app/tests/review-runner.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Une analyse déjà partie vers le moteur ne s'interrompt pas : l'arrêt
|
||||
// prend effet entre deux analyses, et le résultat d'une analyse arrêtée
|
||||
// n'est pas rangé.
|
||||
|
||||
import { type AnalysisTask, missingAnalyses } from "../domain/analysis/game-analysis.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Fait une analyse et renvoie le journal qui la contient. */
|
||||
export type RunAnalysis = (journal: GameJournal, task: AnalysisTask) => Promise<GameJournal>;
|
||||
|
||||
export interface ReviewRunner {
|
||||
/** La partie en cours d'analyse (null : aucune). */
|
||||
readonly running: string | null;
|
||||
/**
|
||||
* Analyse ce qui manque au journal de la partie id ; record reçoit le
|
||||
* journal après chaque analyse rangée, done une fois tout analysé.
|
||||
* Une analyse en cours d'une autre partie est arrêtée ; la même partie
|
||||
* continue sans repartir.
|
||||
*/
|
||||
start(
|
||||
id: string,
|
||||
journal: GameJournal,
|
||||
record: (journal: GameJournal) => void,
|
||||
done?: () => void,
|
||||
): void;
|
||||
/** Arrête l'analyse en cours. */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function createReviewRunner(run: RunAnalysis): ReviewRunner {
|
||||
// Chaque analyse lancée a son jeton : stop() ou une autre partie
|
||||
// l'annulent.
|
||||
let token: object | null = null;
|
||||
let running: string | null = null;
|
||||
|
||||
return {
|
||||
get running() {
|
||||
return running;
|
||||
},
|
||||
start(id, journal, record, done) {
|
||||
if (running === id && token !== null) {
|
||||
return;
|
||||
}
|
||||
const mine = {};
|
||||
token = mine;
|
||||
running = id;
|
||||
void (async () => {
|
||||
let current = journal;
|
||||
try {
|
||||
for (;;) {
|
||||
const task = missingAnalyses(current)[0];
|
||||
if (task === undefined) {
|
||||
break;
|
||||
}
|
||||
const next = await run(current, task);
|
||||
if (token !== mine) {
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
record(current);
|
||||
}
|
||||
} catch {
|
||||
// Le moteur a échoué : l'analyse s'arrête là, ce qui est
|
||||
// rangé reste, et la prochaine demande reprendra.
|
||||
}
|
||||
if (token === mine) {
|
||||
token = null;
|
||||
running = null;
|
||||
done?.();
|
||||
}
|
||||
})();
|
||||
},
|
||||
stop() {
|
||||
token = null;
|
||||
running = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Les textes de l'interface du bilan et de l'écran d'analyse (étape 1e).
|
||||
// Les phrases du maître elles-mêmes (lecture du bilan, dés en mots) sont
|
||||
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { CubeAction } from "../domain/game/engine.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
|
||||
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
|
||||
|
||||
/** « 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
|
||||
summaryLabel: "Bilan de la partie",
|
||||
analysing: "Le maître analyse la partie…",
|
||||
progress: (done: number, total: number) => `Analyse : ${done} sur ${total}`,
|
||||
notOver: "Cette partie n'est pas finie : son bilan viendra à la fin.",
|
||||
missingGame: "Cette partie n'est plus sur ce téléphone.",
|
||||
counts: {
|
||||
good: (n: number) => plural(n, "Bon coup", "Bons coups"),
|
||||
doubtful: (n: number) => plural(n, "Imprécision", "Imprécisions"),
|
||||
bad: (n: number) => plural(n, "Erreur", "Erreurs"),
|
||||
},
|
||||
countsLabel: "Vos décisions, par appréciation",
|
||||
yourPlay: "Votre jeu",
|
||||
theDice: "Les dés",
|
||||
playGauge: (level: string) => `Votre jeu : niveau ${level}`,
|
||||
diceGauge: (words: string) => `Les dés : ${words}`,
|
||||
howToRead: "Comment lire ces mesures",
|
||||
toReview: "Le coup à revoir",
|
||||
yourMove: (notation: string) => `Votre coup : ${notation}`,
|
||||
yourDecision: (action: string) => `Votre décision : ${action}`,
|
||||
reviewMove: "Revoir ce coup",
|
||||
seeAnalysis: "Voir l'analyse",
|
||||
newGame: "Nouvelle partie",
|
||||
close: "Fermer",
|
||||
cubeActions: {
|
||||
double: "doubler",
|
||||
noDouble: "lancer sans doubler",
|
||||
take: "prendre",
|
||||
pass: "passer",
|
||||
} satisfies Record<CubeAction, string>,
|
||||
toReviewBoard: "La position au moment de la décision à revoir.",
|
||||
|
||||
// Analyse
|
||||
analysisTitle: "Analyse de la partie",
|
||||
back: "Retour",
|
||||
curveTitle: "Vos chances de gagner",
|
||||
curveLabel: (turn: number, total: number, chance: string) =>
|
||||
`Courbe de vos chances de gagner : coup ${turn} sur ${total}, ${chance}. Flèches gauche et droite pour changer de coup.`,
|
||||
curveUnknown: "pas encore calculé",
|
||||
turnOf: (turn: number, total: number) => `Coup ${turn} sur ${total}`,
|
||||
roll: (roll: Roll) => `${roll[0]}-${roll[1]}`,
|
||||
previous: "Coup précédent",
|
||||
next: "Coup suivant",
|
||||
who: { player: "Vous", computer: "L'ordinateur" },
|
||||
computerPlayed: (notation: string) => `L'ordinateur a joué ${notation}.`,
|
||||
computerCannotMove: "L'ordinateur ne pouvait pas jouer.",
|
||||
playerCannotMove: "Vous ne pouviez pas jouer.",
|
||||
computerCube: "L'ordinateur a doublé.",
|
||||
chanceAfter: (chance: string) => `Vos chances de gagner après ce coup : ${chance}`,
|
||||
theirMove: "Son coup",
|
||||
masterMove: "Le coup du maître",
|
||||
yourMoveSegment: "Votre coup",
|
||||
choiceLabel: "Coup montré sur le plateau",
|
||||
legendMove: (who: string, notation: string) =>
|
||||
`${who} : ${notation}, joué pion par pion sur la position d'avant le coup.`,
|
||||
legendPosition: "La position avant la décision.",
|
||||
replayFromHere: "Rejouer d'ici",
|
||||
replayHint: "Une nouvelle branche : la partie d'origine reste intacte.",
|
||||
listTitle: "Les coups à revoir",
|
||||
filters: { all: "Tout", errors: "Erreurs", doubtful: "Imprécisions" },
|
||||
filterLabel: "Filtrer les coups",
|
||||
emptyFilter: "Aucun coup de ce genre dans la partie.",
|
||||
rowLabel: (turn: number, roll: string, move: string, word: string) =>
|
||||
`Coup ${turn}, ${roll}, ${move} : ${word}`,
|
||||
numbers: "Voir les chiffres du moteur",
|
||||
numbersRows: {
|
||||
errorRate: "Erreur par décision",
|
||||
decisions: "Décisions comptées",
|
||||
luck: "Chance par tour",
|
||||
level: "Niveau",
|
||||
},
|
||||
numbersHead: { you: "Vous", computer: "L'ordinateur" },
|
||||
numbersNote:
|
||||
"Calculés comme GNU Backgammon : équité perdue par décision non forcée, chance des dés par tour, à 2 plis.",
|
||||
} as const;
|
||||
+139
-2
@@ -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,14 +24,44 @@ 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) ;
|
||||
* 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 ; le prompt du coup pour une IA :
|
||||
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
|
||||
| {
|
||||
readonly screen: "analysis";
|
||||
readonly game: string;
|
||||
readonly turn: number | null;
|
||||
readonly ai?: boolean;
|
||||
}
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
| { readonly screen: "newGame" }
|
||||
/** #/explication/<événement> : la feuille d'explication du maître sur
|
||||
* une décision de la partie en cours (étape 1d) ; 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. */
|
||||
@@ -62,12 +93,23 @@ 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;
|
||||
}
|
||||
if (rest.length > 1) {
|
||||
return unknown;
|
||||
}
|
||||
@@ -90,6 +132,8 @@ export function parseRoute(hash: string): Route {
|
||||
return name === null ? { screen: "menu" } : unknown;
|
||||
case "nouvelle-partie":
|
||||
return name === null ? { screen: "newGame" } : unknown;
|
||||
case "explication":
|
||||
return name === null ? unknown : { screen: "explanation", event: name };
|
||||
case "parties":
|
||||
return { screen: "games", game: name };
|
||||
case "installer":
|
||||
@@ -99,6 +143,55 @@ export function parseRoute(hash: string): Route {
|
||||
}
|
||||
}
|
||||
|
||||
/** Les écrans qui ont une feuille de prompt pour une IA. */
|
||||
export type AiRoute = Extract<
|
||||
Route,
|
||||
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
|
||||
>;
|
||||
|
||||
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
|
||||
function aiRoute(route: Route): AiRoute | null {
|
||||
switch (route.screen) {
|
||||
case "explanation":
|
||||
case "summary":
|
||||
case "analysis":
|
||||
return route.ai === true ? null : { ...route, ai: true };
|
||||
case "glossary":
|
||||
return route.term === null || route.ai === true ? null : { ...route, ai: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** La feuille du prompt pour une IA est-elle ouverte ? */
|
||||
export function isAiRoute(route: Route): route is AiRoute {
|
||||
return "ai" in route && route.ai === true;
|
||||
}
|
||||
|
||||
/** L'écran sous la feuille du prompt. */
|
||||
export function withoutAi(route: AiRoute): AiRoute {
|
||||
const { ai: _, ...below } = route;
|
||||
return below;
|
||||
}
|
||||
|
||||
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
|
||||
function gameReviewRoute(rest: readonly string[]): Route | null {
|
||||
const [game = "", part, turn, ...more] = rest;
|
||||
if (!NAME.test(game) || more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
if (part === "bilan") {
|
||||
return turn === undefined ? { screen: "summary", game } : null;
|
||||
}
|
||||
if (part !== "analyse") {
|
||||
return null;
|
||||
}
|
||||
if (turn === undefined) {
|
||||
return { screen: "analysis", game, turn: null };
|
||||
}
|
||||
return /^[1-9][0-9]{0,3}$/.test(turn) ? { screen: "analysis", game, turn: Number(turn) } : null;
|
||||
}
|
||||
|
||||
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
|
||||
function tutorialRoute(rest: readonly string[]): Route | null {
|
||||
const [name, part, ...more] = rest;
|
||||
@@ -117,6 +210,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
|
||||
@@ -147,6 +263,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 "#/";
|
||||
@@ -164,14 +285,30 @@ 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":
|
||||
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
|
||||
case "summary":
|
||||
return `#/parties/${route.game}/bilan`;
|
||||
case "analysis":
|
||||
return route.turn === null
|
||||
? `#/parties/${route.game}/analyse`
|
||||
: `#/parties/${route.game}/analyse/${route.turn}`;
|
||||
case "menu":
|
||||
return "#/menu";
|
||||
case "newGame":
|
||||
return "#/nouvelle-partie";
|
||||
case "explanation":
|
||||
return `#/explication/${route.event}`;
|
||||
case "position": {
|
||||
const position = `#/position/${encodeURIComponent(route.position)}`;
|
||||
return route.match === null
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
// Le bilan de fin de partie (#/parties/<id>/bilan, étape 1e ; charte,
|
||||
// ecrans-etape-1.md, « Bilan ») : un panneau centré sur un voile, à la
|
||||
// place du panneau de fin de partie. Le résultat et sa lecture, les
|
||||
// décisions par appréciation, deux jauges (votre jeu, les dés), le coup à
|
||||
// revoir, puis « Voir l'analyse » et l'action qui suit la partie.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Tout ce qu'il affiche vient de l'analyse du moteur (game-analysis.ts) et
|
||||
// des phrases du maître (docs/maitre/phrases.md) ; tant que l'analyse
|
||||
// n'est pas prête, il dit où elle en est.
|
||||
|
||||
import {
|
||||
type GameSummary,
|
||||
readyForSummary,
|
||||
summarizeAnalysis,
|
||||
} from "../domain/analysis/game-analysis.js";
|
||||
import {
|
||||
diceWords,
|
||||
type MasterPhrases,
|
||||
masterWords,
|
||||
nothingToReview,
|
||||
summaryReading,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import { masterCubeAction } from "../domain/game/advice.js";
|
||||
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";
|
||||
import { REVIEW_TEXTS } from "./review-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Ce que montre le bilan. */
|
||||
export interface SummaryData {
|
||||
readonly id: string;
|
||||
readonly journal: GameJournal;
|
||||
/** La partie, pour dessiner la position du coup à revoir. */
|
||||
readonly game: Game;
|
||||
/** Le titre et le détail du résultat (null : partie pas finie). */
|
||||
readonly end: { readonly title: string; readonly detail: string } | null;
|
||||
/** L'action qui suit la partie, si c'est la partie à l'écran :
|
||||
* « Rejouer » ou « Partie suivante ». Sinon, « Nouvelle partie » mène à
|
||||
* l'écran des choix. */
|
||||
readonly afterLabel: string | null;
|
||||
readonly phrases: MasterPhrases | null;
|
||||
}
|
||||
|
||||
export interface SummaryViewOptions {
|
||||
/** Le voile du bilan (index.html) ; le panneau est construit ici. */
|
||||
readonly veil: HTMLElement;
|
||||
/** La croix : revenir au plateau. */
|
||||
readonly close: () => void;
|
||||
/** « Rejouer » ou « Partie suivante ». */
|
||||
readonly again: () => void;
|
||||
}
|
||||
|
||||
export interface SummaryView {
|
||||
/** Ouvre (ou met à jour) le bilan. */
|
||||
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>(
|
||||
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;
|
||||
}
|
||||
|
||||
const DICE_SIDE = {
|
||||
veryLucky: "for-2",
|
||||
lucky: "for-1",
|
||||
none: "even",
|
||||
unlucky: "against-1",
|
||||
veryUnlucky: "against-2",
|
||||
} as const;
|
||||
|
||||
export function createSummaryView(options: SummaryViewOptions): SummaryView {
|
||||
const { veil } = options;
|
||||
const panel = element(veil, "div", { id: "summary", className: "panel summary-panel" });
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("aria-labelledby", "summary-title");
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.close();
|
||||
}
|
||||
});
|
||||
// Ce qui est dessiné, pour ne redessiner qu'en cas de changement (le
|
||||
// bilan est mis à jour à chaque analyse rangée).
|
||||
let drawn = "";
|
||||
|
||||
function render(data: SummaryData | null): void {
|
||||
const ready = data !== null && data.phrases !== null && readyForSummary(data.journal);
|
||||
const summary = ready ? summarizeAnalysis(data.journal) : null;
|
||||
// Seulement ce que le bilan montre : les analyses qui arrivent
|
||||
// ensuite en arrière-plan pour l'écran d'analyse (coups de
|
||||
// l'ordinateur, chances de gagner) ne le redessinent pas, sinon le
|
||||
// panneau et son mini-plateau sauteraient sous les yeux du joueur.
|
||||
const shown =
|
||||
summary === null || data === null || data.phrases === null
|
||||
? null
|
||||
: {
|
||||
counts: summary.counts,
|
||||
level: summary.player.errorRate.level,
|
||||
dice: summary.player.luck.rating,
|
||||
reading: summaryReading(data.phrases, summary),
|
||||
toReview: summary.toReview?.advice.eventId ?? null,
|
||||
turn: summary.toReview?.turn ?? null,
|
||||
};
|
||||
const key =
|
||||
data === null
|
||||
? "missing"
|
||||
: [
|
||||
data.id,
|
||||
data.end?.title ?? "",
|
||||
data.end?.detail ?? "",
|
||||
data.afterLabel ?? "",
|
||||
ready,
|
||||
JSON.stringify(shown),
|
||||
].join("|");
|
||||
if (key === drawn) {
|
||||
return;
|
||||
}
|
||||
drawn = key;
|
||||
clearSheet(panel);
|
||||
delete panel.dataset.ready;
|
||||
const head = element(panel, "div", { className: "summary-head" });
|
||||
element(head, "p", { className: "overline", text: TEXTS.endOverline });
|
||||
const close = element(head, "button", { id: "summary-close", className: "icon-button" });
|
||||
close.type = "button";
|
||||
close.append(icon("close"));
|
||||
close.setAttribute("aria-label", REVIEW_TEXTS.close);
|
||||
close.onclick = () => options.close();
|
||||
const title = element(panel, "h2", { id: "summary-title", className: "summary-title" });
|
||||
if (data === null) {
|
||||
title.textContent = REVIEW_TEXTS.summaryLabel;
|
||||
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.missingGame });
|
||||
return;
|
||||
}
|
||||
if (data.end === null) {
|
||||
title.textContent = REVIEW_TEXTS.summaryLabel;
|
||||
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.notOver });
|
||||
actions(data);
|
||||
return;
|
||||
}
|
||||
title.textContent = data.end.title;
|
||||
const reading = element(panel, "p", {
|
||||
id: "summary-reading",
|
||||
className: "summary-reading",
|
||||
});
|
||||
reading.textContent =
|
||||
summary !== null && data.phrases !== null
|
||||
? `${data.end.detail} ${summaryReading(data.phrases, summary)}`
|
||||
: data.end.detail;
|
||||
if (summary === null || data.phrases === null) {
|
||||
const status = element(panel, "p", {
|
||||
id: "summary-status",
|
||||
className: "summary-status",
|
||||
text: REVIEW_TEXTS.analysing,
|
||||
});
|
||||
status.setAttribute("role", "status");
|
||||
actions(data);
|
||||
return;
|
||||
}
|
||||
panel.dataset.ready = "true";
|
||||
counts(summary);
|
||||
gauges(summary, data.phrases);
|
||||
toReview(data, summary, data.phrases);
|
||||
actions(data);
|
||||
}
|
||||
|
||||
function counts(summary: GameSummary): void {
|
||||
const box = element(panel, "div", { id: "summary-counts", className: "summary-counts" });
|
||||
box.setAttribute("role", "list");
|
||||
box.setAttribute("aria-label", REVIEW_TEXTS.countsLabel);
|
||||
const cells = [
|
||||
{ verdict: "good", n: summary.counts.good, word: REVIEW_TEXTS.counts.good },
|
||||
{
|
||||
verdict: "doubtful",
|
||||
n: summary.counts.doubtful,
|
||||
word: REVIEW_TEXTS.counts.doubtful,
|
||||
},
|
||||
{
|
||||
verdict: "bad",
|
||||
n: summary.counts.bad + summary.counts.veryBad,
|
||||
word: REVIEW_TEXTS.counts.bad,
|
||||
},
|
||||
] as const;
|
||||
for (const cell of cells) {
|
||||
const item = element(box, "div", { className: "summary-count" });
|
||||
item.setAttribute("role", "listitem");
|
||||
item.dataset.verdict = cell.verdict;
|
||||
element(item, "span", { className: "summary-number", text: String(cell.n) });
|
||||
const word = element(item, "span", { className: "summary-word" });
|
||||
word.append(icon(VERDICT_ICONS[cell.verdict]), cell.word(cell.n));
|
||||
}
|
||||
}
|
||||
|
||||
function gauges(summary: GameSummary, phrases: MasterPhrases): void {
|
||||
const box = element(panel, "div", { className: "summary-gauges" });
|
||||
const level = summary.player.errorRate.level;
|
||||
const levelName = MATCH_TEXTS.levels[level].name;
|
||||
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
|
||||
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
|
||||
play.setAttribute("role", "img");
|
||||
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
|
||||
const track = element(play, "span", { className: "gauge-track" });
|
||||
element(track, "span", {
|
||||
className: `gauge-fill level-${COMPUTER_LEVELS.indexOf(level) + 1}`,
|
||||
});
|
||||
element(play, "span", { className: "gauge-value", text: levelName });
|
||||
|
||||
const words = diceWords(phrases, summary.player.luck.rating);
|
||||
const dice = element(box, "div", { id: "gauge-dice", className: "gauge" });
|
||||
dice.setAttribute("aria-label", REVIEW_TEXTS.diceGauge(words));
|
||||
dice.setAttribute("role", "img");
|
||||
element(dice, "span", { className: "gauge-name", text: REVIEW_TEXTS.theDice });
|
||||
const centred = element(dice, "span", { className: "gauge-track centred" });
|
||||
element(centred, "span", {
|
||||
className: `gauge-fill dice ${DICE_SIDE[summary.player.luck.rating]}`,
|
||||
});
|
||||
element(dice, "span", { className: "gauge-value", text: words });
|
||||
const link = element(box, "a", {
|
||||
id: "summary-glossary",
|
||||
className: "link-button summary-glossary",
|
||||
text: REVIEW_TEXTS.howToRead,
|
||||
});
|
||||
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
|
||||
}
|
||||
|
||||
function toReview(data: SummaryData, summary: GameSummary, phrases: MasterPhrases): void {
|
||||
const card = element(panel, "section", {
|
||||
id: "summary-review",
|
||||
className: "summary-review",
|
||||
});
|
||||
card.setAttribute("aria-labelledby", "summary-review-title");
|
||||
element(card, "h3", {
|
||||
id: "summary-review-title",
|
||||
className: "overline",
|
||||
text: REVIEW_TEXTS.toReview,
|
||||
});
|
||||
const reviewed = summary.toReview;
|
||||
if (reviewed === null) {
|
||||
element(card, "p", { className: "summary-none", text: nothingToReview(phrases) });
|
||||
return;
|
||||
}
|
||||
const advice = reviewed.advice;
|
||||
const words = masterWords(phrases, advice);
|
||||
const body = element(card, "div", { className: "summary-review-body" });
|
||||
const box = element(body, "div", { className: "summary-board-box" });
|
||||
const svg = boardSvg(box, "summary-board", "summary-board");
|
||||
svg.setAttribute("aria-label", REVIEW_TEXTS.toReviewBoard);
|
||||
const halfMoves = advice.kind === "move" ? advice.played.halfMoves : [];
|
||||
void data.game
|
||||
.stepsBefore(advice.eventId, "player", halfMoves)
|
||||
.then((steps) => drawSteps(svg, steps, steps.halfMoves.length, true, "summary-"))
|
||||
.catch(() => undefined);
|
||||
const text = element(body, "div", { className: "summary-review-text" });
|
||||
const verdict = element(text, "p", { className: "summary-verdict" });
|
||||
verdict.dataset.verdict = words.verdict;
|
||||
verdict.append(icon(VERDICT_ICONS[words.verdict]), `${words.word} · ${words.context}`);
|
||||
element(text, "p", {
|
||||
className: "summary-played",
|
||||
text:
|
||||
advice.kind === "move"
|
||||
? REVIEW_TEXTS.yourMove(advice.played.notation)
|
||||
: REVIEW_TEXTS.yourDecision(REVIEW_TEXTS.cubeActions[advice.action]),
|
||||
});
|
||||
element(text, "p", {
|
||||
className: "summary-master",
|
||||
text:
|
||||
advice.kind === "move" || masterCubeAction(advice) !== advice.action
|
||||
? words.title
|
||||
: words.phrase,
|
||||
});
|
||||
const links = element(card, "div", { className: "summary-review-actions" });
|
||||
const revisit = element(links, "a", {
|
||||
id: "summary-revisit",
|
||||
className: "button secondary",
|
||||
text: REVIEW_TEXTS.reviewMove,
|
||||
});
|
||||
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
|
||||
// « 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 {
|
||||
// Les actions et le lien des réglages, ensemble : en paysage, en bas
|
||||
// de la colonne de droite.
|
||||
const end = element(panel, "div", { className: "summary-end" });
|
||||
const bar = element(end, "div", { className: "panel-actions summary-actions" });
|
||||
const analysis = element(bar, "a", {
|
||||
id: "summary-analysis",
|
||||
className: "button secondary",
|
||||
text: REVIEW_TEXTS.seeAnalysis,
|
||||
});
|
||||
analysis.href = routeHash({ screen: "analysis", game: data.id, turn: null });
|
||||
if (data.afterLabel !== null) {
|
||||
const again = element(bar, "button", {
|
||||
id: "summary-primary",
|
||||
text: data.afterLabel,
|
||||
});
|
||||
again.type = "button";
|
||||
again.onclick = () => options.again();
|
||||
const settings = element(end, "a", {
|
||||
id: "summary-settings",
|
||||
className: "panel-link",
|
||||
text: MATCH_TEXTS.changeSettings,
|
||||
});
|
||||
settings.href = routeHash({ screen: "newGame" });
|
||||
} else {
|
||||
const start = element(bar, "a", {
|
||||
id: "summary-primary",
|
||||
className: "button",
|
||||
text: REVIEW_TEXTS.newGame,
|
||||
});
|
||||
start.href = routeHash({ screen: "newGame" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show(data) {
|
||||
const opening = veil.hidden;
|
||||
veil.hidden = false;
|
||||
render(data);
|
||||
if (opening) {
|
||||
panel.querySelector<HTMLElement>("#summary-close")?.focus();
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
clearSheet(panel);
|
||||
delete panel.dataset.ready;
|
||||
drawn = "";
|
||||
}
|
||||
},
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
panel,
|
||||
};
|
||||
}
|
||||
+12
-9
@@ -136,15 +136,18 @@ export const TEXTS = {
|
||||
// Fin de partie
|
||||
gameOver: "Partie finie",
|
||||
endOverline: "Fin de partie",
|
||||
/** Titre du panneau de fin : qui gagne, et comment. */
|
||||
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
|
||||
if (dropped) {
|
||||
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
|
||||
}
|
||||
return winner === "player"
|
||||
/** Titre du panneau de fin et du bilan : toujours l'issue, qui gagne
|
||||
* et comment (un double passé se dit dans le détail). */
|
||||
endTitle: (winner: "player" | "computer", kind: ResultKind) =>
|
||||
winner === "player"
|
||||
? `Vous gagnez ${RESULT_KIND[kind]}`
|
||||
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
|
||||
},
|
||||
: `L'ordinateur gagne ${RESULT_KIND[kind]}`,
|
||||
/** Avant le détail des points, quand la partie finit sur un double
|
||||
* passé : « Vous avez passé son double : 1 point pour lui. ». */
|
||||
droppedDouble: (winner: "player" | "computer") =>
|
||||
winner === "player"
|
||||
? "L'ordinateur a passé votre double : "
|
||||
: "Vous avez passé son double : ",
|
||||
/** « 8 points pour lui, videau à 4. » */
|
||||
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
|
||||
@@ -185,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,141 @@
|
||||
// Le conseil du maître pendant la partie (app/src/ui/advice-tracker.ts) :
|
||||
// les analyses ne retardent pas le tour de l'ordinateur, la ligne
|
||||
// n'apparaît qu'après la décision et pas si le joueur a déjà agi, et
|
||||
// chaque conseil est rattaché au journal.
|
||||
//
|
||||
// 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 { analyseDecision } from "../src/domain/analysis/analyse.js";
|
||||
import type { Advice } from "../src/domain/game/advice.js";
|
||||
import type { GameJournal } from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { createAdviceTracker } from "../src/ui/advice-tracker.js";
|
||||
import { DOUBLE_TAKE_POSITION } from "./support/advice.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
/** Une analyse qu'on libère à la main. */
|
||||
function heldAnalyses() {
|
||||
const held: { id: string; release: () => Promise<void> }[] = [];
|
||||
const analyse = (journal: GameJournal, eventId: string) =>
|
||||
new Promise<Advice | null>((resolve) => {
|
||||
held.push({
|
||||
id: eventId,
|
||||
release: async () => {
|
||||
resolve(await analyseDecision(engine, journal, eventId));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
},
|
||||
});
|
||||
});
|
||||
return { held, analyse };
|
||||
}
|
||||
|
||||
let engine: Awaited<ReturnType<typeof loadNodeEngine>>;
|
||||
|
||||
async function opening(dice: number[]): Promise<Game> {
|
||||
engine = await loadNodeEngine();
|
||||
return Game.start(engine, {
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
}
|
||||
|
||||
async function play(game: Game, move: string): Promise<void> {
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
}
|
||||
|
||||
test("le tour de l'ordinateur n'attend pas l'analyse ; la ligne vient ensuite", async () => {
|
||||
const game = await opening([3, 1, 6, 5, 4, 2]);
|
||||
const { held, analyse } = heldAnalyses();
|
||||
let saved = 0;
|
||||
let changes = 0;
|
||||
const tracker = createAdviceTracker({
|
||||
analyse,
|
||||
recorded: () => saved++,
|
||||
changed: () => changes++,
|
||||
});
|
||||
let before = game.events;
|
||||
await game.roll();
|
||||
tracker.track(game, before);
|
||||
// Un jet seul ne montre rien : le joueur réfléchit.
|
||||
assert.equal(held.length, 1, "le jet est analysé (aurait-il fallu doubler ?)");
|
||||
before = game.events;
|
||||
await play(game, "24/23 13/10");
|
||||
tracker.track(game, before);
|
||||
// Les analyses sont en attente : l'ordinateur joue quand même.
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(tracker.shown, null);
|
||||
for (const h of held) {
|
||||
await h.release();
|
||||
}
|
||||
const shown = tracker.shown as Advice | null;
|
||||
assert.equal(shown?.kind, "move");
|
||||
assert.equal(shown?.skill, "veryBad");
|
||||
assert.equal(changes, 1);
|
||||
// Le coup est rattaché au journal (le jet aussi s'il y avait à dire).
|
||||
assert.ok(saved >= 1);
|
||||
assert.ok(game.advice[shown?.eventId ?? ""] !== undefined);
|
||||
// L'action suivante du joueur efface la ligne.
|
||||
tracker.clear();
|
||||
assert.equal(tracker.shown, null);
|
||||
});
|
||||
|
||||
test("si le joueur a déjà agi, la ligne n'apparaît pas, mais le conseil reste au journal", async () => {
|
||||
const game = await opening([3, 1, 6, 5]);
|
||||
const { held, analyse } = heldAnalyses();
|
||||
const tracker = createAdviceTracker({ analyse, recorded: () => {}, changed: () => {} });
|
||||
await game.roll();
|
||||
const before = game.events;
|
||||
await play(game, "8/5 6/5");
|
||||
tracker.track(game, before);
|
||||
tracker.clear(); // le joueur a relancé avant l'analyse
|
||||
for (const h of held) {
|
||||
await h.release();
|
||||
}
|
||||
assert.equal(tracker.shown, null);
|
||||
const move = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
assert.equal(game.advice[move?.id ?? ""]?.skill, "none");
|
||||
});
|
||||
|
||||
test("un double manqué avant le jet passe avant le coup dans la ligne", async () => {
|
||||
engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
const tracker = createAdviceTracker({
|
||||
analyse: (journal, id) => analyseDecision(engine, journal, id),
|
||||
recorded: () => {},
|
||||
changed: () => {},
|
||||
});
|
||||
let before = game.events;
|
||||
await game.roll();
|
||||
tracker.track(game, before);
|
||||
const best = await engine.bestMove(
|
||||
game.state.position,
|
||||
{ value: 1, owner: "centered" },
|
||||
[6, 5],
|
||||
0,
|
||||
);
|
||||
assert.ok(best !== null);
|
||||
before = game.events;
|
||||
for (const halfMove of best.halfMoves) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
tracker.track(game, before);
|
||||
for (let i = 0; i < 50 && tracker.shown === null; i++) {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
const shown = tracker.shown as Advice | null;
|
||||
assert.equal(shown?.kind, "cube");
|
||||
assert.equal(shown?.kind === "cube" ? shown.action : null, "noDouble");
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions
|
||||
// connues, phrases rendues sur ces analyses, conseil rattaché au journal et
|
||||
// gardé à l'enregistrement.
|
||||
//
|
||||
// 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 {
|
||||
LINE_LIMIT,
|
||||
masterLine,
|
||||
masterWords,
|
||||
PHRASE_KEYS,
|
||||
} from "../src/domain/analysis/phrases.js";
|
||||
import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { createGameLibrary } from "../src/storage/game-library.js";
|
||||
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../src/storage/open-library.js";
|
||||
import {
|
||||
adviceOn,
|
||||
loadPhrases,
|
||||
playerAnswers,
|
||||
playerDoubles,
|
||||
playerRollsWithoutDoubling,
|
||||
playOpening,
|
||||
} from "./support/advice.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const phrases = loadPhrases();
|
||||
|
||||
test("la source des phrases a toutes ses sections, sans point d'exclamation", () => {
|
||||
for (const key of PHRASE_KEYS) {
|
||||
assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`);
|
||||
}
|
||||
for (const [key, templates] of phrases) {
|
||||
assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`);
|
||||
for (const template of templates) {
|
||||
assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Des décisions réelles, analysées par le moteur à 2 plis. Les coups
|
||||
// d'ouverture et leurs écarts viennent du classement de gnubg.
|
||||
const cases: { name: string; play: () => Promise<Advice>; word: string }[] = [
|
||||
{
|
||||
name: "3-1 joué 8/5 6/5 : le meilleur coup",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/11 6/5 : tout près du meilleur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"),
|
||||
),
|
||||
word: "Bon coup",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 13/10 : imprécision",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"),
|
||||
),
|
||||
word: "Imprécision",
|
||||
},
|
||||
{
|
||||
name: "2-1 joué 24/22 6/5 : erreur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "3-1 joué 24/23 13/10 : grosse erreur",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"),
|
||||
),
|
||||
word: "Grosse erreur",
|
||||
},
|
||||
{
|
||||
name: "double dans une position de double et prise",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "lancer sans doubler dans une position de double",
|
||||
play: async () =>
|
||||
adviceOn(
|
||||
await loadNodeEngine(),
|
||||
await playerRollsWithoutDoubling(await loadNodeEngine()),
|
||||
),
|
||||
word: "Erreur",
|
||||
},
|
||||
{
|
||||
name: "prendre un double juste",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
|
||||
word: "Bonne décision",
|
||||
},
|
||||
{
|
||||
name: "passer un double qu'il fallait prendre",
|
||||
play: async () =>
|
||||
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
|
||||
word: "Erreur",
|
||||
},
|
||||
];
|
||||
|
||||
const analysed: Advice[] = [];
|
||||
|
||||
for (const c of cases) {
|
||||
test(`conseil : ${c.name}`, async () => {
|
||||
const advice = await c.play();
|
||||
analysed.push(advice);
|
||||
const words = masterWords(phrases, advice);
|
||||
assert.equal(words.word, c.word, JSON.stringify(advice));
|
||||
// Chaque gabarit tourne avec l'identifiant de l'événement : la ligne
|
||||
// est rendue pour plusieurs identifiants.
|
||||
for (let id = 1; id <= 40; id++) {
|
||||
const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) }));
|
||||
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
|
||||
assert.ok(!line.includes("!"), line);
|
||||
assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const best = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "8/5 6/5"),
|
||||
)) as MoveAdvice;
|
||||
assert.equal(best.kind, "move");
|
||||
assert.equal(best.version, ADVICE_VERSION);
|
||||
assert.equal(best.rank, 1);
|
||||
assert.equal(best.opening, true);
|
||||
assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] });
|
||||
assert.equal(best.ranking[0]?.notation, "8/5 6/5");
|
||||
const words = masterWords(phrases, best);
|
||||
assert.equal(words.title, "C'est aussi le coup que j'aurais joué");
|
||||
assert.equal(words.context, "votre 3-1 d'ouverture");
|
||||
|
||||
const worst = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "24/23 13/10"),
|
||||
)) as MoveAdvice;
|
||||
assert.equal(worst.skill, "veryBad");
|
||||
assert.equal(worst.best.notation, "8/5 6/5");
|
||||
assert.deepEqual(worst.bestFacts.madePoints, [5]);
|
||||
const said = masterWords(phrases, worst);
|
||||
assert.equal(said.title, "J'aurais joué 8/5 6/5");
|
||||
assert.equal(said.phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
});
|
||||
|
||||
test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const worst = (await adviceOn(
|
||||
engine,
|
||||
await playOpening(engine, [3, 1], "24/23 13/10"),
|
||||
)) as MoveAdvice;
|
||||
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
|
||||
for (const rank of [2, 3, 4, 7, 15]) {
|
||||
const { phrase } = masterWords(phrases, { ...worst, skill, rank });
|
||||
const cited = (skill === "none" || skill === "doubtful") && rank <= 3;
|
||||
if (cited) {
|
||||
assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`);
|
||||
} else {
|
||||
assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`);
|
||||
assert.equal(phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("décisions de videau : la décision du maître et le contexte", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine));
|
||||
const words = masterWords(phrases, missed);
|
||||
assert.equal(words.phrase, "Ici, j'aurais doublé.");
|
||||
assert.equal(words.title, "J'aurais doublé");
|
||||
assert.equal(words.context, "avant votre jet");
|
||||
const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false)));
|
||||
assert.equal(passed.phrase, "Ici, j'aurais pris.");
|
||||
assert.equal(passed.context, "le double de l'ordinateur");
|
||||
});
|
||||
|
||||
test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => {
|
||||
const long = "bar/22* 13/9*(2) 6/off";
|
||||
const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 });
|
||||
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
|
||||
for (const rank of [1, 2, 23]) {
|
||||
for (const facts of [
|
||||
{ hits: 0, madePoints: [] },
|
||||
{ hits: 1, madePoints: [4, 5, 7] },
|
||||
{ hits: 3, madePoints: [] },
|
||||
]) {
|
||||
const advice: MoveAdvice = {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "move",
|
||||
eventId: "7",
|
||||
ply: 2,
|
||||
position: "4HPwATDgc/ABMA",
|
||||
roll: [6, 6],
|
||||
opening: false,
|
||||
moveCount: 87,
|
||||
rank,
|
||||
played: move(-0.2),
|
||||
best: move(0),
|
||||
playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] },
|
||||
bestFacts: facts,
|
||||
ranking: [],
|
||||
equityLoss: rank === 1 ? 0 : -0.2,
|
||||
skill: rank === 1 ? "none" : skill,
|
||||
};
|
||||
for (let id = 0; id < 20; id++) {
|
||||
const line = masterLine(
|
||||
masterWords(phrases, { ...advice, eventId: String(id) }),
|
||||
);
|
||||
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const played = await playOpening(engine, [3, 1], "8/5 6/5");
|
||||
const advice = await adviceOn(engine, played);
|
||||
played.game.recordAdvice(advice);
|
||||
// Le tour de l'ordinateur, puis l'enregistrement : le conseil reste.
|
||||
await played.game.playComputerTurn();
|
||||
assert.deepEqual(played.game.advice[played.eventId], advice);
|
||||
const library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
await library.save("g", played.game.journal, { level: "2ply" });
|
||||
const loaded = await library.load("g");
|
||||
assert.ok(loaded !== null);
|
||||
const resumed = Game.resume(engine, loaded.journal);
|
||||
assert.deepEqual(resumed.advice[played.eventId], advice);
|
||||
// Revenir en arrière garde le conseil, rattaché à son événement.
|
||||
resumed.goBackTo(played.eventId);
|
||||
assert.deepEqual(resumed.advice[played.eventId], advice);
|
||||
});
|
||||
|
||||
test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const played = await playOpening(engine, [3, 1], "8/5 6/5");
|
||||
const { advice: _, ...withoutAdvice } = played.game.journal;
|
||||
assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {});
|
||||
const advice = await adviceOn(engine, played);
|
||||
const future = { ...advice, version: ADVICE_VERSION + 1 };
|
||||
const orphan = { ...advice, eventId: "999" };
|
||||
const resumed = Game.resume(engine, {
|
||||
...played.game.journal,
|
||||
advice: { [played.eventId]: future, "999": orphan },
|
||||
});
|
||||
assert.deepEqual(resumed.advice, {});
|
||||
assert.throws(() => resumed.recordAdvice(orphan), /introuvable/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// L'analyse de partie (étape 1e) : les tours, le format durable de
|
||||
// l'analyse et sa migration, les chiffres du bilan à la manière de gnubg,
|
||||
// et une partie complète analysée par le vrai moteur.
|
||||
//
|
||||
// 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 {
|
||||
chanceCurve,
|
||||
gameTurns,
|
||||
levelOfErrorRate,
|
||||
luckRatingOf,
|
||||
mainAdvice,
|
||||
missingAnalyses,
|
||||
readyForSummary,
|
||||
replayPoint,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { ADVICE_VERSION, type Advice, type CubeAdvice } from "../src/domain/game/advice.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
withAnalysesOf,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.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";
|
||||
|
||||
/** Un journal fait d'événements donnés, à la suite. */
|
||||
function journalOf(events: readonly NewGameEvent[]): GameJournal {
|
||||
return events.reduce(appendEvent, EMPTY_JOURNAL);
|
||||
}
|
||||
|
||||
const START: NewGameEvent = {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
};
|
||||
const P = START_POSITION;
|
||||
|
||||
/** Une petite partie : ouverture (égalité puis 3-1 pour le joueur), un
|
||||
* coup de chaque camp, puis le joueur double et l'ordinateur passe. */
|
||||
const SHORT: NewGameEvent[] = [
|
||||
START,
|
||||
{ type: "DiceRolled", side: null, roll: [2, 2] },
|
||||
{ type: "DiceRolled", side: null, roll: [3, 1] },
|
||||
{ type: "MovePlayed", side: "player", halfMoves: [{ from: 8, to: 5 }], position: P },
|
||||
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
|
||||
{ type: "MovePlayed", side: "computer", halfMoves: [{ from: 24, to: 13 }], position: P },
|
||||
{ type: "CubeOffered", side: "player" },
|
||||
{ type: "CubeDropped", side: "computer" },
|
||||
{ type: "GameEnded", winner: "player", kind: "single", points: 1 },
|
||||
];
|
||||
|
||||
test("les tours d'une partie : ouverture, coups, double passé", () => {
|
||||
const journal = journalOf(SHORT);
|
||||
const turns = gameTurns(journal);
|
||||
assert.deepEqual(
|
||||
turns.map((t) => [t.number, t.side, t.roll, t.move?.id ?? null, t.end?.id ?? null]),
|
||||
[
|
||||
[1, "player", [3, 1], "4", null],
|
||||
[2, "computer", [6, 5], "6", null],
|
||||
[3, "player", null, null, "9"],
|
||||
],
|
||||
);
|
||||
// L'égalité d'ouverture appartient au premier tour.
|
||||
assert.deepEqual(
|
||||
turns[0]?.events.map((e) => e.id),
|
||||
["2", "3", "4"],
|
||||
);
|
||||
// Le jet d'ouverture qui désigne le joueur a une chance, l'égalité non.
|
||||
const luck = missingAnalyses(journal).filter((t) => t.kind === "luck");
|
||||
assert.deepEqual(
|
||||
luck.map((t) => t.eventId),
|
||||
["3", "5"],
|
||||
);
|
||||
});
|
||||
|
||||
/** Un conseil fictif sur un coup du joueur. */
|
||||
function moveAdvice(eventId: string, equityLoss: number, moveCount = 10): Advice {
|
||||
const move = { notation: "8/5", halfMoves: [], equity: 0, ply: 2 };
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "move",
|
||||
eventId,
|
||||
ply: 2,
|
||||
position: P,
|
||||
roll: [3, 1],
|
||||
opening: true,
|
||||
moveCount,
|
||||
rank: equityLoss === 0 ? 1 : 2,
|
||||
played: move,
|
||||
best: move,
|
||||
playedFacts: { hits: 0, madePoints: [] },
|
||||
bestFacts: { hits: 0, madePoints: [] },
|
||||
ranking: [],
|
||||
equityLoss,
|
||||
skill:
|
||||
equityLoss < -0.12
|
||||
? "veryBad"
|
||||
: equityLoss < -0.06
|
||||
? "bad"
|
||||
: equityLoss < -0.03
|
||||
? "doubtful"
|
||||
: "none",
|
||||
};
|
||||
}
|
||||
|
||||
function cubeAdvice(eventId: string, action: CubeAdvice["action"], equityLoss: number): Advice {
|
||||
return {
|
||||
version: ADVICE_VERSION,
|
||||
kind: "cube",
|
||||
eventId,
|
||||
ply: 2,
|
||||
action,
|
||||
position: P,
|
||||
equities: { noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 },
|
||||
equityLoss,
|
||||
skill: equityLoss < -0.06 ? "bad" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
test("le taux d'erreur, le niveau et la chance à la manière de gnubg", () => {
|
||||
// arThrsRating de gnubg : 0,032, 0,020, 0,013, 0,008, 0,005 ; les six
|
||||
// classements de gnubg prennent, dans l'ordre, les noms des six niveaux
|
||||
// de l'ordinateur (Advanced de gnubg : Intermédiaire).
|
||||
assert.equal(levelOfErrorRate(0.05), "beginner");
|
||||
assert.equal(levelOfErrorRate(0.032), "beginner");
|
||||
assert.equal(levelOfErrorRate(0.031), "amateur");
|
||||
assert.equal(levelOfErrorRate(0.015), "intermediate");
|
||||
assert.equal(levelOfErrorRate(0.01), "advanced");
|
||||
assert.equal(levelOfErrorRate(0.006), "expert");
|
||||
assert.equal(levelOfErrorRate(0.001), "champion");
|
||||
// getLuckRating : chance par tour × 10, seuils 0,3 et 0,6.
|
||||
assert.equal(luckRatingOf(0.07), "veryLucky");
|
||||
assert.equal(luckRatingOf(0.04), "lucky");
|
||||
assert.equal(luckRatingOf(0.02), "none");
|
||||
assert.equal(luckRatingOf(-0.04), "unlucky");
|
||||
assert.equal(luckRatingOf(-0.07), "veryUnlucky");
|
||||
|
||||
const base = journalOf(SHORT);
|
||||
const journal: GameJournal = {
|
||||
...base,
|
||||
advice: {
|
||||
"4": moveAdvice("4", -0.08),
|
||||
"7": cubeAdvice("7", "double", 0),
|
||||
},
|
||||
review: {
|
||||
...emptyReview(2),
|
||||
luck: {
|
||||
"3": { value: 0.2, rating: "none" },
|
||||
"5": { value: -0.1, rating: "none" },
|
||||
},
|
||||
computer: { "6": moveAdvice("6", 0) },
|
||||
},
|
||||
};
|
||||
assert.ok(readyForSummary(journal));
|
||||
const summary = summarizeAnalysis(journal);
|
||||
// Un coup non forcé (0,08) et un double (compté toujours) : 0,04 par
|
||||
// décision.
|
||||
assert.equal(summary.player.errorRate.decisions, 2);
|
||||
assert.ok(Math.abs(summary.player.errorRate.perDecision - 0.04) < 1e-9);
|
||||
assert.equal(summary.player.errorRate.level, "beginner");
|
||||
assert.deepEqual(summary.counts, { good: 1, doubtful: 0, bad: 1, veryBad: 0 });
|
||||
assert.equal(summary.toReview?.advice.eventId, "4");
|
||||
assert.equal(summary.toReview?.turn, 1);
|
||||
// Une seule fois la main pour chacun : chance par tour égale au total.
|
||||
assert.equal(summary.player.luck.rolls, 1);
|
||||
assert.equal(summary.player.luck.rating, "veryLucky");
|
||||
assert.equal(summary.computer.luck.rating, "veryUnlucky");
|
||||
// Gagné 1 point, avec 0,2 de chance contre −0,1 pour l'ordinateur :
|
||||
// 1 − 0,2 − 0,1.
|
||||
assert.equal(summary.result, 1);
|
||||
assert.ok(Math.abs(summary.luckAdjustedResult - 0.7) < 1e-9);
|
||||
// Rejouer le premier tour : juste avant le coup, avec le même jet.
|
||||
const first = gameTurns(journal)[0];
|
||||
assert.ok(first !== undefined);
|
||||
assert.equal(mainAdvice(journal, first)?.eventId, "4");
|
||||
assert.equal(replayPoint(journal, first), "3");
|
||||
// La courbe : le dernier tour finit la partie, gagnée.
|
||||
assert.deepEqual(
|
||||
chanceCurve(journal).map((p) => [p.turn, p.chance, p.verdict]),
|
||||
[
|
||||
[1, null, "bad"],
|
||||
[2, null, null],
|
||||
[3, 1, "good"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("un jet sans doubler ne compte que si la décision est serrée (isCloseCubedecision)", () => {
|
||||
const base = journalOf([
|
||||
{ ...START, firstTurn: "player" },
|
||||
{ type: "DiceRolled", side: "player", roll: [3, 1] },
|
||||
]);
|
||||
const noDouble = (equities: CubeAdvice["equities"]): GameJournal => ({
|
||||
...base,
|
||||
advice: { "2": { ...(cubeAdvice("2", "noDouble", 0) as CubeAdvice), equities } },
|
||||
});
|
||||
// Doubler coûterait 0,2 par rapport à l'optimum : pas serré.
|
||||
const far = noDouble({ noDouble: 0.5, doubleTake: 0.3, doublePass: 1, optimal: 0.5 });
|
||||
assert.equal(summarizeAnalysis(far).player.errorRate.decisions, 0);
|
||||
// Doubler est l'optimum : serré, compté.
|
||||
const close = noDouble({ noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 });
|
||||
assert.equal(summarizeAnalysis(close).player.errorRate.decisions, 1);
|
||||
// Un bon jet sans doubler n'est pas une décision visible du bilan.
|
||||
assert.deepEqual(summarizeAnalysis(close).counts, {
|
||||
good: 0,
|
||||
doubtful: 0,
|
||||
bad: 0,
|
||||
veryBad: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("l'analyse de partie enregistrée : migration du format durable", () => {
|
||||
const journal = journalOf(SHORT);
|
||||
const review = {
|
||||
...emptyReview(2),
|
||||
luck: { "3": { value: 0.1, rating: "none" as const } },
|
||||
winChances: { "4": 0.55, "99": 0.5 },
|
||||
unanalysed: ["5", "98"],
|
||||
};
|
||||
// Un journal d'avant l'étape 1e se reprend tel quel.
|
||||
assert.equal(migrateJournal(journal), journal);
|
||||
// Les entrées rattachées à un événement absent sont oubliées.
|
||||
const migrated = migrateJournal({ ...journal, review });
|
||||
assert.deepEqual(migrated.review?.winChances, { "4": 0.55 });
|
||||
assert.deepEqual(migrated.review?.unanalysed, ["5"]);
|
||||
assert.deepEqual(migrated.review?.luck, review.luck);
|
||||
// Une analyse complète et connue ne change pas.
|
||||
const clean = { ...journal, review: migrated.review };
|
||||
assert.equal(migrateJournal(clean), clean);
|
||||
// Une autre version, ou une analyse mal formée, est oubliée.
|
||||
const other = migrateJournal({
|
||||
...journal,
|
||||
review: { ...review, version: REVIEW_VERSION + 1 },
|
||||
});
|
||||
assert.equal(other.review, undefined);
|
||||
const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal;
|
||||
assert.equal(migrateJournal(broken).review, undefined);
|
||||
// Les analyses d'une copie s'ajoutent au journal sans toucher aux
|
||||
// conseils déjà là.
|
||||
const live: GameJournal = { ...journal, advice: { "4": moveAdvice("4", 0) } };
|
||||
const copy: GameJournal = {
|
||||
...journal,
|
||||
advice: { "4": moveAdvice("4", -0.5), "7": cubeAdvice("7", "double", 0) },
|
||||
review,
|
||||
};
|
||||
const merged = withAnalysesOf(live, copy);
|
||||
assert.equal(merged.advice?.["4"]?.equityLoss, 0);
|
||||
assert.ok(merged.advice?.["7"] !== undefined);
|
||||
assert.equal(merged.review, review);
|
||||
});
|
||||
|
||||
/** Toutes les analyses qui manquent, une à une. */
|
||||
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");
|
||||
}
|
||||
|
||||
test("une partie complète analysée par le moteur, comme une partie d'avant 1d", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Une course courte (tests/reference/positions.json, race-1).
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
|
||||
level: "2ply",
|
||||
dice: seededDice(7),
|
||||
});
|
||||
await playToTheEnd(game, engine, 1);
|
||||
// Aucun conseil n'a été rangé pendant la partie (comme avant l'étape
|
||||
// 1d) : tout est à analyser.
|
||||
assert.equal(game.journal.advice, undefined);
|
||||
assert.ok(!readyForSummary(game.journal));
|
||||
const analysed = await analyseAll(engine, game.journal);
|
||||
assert.ok(readyForSummary(analysed));
|
||||
assert.deepEqual(missingAnalyses(analysed), []);
|
||||
|
||||
const summary = summarizeAnalysis(analysed);
|
||||
const advices = Object.values(analysed.advice ?? {});
|
||||
assert.ok(advices.length > 0);
|
||||
// Le coup à revoir est la plus grosse perte d'équité du joueur, s'il en
|
||||
// a une.
|
||||
const worst = advices
|
||||
.filter((a) => a.skill !== "none")
|
||||
.reduce<Advice | null>((w, a) => (w === null || a.equityLoss < w.equityLoss ? a : w), null);
|
||||
assert.equal(summary.toReview?.advice.eventId, worst?.eventId);
|
||||
// La chance de chaque tour d'un camp est comptée.
|
||||
const moves = game.events.filter((e) => e.type === "MovePlayed");
|
||||
assert.equal(summary.player.luck.rolls + summary.computer.luck.rolls, moves.length);
|
||||
// Les chances de gagner sont des probabilités, et la courbe finit sur
|
||||
// le résultat.
|
||||
const curve = chanceCurve(analysed);
|
||||
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
|
||||
const won = game.state.result?.winner === "player";
|
||||
assert.equal(curve.at(-1)?.chance, won ? 1 : 0);
|
||||
// Les coups de l'ordinateur sont analysés aussi.
|
||||
assert.ok(Object.keys(analysed.review?.computer ?? {}).length > 0);
|
||||
// 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");
|
||||
});
|
||||
@@ -80,6 +80,7 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
skill: "none",
|
||||
}),
|
||||
luck: async () => ({ value: 0, rating: "none" }),
|
||||
evaluate: async () => ({ win: 0.5, equity: 0 }),
|
||||
encodeMatch: async () => "MATCH",
|
||||
decodeMatch: async () => ({
|
||||
matchLength: 0,
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
|
||||
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
|
||||
// ou par une autre partie ; et les phrases du bilan.
|
||||
//
|
||||
// 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 AnalysisTask,
|
||||
type GameSummary,
|
||||
missingAnalyses,
|
||||
} from "../src/domain/analysis/game-analysis.js";
|
||||
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
type NewGameEvent,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
|
||||
import { createReviewRunner } from "../src/ui/review-runner.js";
|
||||
import { loadPhrases } from "./support/advice.js";
|
||||
|
||||
const P = START_POSITION;
|
||||
/** Un tour de l'ordinateur, sans décision du joueur. */
|
||||
const EVENTS: NewGameEvent[] = [
|
||||
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
|
||||
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
|
||||
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
|
||||
];
|
||||
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
|
||||
|
||||
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
|
||||
* libère. */
|
||||
function fakeRun() {
|
||||
const waiting: (() => void)[] = [];
|
||||
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
const review: GameReview = journal.review ?? emptyReview(2);
|
||||
if (task.kind === "luck") {
|
||||
return {
|
||||
...journal,
|
||||
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
|
||||
};
|
||||
}
|
||||
if (task.kind === "chance") {
|
||||
return {
|
||||
...journal,
|
||||
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
|
||||
};
|
||||
}
|
||||
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
|
||||
};
|
||||
const release = async () => {
|
||||
while (waiting.length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
waiting.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
};
|
||||
return { run, release };
|
||||
}
|
||||
|
||||
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
|
||||
const { run, release } = fakeRun();
|
||||
const runner = createReviewRunner(run);
|
||||
const recorded: GameJournal[] = [];
|
||||
let done = false;
|
||||
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
|
||||
// après le coup.
|
||||
const total = missingAnalyses(JOURNAL).length;
|
||||
assert.equal(total, 3);
|
||||
runner.start(
|
||||
"a",
|
||||
JOURNAL,
|
||||
(j) => recorded.push(j),
|
||||
() => {
|
||||
done = true;
|
||||
},
|
||||
);
|
||||
assert.equal(runner.running, "a");
|
||||
// La même partie redemandée ne repart pas.
|
||||
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
|
||||
for (let i = 0; i < total; i++) {
|
||||
await release();
|
||||
}
|
||||
assert.equal(recorded.length, total);
|
||||
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
|
||||
assert.ok(done);
|
||||
assert.equal(runner.running, null);
|
||||
});
|
||||
|
||||
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
|
||||
const { run, release } = fakeRun();
|
||||
const runner = createReviewRunner(run);
|
||||
const recorded: string[] = [];
|
||||
runner.start("a", JOURNAL, () => recorded.push("a"));
|
||||
runner.stop();
|
||||
assert.equal(runner.running, null);
|
||||
await release();
|
||||
assert.equal(recorded.length, 0);
|
||||
runner.start("b", JOURNAL, () => recorded.push("b"));
|
||||
runner.start("c", JOURNAL, () => recorded.push("c"));
|
||||
assert.equal(runner.running, "c");
|
||||
await release();
|
||||
await release();
|
||||
assert.deepEqual(recorded, ["c"]);
|
||||
});
|
||||
|
||||
/** Un bilan réduit à ce que lisent les phrases. */
|
||||
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
|
||||
const side = {
|
||||
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
|
||||
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
|
||||
};
|
||||
return {
|
||||
player: side,
|
||||
computer: side,
|
||||
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
|
||||
toReview: null,
|
||||
result,
|
||||
luckAdjustedResult,
|
||||
};
|
||||
}
|
||||
|
||||
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
|
||||
const phrases = loadPhrases();
|
||||
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(1, 1.2)),
|
||||
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
|
||||
);
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(-2, -1.5)),
|
||||
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
|
||||
);
|
||||
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
|
||||
for (const [result, adjusted] of [
|
||||
[1, -0.3],
|
||||
[-1, 0.2],
|
||||
] as const) {
|
||||
assert.equal(
|
||||
summaryReading(phrases, summaryWith(result, adjusted)),
|
||||
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
|
||||
);
|
||||
}
|
||||
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
|
||||
});
|
||||
@@ -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,11 +34,33 @@ 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 }],
|
||||
["#/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 },
|
||||
@@ -51,6 +75,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" });
|
||||
@@ -70,10 +105,28 @@ 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",
|
||||
"#/position/%E0%A4%A",
|
||||
"#/parties/ab/autre",
|
||||
"#/parties/ab/bilan/2",
|
||||
"#/parties/ab/analyse/0",
|
||||
"#/parties/ab/analyse/x",
|
||||
"#/parties/ab/analyse/3/plus",
|
||||
"#/parties/AB/bilan",
|
||||
"#/aide/blot/ia",
|
||||
"#/menu/ia",
|
||||
"#/parties/ab/ia",
|
||||
"#/explication/12/ia/ia",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Étapes du scénario docs/scenarios/comprendre-mes-coups.feature (ADR 0008) :
|
||||
// les décisions passent par le domaine et le vrai moteur, le conseil par
|
||||
// l'analyse du domaine, ses mots par la source des phrases du maître, et
|
||||
// la ligne par le suivi du conseil de l'interface (advice-tracker.ts) avec
|
||||
// le réglage de l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
|
||||
import { 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 { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createAdviceTracker } from "../../src/ui/advice-tracker.js";
|
||||
import { parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { DOUBLE_TAKE_POSITION, loadPhrases } from "../support/advice.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
|
||||
const phrases = loadPhrases();
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let settings: Settings;
|
||||
let tracker: ReturnType<typeof createAdviceTracker>;
|
||||
let ready: Promise<void>;
|
||||
let markReady: () => void;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Before(() => {
|
||||
settings = parseSettings(null);
|
||||
ready = new Promise((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
tracker = createAdviceTracker({
|
||||
analyse: (journal, eventId) => analyseDecision(engine, journal, eventId),
|
||||
recorded: () => {},
|
||||
changed: () => markReady(),
|
||||
});
|
||||
});
|
||||
|
||||
/** Joue une décision du joueur comme l'écran de jeu : la ligne d'avant
|
||||
* s'efface, la décision part à l'analyse. */
|
||||
async function decide(action: (g: Game) => Promise<void> | void): Promise<void> {
|
||||
tracker.clear();
|
||||
const before = game.events;
|
||||
await action(game);
|
||||
tracker.track(game, before);
|
||||
}
|
||||
|
||||
/** Ce que montre la ligne du maître (null : rien), comme l'écran : le
|
||||
* conseil prêt, si le réglage le permet. */
|
||||
async function line(): Promise<{ word: string; phrase: string; advice: Advice } | null> {
|
||||
await Promise.race([ready, new Promise((resolve) => setTimeout(resolve, 10_000))]);
|
||||
const advice = tracker.shown;
|
||||
if (!settings.masterAdvice || advice === null) {
|
||||
return null;
|
||||
}
|
||||
const words = masterWords(phrases, advice);
|
||||
return { word: words.word, phrase: words.phrase, advice };
|
||||
}
|
||||
|
||||
Given("vous avez {int}-{int} à jouer à la position de départ", async (a: number, b: number) => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([a, b, 6, 5]),
|
||||
});
|
||||
await decide((g) => g.roll());
|
||||
});
|
||||
|
||||
Given("vous avez coupé le conseil du maître", () => {
|
||||
settings = { ...settings, masterAdvice: false };
|
||||
});
|
||||
|
||||
When("vous validez le coup {}", async (move: string) => {
|
||||
await decide(async (g) => {
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.ok(await g.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
|
||||
}
|
||||
await g.confirmMove();
|
||||
});
|
||||
// L'ordinateur joue aussitôt, sans attendre l'analyse.
|
||||
await game.playComputerTurn();
|
||||
});
|
||||
|
||||
Then("le maître dit « {}. »", async (word: string) => {
|
||||
const shown = await line();
|
||||
assert.equal(shown?.word, word);
|
||||
});
|
||||
|
||||
Then("sa phrase dit que {} fait votre point {int}", async (move: string, point: number) => {
|
||||
const shown = await line();
|
||||
assert.ok(shown?.phrase.includes(`${move} fait votre point ${point}.`), shown?.phrase);
|
||||
});
|
||||
|
||||
Then("sa phrase commence par « {} »", async (start: string) => {
|
||||
const shown = await line();
|
||||
assert.ok(shown?.phrase.startsWith(start), shown?.phrase);
|
||||
});
|
||||
|
||||
Then("sa phrase dit « {} »", async (phrase: string) => {
|
||||
const shown = await line();
|
||||
assert.equal(shown?.phrase, phrase);
|
||||
});
|
||||
|
||||
Then("l'explication peut montrer votre coup et le coup du maître sur le plateau", async () => {
|
||||
const shown = await line();
|
||||
assert.ok(shown !== null && shown.advice.kind === "move");
|
||||
const advice = shown.advice;
|
||||
const mine = await game.stepsBefore(advice.eventId, "player", advice.played.halfMoves);
|
||||
const best = await game.stepsBefore(advice.eventId, "player", advice.best.halfMoves);
|
||||
// Les deux coups partent de la même position, celle d'avant le coup.
|
||||
assert.deepEqual(mine.boards[0], best.boards[0]);
|
||||
assert.equal(best.halfMoves.length, 2);
|
||||
assert.notDeepEqual(mine.boards.at(-1), best.boards.at(-1));
|
||||
});
|
||||
|
||||
Then("le maître ne dit rien", async () => {
|
||||
assert.equal(await line(), null);
|
||||
// Le coup est tout de même analysé, pour le bilan de fin de partie.
|
||||
const move = game.events.find((e) => e.type === "MovePlayed" && e.side === "player");
|
||||
assert.ok(game.advice[move?.id ?? ""] !== undefined);
|
||||
});
|
||||
|
||||
Given("l'ordinateur vous propose de doubler dans une position où il faut prendre", async () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
|
||||
level: "2ply",
|
||||
dice: fixedDice([]),
|
||||
});
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled");
|
||||
});
|
||||
|
||||
When("vous passez son double", async () => {
|
||||
await decide((g) => g.drop());
|
||||
});
|
||||
@@ -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,232 @@
|
||||
// Étapes du scénario docs/scenarios/revoir-ma-partie.feature (ADR 0008) :
|
||||
// la partie se joue par le domaine sur le vrai moteur, avec des dés
|
||||
// imposés ; son analyse passe par l'analyse en arrière-plan de l'interface
|
||||
// (review-runner.ts, sans DOM), le bilan par le domaine
|
||||
// (game-analysis.ts) et les phrases du maître, et l'ancienne partie par le
|
||||
// stockage des parties sur un localStorage simulé.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
|
||||
import {
|
||||
chanceCurve,
|
||||
gameTurns,
|
||||
missingAnalyses,
|
||||
readyForSummary,
|
||||
replayPoint,
|
||||
runAnalysis,
|
||||
summarizeAnalysis,
|
||||
} from "../../src/domain/analysis/game-analysis.js";
|
||||
import { diceWords, summaryReading } from "../../src/domain/analysis/phrases.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 { 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";
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
|
||||
/** 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 (Position ID de
|
||||
* gnubg, chacun vu de lui-même). */
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
const phrases = loadPhrases();
|
||||
let engine: Engine;
|
||||
let game: Game;
|
||||
let dice: number[];
|
||||
/** Le journal analysé de la partie. */
|
||||
let analysed: GameJournal;
|
||||
/** La partie d'origine, avant « Rejouer d'ici ». */
|
||||
let original: { head: string; events: number };
|
||||
/** Les analyses rangées par l'analyse en arrière-plan. */
|
||||
let recorded: number;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Analyse ce qui manque au journal, comme l'application en
|
||||
* arrière-plan. */
|
||||
async function analyse(journal: GameJournal): Promise<GameJournal> {
|
||||
const runner = createReviewRunner((j, task) => runAnalysis(engine, j, task));
|
||||
recorded = 0;
|
||||
let latest = journal;
|
||||
await new Promise<void>((resolve) => {
|
||||
runner.start(
|
||||
"partie",
|
||||
journal,
|
||||
(j) => {
|
||||
latest = j;
|
||||
recorded++;
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function playMove(move: string): Promise<void> {
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
assert.ok(await game.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
|
||||
}
|
||||
await game.confirmMove();
|
||||
}
|
||||
|
||||
Given(
|
||||
"il vous reste deux pions, sur vos points 6 et 1, et à l'ordinateur deux pions sur ses points 1 et 2",
|
||||
() => {
|
||||
dice = [];
|
||||
// Les dés sont tirés au moment voulu : ceux qu'impose le scénario.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: BEAROFF, firstTurn: "player" },
|
||||
level: "2ply",
|
||||
dice: () => {
|
||||
const die = dice.shift();
|
||||
if (die === undefined) {
|
||||
throw new Error("plus de dés imposés");
|
||||
}
|
||||
return die;
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Given("vous avez {int}-{int} à jouer", async (a: number, b: number) => {
|
||||
dice.push(a, b);
|
||||
await game.roll();
|
||||
assert.deepEqual(game.state.roll, [a, b]);
|
||||
});
|
||||
|
||||
When(/^vous choisissez (?:cette fois )?le coup (.+)$/, async (move: string) => {
|
||||
await playMove(move);
|
||||
});
|
||||
|
||||
/** L'ordinateur, sûr de sortir ses deux pions au jet suivant, double ; le
|
||||
* joueur passe. */
|
||||
async function computerDoublesAndPlayerPasses(): Promise<void> {
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled", "l'ordinateur ne double pas");
|
||||
await game.drop();
|
||||
assert.equal(game.state.phase, "over");
|
||||
}
|
||||
|
||||
When("l'ordinateur, sûr de gagner, double et que vous passez", async () => {
|
||||
await computerDoublesAndPlayerPasses();
|
||||
analysed = await analyse(game.journal);
|
||||
});
|
||||
|
||||
Then("la partie est finie, gagnée par l'ordinateur", () => {
|
||||
assert.equal(game.state.result?.winner, "computer");
|
||||
});
|
||||
|
||||
Then("le bilan compte une grosse erreur", () => {
|
||||
assert.ok(readyForSummary(analysed));
|
||||
assert.equal(summarizeAnalysis(analysed).counts.veryBad, 1);
|
||||
});
|
||||
|
||||
Then("sa phrase dit ce qui a fait la différence, et comment étaient vos dés", () => {
|
||||
const summary = summarizeAnalysis(analysed);
|
||||
const reading = summaryReading(phrases, summary);
|
||||
assert.ok(reading.startsWith("D'après mon analyse, "), reading);
|
||||
assert.ok(!reading.includes("!"), reading);
|
||||
// Perdue : le jeu de l'ordinateur a fait la différence, si le résultat
|
||||
// corrigé de la chance reste négatif ; sinon les dés.
|
||||
if (summary.luckAdjustedResult < 0) {
|
||||
assert.ok(reading.includes(diceWords(phrases, summary.player.luck.rating)), reading);
|
||||
} else {
|
||||
assert.ok(reading.includes("les dés ont fait la différence"), reading);
|
||||
}
|
||||
});
|
||||
|
||||
Then("le coup à revoir est votre coup {}, le premier de la partie", (move: string) => {
|
||||
const toReview = summarizeAnalysis(analysed).toReview;
|
||||
assert.equal(toReview?.turn, 1);
|
||||
assert.equal(toReview?.advice.kind, "move");
|
||||
assert.equal(toReview?.advice.kind === "move" && toReview.advice.played.notation, move);
|
||||
});
|
||||
|
||||
Then("le maître y aurait joué {}", (move: string) => {
|
||||
const advice = summarizeAnalysis(analysed).toReview?.advice;
|
||||
assert.equal(advice?.kind === "move" && advice.best.notation, move);
|
||||
assert.equal(advice?.skill, "veryBad");
|
||||
});
|
||||
|
||||
Then("la courbe de vos chances de gagner finit à {int} %", (percent: number) => {
|
||||
const curve = chanceCurve(analysed);
|
||||
assert.equal(curve.length, gameTurns(analysed).length);
|
||||
assert.equal(curve.at(-1)?.chance, percent / 100);
|
||||
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
|
||||
});
|
||||
|
||||
When("vous rejouez d'ici le coup à revoir", () => {
|
||||
game.recordAnalyses(analysed);
|
||||
original = { head: game.journal.head ?? "", events: game.events.length };
|
||||
const toReview = summarizeAnalysis(game.journal).toReview;
|
||||
const turn = gameTurns(game.journal)[(toReview?.turn ?? 0) - 1];
|
||||
assert.ok(turn !== undefined);
|
||||
const point = replayPoint(game.journal, turn);
|
||||
assert.ok(point !== null);
|
||||
game.goBackTo(point);
|
||||
});
|
||||
|
||||
Then("vous avez de nouveau {int}-{int} à jouer", (a: number, b: number) => {
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.deepEqual(game.state.roll, [a, b]);
|
||||
});
|
||||
|
||||
Then("la partie est finie, gagnée par vous", () => {
|
||||
assert.equal(game.state.result?.winner, "player");
|
||||
});
|
||||
|
||||
Then("la partie a deux branches, dont la partie d'origine, intacte", () => {
|
||||
const branches = game.branches;
|
||||
assert.equal(branches.length, 2);
|
||||
const origin = branches.find((b) => b.forkedFrom === null);
|
||||
assert.equal(origin?.head, original.head);
|
||||
assert.ok(origin?.over);
|
||||
// L'analyse de la partie d'origine reste attachée à ses événements.
|
||||
assert.ok(game.journal.review !== undefined);
|
||||
});
|
||||
|
||||
Given("une partie finie enregistrée sans aucune analyse", async () => {
|
||||
dice = [6, 1];
|
||||
game = Game.start(engine, {
|
||||
setup: { position: BEAROFF, firstTurn: "player" },
|
||||
level: "2ply",
|
||||
dice: fixedDice(dice),
|
||||
});
|
||||
await game.roll();
|
||||
await playMove("6/5 5/off");
|
||||
await computerDoublesAndPlayerPasses();
|
||||
const { library } = await openGameLibrary({ localStorage: memoryStorage() });
|
||||
await library.save("ancienne", game.journal, { level: "2ply" });
|
||||
const saved = await library.load("ancienne");
|
||||
assert.ok(saved !== null);
|
||||
analysed = saved.journal;
|
||||
assert.equal(analysed.advice, undefined);
|
||||
assert.equal(analysed.review, undefined);
|
||||
});
|
||||
|
||||
When("vous ouvrez son bilan", () => {
|
||||
assert.ok(!readyForSummary(analysed));
|
||||
});
|
||||
|
||||
Then("le maître l'analyse d'abord en arrière-plan", async () => {
|
||||
const before = missingAnalyses(analysed).length;
|
||||
analysed = await analyse(analysed);
|
||||
assert.equal(recorded, before);
|
||||
assert.ok(readyForSummary(analysed));
|
||||
});
|
||||
|
||||
Then("le bilan nomme ensuite le coup à revoir", () => {
|
||||
const advice = summarizeAnalysis(analysed).toReview?.advice;
|
||||
assert.equal(advice?.kind === "move" && advice.played.notation, "6/off");
|
||||
});
|
||||
@@ -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,108 @@
|
||||
// Des décisions du joueur dans des positions connues, jouées par le domaine
|
||||
// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître
|
||||
// (tests unitaires et scénarios).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
|
||||
import { type MasterPhrases, parsePhrases } 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 { Game } from "../../src/domain/game/game.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
/** Les phrases du maître, lues dans leur source. */
|
||||
export function loadPhrases(): MasterPhrases {
|
||||
return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8"));
|
||||
}
|
||||
|
||||
/** Une position de double et prise (videau au centre), vue de celui qui a
|
||||
* la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */
|
||||
export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA";
|
||||
|
||||
/** Le joueur, au trait à la position de départ, lance dice et joue move ;
|
||||
* la partie et l'événement du coup. */
|
||||
export async function playOpening(
|
||||
engine: Engine,
|
||||
dice: readonly [number, number],
|
||||
move: string,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
// Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite.
|
||||
dice: fixedDice([...dice, 6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
for (const halfMove of parseHalfMoves(move)) {
|
||||
if (!(await game.playHalfMove(halfMove))) {
|
||||
throw new Error(`demi-coup refusé : ${move}`);
|
||||
}
|
||||
}
|
||||
await game.confirmMove();
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
return { game, eventId: played?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le joueur, au trait dans la position de double et prise, double ;
|
||||
* l'ordinateur répond. */
|
||||
export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([]),
|
||||
});
|
||||
await game.double();
|
||||
const offered = game.events.find((e) => e.type === "CubeOffered");
|
||||
return { game, eventId: offered?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le joueur, au trait dans la position de double et prise, lance sans
|
||||
* doubler. */
|
||||
export async function playerRollsWithoutDoubling(
|
||||
engine: Engine,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await game.roll();
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
return { game, eventId: rolled?.id ?? "" };
|
||||
}
|
||||
|
||||
/** L'ordinateur, au trait dans la position de double et prise, double ; le
|
||||
* joueur prend (take) ou passe. */
|
||||
export async function playerAnswers(
|
||||
engine: Engine,
|
||||
take: boolean,
|
||||
): Promise<{ game: Game; eventId: string }> {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
|
||||
level: "2ply",
|
||||
dice: fixedDice([]),
|
||||
});
|
||||
await game.playComputerTurn();
|
||||
if (take) {
|
||||
game.take();
|
||||
} else {
|
||||
await game.drop();
|
||||
}
|
||||
const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped"));
|
||||
return { game, eventId: answer?.id ?? "" };
|
||||
}
|
||||
|
||||
/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */
|
||||
export async function adviceOn(
|
||||
engine: Engine,
|
||||
played: { game: Game; eventId: string },
|
||||
): Promise<Advice> {
|
||||
const advice = await analyseDecision(engine, played.game.journal, played.eventId);
|
||||
if (advice === null) {
|
||||
throw new Error(`aucun conseil pour l'événement ${played.eventId}`);
|
||||
}
|
||||
return advice;
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
+10
-6
@@ -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"));
|
||||
@@ -242,11 +242,15 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
});
|
||||
|
||||
test("les textes de fin de partie emploient les mots du glossaire", () => {
|
||||
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
|
||||
assert.equal(TEXTS.endTitle("computer", "gammon"), "L'ordinateur gagne un gammon");
|
||||
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
|
||||
assert.equal(TEXTS.endTitle("player", "single"), "Vous gagnez la partie simple");
|
||||
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
|
||||
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
|
||||
// Un double passé : le titre donne l'issue, le détail dit comment.
|
||||
assert.equal(
|
||||
`${TEXTS.droppedDouble("computer")}${TEXTS.endDetail("computer", 1, 1)}`,
|
||||
"Vous avez passé son double : 1 point pour lui.",
|
||||
);
|
||||
assert.equal(
|
||||
TEXTS.computerMoved([6, 4], "24/18 13/9"),
|
||||
"L'ordinateur a joué 6-4 : 24/18 13/9.",
|
||||
|
||||
@@ -128,3 +128,102 @@ ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif
|
||||
`match`, recalculé à chaque enregistrement, et les réglages
|
||||
(`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`,
|
||||
`masterAdvice`) qui ont une valeur par défaut quand ils manquent.
|
||||
|
||||
### 2026-11-10 — Le conseil du maître rattaché au journal
|
||||
|
||||
Avec le conseil du maître (étape 1d), le journal d'une partie
|
||||
(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif
|
||||
`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler,
|
||||
double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée
|
||||
sous l'identifiant de l'événement de la décision
|
||||
(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse
|
||||
arrive après coup, d'un second moteur, et ne change pas la partie ; les
|
||||
événements gardent leur version (2). Le bilan de fin de partie (1e) la
|
||||
relira sans recalcul.
|
||||
|
||||
- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise
|
||||
(`migrateJournal`), un conseil d'une autre version, ou rattaché à un
|
||||
événement absent, est oublié : ce n'est qu'une analyse, qui peut être
|
||||
refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice`
|
||||
et se reprend tel quel. Testé dans `app/tests/advice.test.ts`.
|
||||
- Ajouter un événement, revenir en arrière ou changer de branche garde les
|
||||
conseils ; un conseil reste attaché à son événement, même hors de la
|
||||
branche courante.
|
||||
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
|
||||
|
||||
### 2026-11-10 — L'analyse de partie rattachée au journal
|
||||
|
||||
Avec le bilan de fin de partie et l'écran d'analyse (étape 1e), le journal
|
||||
d'une partie (`GameJournal`) gagne un second champ facultatif, `review`
|
||||
(`GameReview`, `app/src/domain/game/review.ts`) : le reste de l'analyse
|
||||
de la partie par le moteur, à côté des conseils du maître (`advice`) :
|
||||
|
||||
- `luck` : la chance de chaque jet (`DiceRolled`, jet d'ouverture compris)
|
||||
pour le camp qui le joue ;
|
||||
- `winChances` : les chances de gagner du joueur après chaque coup
|
||||
(`MovePlayed`), de 0 à 1 ;
|
||||
- `computer` : les décisions de l'ordinateur, analysées comme celles du
|
||||
joueur (même format que les conseils) ;
|
||||
- `unanalysed` : les décisions que le moteur n'analyse pas (videau
|
||||
inaccessible, position de départ), pour ne plus les redemander ;
|
||||
- `version` (`REVIEW_VERSION`, 1) et `ply`, le niveau des analyses (2).
|
||||
|
||||
Comme les conseils, ce n'est pas un événement : les événements gardent leur
|
||||
version (2). Les analyses qui manquent (dont tous les conseils d'une partie
|
||||
jouée avant l'étape 1d) sont calculées en arrière-plan sur le second moteur,
|
||||
une à une, rangées au fur et à mesure, et l'ensemble s'arrête quand on n'en
|
||||
a plus besoin (`app/src/ui/review-runner.ts`).
|
||||
|
||||
- À la reprise (`migrateJournal`), une analyse d'une autre version ou mal
|
||||
formée est oubliée ; ses entrées rattachées à un événement absent aussi.
|
||||
Un journal enregistré avant l'étape 1e n'a pas de champ `review` et se
|
||||
reprend tel quel. Testé dans `app/tests/game-analysis.test.ts`.
|
||||
- Une analyse faite en arrière-plan sur une copie du journal s'y range
|
||||
sans toucher aux conseils déjà là (`withAnalysesOf`).
|
||||
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
|
||||
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).
|
||||
|
||||
@@ -112,3 +112,109 @@ Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Comme
|
||||
remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone
|
||||
ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le
|
||||
Match ID du match (longueur, score, partie Crawford), écrit par le moteur.
|
||||
|
||||
### 2026-11-10 — Adresse de la feuille d'explication du maître
|
||||
|
||||
Ajoutée avec le conseil du maître (étape 1d), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` |
|
||||
|
||||
`<événement>` est l'identifiant, dans le journal de la partie en cours, de
|
||||
l'événement de la décision commentée (le coup, le double, la prise…). La
|
||||
ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus
|
||||
de la partie, comme le menu, et « Fermer » (ou le retour du téléphone)
|
||||
revient à la partie. Rechargée, l'adresse rouvre la même explication : le
|
||||
conseil est enregistré dans le journal (ADR 0007, amendement du
|
||||
2026-11-10). Un événement sans conseil donne un message, jamais une page
|
||||
cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce
|
||||
n'est pas un lien à partager (le lien de la position, lui, se partage).
|
||||
|
||||
### 2026-11-10 — Adresses du bilan et de l'analyse d'une partie
|
||||
|
||||
Ajoutées avec le bilan de fin de partie et l'écran d'analyse (étape 1e),
|
||||
dans le même schéma, sous l'adresse de la partie dans « Mes parties » :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Bilan de fin de partie | `#/parties/<id>/bilan` |
|
||||
| Analyse d'une partie, placée sur un coup | `#/parties/<id>/analyse/<coup>` |
|
||||
| Analyse d'une partie, sur le coup à revoir | `#/parties/<id>/analyse` |
|
||||
|
||||
`<id>` est l'identifiant de la partie enregistrée ; `<coup>` le numéro d'un
|
||||
tour de la partie (ce qu'un camp a fait quand il avait la main : doubler ou
|
||||
non, lancer, jouer), à partir de 1, sur sa ligne courante.
|
||||
|
||||
- Le bilan remplace le panneau de fin de partie dès que l'analyse est
|
||||
prête : l'application va à l'adresse du bilan, par-dessus celle de la
|
||||
partie (`#/`). Il monte au-dessus de la partie, comme une
|
||||
feuille ; le fermer (croix, retour du téléphone) laisse voir la partie
|
||||
finie, sans le panneau. « Mes parties » donne le bilan de chaque partie
|
||||
finie.
|
||||
- L'analyse est une page. Choisir un autre coup (courbe, flèches, liste)
|
||||
remplace l'adresse : le retour du téléphone quitte l'écran au lieu de
|
||||
repasser par chaque coup. « Rejouer d'ici » ramène à la partie (`#/`),
|
||||
reprise juste avant la décision du coup.
|
||||
- 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.
|
||||
|
||||
+5
-5
@@ -20,13 +20,13 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 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 | 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 | 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 |
|
||||
| 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 (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 | 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 |
|
||||
| 1j. Plan de test (fait) | `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 |
|
||||
|
||||
## Orientation donnée par Pierre
|
||||
|
||||
|
||||
@@ -40,10 +40,11 @@ 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 |
|
||||
| [Coup](coup.md) | Move |
|
||||
| [Courbe des chances de gagner](courbe-des-chances.md) | Win chance curve |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
| [Course](course.md) | Race |
|
||||
| [Doubler](doubler.md) | Double |
|
||||
@@ -52,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 |
|
||||
@@ -65,8 +69,10 @@ 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 |
|
||||
| [Sortie](sortie.md) | Bear-off |
|
||||
| [Taux d'erreur](taux-d-erreur.md) | Error rate |
|
||||
| [Videau](videau.md) | Cube |
|
||||
|
||||
@@ -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,18 @@
|
||||
---
|
||||
term: Conseil du maître
|
||||
english: Advice
|
||||
code: Advice
|
||||
---
|
||||
# Conseil du maître
|
||||
|
||||
**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué.
|
||||
|
||||
Le maître ne parle jamais avant votre coup : il commente ce que vous venez de
|
||||
jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi
|
||||
d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir
|
||||
[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre
|
||||
coup et celui du maître, joués sur le plateau, et les chiffres du
|
||||
[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou
|
||||
sur l'écran « Nouvelle partie ».
|
||||
|
||||
Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md)
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
term: Courbe des chances de gagner
|
||||
english: Win chance curve
|
||||
code: WinChanceCurve
|
||||
---
|
||||
# Courbe des chances de gagner
|
||||
|
||||
**En bref** : vos chances de gagner la partie après chaque coup, selon le moteur, tracées coup après coup en pourcentage.
|
||||
|
||||
Après chaque coup, des deux camps, le moteur évalue la position et donne
|
||||
vos chances de gagner la partie, sans tenir compte du videau. La courbe
|
||||
monte quand la partie tourne en votre faveur, descend sinon ; la ligne en
|
||||
pointillé marque 50 %. Le dernier point est le résultat : 100 % si vous avez
|
||||
gagné, 0 % sinon.
|
||||
|
||||
Les points en couleur sont vos coups commentés par le maître, avec la
|
||||
couleur de leur [appréciation](appreciation.md) ; les petits points sont les
|
||||
coups de l'ordinateur. Une chute juste après l'un de vos coups montre ce
|
||||
qu'une erreur a coûté ; une chute après un jet montre la part des dés.
|
||||
|
||||
C'est le « graphe d'équité » de GNU Backgammon, en pourcentage plutôt qu'en
|
||||
équité, plus parlant pour un débutant.
|
||||
|
||||
Voir aussi : [Équité](equite.md), [Taux d'erreur](taux-d-erreur.md), [Chance](chance.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)
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
term: Taux d'erreur
|
||||
english: Error rate
|
||||
code: ErrorRate
|
||||
---
|
||||
# Taux d'erreur
|
||||
|
||||
**En bref** : l'équité que vous avez perdue en moyenne à chaque décision de la partie, d'après le moteur ; plus il est bas, mieux vous avez joué.
|
||||
|
||||
Le moteur compare chacun de vos coups et chacune de vos décisions de videau
|
||||
au meilleur choix, et note l'équité perdue (0 pour le meilleur coup). Le
|
||||
taux d'erreur additionne ces pertes et les divise par le nombre de
|
||||
décisions qui comptent : les coups où vous aviez le choix (un coup forcé ne
|
||||
compte pas), vos doubles, vos prises et vos refus, et vos jets sans doubler
|
||||
quand doubler se discutait. C'est le calcul de GNU Backgammon (« Error rate
|
||||
per decision »).
|
||||
|
||||
Le bilan traduit ce taux en niveau, avec les seuils de GNU Backgammon et
|
||||
les noms des niveaux de l'ordinateur, dans le même ordre :
|
||||
|
||||
| Équité perdue par décision | Classement de GNU Backgammon | Niveau dans Ustagammon |
|
||||
| --- | --- | --- |
|
||||
| moins de 0,005 | Super Grandmaster | Champion |
|
||||
| 0,005 à 0,008 | Grandmaster | Expert |
|
||||
| 0,008 à 0,013 | Master | Avancé |
|
||||
| 0,013 à 0,020 | Advanced | Intermédiaire |
|
||||
| 0,020 à 0,032 | Intermediate | Amateur |
|
||||
| 0,032 et plus | Beginner | Débutant |
|
||||
|
||||
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
|
||||
phrase du bilan compare enfin le résultat à ce qu'il aurait été sans la
|
||||
chance des deux camps : si l'écart vient surtout des dés, elle le dit.
|
||||
|
||||
Voir aussi : [Appréciation](appreciation.md), [Chance](chance.md), [Équité](equite.md), [Courbe des chances de gagner](courbe-des-chances.md)
|
||||
@@ -0,0 +1,154 @@
|
||||
# Guide de test de l'étape 1 : « Jouer et comprendre »
|
||||
|
||||
Document temporaire : il sert à la vérification de l'étape 1 avec Pierre, sur
|
||||
le Fairphone 4, et sera supprimé à la clôture de l'étape (ce qui en reste de
|
||||
durable va dans le journal).
|
||||
|
||||
**Critère de fin de l'étape** (cadrage) : un débutant apprend et joue seul ; un
|
||||
joueur de club comprend ses erreurs.
|
||||
|
||||
Le guide suit deux parcours : celui du **débutant** (parties A à C) et celui du
|
||||
**joueur de club** (parties D à F). Une partie G regroupe ce qui est commun
|
||||
(installation, hors ligne, mise à jour, affichage). Comptez environ une heure
|
||||
pour l'ensemble, en plusieurs fois si besoin : l'application reprend là où
|
||||
vous en étiez.
|
||||
|
||||
Pour chaque point : cochez ce qui marche, et notez ce qui coince avec le
|
||||
numéro du point (par exemple « C4 : le bilan s'ouvre deux fois »). Une capture
|
||||
d'écran aide beaucoup.
|
||||
|
||||
## Avant de commencer
|
||||
|
||||
- Adresse : https://ustagammon.sans.pub, dans Chrome sur le Fairphone 4.
|
||||
- Version : le menu, puis « Aide et glossaire », onglet « À propos », donne le
|
||||
numéro de version (un commit). Notez-le en tête de vos retours.
|
||||
- Pour repartir de zéro (comme un nouveau joueur) : dans Chrome, Paramètres du
|
||||
site pour ustagammon.sans.pub, puis « Effacer et réinitialiser ». Toutes les
|
||||
parties et la progression sont alors perdues.
|
||||
|
||||
## A. Premier contact (débutant)
|
||||
|
||||
1. Ouvrez l'application après l'avoir réinitialisée. L'écran de premier
|
||||
lancement propose « Je découvre » et « Je connais les règles ».
|
||||
2. Touchez « Je découvre » : le tutoriel des règles s'ouvre à l'étape 1 sur 8.
|
||||
3. Suivez les huit étapes jusqu'au bout. À chaque écran « à vous » : jouez
|
||||
d'abord un mauvais coup (le pion revient, une phrase explique sans
|
||||
reproche), puis le bon (coche, « Continuer »).
|
||||
4. Étape 2 : deux flèches circulaires montrent le sens de chaque camp, sous
|
||||
les pions.
|
||||
5. Le texte est-il clair pour quelqu'un qui n'a jamais joué ? Notez les mots
|
||||
ou phrases qui bloquent. Les mots soulignés ouvrent leur fiche du glossaire.
|
||||
6. À la fin, l'accueil propose la suite (partie, leçons).
|
||||
|
||||
**À rapporter** : une étape incompréhensible, un coup juste refusé ou un coup
|
||||
faux accepté, un texte coupé ou qui déborde.
|
||||
|
||||
## B. Première partie (débutant)
|
||||
|
||||
1. Depuis l'accueil, « Nouvelle partie » : choisissez « Partie libre » et le
|
||||
niveau « Débutant », conseil du maître activé, puis « Commencer la partie ».
|
||||
2. Touchez les dés pour les lancer. Touchez un pion : s'il n'a qu'une case
|
||||
d'arrivée, il y va tout seul ; sinon, ses cases d'arrivée s'allument.
|
||||
3. Essayez aussi de glisser un pion avec le doigt jusqu'à sa case ; lâché
|
||||
ailleurs, il revient à sa place.
|
||||
4. Un double toucher sur un pion joue le coup évident s'il n'y en a qu'un.
|
||||
5. Avant de valider, « Annuler » reprend le dernier pion, un à la fois. Puis
|
||||
validez en touchant les dés ou « Jouer ce coup ».
|
||||
6. Après votre coup, une ligne discrète du maître apparaît (« Bon coup. »,
|
||||
« Erreur. J'aurais joué… »). Touchez-la : la feuille montre votre coup et
|
||||
celui du maître, joués pion par pion.
|
||||
7. Regardez le coup de l'ordinateur : ses pions glissent, des traces restent,
|
||||
« Revoir » le rejoue. Touchez la notation du coup : la fiche « Notation des
|
||||
coups » s'ouvre.
|
||||
8. Quand aucun coup n'est possible, les dés sont barrés : touchez-les pour
|
||||
passer.
|
||||
9. Jouez la partie jusqu'au bout.
|
||||
|
||||
**À rapporter** : un pion qui ne bouge pas comme prévu, un geste raté, une
|
||||
phrase du maître fausse ou blessante, la durée d'un tour de l'ordinateur si
|
||||
elle dépasse 2 s.
|
||||
|
||||
## C. Bilan, leçons et retour (débutant)
|
||||
|
||||
1. En fin de partie, le bilan s'affiche : l'issue, ce qui a fait la
|
||||
différence, vos décisions comptées, votre jeu et vos dés, le coup à revoir.
|
||||
2. « Rejouer » relance une partie avec les mêmes réglages ; « Changer les
|
||||
réglages » ouvre l'écran « Nouvelle partie ».
|
||||
3. Depuis l'accueil, ouvrez « Toutes les leçons » puis la leçon 1. Suivez-la,
|
||||
en faisant au moins un exercice faux puis juste.
|
||||
4. Revenez à la liste : la leçon affiche « k exercices sur n ». Fermez
|
||||
l'application, rouvrez-la : la progression est gardée.
|
||||
5. Faites les cinq leçons si vous avez le temps : chaque réponse attendue est
|
||||
le meilleur coup du moteur.
|
||||
|
||||
**À rapporter** : une explication de leçon qui vous semble fausse ou trop
|
||||
difficile pour un débutant, un exercice où le refus ne met pas sur la voie.
|
||||
|
||||
## D. Match et niveaux (joueur de club)
|
||||
|
||||
1. « Nouvelle partie » : choisissez « Match », 3 points, niveau « Avancé »,
|
||||
puis « Commencer le match ».
|
||||
2. Le score du match s'affiche (« Match en 3 points ») ; à 2 points d'un
|
||||
joueur, la partie Crawford est signalée et le videau disparaît.
|
||||
3. Doublez au moins une fois (le videau demande confirmation). Répondez au
|
||||
moins une fois à un double de l'ordinateur (Prendre ou Passer).
|
||||
4. Entre deux parties, « Partie suivante » ; à la fin du match, le score final.
|
||||
5. Le niveau de l'ordinateur paraît-il juste ? « Champion » est le plus fort.
|
||||
|
||||
**À rapporter** : une décision de videau de l'ordinateur qui vous surprend
|
||||
(notez la position avec « Copier le lien de la position » dans le menu), un
|
||||
score de match faux.
|
||||
|
||||
## E. Comprendre ses erreurs (joueur de club)
|
||||
|
||||
1. Après une partie, depuis le bilan, « Voir l'analyse ».
|
||||
2. La courbe de vos chances de gagner se parcourt du doigt ; toucher un point
|
||||
ouvre le coup.
|
||||
3. Le visualiseur montre « Votre coup » et « Le coup du maître ». Les chiffres
|
||||
du moteur sont repliés en dessous.
|
||||
4. Filtrez la liste des coups par appréciation (erreurs, grosses erreurs).
|
||||
5. Sur un coup, « Rejouer d'ici » : une branche part de ce coup, la partie
|
||||
d'origine reste dans « Mes parties ».
|
||||
6. Sur une erreur, « Demander à une IA » : le prompt s'affiche, « Copier le
|
||||
prompt » confirme par « ✓ Copié ». Collez-le dans l'IA de votre choix et
|
||||
jugez la réponse.
|
||||
7. Ouvrez la progression (la ligne de l'accueil) après trois parties : la
|
||||
courbe du taux d'erreur, la liste des parties, « Créer des exercices ».
|
||||
8. « Mes parties » (menu) : reprenez une ancienne partie, exportez-en une en
|
||||
SGF et ouvrez-la dans GNU Backgammon de bureau (`load game fichier.sgf`).
|
||||
|
||||
**À rapporter** : une analyse qui contredit ce que vous pensiez (c'est peut-être
|
||||
juste, mais notez-la), un prompt incomplet, une partie absente de la liste.
|
||||
|
||||
## F. Liens et positions (joueur de club)
|
||||
|
||||
1. Dans le menu, « Copier le lien de la position ». Ouvrez ce lien dans un
|
||||
nouvel onglet : la même position s'affiche, avec « Jouer à partir d'ici ».
|
||||
2. L'aide, une question de l'aide, un terme du glossaire, la progression : chaque
|
||||
écran a son adresse ; le bouton retour du téléphone ferme d'abord la feuille
|
||||
ouverte, puis revient à l'écran précédent.
|
||||
|
||||
## G. Commun : installation, hors ligne, affichage
|
||||
|
||||
1. Après une partie terminée, l'accueil propose « Installer l'application » :
|
||||
installez-la, puis ouvrez-la depuis l'écran d'accueil du téléphone.
|
||||
2. Mode avion : l'application s'ouvre et se joue normalement.
|
||||
3. Tournez le téléphone : en paysage, le plateau prend l'écran, la partie
|
||||
continue sans perte. Essayez « Plein écran » dans le menu.
|
||||
4. Menu, thème clair puis sombre : les pions blancs et noirs se distinguent
|
||||
bien partout, y compris sur la barre.
|
||||
5. Vibrations : pion posé, pion frappé, double de l'ordinateur, fin de partie.
|
||||
Coupez-les dans le menu.
|
||||
6. Mise à jour : à la prochaine publication, le bandeau « Nouvelle version
|
||||
prête » apparaît en bas ; « Mettre à jour » vous ramène à votre partie.
|
||||
7. Performances : notez si l'application ralentit, chauffe ou se recharge
|
||||
seule (elle fait tourner deux moteurs : un pour jouer, un pour analyser).
|
||||
|
||||
## Ce qu'il faut rapporter
|
||||
|
||||
- Le numéro de version (onglet « À propos »).
|
||||
- Pour chaque problème : le numéro du point, ce que vous avez fait, ce que vous
|
||||
attendiez, ce qui s'est passé, et si possible une capture ou le lien de la
|
||||
position.
|
||||
- Votre avis sur les deux critères : un débutant peut-il apprendre et jouer
|
||||
seul ? Un joueur de club comprend-il ses erreurs ?
|
||||
@@ -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.
|
||||
@@ -78,7 +78,11 @@ Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne
|
||||
trop tard (le joueur a déjà relancé), la ligne n'apparaît pas, et le coup
|
||||
reste commenté dans le bilan ;
|
||||
- conseil désactivé : la ligne reste réservée et vide (la mise en page ne
|
||||
bouge pas).
|
||||
bouge pas) ;
|
||||
- tant qu'elle est affichée, elle prend la place de « L'ordinateur a joué… »
|
||||
(les traces laiton et ↻ restent) ;
|
||||
- **en paysage** : une pastille (pictogramme et mot d'appréciation) en tête
|
||||
de la bande centrale, à gauche de la consigne ; la toucher ouvre la feuille.
|
||||
|
||||
### Les mots
|
||||
|
||||
@@ -89,7 +93,7 @@ Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne
|
||||
| 0,06 à 0,12 (bad) | Erreur | `verdict-bad` | croix |
|
||||
| plus de 0,12 (very bad) | Grosse erreur | `verdict-bad` | croix |
|
||||
|
||||
Pour le videau : « Bon choix » et « Bonne décision » remplacent « Bon coup ».
|
||||
Pour le videau : « Bonne décision » remplace « Bon coup ».
|
||||
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
|
||||
« gaffe », et le choix final revient à Pierre (ton).
|
||||
|
||||
@@ -130,13 +134,17 @@ permet de le déterminer, sinon rien.
|
||||
de superlatif. Les bons coups ont plusieurs formules qui tournent
|
||||
(« C'est le coup du maître. », « Exactement ce que j'aurais joué. »,
|
||||
« Bien vu. »), pour ne pas lasser.
|
||||
- **Le rang de votre coup** n'est cité que pour un bon coup qui n'est pas le
|
||||
meilleur et pour une imprécision, et seulement s'il vaut 2 ou 3. Jamais pour
|
||||
une erreur ni une grosse erreur : le rang mesure mal la gravité (« 2e
|
||||
choix » peut cacher un écart de 0,2). Le classement complet reste dans
|
||||
« Voir les chiffres du moteur ».
|
||||
- **Exemples** (gabarits à remplir avec les données du moteur) :
|
||||
- Bon coup : « Bien vu. 8/5 6/5 fait votre point 5. »
|
||||
- Imprécision : « J'aurais joué {meilleur}. Votre coup était le
|
||||
{rang}e choix, tout près. »
|
||||
- Erreur : « J'aurais joué {meilleur}. Votre {joué} était le {rang}e
|
||||
choix. »
|
||||
- Videau, prise juste : « Bonne décision : prendre était le bon choix. »
|
||||
- Erreur : « J'aurais joué {meilleur}. Mon coup fait votre point 5. »
|
||||
- Videau, prise juste : « Prendre était le bon choix. »
|
||||
- Videau, double manqué : « Ici, j'aurais doublé. »
|
||||
|
||||
## 1e. Bilan et analyse
|
||||
@@ -198,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>`)
|
||||
|
||||
@@ -216,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`)
|
||||
|
||||
|
||||
@@ -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é.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user