Compare commits
82
Commits
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
|
||||
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
|
||||
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
|
||||
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
|
||||
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
|
||||
REFERENCE_IMAGE
|
||||
|
||||
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
|
||||
# build/ lui appartienne.
|
||||
@@ -126,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 et des scénarios sous docs/, lues par l'aide de
|
||||
# l'application (ADR 0008 : jamais commitées ailleurs) ;
|
||||
# - les copies du glossaire, des scénarios, du tutoriel et des phrases du
|
||||
# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître
|
||||
# (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).
|
||||
site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
@@ -139,7 +142,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
|
||||
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
|
||||
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
|
||||
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
|
||||
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
|
||||
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(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,190 @@
|
||||
// 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;
|
||||
}
|
||||
const ranking =
|
||||
options.ranking === false
|
||||
? []
|
||||
: await engine.rankMoves(before.position, cube, before.roll, ply);
|
||||
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,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,124 @@
|
||||
// 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, Ply, Skill } from "./engine.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
|
||||
/** Version du format d'un conseil enregistré. Un conseil d'une autre
|
||||
* version est ignoré à la reprise (il peut être recalculé). */
|
||||
export const ADVICE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 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: string;
|
||||
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 du moteur, du meilleur au moins
|
||||
* bon (cinq au plus). */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Les branches d'une partie : les lignes de jeu de son journal.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
|
||||
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
|
||||
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
|
||||
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
|
||||
// deux enfants d'un même événement, le plus ancien a le plus petit indice
|
||||
// dans le journal, et c'est lui qui continue la ligne d'origine.
|
||||
|
||||
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Une branche de la partie : la suite d'événements du début jusqu'à head.
|
||||
*
|
||||
* Glossaire : docs/glossaire/branche.md
|
||||
*/
|
||||
export interface Branch {
|
||||
/** Le dernier événement de la branche (une feuille de l'arbre). */
|
||||
readonly head: string;
|
||||
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
|
||||
* null pour la ligne d'origine de la partie. */
|
||||
readonly forkedFrom: string | null;
|
||||
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
|
||||
* la ligne d'origine. */
|
||||
readonly forkMoveCount: number;
|
||||
/** Les coups joués sur toute la branche, depuis le début. */
|
||||
readonly moveCount: number;
|
||||
/** La partie est finie au bout de cette branche. */
|
||||
readonly over: boolean;
|
||||
/** L'événement courant de la partie est sur cette branche. */
|
||||
readonly containsHead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les branches de la partie, dans l'ordre de leur création (celui de leur
|
||||
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
|
||||
* journal vide.
|
||||
*/
|
||||
export function branches(journal: GameJournal): Branch[] {
|
||||
const order = new Map(journal.events.map((event, index) => [event.id, index]));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
const leaves = journal.events.filter((event) => !children.has(event.id));
|
||||
return leaves
|
||||
.map((leaf) => {
|
||||
const line = currentLine({ events: journal.events, head: leaf.id });
|
||||
// Le point de départ de la branche : le parent de l'événement le
|
||||
// plus récent du chemin qui n'est pas le premier enfant de son
|
||||
// parent (la ligne d'origine n'en a aucun).
|
||||
let forkIndex = -1;
|
||||
for (let i = line.length - 1; i > 0; i--) {
|
||||
const event = line[i] as GameEvent;
|
||||
const first = children.get(event.parent as string)?.[0];
|
||||
if (first !== undefined && first.id !== event.id) {
|
||||
forkIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const moves = (events: readonly GameEvent[]) =>
|
||||
events.filter((event) => event.type === "MovePlayed").length;
|
||||
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
|
||||
const start = line[forkIndex + 1] as GameEvent;
|
||||
return {
|
||||
created: order.get(start.id) ?? 0,
|
||||
head: leaf.id,
|
||||
forkedFrom: fork?.id ?? null,
|
||||
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
|
||||
moveCount: moves(line),
|
||||
over: replay(line).phase === "over",
|
||||
containsHead: line.some((event) => event.id === journal.head),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.created - b.created)
|
||||
.map(({ created: _, ...branch }) => branch);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
|
||||
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
|
||||
if (!branches(journal).some((branch) => branch.head === head)) {
|
||||
throw new Error(`branche introuvable : ${head}`);
|
||||
}
|
||||
return goBackTo(journal, head);
|
||||
}
|
||||
@@ -51,6 +51,24 @@ export interface Engine {
|
||||
/** La décision de videau de l'ordinateur à ce niveau, bruit compris. */
|
||||
levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise<CubeDecision>;
|
||||
|
||||
/** L'ordinateur au trait, à ce niveau, double-t-il avant de lancer les
|
||||
* dés ? Comme ComputerTurn de gnubg (play.c) : accès au videau, fenêtre
|
||||
* de marché, double facultatif seulement à 0 pli. */
|
||||
levelDouble(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
|
||||
|
||||
/** Le joueur au trait double : l'ordinateur, son adversaire, prend-il à
|
||||
* ce niveau ? Comme ComputerTurn de gnubg (play.c). */
|
||||
levelTake(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
|
||||
|
||||
/** Le match après une partie gagnée par winner pour points points :
|
||||
* score, fin du match et partie Crawford suivante, comme gnubg de
|
||||
* bureau. */
|
||||
matchAfterGame(
|
||||
match: EngineMatch,
|
||||
winner: "onRoll" | "opponent",
|
||||
points: number,
|
||||
): Promise<EngineMatchAfter>;
|
||||
|
||||
/** L'analyse du coup joué (played : position après le coup, vue du joueur
|
||||
* qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si
|
||||
* aucun coup n'était possible. */
|
||||
@@ -62,6 +80,17 @@ export interface Engine {
|
||||
ply: Ply,
|
||||
): Promise<MoveAnalysis | null>;
|
||||
|
||||
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
|
||||
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
|
||||
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
|
||||
* ply). */
|
||||
rankMoves(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
roll: Roll,
|
||||
ply: Ply,
|
||||
): Promise<EvaluatedMove[]>;
|
||||
|
||||
/** L'analyse d'une décision de videau, position vue du joueur au trait
|
||||
* (celui qui double ou non ; take et pass sont la réponse de son
|
||||
* adversaire). */
|
||||
@@ -74,6 +103,41 @@ 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>;
|
||||
|
||||
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
|
||||
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur
|
||||
* du match, videau, joueur au trait. Le domaine ne le décode jamais
|
||||
* lui-même : il le passe au moteur.
|
||||
*
|
||||
* Glossaire : docs/glossaire/identifiant-de-match.md
|
||||
*/
|
||||
export type MatchId = string;
|
||||
|
||||
/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1,
|
||||
* comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */
|
||||
export interface MatchDescription {
|
||||
/** 0 : partie libre. */
|
||||
readonly matchLength: number;
|
||||
readonly score: readonly [number, number];
|
||||
readonly cubeValue: number;
|
||||
/** -1 : videau au centre ; sinon le joueur qui le possède. */
|
||||
readonly cubeOwner: -1 | 0 | 1;
|
||||
readonly playerOnRoll: 0 | 1;
|
||||
readonly crawford: boolean;
|
||||
readonly jacoby: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +194,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;
|
||||
@@ -182,10 +256,29 @@ export interface CubeEquities {
|
||||
*/
|
||||
export type Ply = number;
|
||||
|
||||
/** Le videau vu du joueur au trait. */
|
||||
/** Le videau vu du joueur au trait, et le match s'il y en a un (sans lui :
|
||||
* partie libre, avec la règle Jacoby). */
|
||||
export interface EngineCube {
|
||||
readonly value: number;
|
||||
readonly owner: "centered" | "onRoll" | "opponent";
|
||||
readonly match?: EngineMatch;
|
||||
}
|
||||
|
||||
/** Un match vu du joueur au trait : sa longueur, le score avant la partie,
|
||||
* et si la partie est la partie Crawford. Le moteur le code dans le Match
|
||||
* ID. */
|
||||
export interface EngineMatch {
|
||||
readonly length: number;
|
||||
readonly score: { readonly onRoll: number; readonly opponent: number };
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/** Le match après une partie, vu du même joueur que le match d'avant. */
|
||||
export interface EngineMatchAfter {
|
||||
readonly score: { readonly onRoll: number; readonly opponent: number };
|
||||
readonly over: boolean;
|
||||
/** La partie suivante est la partie Crawford. */
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,14 +12,131 @@
|
||||
// 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 { ResultKind } from "./engine.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";
|
||||
|
||||
/** Version du format des événements. */
|
||||
export const EVENT_VERSION = 1;
|
||||
/**
|
||||
* Version du format des événements :
|
||||
* - 1 : jusqu'à l'étape 1b, partie libre au niveau 2 plis ;
|
||||
* - 2 (étape 1c) : GameStarted porte le niveau de l'ordinateur (level) et,
|
||||
* pour une partie de match, le match (match) ; GameEnded porte le match
|
||||
* après la partie. migrateJournal passe un journal de la version 1 à la 2.
|
||||
*/
|
||||
export const EVENT_VERSION = 2;
|
||||
|
||||
/** Le niveau des parties de la version 1 : 2 plis (STANDARD_PLY, sans
|
||||
* bruit), le seul qui existait. */
|
||||
const VERSION_1_LEVEL: Level = "2ply";
|
||||
|
||||
/**
|
||||
* Un journal enregistré, à la version courante du format. Version 1 : le
|
||||
* niveau, implicite, devient explicite (2 plis) ; rien d'autre ne change,
|
||||
* 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(saved: GameJournal): GameJournal {
|
||||
const journal = keepKnownReview(keepKnownAdvice(saved));
|
||||
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
|
||||
return journal;
|
||||
}
|
||||
const events = journal.events.map((event): GameEvent => {
|
||||
if (event.version !== 1) {
|
||||
return event;
|
||||
}
|
||||
if (event.type === "GameStarted") {
|
||||
return Object.freeze({
|
||||
...event,
|
||||
level: event.level ?? VERSION_1_LEVEL,
|
||||
version: EVENT_VERSION,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ ...event, version: EVENT_VERSION });
|
||||
});
|
||||
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 {
|
||||
/** Identifiant, unique dans la partie. */
|
||||
@@ -31,12 +148,18 @@ interface EventBase {
|
||||
|
||||
/** Début de partie : la position, le videau, et qui commence (null : le jet
|
||||
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
|
||||
* départ est la même pour les deux. */
|
||||
* départ est la même pour les deux. level et match viennent avec la version
|
||||
* 2 du format (étape 1c). */
|
||||
export interface GameStarted extends EventBase {
|
||||
readonly type: "GameStarted";
|
||||
readonly position: PositionId;
|
||||
readonly cube: Cube;
|
||||
readonly firstTurn: Side | null;
|
||||
/** Le niveau de l'ordinateur ; absent : DEFAULT_LEVEL (state.ts). Les
|
||||
* journaux de la version 1 l'ont après migrateJournal. */
|
||||
readonly level?: Level;
|
||||
/** Le match dont c'est une partie ; absent : partie libre. */
|
||||
readonly match?: Match;
|
||||
}
|
||||
|
||||
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
|
||||
@@ -80,6 +203,9 @@ export interface GameEnded extends EventBase {
|
||||
readonly winner: Side;
|
||||
readonly kind: ResultKind;
|
||||
readonly points: number;
|
||||
/** Dans un match : le match après cette partie, dit par le moteur
|
||||
* (absent en partie libre). */
|
||||
readonly match?: MatchAfterGame;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,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 };
|
||||
@@ -120,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. */
|
||||
@@ -144,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 };
|
||||
}
|
||||
|
||||
+197
-81
@@ -1,4 +1,4 @@
|
||||
// Une partie libre contre l'ordinateur.
|
||||
// Une partie contre l'ordinateur : partie libre, ou une partie d'un match.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -6,10 +6,13 @@
|
||||
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
|
||||
// des événements au journal (events.ts), d'où l'état est recalculé
|
||||
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
|
||||
// l'ordinateur, videau, fin de partie et points.
|
||||
// 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, Ply } from "./engine.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
@@ -18,16 +21,20 @@ import {
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
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 { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
import { type GameState, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
|
||||
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
|
||||
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const STANDARD_PLY: Ply = 2;
|
||||
|
||||
@@ -61,12 +68,19 @@ export interface MoveSteps {
|
||||
readonly hits: readonly boolean[];
|
||||
}
|
||||
|
||||
export interface GameOptions {
|
||||
/** Ce que l'on choisit pour une nouvelle partie. */
|
||||
export interface NewGame {
|
||||
/** Une position de départ particulière (sans jet d'ouverture). */
|
||||
readonly setup?: GameSetup;
|
||||
/** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */
|
||||
readonly level?: Level;
|
||||
/** Le match dont ce sera une partie ; absent : partie libre. */
|
||||
readonly match?: Match;
|
||||
}
|
||||
|
||||
export interface GameOptions extends NewGame {
|
||||
/** Source des dés ; le hasard ordinaire par défaut. */
|
||||
readonly dice?: DiceSource;
|
||||
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
|
||||
readonly ply?: Ply;
|
||||
readonly setup?: GameSetup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +91,6 @@ export interface GameOptions {
|
||||
export class Game {
|
||||
readonly #engine: Engine;
|
||||
readonly #dice: DiceSource;
|
||||
readonly #ply: Ply;
|
||||
#journal: GameJournal = EMPTY_JOURNAL;
|
||||
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
|
||||
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
|
||||
@@ -87,11 +100,11 @@ export class Game {
|
||||
private constructor(engine: Engine, options: GameOptions) {
|
||||
this.#engine = engine;
|
||||
this.#dice = options.dice ?? randomDice;
|
||||
this.#ply = options.ply ?? STANDARD_PLY;
|
||||
}
|
||||
|
||||
/** Commence une partie : à la position de départ avec le jet d'ouverture,
|
||||
* ou à la position de options.setup. */
|
||||
* ou à la position de options.setup ; au niveau options.level, et dans
|
||||
* le match options.match s'il est donné. */
|
||||
static start(engine: Engine, options: GameOptions = {}): Game {
|
||||
const game = new Game(engine, options);
|
||||
const setup = options.setup;
|
||||
@@ -100,6 +113,8 @@ export class Game {
|
||||
position: setup?.position ?? START_POSITION,
|
||||
cube: setup?.cube ?? CENTERED_CUBE,
|
||||
firstTurn: setup?.firstTurn ?? null,
|
||||
...(options.level === undefined ? {} : { level: options.level }),
|
||||
...(options.match === undefined ? {} : { match: options.match }),
|
||||
});
|
||||
while (game.state.phase === "opening") {
|
||||
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
|
||||
@@ -111,11 +126,13 @@ export class Game {
|
||||
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
|
||||
* journal est d'un autre format ou incohérent.
|
||||
*/
|
||||
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = journal.events[0];
|
||||
if (first?.type !== "GameStarted" || journal.head === null) {
|
||||
static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game {
|
||||
const first = saved.events[0];
|
||||
if (first?.type !== "GameStarted" || saved.head === null) {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
// Un journal d'une version précédente passe à la version courante.
|
||||
const journal = migrateJournal(saved);
|
||||
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
|
||||
throw new Error("journal de partie d'une autre version");
|
||||
}
|
||||
@@ -140,6 +157,29 @@ export class Game {
|
||||
return replay(this.events);
|
||||
}
|
||||
|
||||
/** La partie finie appartient à un match qui continue : ce que sera la
|
||||
* partie suivante (score et Crawford dits par le moteur à la fin de
|
||||
* celle-ci) ; null sinon. */
|
||||
get nextGame(): NewGame | null {
|
||||
const state = this.state;
|
||||
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
|
||||
return null;
|
||||
}
|
||||
const match = nextGameOf(state.match, state.matchAfter);
|
||||
return match === null ? null : { level: state.level, match };
|
||||
}
|
||||
|
||||
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
|
||||
* au même niveau ; pour un match, un nouveau match de même longueur,
|
||||
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
|
||||
* la position de départ. */
|
||||
get rematch(): NewGame {
|
||||
const state = this.state;
|
||||
return state.match === null
|
||||
? { level: state.level }
|
||||
: { level: state.level, match: firstGameOf(state.match.length) };
|
||||
}
|
||||
|
||||
/** Les demi-coups déjà saisis du coup en cours. */
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#pending.map((p) => p.halfMove);
|
||||
@@ -181,7 +221,7 @@ export class Game {
|
||||
const state = this.state;
|
||||
// La position est vue du camp qui a la main (le joueur pendant le jet
|
||||
// d'ouverture : la position de départ est la même pour les deux).
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
|
||||
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,36 +230,65 @@ 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.cube)),
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,10 +328,11 @@ export class Game {
|
||||
if (state.turn !== "player" || state.phase !== "rolling") {
|
||||
return false;
|
||||
}
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
|
||||
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
|
||||
// moteur le refuse aussi pendant la partie Crawford.
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
engineCube(state, "player"),
|
||||
0,
|
||||
);
|
||||
return decision.available;
|
||||
@@ -348,62 +418,61 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
|
||||
* selon le moteur. */
|
||||
* à son niveau, selon le moteur. */
|
||||
async double(): Promise<void> {
|
||||
const state = this.#expect("player", "rolling");
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
state.position,
|
||||
engineCube(state.cube, "player"),
|
||||
this.#ply,
|
||||
);
|
||||
const cube = engineCube(state, "player");
|
||||
const decision = await this.#engine.cubeDecision(state.position, cube, 0);
|
||||
if (!decision.available) {
|
||||
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
|
||||
throw new Error("vous ne pouvez pas doubler maintenant");
|
||||
}
|
||||
const take = await this.#engine.levelTake(state.position, cube, state.level);
|
||||
this.#append({ type: "CubeOffered", side: "player" });
|
||||
this.#answerDouble("computer", decision.take);
|
||||
await this.#answerDouble("computer", take);
|
||||
}
|
||||
|
||||
/** Le joueur prend le double de l'ordinateur. */
|
||||
take(): void {
|
||||
this.#expect("computer", "doubled");
|
||||
this.#answerDouble("player", true);
|
||||
this.#append({ type: "CubeTaken", side: "player" });
|
||||
}
|
||||
|
||||
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
|
||||
drop(): void {
|
||||
async drop(): Promise<void> {
|
||||
this.#expect("computer", "doubled");
|
||||
this.#answerDouble("player", false);
|
||||
await this.#answerDouble("player", false);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- Ordinateur
|
||||
|
||||
/**
|
||||
* Le tour de l'ordinateur : il double si le moteur le recommande (le
|
||||
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
|
||||
* les dés et joue le meilleur coup du moteur.
|
||||
* Le tour de l'ordinateur, à son niveau : il double si le moteur le dit
|
||||
* (le tour s'arrête alors, en attendant la réponse du joueur), sinon il
|
||||
* lance les dés et joue son coup. Le déroulement est celui de
|
||||
* l'ordinateur de gnubg (ComputerTurn, play.c), repris par le moteur.
|
||||
*/
|
||||
async playComputerTurn(): Promise<void> {
|
||||
let state = this.#expect("computer", "rolling", "moving");
|
||||
if (state.phase === "rolling") {
|
||||
const decision = await this.#engine.cubeDecision(
|
||||
const doubles = await this.#engine.levelDouble(
|
||||
state.position,
|
||||
engineCube(state.cube, "computer"),
|
||||
this.#ply,
|
||||
engineCube(state, "computer"),
|
||||
state.level,
|
||||
);
|
||||
if (decision.double) {
|
||||
if (doubles) {
|
||||
this.#append({ type: "CubeOffered", side: "computer" });
|
||||
return;
|
||||
}
|
||||
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
|
||||
state = this.state;
|
||||
}
|
||||
const best = await this.#engine.bestMove(
|
||||
const move = await this.#engine.levelMove(
|
||||
state.position,
|
||||
engineCube(state.cube, "computer"),
|
||||
engineCube(state, "computer"),
|
||||
rollToPlay(state),
|
||||
this.#ply,
|
||||
state.level,
|
||||
);
|
||||
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
|
||||
await this.#playMove("computer", move?.halfMoves ?? [], move?.position ?? null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Retour
|
||||
@@ -415,6 +484,19 @@ export class Game {
|
||||
this.#journal = goBackTo(this.#journal, eventId);
|
||||
}
|
||||
|
||||
/** Les branches de la partie (voir branches.ts), la ligne d'origine
|
||||
* d'abord. */
|
||||
get branches(): Branch[] {
|
||||
return branches(this.#journal);
|
||||
}
|
||||
|
||||
/** Passe à une autre branche, désignée par son dernier événement : la
|
||||
* partie reprend au bout de celle-ci ; les autres restent intactes. */
|
||||
switchBranch(head: string): void {
|
||||
this.cancelHalfMoves();
|
||||
this.#journal = switchToBranch(this.#journal, head);
|
||||
}
|
||||
|
||||
/** Annule le dernier événement : la partie repart de son parent. */
|
||||
undo(): void {
|
||||
const last = this.events.at(-1);
|
||||
@@ -431,8 +513,8 @@ export class Game {
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
|
||||
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(cube, onRoll));
|
||||
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
|
||||
const board = await this.#engine.board(position, engineCube(state, onRoll));
|
||||
return onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
@@ -460,10 +542,7 @@ export class Game {
|
||||
after: PositionId | null,
|
||||
): Promise<void> {
|
||||
const state = this.state;
|
||||
const board = await this.#engine.board(
|
||||
after ?? state.position,
|
||||
engineCube(state.cube, side),
|
||||
);
|
||||
const board = await this.#engine.board(after ?? state.position, engineCube(state, side));
|
||||
this.#append({
|
||||
type: "MovePlayed",
|
||||
side,
|
||||
@@ -471,28 +550,54 @@ export class Game {
|
||||
position: board.swappedPosition,
|
||||
});
|
||||
if (board.result !== null) {
|
||||
this.#append({
|
||||
type: "GameEnded",
|
||||
winner: board.result.winner === "onRoll" ? side : otherSide(side),
|
||||
kind: board.result.kind,
|
||||
points: board.result.points,
|
||||
});
|
||||
await this.#endGame(
|
||||
board.result.winner === "onRoll" ? side : otherSide(side),
|
||||
board.result.kind,
|
||||
board.result.points,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** taker prend ou passe le double proposé par l'autre camp. */
|
||||
#answerDouble(taker: Side, take: boolean): void {
|
||||
async #answerDouble(taker: Side, take: boolean): Promise<void> {
|
||||
if (take) {
|
||||
this.#append({ type: "CubeTaken", side: taker });
|
||||
return;
|
||||
}
|
||||
// Passer : on perd la partie à la valeur du videau d'avant le double.
|
||||
this.#append({ type: "CubeDropped", side: taker });
|
||||
await this.#endGame(otherSide(taker), "single", this.state.cube.value);
|
||||
}
|
||||
|
||||
/** Fin de partie ; dans un match, le moteur dit ce que devient le match
|
||||
* (score, fin du match, partie Crawford suivante). */
|
||||
async #endGame(winner: Side, kind: ResultKind, points: number): Promise<void> {
|
||||
const match = this.state.match;
|
||||
if (match === null) {
|
||||
this.#append({ type: "GameEnded", winner, kind, points });
|
||||
return;
|
||||
}
|
||||
// Le joueur est « au trait » pour le moteur : les scores restent à
|
||||
// leur place.
|
||||
const after = await this.#engine.matchAfterGame(
|
||||
{
|
||||
length: match.length,
|
||||
score: { onRoll: match.score.player, opponent: match.score.computer },
|
||||
crawford: match.crawford,
|
||||
},
|
||||
winner === "player" ? "onRoll" : "opponent",
|
||||
points,
|
||||
);
|
||||
this.#append({
|
||||
type: "GameEnded",
|
||||
winner: otherSide(taker),
|
||||
kind: "single",
|
||||
points: this.state.cube.value,
|
||||
winner,
|
||||
kind,
|
||||
points,
|
||||
match: {
|
||||
score: { player: after.score.onRoll, computer: after.score.opponent },
|
||||
over: after.over,
|
||||
crawford: after.crawford,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -562,10 +667,21 @@ function occupiedPoints(side: BoardSide): number[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait, pour le moteur. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
if (cube.owner === null) {
|
||||
return { value: cube.value, owner: "centered" };
|
||||
/** Le videau et le match vus du camp au trait, pour le moteur. */
|
||||
export function engineCube(state: Pick<GameState, "cube" | "match">, onRoll: Side): EngineCube {
|
||||
const { cube, match } = state;
|
||||
const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent";
|
||||
if (match === null) {
|
||||
return { value: cube.value, owner };
|
||||
}
|
||||
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
|
||||
const opponent = otherSide(onRoll);
|
||||
return {
|
||||
value: cube.value,
|
||||
owner,
|
||||
match: {
|
||||
length: match.length,
|
||||
score: { onRoll: match.score[onRoll], opponent: match.score[opponent] },
|
||||
crawford: match.crawford,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Les niveaux de l'ordinateur tels que le joueur les choisit.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Six noms (docs/identite/charte/ecrans-etape-1.md, chapitre 1c), chacun
|
||||
// relié à un des niveaux prédéfinis de GNU Backgammon (Level, engine.ts). La
|
||||
// correspondance est documentée dans docs/glossaire/niveau-de-jeu.md :
|
||||
// - Débutant, Amateur, Intermédiaire et Avancé : les quatre niveaux de gnubg
|
||||
// à 0 pli avec bruit (beginner, casual, intermediate, advanced) ;
|
||||
// - Expert : 0 pli sans bruit, que gnubg appelait autrefois « expert » ;
|
||||
// - Champion : 2 plis, l'ancien « world class » de gnubg, le plus fort qui
|
||||
// tienne dans le temps du téléphone (au plus 348 ms par coup sur le
|
||||
// Fairphone 4, ADR 0009). 3 et 4 plis ne sont pas proposés.
|
||||
|
||||
import type { Level } from "./engine.js";
|
||||
|
||||
/** Un niveau proposé au joueur, du plus faible au plus fort. */
|
||||
export type ComputerLevel =
|
||||
| "beginner"
|
||||
| "amateur"
|
||||
| "intermediate"
|
||||
| "advanced"
|
||||
| "expert"
|
||||
| "champion";
|
||||
|
||||
export const COMPUTER_LEVELS: readonly ComputerLevel[] = [
|
||||
"beginner",
|
||||
"amateur",
|
||||
"intermediate",
|
||||
"advanced",
|
||||
"expert",
|
||||
"champion",
|
||||
];
|
||||
|
||||
/** Le niveau de gnubg de chaque niveau proposé. */
|
||||
export const ENGINE_LEVELS: Readonly<Record<ComputerLevel, Level>> = {
|
||||
beginner: "beginner",
|
||||
amateur: "casual",
|
||||
intermediate: "intermediate",
|
||||
advanced: "advanced",
|
||||
expert: "0ply",
|
||||
champion: "2ply",
|
||||
};
|
||||
|
||||
/** Le niveau proposé qui correspond à un niveau de gnubg (null pour 1, 3 et
|
||||
* 4 plis, jamais proposés). */
|
||||
export function computerLevelOf(level: Level): ComputerLevel | null {
|
||||
return COMPUTER_LEVELS.find((name) => ENGINE_LEVELS[name] === level) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Le match : une suite de parties jusqu'à un nombre de points.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque partie d'un match est une partie à part entière (son journal, son
|
||||
// enregistrement), qui porte le match tel qu'il est à son début. Les règles
|
||||
// viennent du moteur : le score après une partie, la fin du match et la
|
||||
// partie Crawford (Engine.matchAfterGame, qui reprend gnubg de bureau). Ce
|
||||
// module ne fait que recopier ce que le moteur a dit.
|
||||
|
||||
import type { Side } from "./side.js";
|
||||
|
||||
/**
|
||||
* Un match, au début d'une de ses parties : sa longueur, le score avant
|
||||
* cette partie, et si elle est la partie Crawford (où personne ne peut
|
||||
* doubler).
|
||||
*
|
||||
* Glossaire : docs/glossaire/match.md
|
||||
*/
|
||||
export interface Match {
|
||||
/** Le nombre de points à atteindre pour gagner le match. */
|
||||
readonly length: number;
|
||||
readonly score: Readonly<Record<Side, number>>;
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/** Le match après une partie, tel que le moteur le calcule. */
|
||||
export interface MatchAfterGame {
|
||||
readonly score: Readonly<Record<Side, number>>;
|
||||
/** Le gagnant de la partie a gagné le match. */
|
||||
readonly over: boolean;
|
||||
/** La partie suivante est la partie Crawford. */
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/** Les longueurs de match proposées à l'écran « Nouvelle partie ». */
|
||||
export const MATCH_LENGTHS = [3, 5, 7, 11] as const;
|
||||
|
||||
/** La première partie d'un match en length points : 0 à 0. */
|
||||
export function firstGameOf(length: number): Match {
|
||||
if (!Number.isInteger(length) || length < 1) {
|
||||
throw new Error(`longueur de match invalide : ${length}`);
|
||||
}
|
||||
return { length, score: { player: 0, computer: 0 }, crawford: false };
|
||||
}
|
||||
|
||||
/** La partie suivante d'un match, d'après ce que le moteur a dit à la fin
|
||||
* de la précédente ; null si le match est fini. */
|
||||
export function nextGameOf(match: Match, after: MatchAfterGame): Match | null {
|
||||
if (after.over) {
|
||||
return null;
|
||||
}
|
||||
return { length: match.length, score: after.score, crawford: after.crawford };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Une position du plateau dans un lien (ADR 0013) : le Position ID et le
|
||||
// Match ID de GNU Backgammon, lus et écrits par le moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Un lien décrit la position du point de vue du joueur au trait : c'est lui
|
||||
// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à
|
||||
// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux
|
||||
// identifiants (aucune règle de représentation n'est réimplémentée).
|
||||
|
||||
import type { Cube } from "./cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
|
||||
import { engineCube, type GameSetup, type SideBoards } from "./game.js";
|
||||
import type { PositionId } from "./position.js";
|
||||
import type { Side } from "./side.js";
|
||||
import type { GameState } from "./state.js";
|
||||
|
||||
/**
|
||||
* Ce que porte l'adresse d'une position : son Position ID, et son Match ID
|
||||
* s'il est donné (sans lui : partie libre, videau au centre).
|
||||
*
|
||||
* Glossaire : docs/glossaire/lien-de-position.md
|
||||
*/
|
||||
export interface PositionLink {
|
||||
readonly position: PositionId;
|
||||
readonly matchId: MatchId | null;
|
||||
}
|
||||
|
||||
/** Une position partagée, lue par le moteur. Le camp « player » est celui
|
||||
* au trait. */
|
||||
export interface SharedPosition {
|
||||
readonly link: PositionLink;
|
||||
readonly sides: SideBoards;
|
||||
readonly cube: Cube;
|
||||
/** null en partie libre ; sinon la longueur du match et le score. */
|
||||
readonly match: {
|
||||
readonly length: number;
|
||||
readonly score: Readonly<Record<Side, number>>;
|
||||
} | null;
|
||||
/** La partie est déjà finie dans cette position (null sinon). */
|
||||
readonly result: BoardResult | null;
|
||||
}
|
||||
|
||||
/** Un lien que le moteur ne sait pas lire. */
|
||||
export class UnreadableLinkError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = "UnreadableLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit une position partagée : le moteur décode le Match ID (s'il est
|
||||
* donné), puis la position avec son videau. Erreur UnreadableLinkError si
|
||||
* l'un des deux est illisible.
|
||||
*/
|
||||
export async function readPosition(engine: Engine, link: PositionLink): Promise<SharedPosition> {
|
||||
let cube: EngineCube = { value: 1, owner: "centered" };
|
||||
let match: SharedPosition["match"] = null;
|
||||
if (link.matchId !== null) {
|
||||
const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => {
|
||||
throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`);
|
||||
});
|
||||
const onRoll = description.playerOnRoll;
|
||||
cube = {
|
||||
value: description.cubeValue,
|
||||
owner:
|
||||
description.cubeOwner === -1
|
||||
? "centered"
|
||||
: description.cubeOwner === onRoll
|
||||
? "onRoll"
|
||||
: "opponent",
|
||||
};
|
||||
if (description.matchLength > 0) {
|
||||
match = {
|
||||
length: description.matchLength,
|
||||
score: {
|
||||
player: description.score[onRoll],
|
||||
computer: description.score[onRoll === 0 ? 1 : 0],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const board = await engine.board(link.position, cube).catch((error: unknown) => {
|
||||
throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`);
|
||||
});
|
||||
return {
|
||||
link,
|
||||
sides: { player: board.onRoll, computer: board.opponent },
|
||||
cube: {
|
||||
value: cube.value,
|
||||
owner:
|
||||
cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer",
|
||||
},
|
||||
match,
|
||||
result: board.result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le lien de la position d'une partie : la position vue du camp au trait,
|
||||
* et le Match ID que le moteur écrit pour son videau et son match (score,
|
||||
* Crawford), sans dés. Pendant le jet d'ouverture, la position de départ, vue
|
||||
* du joueur.
|
||||
*/
|
||||
export async function linkOf(engine: Engine, state: GameState): Promise<PositionLink> {
|
||||
const onRoll: Side = state.turn ?? "player";
|
||||
return {
|
||||
position: state.position,
|
||||
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
|
||||
};
|
||||
}
|
||||
|
||||
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
|
||||
* place du camp au trait, avec le même videau ; partie libre. */
|
||||
export function setupOf(shared: SharedPosition): GameSetup {
|
||||
return { position: shared.link.position, firstTurn: "player", cube: shared.cube };
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -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,93 @@
|
||||
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
|
||||
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
|
||||
// relire tous les journaux.
|
||||
|
||||
import { branches } from "./branches.js";
|
||||
import type { Level } from "./engine.js";
|
||||
import { currentLine, type GameJournal } from "./events.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Le résumé d'une partie enregistrée.
|
||||
*
|
||||
* Glossaire : docs/glossaire/partie-enregistree.md
|
||||
*/
|
||||
export interface SavedGameSummary {
|
||||
/** Identifiant de la partie, unique sur l'appareil. */
|
||||
readonly id: string;
|
||||
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
|
||||
readonly startedAt: number;
|
||||
readonly updatedAt: number;
|
||||
/** Niveau de l'ordinateur, l'adversaire. */
|
||||
readonly level: Level;
|
||||
/** En cours ou finie, sur la branche courante. */
|
||||
readonly status: "playing" | "over";
|
||||
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
|
||||
readonly result: GameResult | null;
|
||||
/** Coups joués sur la branche courante. */
|
||||
readonly moveCount: number;
|
||||
/** Nombre de branches (1 : jamais rejouée). */
|
||||
readonly branchCount: number;
|
||||
/** La partie d'un match : sa longueur, le score (après la partie si
|
||||
* elle est finie, avant sinon) et si le match est fini. Absent en
|
||||
* partie libre, et dans les résumés enregistrés avant l'étape 1c. */
|
||||
readonly match?: SavedMatch;
|
||||
}
|
||||
|
||||
/** Le match d'une partie enregistrée, pour la liste. */
|
||||
export interface SavedMatch {
|
||||
readonly length: number;
|
||||
readonly score: Readonly<Record<Side, number>>;
|
||||
readonly crawford: boolean;
|
||||
readonly over: boolean;
|
||||
}
|
||||
|
||||
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
|
||||
* journal. */
|
||||
export interface SavedGameDetails {
|
||||
readonly level: Level;
|
||||
}
|
||||
|
||||
/** Le niveau de l'ordinateur d'une partie, d'après son journal. */
|
||||
export function levelOf(journal: GameJournal): Level {
|
||||
const start = journal.events[0];
|
||||
return start?.type === "GameStarted" ? (start.level ?? DEFAULT_LEVEL) : DEFAULT_LEVEL;
|
||||
}
|
||||
|
||||
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
|
||||
* (le domaine ne lit pas l'horloge). */
|
||||
export function summarizeGame(
|
||||
id: string,
|
||||
journal: GameJournal,
|
||||
details: SavedGameDetails,
|
||||
times: { readonly startedAt: number; readonly updatedAt: number },
|
||||
): SavedGameSummary {
|
||||
const line = currentLine(journal);
|
||||
const state = replay(line);
|
||||
return {
|
||||
id,
|
||||
startedAt: times.startedAt,
|
||||
updatedAt: times.updatedAt,
|
||||
level: details.level,
|
||||
status: state.phase === "over" ? "over" : "playing",
|
||||
result: state.result,
|
||||
moveCount: line.filter((event) => event.type === "MovePlayed").length,
|
||||
branchCount: branches(journal).length,
|
||||
...(state.match === null
|
||||
? {}
|
||||
: {
|
||||
match: {
|
||||
length: state.match.length,
|
||||
score: state.matchAfter?.score ?? state.match.score,
|
||||
crawford: state.match.crawford,
|
||||
over: state.matchAfter?.over ?? false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Export d'une partie au format SGF de GNU Backgammon.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
|
||||
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
|
||||
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
|
||||
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
|
||||
// devient une variante. GNU Backgammon ne lit que la première variante de
|
||||
// chaque embranchement ; la branche courante de la partie vient donc en
|
||||
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
|
||||
//
|
||||
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
|
||||
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
|
||||
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
|
||||
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
|
||||
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
|
||||
// - [double], [take], [drop] pour le videau ;
|
||||
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
|
||||
// PL, le videau par CV et CP.
|
||||
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
|
||||
// et une position de départ particulière est décodée par le moteur.
|
||||
|
||||
import type { Engine } from "./engine.js";
|
||||
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
|
||||
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { replay } from "./state.js";
|
||||
|
||||
export interface SgfOptions {
|
||||
/** Nom du joueur (PB). */
|
||||
readonly playerName: string;
|
||||
/** Nom de l'ordinateur (PW). */
|
||||
readonly computerName: string;
|
||||
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
|
||||
readonly date?: string;
|
||||
}
|
||||
|
||||
/** Un nœud SGF et les variantes qui le suivent. */
|
||||
interface SgfNode {
|
||||
readonly properties: string;
|
||||
readonly children: SgfNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* La partie au format SGF de GNU Backgammon, branches comprises (en
|
||||
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
|
||||
* une position de départ particulière. (Le format : docs/glossaire/sgf.md.)
|
||||
*/
|
||||
export async function exportSgf(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
options: SgfOptions,
|
||||
): Promise<string> {
|
||||
const start = journal.events[0];
|
||||
if (start?.type !== "GameStarted") {
|
||||
throw new Error("journal de partie vide ou mal formé");
|
||||
}
|
||||
const byId = new Map(journal.events.map((event) => [event.id, event]));
|
||||
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
|
||||
const children = new Map<string, GameEvent[]>();
|
||||
for (const event of journal.events) {
|
||||
if (event.parent !== null) {
|
||||
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
|
||||
}
|
||||
}
|
||||
// La branche courante d'abord, puis les autres dans l'ordre du journal.
|
||||
for (const list of children.values()) {
|
||||
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
|
||||
}
|
||||
|
||||
/** Les variantes qui suivent un événement. */
|
||||
const variations = (id: string): SgfNode[] =>
|
||||
(children.get(id) ?? []).flatMap((child) => {
|
||||
const properties = nodeProperties(child, byId, children.has(child.id));
|
||||
const below = variations(child.id);
|
||||
return properties === null ? below : [{ properties, children: below }];
|
||||
});
|
||||
|
||||
// La partie telle que la voit gnubg : la ligne courante. Dans un match,
|
||||
// sa longueur et le score avant la partie (W, l'ordinateur, est le
|
||||
// joueur 0 de gnubg), et les règles comme gnubg les écrit (sgf.c) :
|
||||
// Crawford, et CrawfordGame pour la partie Crawford. Le numéro de la
|
||||
// partie dans le match n'est pas gardé : 0.
|
||||
const { result, match } = replay(currentLine(journal));
|
||||
const root = [
|
||||
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
|
||||
match === null
|
||||
? "MI[length:0][game:0][ws:0][bs:0]"
|
||||
: `MI[length:${match.length}][game:0][ws:${match.score.computer}][bs:${match.score.player}]` +
|
||||
`RU[Crawford${match.crawford ? ":CrawfordGame" : ""}]`,
|
||||
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
|
||||
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
|
||||
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
|
||||
].join("");
|
||||
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
|
||||
const setup = await setupProperties(engine, start);
|
||||
const tree = setup.reduceRight<SgfNode[]>(
|
||||
(below, properties) => [{ properties, children: below }],
|
||||
variations(start.id),
|
||||
);
|
||||
return `(;${root}${sequence(tree)})\n`;
|
||||
}
|
||||
|
||||
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
|
||||
* chaque variante entre parenthèses. */
|
||||
function sequence(nodes: readonly SgfNode[]): string {
|
||||
if (nodes.length === 1) {
|
||||
const [node] = nodes as [SgfNode];
|
||||
return `\n;${node.properties}${sequence(node.children)}`;
|
||||
}
|
||||
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
|
||||
}
|
||||
|
||||
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
|
||||
* (début, dés suivis d'un coup, fin de partie). */
|
||||
function nodeProperties(
|
||||
event: GameEvent,
|
||||
byId: ReadonlyMap<string, GameEvent>,
|
||||
hasChildren: boolean,
|
||||
): string | null {
|
||||
switch (event.type) {
|
||||
case "MovePlayed": {
|
||||
const rolled = event.parent === null ? undefined : byId.get(event.parent);
|
||||
if (rolled?.type !== "DiceRolled") {
|
||||
throw new Error(`coup sans jet de dés : ${event.id}`);
|
||||
}
|
||||
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
|
||||
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
|
||||
}
|
||||
case "CubeOffered":
|
||||
return `${letter(event.side)}[double]`;
|
||||
case "CubeTaken":
|
||||
return `${letter(event.side)}[take]`;
|
||||
case "CubeDropped":
|
||||
return `${letter(event.side)}[drop]`;
|
||||
case "DiceRolled": {
|
||||
// Des dés pas encore joués, au bout d'une branche.
|
||||
if (hasChildren) {
|
||||
return null;
|
||||
}
|
||||
const [a, b] = event.roll;
|
||||
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
|
||||
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
|
||||
}
|
||||
case "GameStarted":
|
||||
case "GameEnded":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Les propriétés des nœuds qui posent une position de départ particulière
|
||||
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
|
||||
* faire). */
|
||||
async function setupProperties(
|
||||
engine: Engine,
|
||||
start: Extract<GameEvent, { type: "GameStarted" }>,
|
||||
): Promise<string[]> {
|
||||
const usual =
|
||||
start.position === START_POSITION &&
|
||||
start.firstTurn === null &&
|
||||
start.cube.value === 1 &&
|
||||
start.cube.owner === null;
|
||||
if (usual) {
|
||||
return [];
|
||||
}
|
||||
const onRoll: Side = start.firstTurn ?? "player";
|
||||
const owner =
|
||||
start.cube.owner === null
|
||||
? "centered"
|
||||
: start.cube.owner === onRoll
|
||||
? "onRoll"
|
||||
: "opponent";
|
||||
const board = await engine.board(start.position, { value: start.cube.value, owner });
|
||||
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
|
||||
// 'x' - (c - 1), la barre en 'y' (comme les coups).
|
||||
const stones = (side: Side, points: readonly number[], bar: number) => {
|
||||
const out: string[] = [];
|
||||
points.forEach((count, i) => {
|
||||
for (let k = 0; k < count; k++) {
|
||||
out.push(`[${pointLetter(i + 1, side)}]`);
|
||||
}
|
||||
});
|
||||
for (let k = 0; k < bar; k++) {
|
||||
out.push("[y]");
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
const other: Side = onRoll === "player" ? "computer" : "player";
|
||||
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
|
||||
Side,
|
||||
typeof board.onRoll
|
||||
>;
|
||||
const white = stones("computer", sides.computer.points, sides.computer.bar);
|
||||
const black = stones("player", sides.player.points, sides.player.bar);
|
||||
const nodes = [
|
||||
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
|
||||
];
|
||||
if (start.cube.value !== 1) {
|
||||
nodes.push(`CV[${start.cube.value}]`);
|
||||
}
|
||||
if (start.cube.owner !== null) {
|
||||
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function letter(side: Side): "B" | "W" {
|
||||
return side === "player" ? "B" : "W";
|
||||
}
|
||||
|
||||
function halfMove(h: HalfMove, side: Side): string {
|
||||
return pointLetter(h.from, side) + pointLetter(h.to, side);
|
||||
}
|
||||
|
||||
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
|
||||
function pointLetter(point: number, side: Side): string {
|
||||
if (point === BAR) {
|
||||
return "y";
|
||||
}
|
||||
if (point === OFF) {
|
||||
return "z";
|
||||
}
|
||||
const code =
|
||||
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
|
||||
/** Échappe un texte libre dans une valeur SGF. */
|
||||
function escapeText(text: string): string {
|
||||
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { ResultKind } from "./engine.js";
|
||||
import type { Level, ResultKind } from "./engine.js";
|
||||
import type { GameEvent } from "./events.js";
|
||||
import type { Match, MatchAfterGame } from "./match.js";
|
||||
import { type PositionId, START_POSITION } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
import { otherSide, type Side } from "./side.js";
|
||||
@@ -36,8 +37,22 @@ export interface GameState {
|
||||
readonly roll: Roll | null;
|
||||
readonly cube: Cube;
|
||||
readonly result: GameResult | null;
|
||||
/** Le niveau de l'ordinateur. */
|
||||
readonly level: Level;
|
||||
/** Le match au début de la partie (null : partie libre). */
|
||||
readonly match: Match | null;
|
||||
/** Le match après la partie, une fois finie (null sinon, ou en partie
|
||||
* libre). */
|
||||
readonly matchAfter: MatchAfterGame | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le niveau d'une partie qui n'en dit rien : 2 plis, celui de toutes les
|
||||
* parties d'avant le choix du niveau (étape 1c). Sur le Fairphone 4, le
|
||||
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
|
||||
*/
|
||||
export const DEFAULT_LEVEL: Level = "2ply";
|
||||
|
||||
/** Avant le premier événement. */
|
||||
const NOT_STARTED: GameState = {
|
||||
phase: "opening",
|
||||
@@ -46,6 +61,9 @@ const NOT_STARTED: GameState = {
|
||||
roll: null,
|
||||
cube: CENTERED_CUBE,
|
||||
result: null,
|
||||
level: DEFAULT_LEVEL,
|
||||
match: null,
|
||||
matchAfter: null,
|
||||
};
|
||||
|
||||
/** Rejoue des événements, du premier au dernier. */
|
||||
@@ -63,6 +81,8 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
|
||||
cube: event.cube,
|
||||
turn: event.firstTurn,
|
||||
phase: event.firstTurn === null ? "opening" : "rolling",
|
||||
level: event.level ?? DEFAULT_LEVEL,
|
||||
match: event.match ?? null,
|
||||
};
|
||||
case "DiceRolled":
|
||||
if (event.side !== null) {
|
||||
@@ -103,6 +123,7 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
|
||||
phase: "over",
|
||||
roll: null,
|
||||
result: { winner: event.winner, kind: event.kind, points: event.points },
|
||||
matchAfter: event.match ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Le tutoriel des règles, côté domaine : les positions illustrées et les
|
||||
// écrans « à vous », vérifiés par le moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
|
||||
// source) ; il donne pour chaque écran une position par son identifiant
|
||||
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
|
||||
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
|
||||
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
|
||||
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
|
||||
// reconnus.
|
||||
|
||||
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
|
||||
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
|
||||
import type { HalfMove, PositionId } from "../game/position.js";
|
||||
import { fixedDice, type Roll } from "../game/roll.js";
|
||||
import type { Side } from "../game/side.js";
|
||||
|
||||
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
|
||||
* partie y est finie (vu du camp « player »). */
|
||||
export interface TutorialBoard {
|
||||
readonly sides: SideBoards;
|
||||
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
|
||||
}
|
||||
|
||||
/** Le videau vu du camp au trait. */
|
||||
function engineCube(cube: Cube, onRoll: Side): EngineCube {
|
||||
return {
|
||||
value: cube.value,
|
||||
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
|
||||
};
|
||||
}
|
||||
|
||||
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
|
||||
* l'identifiant est illisible). */
|
||||
export async function tutorialBoard(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
onRoll: Side = "player",
|
||||
cube: Cube = CENTERED_CUBE,
|
||||
): Promise<TutorialBoard> {
|
||||
const board = await engine.board(position, engineCube(cube, onRoll));
|
||||
const sides =
|
||||
onRoll === "player"
|
||||
? { player: board.onRoll, computer: board.opponent }
|
||||
: { player: board.opponent, computer: board.onRoll };
|
||||
const result =
|
||||
board.result === null
|
||||
? null
|
||||
: {
|
||||
winner:
|
||||
(board.result.winner === "onRoll") === (onRoll === "player")
|
||||
? ("player" as const)
|
||||
: ("computer" as const),
|
||||
kind: board.result.kind,
|
||||
};
|
||||
return { sides, result };
|
||||
}
|
||||
|
||||
/** Les coups du jet que le moteur déclare légaux et dont la notation est
|
||||
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
|
||||
* légal : le texte du tutoriel ne correspond plus au moteur. */
|
||||
export async function expectedMoves(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<Move[]> {
|
||||
const legal = await engine.legalMoves(position, roll);
|
||||
return notations.map((notation) => {
|
||||
const move = legal.find((m) => m.notation === notation);
|
||||
if (move === undefined) {
|
||||
throw new Error(
|
||||
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
|
||||
);
|
||||
}
|
||||
return move;
|
||||
});
|
||||
}
|
||||
|
||||
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
|
||||
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
|
||||
* finit la partie. */
|
||||
export async function illustratedMove(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notation: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
|
||||
const [move] = await expectedMoves(engine, position, roll, [notation]);
|
||||
if (move === undefined) {
|
||||
throw new Error(`coup illustré introuvable : ${notation}`);
|
||||
}
|
||||
const positions = [position];
|
||||
for (let i = 1; i <= move.halfMoves.length; i++) {
|
||||
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
|
||||
if (check.position === null) {
|
||||
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
|
||||
}
|
||||
positions.push(check.position);
|
||||
}
|
||||
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
|
||||
const hits = move.halfMoves.map(
|
||||
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
|
||||
);
|
||||
const after = boards.at(-1) ?? boards[0];
|
||||
if (after === undefined) {
|
||||
throw new Error("aucun plateau");
|
||||
}
|
||||
return {
|
||||
steps: {
|
||||
side: "player",
|
||||
halfMoves: move.halfMoves,
|
||||
boards: boards.map((b) => b.sides),
|
||||
hits,
|
||||
},
|
||||
after,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
|
||||
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
|
||||
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
|
||||
* position (verdict de MovePractice).
|
||||
*/
|
||||
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
|
||||
return expected.some((move) => {
|
||||
const left = [...move.halfMoves];
|
||||
return played.every((h) => {
|
||||
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
|
||||
if (i < 0) {
|
||||
return false;
|
||||
}
|
||||
left.splice(i, 1);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Ce que devient un écran « à vous » après un demi-coup. */
|
||||
export type PracticeVerdict = "continue" | "right" | "wrong";
|
||||
|
||||
/**
|
||||
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
|
||||
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
|
||||
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
|
||||
* coup est juste s'il mène à la position de l'un des coups attendus.
|
||||
*/
|
||||
export class MovePractice {
|
||||
readonly #game: Game;
|
||||
readonly #expected: readonly Move[];
|
||||
readonly roll: Roll;
|
||||
|
||||
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
|
||||
this.#game = game;
|
||||
this.#expected = expected;
|
||||
this.roll = roll;
|
||||
}
|
||||
|
||||
/** Prépare l'écran : la position, le jet, et les coups attendus,
|
||||
* reconnus par le moteur (erreur sinon). */
|
||||
static async start(
|
||||
engine: Engine,
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
notations: readonly string[],
|
||||
): Promise<MovePractice> {
|
||||
const expected = await expectedMoves(engine, position, roll, notations);
|
||||
const game = Game.start(engine, {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(roll),
|
||||
level: "0ply",
|
||||
});
|
||||
await game.roll();
|
||||
return new MovePractice(game, expected, roll);
|
||||
}
|
||||
|
||||
/** Le plateau affiché, demi-coups saisis compris. */
|
||||
board(): Promise<SideBoards> {
|
||||
return this.#game.displayedBoard();
|
||||
}
|
||||
|
||||
/** Les demi-coups permis maintenant (le moteur le dit). */
|
||||
nextHalfMoves(): Promise<HalfMove[]> {
|
||||
return this.#game.nextHalfMoves();
|
||||
}
|
||||
|
||||
get pendingHalfMoves(): readonly HalfMove[] {
|
||||
return this.#game.pendingHalfMoves;
|
||||
}
|
||||
|
||||
/** Les dés du jet, et ceux déjà utilisés. */
|
||||
get diceToPlay(): { value: number; used: boolean }[] {
|
||||
return this.#game.diceToPlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
|
||||
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
|
||||
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
|
||||
* arrière (reset). « right » quand le coup complet est l'un des coups
|
||||
* attendus.
|
||||
*/
|
||||
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
|
||||
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";
|
||||
}
|
||||
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
|
||||
}
|
||||
|
||||
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
|
||||
reset(): void {
|
||||
this.#game.cancelHalfMoves();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
|
||||
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
|
||||
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
|
||||
* peut pas doubler dans cette position.
|
||||
*/
|
||||
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
|
||||
const decision = await engine.cubeDecision(
|
||||
position,
|
||||
{ value: 1, owner: "centered" },
|
||||
STANDARD_PLY,
|
||||
);
|
||||
if (!decision.available) {
|
||||
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
|
||||
}
|
||||
return decision.take;
|
||||
}
|
||||
@@ -17,17 +17,26 @@ import type {
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
EngineMatch,
|
||||
EvaluatedMove,
|
||||
Evaluation,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Luck,
|
||||
LuckRating,
|
||||
MatchDescription,
|
||||
MatchId,
|
||||
Move,
|
||||
MoveAnalysis,
|
||||
Ply,
|
||||
Skill,
|
||||
} from "../domain/game/engine.js";
|
||||
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
|
||||
import {
|
||||
formatHalfMoves,
|
||||
type HalfMove,
|
||||
type PositionId,
|
||||
START_POSITION,
|
||||
} from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
|
||||
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
|
||||
@@ -68,13 +77,23 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
const matchIds = new Map<string, string>();
|
||||
|
||||
async function matchId(cube: EngineCube): Promise<string> {
|
||||
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
|
||||
// trait, 1 son adversaire, -1 le centre.
|
||||
// Joueur 0 au trait : le propriétaire 0 est le joueur au trait, 1 son
|
||||
// adversaire, -1 le centre ; le score 0 est le sien.
|
||||
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
|
||||
const key = `${cube.value} ${owner}`;
|
||||
const args = ["matchid", String(cube.value), String(owner)];
|
||||
const match = cube.match;
|
||||
if (match !== undefined) {
|
||||
args.push(
|
||||
String(match.length),
|
||||
String(match.score.onRoll),
|
||||
String(match.score.opponent),
|
||||
match.crawford ? "1" : "0",
|
||||
);
|
||||
}
|
||||
const key = args.join(" ");
|
||||
let id = matchIds.get(key);
|
||||
if (id === undefined) {
|
||||
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
|
||||
const answer = (await run(args)) as {
|
||||
match_id: string;
|
||||
};
|
||||
id = answer.match_id;
|
||||
@@ -122,6 +141,32 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
return toCubeDecision((await run(args)) as CliCubeDecision);
|
||||
},
|
||||
|
||||
async levelDouble(position: PositionId, cube: EngineCube, level: Level) {
|
||||
const args = ["--level", level, "double", position, await matchId(cube)];
|
||||
return ((await run(args)) as { double: boolean }).double;
|
||||
},
|
||||
|
||||
async levelTake(position: PositionId, cube: EngineCube, level: Level) {
|
||||
const args = ["--level", level, "take", position, await matchId(cube)];
|
||||
return ((await run(args)) as { take: boolean }).take;
|
||||
},
|
||||
|
||||
async matchAfterGame(match: EngineMatch, winner: "onRoll" | "opponent", points: number) {
|
||||
// Le joueur au trait est le joueur 0 du Match ID.
|
||||
const id = await matchId({ value: 1, owner: "centered", match });
|
||||
const answer = (await run([
|
||||
"aftergame",
|
||||
id,
|
||||
winner === "onRoll" ? "0" : "1",
|
||||
String(points),
|
||||
])) as { score: [number, number]; match_over: boolean; crawford: boolean };
|
||||
return {
|
||||
score: { onRoll: answer.score[0], opponent: answer.score[1] },
|
||||
over: answer.match_over,
|
||||
crawford: answer.crawford,
|
||||
};
|
||||
},
|
||||
|
||||
async analyseMove(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
@@ -151,6 +196,14 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
};
|
||||
},
|
||||
|
||||
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
|
||||
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
|
||||
const answer = (await run([...args, diceText(roll)])) as {
|
||||
moves: CliEvaluatedMove[];
|
||||
};
|
||||
return answer.moves.map(toEvaluatedMove);
|
||||
},
|
||||
|
||||
async analyseCube(
|
||||
position: PositionId,
|
||||
cube: EngineCube,
|
||||
@@ -208,6 +261,44 @@ 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> {
|
||||
// engine-cli n'a pas de commande qui décode seule un Match ID :
|
||||
// l'évaluation à 0 pli (quelques millisecondes) le décode, le
|
||||
// valide (SetCubeInfo de gnubg) et le renvoie lisible.
|
||||
const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as {
|
||||
match: {
|
||||
match_length: number;
|
||||
score: [number, number];
|
||||
cube_value: number;
|
||||
cube_owner: number;
|
||||
player_on_roll: number;
|
||||
crawford: boolean;
|
||||
jacoby: boolean;
|
||||
};
|
||||
};
|
||||
const m = answer.match;
|
||||
return {
|
||||
matchLength: m.match_length,
|
||||
score: m.score,
|
||||
cubeValue: m.cube_value,
|
||||
cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1,
|
||||
playerOnRoll: m.player_on_roll === 1 ? 1 : 0,
|
||||
crawford: m.crawford,
|
||||
jacoby: m.jacoby,
|
||||
};
|
||||
},
|
||||
|
||||
async checkHalfMoves(
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
|
||||
+152
-28
@@ -5,50 +5,174 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { 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,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameStore } from "./storage/game-store.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";
|
||||
import { startApp } from "./ui/app.js";
|
||||
import { startUpdates } from "./ui/updates.js";
|
||||
|
||||
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
|
||||
// racine du site.
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
const store = createGameStore(window.localStorage);
|
||||
const settings = createSettingsStore(window.localStorage);
|
||||
|
||||
// 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);
|
||||
const settings = createSettingsStore(localStorage ?? memoryStorage());
|
||||
|
||||
// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes
|
||||
// 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({
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
// Le niveau et le match de chaque partie viennent de l'écran « Nouvelle
|
||||
// partie » (ou de la partie précédente du match).
|
||||
const session = createGameSession(library, {
|
||||
start: (newGame) => Game.start(engine, newGame ?? {}),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
const saved = await session.restore();
|
||||
return { library, session, saved };
|
||||
})();
|
||||
let stored: Awaited<typeof opening> | null = null;
|
||||
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
|
||||
function storage(): Awaited<typeof opening> {
|
||||
if (stored === null) {
|
||||
throw new Error("stockage pas encore ouvert");
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
startApp({
|
||||
engineReady: worker.ready,
|
||||
newGame: () => Game.start(engine),
|
||||
savedGame: () => {
|
||||
const journal = store.load();
|
||||
if (journal === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Game.resume(engine, journal);
|
||||
} catch {
|
||||
return null; // partie illisible : on en commence une nouvelle
|
||||
}
|
||||
engineReady: Promise.all([
|
||||
worker.ready,
|
||||
opening.then((opened) => {
|
||||
stored = opened;
|
||||
}),
|
||||
]),
|
||||
newGame: (newGame) => storage().session.newGame(newGame),
|
||||
positions: {
|
||||
read: (link) => readPosition(engine, link),
|
||||
linkOf: (game) => linkOf(engine, game.state),
|
||||
},
|
||||
savedGame: () => storage().saved,
|
||||
resumePrevious: () => storage().session.resumePrevious(),
|
||||
saveGame: (journal) => storage().session.save(journal),
|
||||
games: {
|
||||
list: () => storage().library.list(),
|
||||
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);
|
||||
if (game === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const started = new Date(game.summary.startedAt);
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`;
|
||||
const text = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
date,
|
||||
});
|
||||
const time = `${two(started.getHours())}${two(started.getMinutes())}`;
|
||||
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
|
||||
},
|
||||
},
|
||||
resumeGame: (journal) => Game.resume(engine, journal),
|
||||
saveGame: (journal) => store.save(journal),
|
||||
cachedBoard: () => settings.loadBoard(),
|
||||
saveBoard: (board) => settings.saveBoard(board),
|
||||
loadSettings: () => settings.loadSettings(),
|
||||
saveSettings: (json) => settings.saveSettings(json),
|
||||
// Le tutoriel : positions, coups illustrés et gestes jugés par le
|
||||
// domaine, sur le moteur.
|
||||
tutorial: {
|
||||
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
|
||||
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
|
||||
practice: (position, roll, expected) =>
|
||||
MovePractice.start(engine, position, roll, expected),
|
||||
shouldTake: (position) => shouldTake(engine, position),
|
||||
},
|
||||
// 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
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
|
||||
// Chromium fait échouer l'installation (« Cache.add() encountered a
|
||||
// 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),
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
// 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
|
||||
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
|
||||
// fait échouer l'installation (« Cache.add() encountered a 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.
|
||||
if ("serviceWorker" in navigator) {
|
||||
const sw = new URL("../sw.js", import.meta.url);
|
||||
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
|
||||
/** La valeur, ou undefined si l'obtenir lève une exception. */
|
||||
function attempt<T>(get: () => T): T | undefined {
|
||||
try {
|
||||
return get();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
|
||||
// d'événements, et laquelle est la partie en cours.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Deux stockages derrière la même interface (GameLibrary) :
|
||||
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
|
||||
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
|
||||
// indisponible, par exemple en navigation privée de certains navigateurs.
|
||||
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
|
||||
// appels : un enregistrement lancé sans être attendu est donc toujours fait
|
||||
// avant la lecture qui suit.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import {
|
||||
type SavedGameDetails,
|
||||
type SavedGameSummary,
|
||||
summarizeGame,
|
||||
} from "../domain/game/saved-game.js";
|
||||
|
||||
/** Une partie enregistrée : son résumé et son journal. */
|
||||
export interface SavedGame {
|
||||
readonly summary: SavedGameSummary;
|
||||
readonly journal: GameJournal;
|
||||
}
|
||||
|
||||
export interface GameLibrary {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
list(): Promise<SavedGameSummary[]>;
|
||||
/** Une partie, ou null si elle n'existe pas ou est illisible. */
|
||||
load(id: string): Promise<SavedGame | null>;
|
||||
/** 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),
|
||||
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
|
||||
* aucune. */
|
||||
current(): Promise<SavedGame | null>;
|
||||
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
|
||||
* (pour la reprendre). */
|
||||
setCurrent(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
|
||||
* opérations, les dates et la validation. */
|
||||
export interface LibraryBackend {
|
||||
summaries(): Promise<SavedGameSummary[]>;
|
||||
summary(id: string): Promise<SavedGameSummary | undefined>;
|
||||
journal(id: string): Promise<unknown>;
|
||||
/** Écrit le résumé et le journal ensemble. */
|
||||
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
currentId(): Promise<string | undefined>;
|
||||
setCurrentId(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LibraryOptions {
|
||||
/** L'horloge, en millisecondes ; Date.now par défaut. */
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
/** Un identifiant de partie : la date et du hasard, sans dépendre de
|
||||
* crypto.randomUUID, absent hors https. */
|
||||
export function newGameId(now: number = Date.now()): string {
|
||||
const random = Math.floor(Math.random() * 36 ** 6)
|
||||
.toString(36)
|
||||
.padStart(6, "0");
|
||||
return `${now.toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
|
||||
* domaine le vérifie en reprenant la partie). */
|
||||
export function isJournal(value: unknown): value is GameJournal {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const journal = value as { events?: unknown; head?: unknown };
|
||||
return (
|
||||
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function createGameLibrary(
|
||||
backend: LibraryBackend,
|
||||
options: LibraryOptions = {},
|
||||
): GameLibrary {
|
||||
const now = options.now ?? Date.now;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
/** Exécute op après toutes les opérations déjà demandées. */
|
||||
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
|
||||
const result = queue.then(op, op);
|
||||
queue = result.catch(() => {});
|
||||
return result;
|
||||
};
|
||||
|
||||
const load = async (id: string): Promise<SavedGame | null> => {
|
||||
const summary = await backend.summary(id);
|
||||
const journal = await backend.journal(id);
|
||||
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
|
||||
};
|
||||
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return {
|
||||
list: () => enqueue(list),
|
||||
load: (id) => enqueue(() => load(id)),
|
||||
save: (id, journal, details) =>
|
||||
enqueue(async () => {
|
||||
const time = now();
|
||||
const startedAt = (await backend.summary(id))?.startedAt ?? time;
|
||||
const summary = summarizeGame(id, journal, details, {
|
||||
startedAt,
|
||||
updatedAt: Math.max(time, startedAt),
|
||||
});
|
||||
await backend.write(summary, journal);
|
||||
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 () => {
|
||||
const id = await backend.currentId();
|
||||
const chosen = id === undefined ? null : await load(id);
|
||||
if (chosen !== null) {
|
||||
return chosen;
|
||||
}
|
||||
const latest = (await list())[0];
|
||||
return latest === undefined ? null : load(latest.id);
|
||||
}),
|
||||
setCurrent: (id) =>
|
||||
enqueue(async () => {
|
||||
if ((await backend.summary(id)) === undefined) {
|
||||
throw new Error(`partie introuvable : ${id}`);
|
||||
}
|
||||
await backend.setCurrentId(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// La partie à l'écran et son enregistrement : quel identifiant elle porte,
|
||||
// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la
|
||||
// reprise d'une partie enregistrée ou de l'une de ses branches.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle
|
||||
// quelle, sur un stockage en mémoire.
|
||||
|
||||
import { switchToBranch } from "../domain/game/branches.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, NewGame } from "../domain/game/game.js";
|
||||
import { levelOf } from "../domain/game/saved-game.js";
|
||||
import { type GameLibrary, newGameId } from "./game-library.js";
|
||||
|
||||
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
|
||||
* le journal est illisible). */
|
||||
export interface GameFactory {
|
||||
readonly start: (newGame?: NewGame) => Game;
|
||||
readonly resume: (journal: GameJournal) => Game;
|
||||
}
|
||||
|
||||
export interface GameSession {
|
||||
/** L'identifiant de la partie à l'écran (null avant la première). */
|
||||
readonly shownId: string | null;
|
||||
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
|
||||
* si elle est illisible. */
|
||||
restore(): Promise<Game | null>;
|
||||
/** Une nouvelle partie (niveau, match, position de départ éventuels) ;
|
||||
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
|
||||
* prochain changement de partie. */
|
||||
newGame(newGame?: NewGame): Game;
|
||||
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
|
||||
* un échec est ignoré : la partie continue). */
|
||||
save(journal: GameJournal): void;
|
||||
/** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans
|
||||
* le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */
|
||||
resumePrevious(): Promise<Game | null>;
|
||||
/** Reprend une partie enregistrée, au bout de la branche branchHead si
|
||||
* elle est donnée. Erreur si elle n'existe pas ou est illisible. */
|
||||
open(id: string, branchHead?: string): Promise<Game>;
|
||||
}
|
||||
|
||||
export function createGameSession(library: GameLibrary, games: GameFactory): GameSession {
|
||||
let shownId: string | null = null;
|
||||
let previousId: string | null = null;
|
||||
|
||||
/** Une partie enregistrée, reprise ; null si absente ou illisible. */
|
||||
const resume = async (id: string): Promise<Game | null> => {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return games.resume(saved.journal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get shownId() {
|
||||
return shownId;
|
||||
},
|
||||
async restore() {
|
||||
const saved = await library.current().catch(() => null);
|
||||
if (saved === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const game = games.resume(saved.journal);
|
||||
shownId = saved.summary.id;
|
||||
return game;
|
||||
} catch {
|
||||
return null; // partie illisible : on en commencera une nouvelle
|
||||
}
|
||||
},
|
||||
newGame(newGame) {
|
||||
previousId = shownId;
|
||||
shownId = newGameId();
|
||||
return games.start(newGame);
|
||||
},
|
||||
save(journal) {
|
||||
if (shownId !== null) {
|
||||
library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {});
|
||||
}
|
||||
},
|
||||
async resumePrevious() {
|
||||
const id = previousId;
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
const resumed = await resume(id);
|
||||
if (resumed === null) {
|
||||
return null;
|
||||
}
|
||||
const abandoned = shownId;
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
await library.setCurrent(id);
|
||||
if (abandoned !== null) {
|
||||
await library.remove(abandoned);
|
||||
}
|
||||
return resumed;
|
||||
},
|
||||
async open(id, branchHead) {
|
||||
const saved = await library.load(id);
|
||||
if (saved === null) {
|
||||
throw new Error("partie introuvable");
|
||||
}
|
||||
const journal =
|
||||
branchHead === undefined
|
||||
? saved.journal
|
||||
: switchToBranch(saved.journal, branchHead);
|
||||
const game = games.resume(journal);
|
||||
previousId = null;
|
||||
shownId = id;
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
await library.save(id, journal, { level: levelOf(journal) });
|
||||
}
|
||||
return game;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
|
||||
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
|
||||
// (localStorage dans le navigateur).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface GameStore {
|
||||
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
|
||||
load(): GameJournal | null;
|
||||
save(journal: GameJournal): void;
|
||||
}
|
||||
|
||||
const KEY = "ustagammon.current-game";
|
||||
|
||||
export function createGameStore(storage: KeyValueStorage): GameStore {
|
||||
return {
|
||||
load() {
|
||||
try {
|
||||
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
|
||||
const valid =
|
||||
journal !== null &&
|
||||
Array.isArray(journal.events) &&
|
||||
(journal.head === null || typeof journal.head === "string");
|
||||
return valid ? journal : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
save(journal) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(journal));
|
||||
} catch {
|
||||
// Stockage plein ou interdit (navigation privée) : la partie
|
||||
// continue, elle ne pourra seulement pas être reprise.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Les parties dans IndexedDB, le stockage de données du navigateur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// 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 »).
|
||||
// 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.
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import type { LibraryBackend } from "./game-library.js";
|
||||
|
||||
const DATABASE = "ustagammon";
|
||||
const VERSION = 1;
|
||||
const GAMES = "games";
|
||||
const JOURNALS = "journals";
|
||||
const SETTINGS = "settings";
|
||||
|
||||
/** Le résultat d'une requête IndexedDB, en promesse. */
|
||||
function done<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
|
||||
function committed(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
|
||||
});
|
||||
}
|
||||
|
||||
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
|
||||
* indisponible ou refusé. */
|
||||
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
|
||||
const request = factory.open(DATABASE, VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
db.createObjectStore(GAMES, { keyPath: "id" });
|
||||
db.createObjectStore(JOURNALS, { keyPath: "id" });
|
||||
db.createObjectStore(SETTINGS, { keyPath: "key" });
|
||||
};
|
||||
const db = await done(request);
|
||||
|
||||
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
|
||||
done(db.transaction(store).objectStore(store).get(key));
|
||||
|
||||
return {
|
||||
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
|
||||
summary: (id) => get<SavedGameSummary>(GAMES, id),
|
||||
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
|
||||
write: async (summary: SavedGameSummary, journal: GameJournal) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
|
||||
transaction.objectStore(GAMES).put(summary);
|
||||
await committed(transaction);
|
||||
},
|
||||
remove: async (id) => {
|
||||
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
|
||||
transaction.objectStore(GAMES).delete(id);
|
||||
transaction.objectStore(JOURNALS).delete(id);
|
||||
await committed(transaction);
|
||||
},
|
||||
currentId: async () => {
|
||||
const setting = await get<{ value: unknown }>(SETTINGS, "current");
|
||||
return typeof setting?.value === "string" ? setting.value : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => {
|
||||
const transaction = db.transaction(SETTINGS, "readwrite");
|
||||
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
|
||||
await committed(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
|
||||
// repli quand IndexedDB est indisponible, et la lecture de la partie
|
||||
// enregistrée par les versions précédentes, pour la migrer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
|
||||
|
||||
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
|
||||
export interface KeyValueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
const SUMMARIES = "ustagammon.games";
|
||||
const CURRENT = "ustagammon.current-game-id";
|
||||
const journalKey = (id: string) => `ustagammon.game.${id}`;
|
||||
|
||||
/** La partie unique des versions précédentes (étape 0), à migrer. */
|
||||
const LEGACY_GAME = "ustagammon.current-game";
|
||||
|
||||
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
|
||||
* stockage est interdit. */
|
||||
function read(storage: KeyValueStorage, key: string): unknown {
|
||||
try {
|
||||
const text = storage.getItem(key);
|
||||
return text === null ? undefined : JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
|
||||
const summaries = (): SavedGameSummary[] => {
|
||||
const value = read(storage, SUMMARIES);
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
const writeSummaries = (list: readonly SavedGameSummary[]) =>
|
||||
storage.setItem(SUMMARIES, JSON.stringify(list));
|
||||
return {
|
||||
summaries: async () => summaries(),
|
||||
summary: async (id) => summaries().find((s) => s.id === id),
|
||||
journal: async (id) => read(storage, journalKey(id)),
|
||||
write: async (summary, journal) => {
|
||||
// Le journal d'abord : un résumé ne désigne jamais un journal
|
||||
// absent.
|
||||
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
|
||||
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
|
||||
},
|
||||
remove: async (id) => {
|
||||
writeSummaries(summaries().filter((s) => s.id !== id));
|
||||
storage.removeItem(journalKey(id));
|
||||
},
|
||||
currentId: async () => {
|
||||
const id = read(storage, CURRENT);
|
||||
return typeof id === "string" ? id : undefined;
|
||||
},
|
||||
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend dans library la partie enregistrée par les versions précédentes
|
||||
* (une seule partie, sous une clé de localStorage), qui devient la partie
|
||||
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
|
||||
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
|
||||
*/
|
||||
export async function migrateLegacyGame(
|
||||
storage: KeyValueStorage,
|
||||
library: GameLibrary,
|
||||
now: number = Date.now(),
|
||||
): Promise<string | null> {
|
||||
let present: boolean;
|
||||
try {
|
||||
present = storage.getItem(LEGACY_GAME) !== null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!present) {
|
||||
return null;
|
||||
}
|
||||
const journal: unknown = read(storage, LEGACY_GAME);
|
||||
let id: string | null = null;
|
||||
if (isJournal(journal) && journal.events.length > 0) {
|
||||
id = newGameId(now);
|
||||
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
|
||||
await library.save(id, journal as GameJournal, { level: "2ply" });
|
||||
}
|
||||
try {
|
||||
storage.removeItem(LEGACY_GAME);
|
||||
} catch {
|
||||
// Stockage interdit : la migration sera refaite, sans dommage.
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
|
||||
// sinon localStorage, puis migration de la partie des versions précédentes.
|
||||
//
|
||||
// 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 { openIndexedDbBackend } from "./indexeddb-backend.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "./key-value-backend.js";
|
||||
|
||||
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
|
||||
export interface BrowserStorage {
|
||||
readonly indexedDB?: IDBFactory;
|
||||
readonly localStorage?: KeyValueStorage;
|
||||
}
|
||||
|
||||
export interface OpenedLibrary {
|
||||
readonly library: GameLibrary;
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
|
||||
* doit pas empêcher de jouer). */
|
||||
const OPEN_TIMEOUT_MS = 5000;
|
||||
|
||||
export async function openGameLibrary(
|
||||
storage: BrowserStorage,
|
||||
options: LibraryOptions = {},
|
||||
): Promise<OpenedLibrary> {
|
||||
const opened = await open(storage, options);
|
||||
if (storage.localStorage !== undefined) {
|
||||
try {
|
||||
await migrateLegacyGame(storage.localStorage, opened.library);
|
||||
} catch {
|
||||
// Migration impossible (stockage plein ou refusé) : l'ancienne
|
||||
// partie reste où elle est, on la retentera au prochain démarrage.
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
|
||||
if (storage.indexedDB !== undefined) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const backend = await Promise.race([
|
||||
openIndexedDbBackend(storage.indexedDB),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
|
||||
} catch {
|
||||
// Indisponible (navigation privée de certains navigateurs) : repli.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
|
||||
kind: "localStorage",
|
||||
};
|
||||
}
|
||||
return {
|
||||
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
|
||||
kind: "memory",
|
||||
};
|
||||
}
|
||||
|
||||
/** localStorage peut exister et refuser toute écriture. */
|
||||
function usable(storage: KeyValueStorage): boolean {
|
||||
try {
|
||||
storage.setItem("ustagammon.probe", "1");
|
||||
storage.removeItem("ustagammon.probe");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Un stockage clé-valeur en mémoire. */
|
||||
export function memoryStorage(): KeyValueStorage {
|
||||
const items = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => items.get(key) ?? null,
|
||||
setItem: (key, value) => void items.set(key, value),
|
||||
removeItem: (key) => void items.delete(key),
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import type { KeyValueStorage } from "./game-store.js";
|
||||
import type { KeyValueStorage } from "./key-value-backend.js";
|
||||
|
||||
const SETTINGS_KEY = "ustagammon.settings";
|
||||
const BOARD_KEY = "ustagammon.last-board";
|
||||
@@ -41,7 +41,9 @@ function isSide(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function createSettingsStore(storage: KeyValueStorage): SettingsStore {
|
||||
export function createSettingsStore(
|
||||
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
|
||||
): SettingsStore {
|
||||
const write = (key: string, value: string) => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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: (ply: number) =>
|
||||
`Écart d'équité à ${ply} plis, 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 ». */
|
||||
export function formatEquity(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
@@ -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,343 @@
|
||||
// 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 { 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 { type IconName, icon } from "./icons.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.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;
|
||||
}
|
||||
|
||||
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;
|
||||
sheet.replaceChildren();
|
||||
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();
|
||||
close.focus();
|
||||
}
|
||||
|
||||
function closeButton(): void {
|
||||
const actions = element(sheet, "div", { className: "explain-actions" });
|
||||
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";
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
|
||||
legend.textContent = ADVICE_TEXTS.legendSame(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);
|
||||
legend.textContent = text;
|
||||
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é.
|
||||
const first = advice.ranking[0]?.equity ?? advice.best.equity;
|
||||
const listed = advice.ranking.map((move, i) => ({
|
||||
move,
|
||||
rank: i + 1,
|
||||
loss: move.equity - first,
|
||||
}));
|
||||
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
|
||||
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
|
||||
}
|
||||
for (const { move, rank, loss } of listed) {
|
||||
const mine = move.notation === advice.played.notation;
|
||||
row(
|
||||
[
|
||||
String(rank),
|
||||
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
|
||||
formatEquity(loss),
|
||||
],
|
||||
mine,
|
||||
);
|
||||
}
|
||||
element(details, "p", {
|
||||
className: "explain-note",
|
||||
text: ADVICE_TEXTS.moveNote(advice.ply),
|
||||
});
|
||||
} 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,
|
||||
hide() {
|
||||
opened++;
|
||||
player?.stop();
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
// 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 { 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";
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "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("|");
|
||||
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,
|
||||
};
|
||||
}
|
||||
+1340
-83
File diff suppressed because it is too large
Load Diff
+171
-11
@@ -27,6 +27,7 @@ import {
|
||||
} from "./board-model.js";
|
||||
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
|
||||
import { ICON_PATHS, type IconName } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
|
||||
@@ -49,12 +50,22 @@ export interface BoardControls {
|
||||
readonly back: "undo" | "replay";
|
||||
readonly backEnabled: boolean;
|
||||
readonly menuEnabled: boolean;
|
||||
/** Le score du match, sous le menu (null : partie libre). */
|
||||
readonly match: MatchScore | null;
|
||||
}
|
||||
|
||||
/** Le score d'un match, à afficher. */
|
||||
export interface MatchScore {
|
||||
readonly length: number;
|
||||
readonly computer: number;
|
||||
readonly player: number;
|
||||
}
|
||||
|
||||
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
|
||||
export interface BoardDecor {
|
||||
readonly dice: readonly ShownDie[];
|
||||
readonly cube: Cube;
|
||||
/** null : pas de videau (partie Crawford). */
|
||||
readonly cube: Cube | null;
|
||||
/** Les pions que le joueur peut déplacer. */
|
||||
readonly movable: ReadonlySet<Place>;
|
||||
/** Le pion touché, dont on montre les cases d'arrivée. */
|
||||
@@ -72,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. */
|
||||
@@ -166,6 +192,7 @@ export function renderBoard(
|
||||
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
|
||||
// de l'ordinateur : une flèche ne cache jamais un pion.
|
||||
const pieces = document.createElementNS(SVG_NS, "g");
|
||||
pieces.setAttribute("class", "pieces");
|
||||
for (const point of board.points) {
|
||||
drawPoint(svg, pieces, g, hits, board, point.number, decor);
|
||||
}
|
||||
@@ -176,11 +203,13 @@ 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 = drawCube(svg, g, decor.cube);
|
||||
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
|
||||
if (decor.trace !== null) {
|
||||
drawTraceRings(svg, g, board, decor.trace);
|
||||
}
|
||||
@@ -196,13 +225,16 @@ export function renderBoard(
|
||||
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
|
||||
}
|
||||
}
|
||||
if (decor.canDouble) {
|
||||
if (decor.canDouble && cube !== null && decor.cube !== null) {
|
||||
cube.classList.add("can-double");
|
||||
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -484,12 +516,18 @@ 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
|
||||
// 0,45 em.
|
||||
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
|
||||
add(svg, "rect", {
|
||||
class: "band-pill",
|
||||
x,
|
||||
@@ -505,13 +543,113 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
|
||||
class: "band-text",
|
||||
id: "band-text",
|
||||
x: x + width / 2,
|
||||
y: middle + g.bandFont * 0.35,
|
||||
"font-size": g.bandFont,
|
||||
y: middle + fontSize * 0.35,
|
||||
"font-size": fontSize,
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
|
||||
*/
|
||||
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
|
||||
const line = g.chipFont * 1.2;
|
||||
const width = g.left - 12;
|
||||
const group = add(svg, "g", {
|
||||
class: "match-box",
|
||||
role: "img",
|
||||
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
|
||||
});
|
||||
add(group, "rect", {
|
||||
class: "match-box-frame",
|
||||
x: x - width / 2,
|
||||
y: top,
|
||||
width,
|
||||
height: 3 * line + 8,
|
||||
rx: 6,
|
||||
});
|
||||
const texts: [string, string][] = [
|
||||
[String(match.computer), "match-box-score"],
|
||||
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
|
||||
[String(match.player), "match-box-score"],
|
||||
];
|
||||
for (const [i, [text, cssClass]] of texts.entries()) {
|
||||
add(
|
||||
group,
|
||||
"text",
|
||||
{
|
||||
class: cssClass,
|
||||
x,
|
||||
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
|
||||
"font-size": g.chipFont,
|
||||
"text-anchor": "middle",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
|
||||
function drawControls(
|
||||
svg: SVGSVGElement,
|
||||
@@ -532,11 +670,33 @@ function drawControls(
|
||||
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
|
||||
});
|
||||
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
|
||||
if (on) {
|
||||
if (!on) {
|
||||
return;
|
||||
}
|
||||
if (action === "menu") {
|
||||
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
|
||||
// la barre haute du portrait.
|
||||
const link = add(hits, "a", {
|
||||
href: "#/menu",
|
||||
"data-action": action,
|
||||
role: "link",
|
||||
"aria-label": label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x: Math.max(0, x - g.target / 2),
|
||||
y: Math.max(0, y - g.target / 2),
|
||||
width: g.target,
|
||||
height: g.target,
|
||||
});
|
||||
} else {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
|
||||
if (controls.match !== null) {
|
||||
drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match);
|
||||
}
|
||||
if (controls.back === "replay") {
|
||||
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
|
||||
// temps que l'écran rejoigne le menu de l'application.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
|
||||
const RESULT_KIND: Record<ResultKind, string> = {
|
||||
single: "partie simple",
|
||||
gammon: "gammon",
|
||||
backgammon: "backgammon",
|
||||
};
|
||||
|
||||
function moves(n: number): string {
|
||||
return n > 1 ? `${n} coups` : `${n} coup`;
|
||||
}
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const GAMES_TEXTS = {
|
||||
button: "Mes parties",
|
||||
title: "Mes parties",
|
||||
empty: "Aucune partie enregistrée.",
|
||||
close: "Fermer",
|
||||
resume: "Reprendre",
|
||||
showBranches: "Branches",
|
||||
summary: "Bilan",
|
||||
resumeBranch: "Reprendre cette branche",
|
||||
exportSgf: "Exporter (SGF)",
|
||||
remove: "Supprimer",
|
||||
confirmRemove: "Supprimer pour de bon (branches comprises)",
|
||||
shown: "partie affichée",
|
||||
busy: "Attendez la fin du coup en cours.",
|
||||
failed: (message: string) => `Erreur : ${message}`,
|
||||
|
||||
/** « 3 nov. 2026, 14:05 ». */
|
||||
date: (ms: number) =>
|
||||
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
||||
|
||||
status: (
|
||||
result: { winner: Side; kind: ResultKind; points: number } | null,
|
||||
moveCount: number,
|
||||
) => {
|
||||
if (result === null) {
|
||||
return `En cours, ${moves(moveCount)}`;
|
||||
}
|
||||
const who = result.winner === "player" ? "Gagnée" : "Perdue";
|
||||
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
|
||||
},
|
||||
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
|
||||
|
||||
/** Une branche : d'où elle part et où elle en est. */
|
||||
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
|
||||
const start =
|
||||
forkMoveCount === null
|
||||
? "Partie d'origine"
|
||||
: forkMoveCount === 0
|
||||
? "Rejouée depuis le début"
|
||||
: `Rejouée après le coup ${forkMoveCount}`;
|
||||
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
|
||||
},
|
||||
currentBranch: " (branche courante)",
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
|
||||
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne
|
||||
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
|
||||
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
|
||||
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
|
||||
//
|
||||
// La feuille a son adresse (#/parties), et chaque partie la sienne
|
||||
// (#/parties/<id>, ses branches dépliées) : l'écran de jeu l'ouvre et la
|
||||
// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien.
|
||||
|
||||
import { branches } from "../domain/game/branches.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { GAMES_TEXTS } from "./games-texts.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
export interface GamesViewOptions {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
readonly list: () => Promise<SavedGameSummary[]>;
|
||||
/** Le journal d'une partie (null si elle a disparu). */
|
||||
readonly journal: (id: string) => Promise<GameJournal | null>;
|
||||
/** L'identifiant de la partie affichée à l'écran. */
|
||||
readonly shownId: () => string | null;
|
||||
/** Reprend une partie, au bout de la branche branchHead si elle est
|
||||
* donnée (sinon là où elle en était), et quitte la feuille. */
|
||||
readonly resume: (id: string, branchHead?: string) => Promise<void>;
|
||||
readonly remove: (id: string) => Promise<void>;
|
||||
/** Le fichier SGF d'une partie : son nom et son contenu. */
|
||||
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
|
||||
/** La croix ou le voile touché : quitter la feuille (l'écran de jeu
|
||||
* change d'adresse, puis appelle hide). */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export interface GamesView {
|
||||
/** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les
|
||||
* branches de la partie expanded dépliées. */
|
||||
show(expanded: string | null): Promise<void>;
|
||||
hide(): void;
|
||||
readonly isOpen: boolean;
|
||||
}
|
||||
|
||||
/** Crée la feuille « Mes parties » (fermée) dans la page. */
|
||||
export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
const veil = element("div", "veil sheet-veil");
|
||||
veil.id = "games-veil";
|
||||
veil.hidden = true;
|
||||
const sheet = element("div", "sheet games");
|
||||
sheet.id = "games";
|
||||
sheet.setAttribute("role", "dialog");
|
||||
sheet.setAttribute("aria-modal", "true");
|
||||
sheet.setAttribute("aria-labelledby", "games-title");
|
||||
const head = element("div", "sheet-head");
|
||||
const title = element("h2", "sheet-title");
|
||||
title.id = "games-title";
|
||||
title.textContent = GAMES_TEXTS.title;
|
||||
const close = button("", () => options.onClose(), "icon-button");
|
||||
close.id = "games-close";
|
||||
close.setAttribute("aria-label", GAMES_TEXTS.close);
|
||||
close.append(icon("close"));
|
||||
head.append(title, close);
|
||||
const list = element("ul", "games-list");
|
||||
const message = element("p", "games-message");
|
||||
message.setAttribute("aria-live", "polite");
|
||||
sheet.append(head, message, list);
|
||||
veil.append(sheet);
|
||||
document.body.append(veil);
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
options.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
// La partie dont les branches sont dépliées (adresse #/parties/<id>).
|
||||
let expanded: string | null = null;
|
||||
|
||||
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
|
||||
const run = (action: () => Promise<void>) => async () => {
|
||||
message.textContent = "";
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
message.textContent = GAMES_TEXTS.failed(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function render(): Promise<void> {
|
||||
const games = await options.list();
|
||||
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
|
||||
if (games.length === 0) {
|
||||
message.textContent = GAMES_TEXTS.empty;
|
||||
}
|
||||
}
|
||||
|
||||
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
|
||||
const item = element("li", "games-item");
|
||||
item.dataset.id = game.id;
|
||||
const heading = element("p");
|
||||
heading.textContent = [
|
||||
GAMES_TEXTS.date(game.startedAt),
|
||||
GAMES_TEXTS.status(game.result, game.moveCount),
|
||||
GAMES_TEXTS.branchCount(game.branchCount),
|
||||
shown ? GAMES_TEXTS.shown : "",
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" · ");
|
||||
const branchList = element("ul", "games-branches");
|
||||
branchList.hidden = true;
|
||||
const actions = element("div", "games-actions");
|
||||
actions.append(
|
||||
button(
|
||||
GAMES_TEXTS.resume,
|
||||
run(() => options.resume(game.id)),
|
||||
"",
|
||||
"resume",
|
||||
),
|
||||
branchesLink(game.id),
|
||||
...(game.status === "over" ? [summaryLink(game.id)] : []),
|
||||
button(
|
||||
GAMES_TEXTS.exportSgf,
|
||||
run(async () => download(await options.exportSgf(game.id))),
|
||||
"secondary",
|
||||
"export",
|
||||
),
|
||||
// Supprimer se fait en deux touchers, sans boîte du navigateur :
|
||||
// le premier change le bouton en demande de confirmation.
|
||||
button(
|
||||
GAMES_TEXTS.remove,
|
||||
run(async () => {
|
||||
const remove = item.querySelector<HTMLElement>('[data-action="remove"]');
|
||||
if (remove?.dataset.armed !== "true") {
|
||||
if (remove !== null) {
|
||||
remove.dataset.armed = "true";
|
||||
remove.textContent = GAMES_TEXTS.confirmRemove;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await options.remove(game.id);
|
||||
await render();
|
||||
await expand();
|
||||
}),
|
||||
"secondary",
|
||||
"remove",
|
||||
),
|
||||
);
|
||||
// La partie à l'écran ne se supprime pas : elle continue d'être
|
||||
// enregistrée.
|
||||
const remove = actions.querySelector<HTMLElement>('[data-action="remove"]');
|
||||
if (remove !== null) {
|
||||
remove.hidden = shown;
|
||||
}
|
||||
item.append(heading, actions, branchList);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** « Branches » : le lien qui déplie les branches d'une partie (ou les
|
||||
* replie, quand elles le sont déjà). */
|
||||
function branchesLink(id: string): HTMLAnchorElement {
|
||||
const link = element("a", "button secondary");
|
||||
link.textContent = GAMES_TEXTS.showBranches;
|
||||
link.dataset.action = "branches";
|
||||
link.href = routeHash({ screen: "games", game: id });
|
||||
link.setAttribute("aria-expanded", "false");
|
||||
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);
|
||||
target.replaceChildren(
|
||||
...all.map((branch) => {
|
||||
const item = element("li", "games-branch");
|
||||
item.dataset.head = branch.head;
|
||||
const text = element("span");
|
||||
text.textContent =
|
||||
GAMES_TEXTS.branch(
|
||||
branch.forkedFrom === null ? null : branch.forkMoveCount,
|
||||
branch.moveCount,
|
||||
branch.over,
|
||||
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
|
||||
item.append(
|
||||
text,
|
||||
button(
|
||||
GAMES_TEXTS.resumeBranch,
|
||||
run(() => options.resume(id, branch.head)),
|
||||
"secondary",
|
||||
"resume-branch",
|
||||
),
|
||||
);
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Déplie les branches de la partie expanded, replie les autres. */
|
||||
async function expand(): Promise<void> {
|
||||
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
|
||||
const id = item.dataset.id ?? "";
|
||||
const open = id === expanded;
|
||||
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
|
||||
link?.setAttribute("aria-expanded", String(open));
|
||||
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
|
||||
const branchList = item.querySelector<HTMLElement>(".games-branches");
|
||||
if (branchList === null) {
|
||||
continue;
|
||||
}
|
||||
if (open && branchList.hidden) {
|
||||
await run(() => renderBranches(id, branchList))();
|
||||
}
|
||||
branchList.hidden = !open;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async show(next) {
|
||||
expanded = next;
|
||||
if (veil.hidden) {
|
||||
message.textContent = "";
|
||||
veil.hidden = false;
|
||||
await run(render)();
|
||||
close.focus();
|
||||
}
|
||||
await expand();
|
||||
},
|
||||
hide() {
|
||||
veil.hidden = true;
|
||||
},
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className = "",
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const created = document.createElement(tag);
|
||||
if (className !== "") {
|
||||
created.className = className;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
function button(
|
||||
text: string,
|
||||
onClick: () => unknown,
|
||||
className = "",
|
||||
action = "",
|
||||
): HTMLButtonElement {
|
||||
const created = element("button", className);
|
||||
created.type = "button";
|
||||
created.textContent = text;
|
||||
if (action !== "") {
|
||||
created.dataset.action = action;
|
||||
}
|
||||
created.onclick = () => void onClick();
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Fait télécharger un fichier texte au navigateur. */
|
||||
function download(file: { fileName: string; text: string }): void {
|
||||
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
|
||||
const link = element("a");
|
||||
link.href = url;
|
||||
link.download = file.fileName;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// La question de l'aide qui correspond au moment de la partie : le bouton
|
||||
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
|
||||
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
|
||||
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
|
||||
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
|
||||
// titre dans un .feature demande de le changer ici.
|
||||
|
||||
import { routeHash, slug } from "./router.js";
|
||||
|
||||
/** Le moment de la partie, vu de l'aide. */
|
||||
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
|
||||
|
||||
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
|
||||
moving: "Comment jouer un coup ?",
|
||||
stuck: "Je ne peux rien jouer avec mes dés",
|
||||
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
|
||||
over: "Comment se termine une partie ?",
|
||||
};
|
||||
|
||||
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
|
||||
* question particulière : null). */
|
||||
export function helpHash(moment: HelpMoment | null): string {
|
||||
return routeHash({
|
||||
screen: "help",
|
||||
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
|
||||
});
|
||||
}
|
||||
+54
-6
@@ -14,10 +14,16 @@ export interface HelpFeature {
|
||||
}
|
||||
|
||||
export interface HelpScenario {
|
||||
/** Le titre, formulé comme la question du joueur. */
|
||||
readonly title: string;
|
||||
/** La réponse : le paragraphe sous le titre. */
|
||||
readonly description: readonly string[];
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
|
||||
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
|
||||
* s'affichent pas dans l'aide : elles prouvent la réponse. */
|
||||
readonly steps: readonly string[];
|
||||
/** La position de l'exemple, donnée par l'étiquette
|
||||
* @position:<Position ID> du scénario (null sans étiquette). */
|
||||
readonly position: string | null;
|
||||
}
|
||||
|
||||
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
|
||||
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
|
||||
/**
|
||||
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
|
||||
* libre consécutives forment un paragraphe ; les commentaires (#) sont
|
||||
* ignorés.
|
||||
* ignorés ; une étiquette @position:<Position ID> donne la position de
|
||||
* l'exemple du scénario qui la suit.
|
||||
*/
|
||||
export function parseFeature(text: string): HelpFeature {
|
||||
let title = "";
|
||||
const description: string[] = [];
|
||||
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
|
||||
const scenarios: {
|
||||
title: string;
|
||||
description: string[];
|
||||
steps: string[];
|
||||
position: string | null;
|
||||
}[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let tags: string[] = [];
|
||||
|
||||
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
|
||||
// du dernier scénario.
|
||||
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
|
||||
const scenario = SCENARIO.exec(line);
|
||||
if (line === "" || line.startsWith("#")) {
|
||||
endParagraph();
|
||||
} else if (line.startsWith("@")) {
|
||||
endParagraph();
|
||||
tags.push(...line.split(/\s+/));
|
||||
} else if (feature) {
|
||||
endParagraph();
|
||||
title = feature[1] ?? "";
|
||||
tags = [];
|
||||
} else if (scenario) {
|
||||
endParagraph();
|
||||
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
|
||||
const position = tags.find((tag) => tag.startsWith("@position:"));
|
||||
scenarios.push({
|
||||
title: scenario[2] ?? "",
|
||||
description: [],
|
||||
steps: [],
|
||||
position: position === undefined ? null : position.slice("@position:".length),
|
||||
});
|
||||
tags = [];
|
||||
} else if (STEP.test(line) && scenarios.length > 0) {
|
||||
endParagraph();
|
||||
scenarios.at(-1)?.steps.push(line);
|
||||
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
|
||||
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
|
||||
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
|
||||
* phrase « En bref », et ses renvois « Voir aussi ». */
|
||||
export interface GlossaryEntry {
|
||||
readonly term: string;
|
||||
readonly english: string;
|
||||
readonly brief: string;
|
||||
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
|
||||
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
|
||||
}
|
||||
|
||||
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
|
||||
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
|
||||
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
|
||||
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
||||
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
|
||||
if (term === undefined || brief === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { term, brief: brief.replaceAll("**", "") };
|
||||
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
|
||||
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
|
||||
term: m[1] ?? "",
|
||||
name: m[2] ?? "",
|
||||
}));
|
||||
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
|
||||
}
|
||||
|
||||
/** Un texte sans accents ni majuscules, pour la recherche dans le
|
||||
* glossaire. */
|
||||
export function searchable(text: string): string {
|
||||
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
|
||||
* phrase « En bref » contiennent chacun des mots cherchés) ? */
|
||||
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
|
||||
const words = searchable(query).split(/\s+/).filter(Boolean);
|
||||
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
|
||||
return words.every((word) => haystack.includes(word));
|
||||
}
|
||||
|
||||
+282
-51
@@ -1,23 +1,43 @@
|
||||
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
|
||||
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
|
||||
// (ADR 0008). Rien n'est recopié dans le code.
|
||||
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
|
||||
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
|
||||
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
|
||||
// dans le code.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
|
||||
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
|
||||
// est ouvert (show). Le titre d'un scénario est la question, sa
|
||||
// description la réponse ; ses étapes, la preuve que l'application fait ce
|
||||
// que dit la réponse, ne s'affichent pas
|
||||
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
|
||||
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
type HelpScenario,
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "./help-text.js";
|
||||
import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { type HelpRoute, routeHash, slug } from "./router.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
const FEATURES = [
|
||||
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/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
"docs/scenarios/installer-l-application.feature",
|
||||
"docs/scenarios/decouvrir-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
|
||||
@@ -29,10 +49,19 @@ async function fetchText(url: string): Promise<string> {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
/** Une fiche du glossaire et son nom de fichier, sans .md. */
|
||||
interface GlossaryCard extends GlossaryEntry {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryCard[]> {
|
||||
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
|
||||
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
return texts.flatMap((text, i) => {
|
||||
const entry = parseGlossaryEntry(text);
|
||||
const name = (files[i] ?? "").replace(/\.md$/, "");
|
||||
return entry === null ? [] : [{ ...entry, name }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
|
||||
@@ -41,6 +70,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
|
||||
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
|
||||
}
|
||||
|
||||
/** Le commit publié (version.json, écrit par make site), en abrégé. */
|
||||
async function publishedVersion(): Promise<string | null> {
|
||||
try {
|
||||
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
|
||||
return typeof version.commit === "string" && version.commit !== ""
|
||||
? version.commit.slice(0, 12)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
parent: HTMLElement,
|
||||
tag: K,
|
||||
@@ -54,56 +95,246 @@ function element<K extends keyof HTMLElementTagNameMap>(
|
||||
return child;
|
||||
}
|
||||
|
||||
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
|
||||
element(parent, "h2", feature.title);
|
||||
for (const paragraph of feature.description) {
|
||||
element(parent, "p", paragraph);
|
||||
}
|
||||
for (const scenario of feature.scenarios) {
|
||||
element(parent, "h3", scenario.title);
|
||||
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
|
||||
interface Question {
|
||||
readonly id: string;
|
||||
readonly section: HTMLElement;
|
||||
readonly link: HTMLAnchorElement;
|
||||
readonly answer: HTMLElement;
|
||||
}
|
||||
|
||||
export interface HelpView {
|
||||
/** Montre l'onglet, la question ou le terme que désigne l'adresse
|
||||
* (l'aide est lue au premier appel). */
|
||||
show(route: HelpRoute): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
|
||||
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
|
||||
*/
|
||||
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
|
||||
const play = document.createElement("div");
|
||||
play.id = "help-play";
|
||||
const glossary = document.createElement("div");
|
||||
glossary.id = "help-glossary";
|
||||
const about = document.createElement("div");
|
||||
about.id = "help-about";
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "help-notice";
|
||||
notice.setAttribute("role", "status");
|
||||
const questions: Question[] = [];
|
||||
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
|
||||
let loading: Promise<boolean> | null = null;
|
||||
// La question ouverte au dernier affichage : refermée, elle reste en vue.
|
||||
let lastOpen: Question | null = null;
|
||||
|
||||
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
|
||||
let id = slug(scenario.title);
|
||||
for (let n = 2; questions.some((q) => q.id === id); n++) {
|
||||
id = `${slug(scenario.title)}-${n}`;
|
||||
}
|
||||
const section = element(parent, "section");
|
||||
section.className = "question";
|
||||
section.id = `aide-${id}`;
|
||||
const heading = element(section, "h3");
|
||||
const link = element(heading, "a", scenario.title);
|
||||
link.href = routeHash({ screen: "help", question: id });
|
||||
link.setAttribute("aria-controls", `aide-${id}-reponse`);
|
||||
link.setAttribute("aria-expanded", "false");
|
||||
const answer = element(section, "div");
|
||||
answer.id = `aide-${id}-reponse`;
|
||||
answer.className = "answer";
|
||||
answer.hidden = true;
|
||||
for (const paragraph of scenario.description) {
|
||||
element(parent, "p", paragraph);
|
||||
element(answer, "p", paragraph);
|
||||
}
|
||||
const steps = element(parent, "ul");
|
||||
steps.className = "steps";
|
||||
for (const step of scenario.steps) {
|
||||
element(steps, "li", step);
|
||||
if (scenario.position !== null) {
|
||||
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
|
||||
board.className = "see-board";
|
||||
board.href = routeHash({
|
||||
screen: "position",
|
||||
position: scenario.position,
|
||||
match: null,
|
||||
});
|
||||
}
|
||||
questions.push({ id, section, link, answer });
|
||||
}
|
||||
}
|
||||
|
||||
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
|
||||
element(parent, "h2", TEXTS.glossaryTitle);
|
||||
const list = element(parent, "dl");
|
||||
list.className = "glossary";
|
||||
for (const entry of entries) {
|
||||
element(list, "dt", entry.term);
|
||||
element(list, "dd", entry.brief);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
|
||||
* font rien). */
|
||||
export async function showHelp(container: HTMLElement): Promise<void> {
|
||||
if (container.dataset.loaded === "true") {
|
||||
return;
|
||||
}
|
||||
container.replaceChildren();
|
||||
const status = element(container, "p", TEXTS.helpLoading);
|
||||
try {
|
||||
const [features, glossary] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
]);
|
||||
status.remove();
|
||||
for (const feature of features) {
|
||||
renderFeature(container, feature);
|
||||
function renderFeature(feature: HelpFeature): void {
|
||||
element(play, "h2", feature.title);
|
||||
for (const paragraph of feature.description) {
|
||||
element(play, "p", paragraph).className = "feature-intro";
|
||||
}
|
||||
for (const scenario of feature.scenarios) {
|
||||
renderScenario(play, scenario);
|
||||
}
|
||||
renderGlossary(container, glossary);
|
||||
const about = element(container, "p", TEXTS.about);
|
||||
about.className = "about";
|
||||
container.dataset.loaded = "true";
|
||||
} catch {
|
||||
status.textContent = TEXTS.helpFailed;
|
||||
}
|
||||
|
||||
function renderGlossary(entries: readonly GlossaryCard[]): void {
|
||||
element(glossary, "h2", TEXTS.glossaryTitle);
|
||||
const label = element(glossary, "label");
|
||||
label.className = "glossary-search";
|
||||
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
|
||||
const search = element(label, "input");
|
||||
search.id = "glossary-search";
|
||||
search.type = "search";
|
||||
search.autocomplete = "off";
|
||||
search.spellcheck = false;
|
||||
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
|
||||
none.className = "glossary-none muted";
|
||||
none.hidden = true;
|
||||
const list = element(glossary, "dl");
|
||||
list.className = "glossary";
|
||||
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
|
||||
for (const entry of sorted) {
|
||||
const card = element(list, "div");
|
||||
card.className = "term-card";
|
||||
const dt = element(card, "dt");
|
||||
dt.id = `glossaire-${entry.name}`;
|
||||
const link = element(dt, "a", entry.term);
|
||||
link.href = routeHash({ screen: "glossary", term: entry.name });
|
||||
if (entry.english !== "") {
|
||||
const english = element(dt, "span", entry.english);
|
||||
english.className = "english";
|
||||
english.lang = "en";
|
||||
}
|
||||
const dd = element(card, "dd");
|
||||
element(dd, "p", entry.brief);
|
||||
if (entry.seeAlso.length > 0) {
|
||||
const see = element(dd, "p");
|
||||
see.className = "see-also";
|
||||
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
|
||||
entry.seeAlso.forEach((other, i) => {
|
||||
if (i > 0) {
|
||||
see.append(", ");
|
||||
}
|
||||
const a = element(see, "a", other.term);
|
||||
a.href = routeHash({ screen: "glossary", term: other.name });
|
||||
});
|
||||
}
|
||||
terms.set(entry.name, { dt, card, entry });
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
let shown = 0;
|
||||
for (const { card, entry } of terms.values()) {
|
||||
const match = matchesSearch(entry, search.value);
|
||||
card.hidden = !match;
|
||||
shown += match ? 1 : 0;
|
||||
}
|
||||
none.hidden = shown > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAbout(version: string | null): void {
|
||||
element(about, "h2", TEXTS.aboutTitle);
|
||||
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
|
||||
element(about, "p", paragraph).className = "about";
|
||||
}
|
||||
const line = element(
|
||||
about,
|
||||
"p",
|
||||
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
|
||||
);
|
||||
line.id = "about-version";
|
||||
line.className = "about";
|
||||
}
|
||||
|
||||
function renderTutorialCard(): void {
|
||||
const card = element(container, "a");
|
||||
card.id = "help-tutorial";
|
||||
card.className = "card help-tutorial";
|
||||
card.href = "#/tutoriel/1";
|
||||
const text = element(card, "span");
|
||||
text.className = "help-tutorial-text";
|
||||
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
|
||||
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
|
||||
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
|
||||
}
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
container.replaceChildren(notice);
|
||||
notice.textContent = TEXTS.helpLoading;
|
||||
try {
|
||||
const [features, entries, version] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
publishedVersion(),
|
||||
]);
|
||||
const intro = element(play, "p", HOME_TEXTS.helpIntro);
|
||||
intro.className = "help-intro muted";
|
||||
for (const feature of features) {
|
||||
renderFeature(feature);
|
||||
}
|
||||
renderGlossary(entries);
|
||||
renderAbout(version);
|
||||
notice.textContent = "";
|
||||
renderTutorialCard();
|
||||
container.append(play, glossary, about);
|
||||
container.dataset.loaded = "true";
|
||||
return true;
|
||||
} catch {
|
||||
notice.textContent = TEXTS.helpFailed;
|
||||
loading = null; // on réessaiera à la prochaine ouverture
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async show(route) {
|
||||
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
|
||||
if (tab.dataset.screen === route.screen) {
|
||||
tab.setAttribute("aria-current", "page");
|
||||
} else {
|
||||
tab.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
loading ??= load();
|
||||
if (!(await loading)) {
|
||||
return;
|
||||
}
|
||||
play.hidden = route.screen !== "help";
|
||||
glossary.hidden = route.screen !== "glossary";
|
||||
about.hidden = route.screen !== "about";
|
||||
|
||||
const question = route.screen === "help" ? route.question : null;
|
||||
for (const q of questions) {
|
||||
const open = q.id === question;
|
||||
q.answer.hidden = !open;
|
||||
q.link.setAttribute("aria-expanded", String(open));
|
||||
// Le lien d'une question ouverte la referme.
|
||||
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
|
||||
q.section.classList.toggle("open", open);
|
||||
}
|
||||
const term = route.screen === "glossary" ? route.term : null;
|
||||
for (const [name, { dt, card }] of terms) {
|
||||
dt.classList.toggle("current", name === term);
|
||||
card.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
dt.setAttribute("aria-current", "true");
|
||||
card.hidden = false;
|
||||
} else {
|
||||
dt.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
const missing =
|
||||
(question !== null && !questions.some((q) => q.id === question)) ||
|
||||
(term !== null && !terms.has(term));
|
||||
notice.textContent = missing ? TEXTS.helpNotFound : "";
|
||||
|
||||
const opened = questions.find((q) => q.id === question) ?? null;
|
||||
const closed = route.screen === "help" && opened === null ? lastOpen : null;
|
||||
lastOpen = opened;
|
||||
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
|
||||
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
|
||||
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
} else if (closed !== null) {
|
||||
closed.section.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
|
||||
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
|
||||
|
||||
/** Les deux choix du premier lancement : « Je découvre » mène au
|
||||
* tutoriel, « Je connais les règles » à la partie. */
|
||||
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
|
||||
|
||||
/** Les réglages après l'un des deux choix du premier lancement. */
|
||||
export function welcomed(): Partial<Settings> {
|
||||
return { welcomed: true };
|
||||
}
|
||||
|
||||
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
|
||||
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
|
||||
* gardée (null). */
|
||||
export function launchHash(hash: string): string | null {
|
||||
return hash === "" || hash === "#" ? "#/accueil" : null;
|
||||
}
|
||||
|
||||
/** L'accueil, au premier lancement ou ensuite. */
|
||||
export interface HomeModel {
|
||||
/** Premier lancement : le choix « Je découvre » / « Je connais les
|
||||
* règles », et rien d'autre. */
|
||||
readonly welcome: boolean;
|
||||
/** La carte de la partie : « Reprendre la partie » si une partie est en
|
||||
* cours, sinon « Nouvelle partie » en action principale. */
|
||||
readonly resume: boolean;
|
||||
/** L'invitation à installer. */
|
||||
readonly installCard: boolean;
|
||||
}
|
||||
|
||||
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
|
||||
* encore connu, le moteur se charge). */
|
||||
export function homeModel(
|
||||
settings: Settings,
|
||||
playing: boolean | null,
|
||||
installed: boolean,
|
||||
): HomeModel {
|
||||
const welcome = isFirstLaunch(settings);
|
||||
return {
|
||||
welcome,
|
||||
resume: playing !== false,
|
||||
installCard: !welcome && installCardShown(settings, installed),
|
||||
};
|
||||
}
|
||||
|
||||
/** La ligne « Installer l'application » du menu : tant que l'application
|
||||
* n'est pas installée. */
|
||||
export function installMenuShown(settings: Settings, installed: boolean): boolean {
|
||||
return !installed && !settings.installed;
|
||||
}
|
||||
|
||||
/** Les réglages après « Plus tard » sur l'invitation à installer. */
|
||||
export function dismissInstall(settings: Settings): Partial<Settings> {
|
||||
return {
|
||||
installDismissals: settings.installDismissals + 1,
|
||||
installDismissedAt: settings.gamesFinished,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
|
||||
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
|
||||
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
|
||||
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ResultKind } from "../domain/game/engine.js";
|
||||
import type { InstallPlatform } from "./install.js";
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n > 1 ? many : one}`;
|
||||
}
|
||||
|
||||
export const HOME_TEXTS = {
|
||||
// Premier lancement
|
||||
welcomeTitle: "Bienvenue à la table",
|
||||
welcomeText:
|
||||
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
|
||||
discover: "Je découvre",
|
||||
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
|
||||
knowRules: "Je connais les règles",
|
||||
knowRulesDetail: "Une partie tout de suite",
|
||||
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
|
||||
|
||||
// Accueil
|
||||
homeLink: "Accueil",
|
||||
homeTitle: "À vous de jouer",
|
||||
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
|
||||
gameOverline: "Partie en cours",
|
||||
noGameOverline: "Pas de partie en cours",
|
||||
againstComputer: "Contre l'ordinateur",
|
||||
homePips: (player: number, computer: number) =>
|
||||
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
|
||||
homeLoading: "Chargement…",
|
||||
resumeGame: "Reprendre la partie",
|
||||
rulesTitle: "Les règles du jeu",
|
||||
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
|
||||
rulesStart: "Commencer",
|
||||
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")}`,
|
||||
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
|
||||
|
||||
// Installation
|
||||
installTitle: "Installer Ustagammon",
|
||||
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
|
||||
install: "Installer",
|
||||
installLater: "Plus tard",
|
||||
installMenu: "Installer l'application",
|
||||
installLead:
|
||||
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
|
||||
installSteps: {
|
||||
firefox: [
|
||||
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
|
||||
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
|
||||
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
ios: [
|
||||
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
|
||||
"Choisissez « Sur l'écran d'accueil ».",
|
||||
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
|
||||
],
|
||||
} satisfies Record<InstallPlatform, readonly string[]>,
|
||||
installUnderstood: "J'ai compris",
|
||||
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
|
||||
|
||||
// Mise à jour
|
||||
updateAvailable: "Nouvelle version prête.",
|
||||
updateApply: "Mettre à jour",
|
||||
updateCheck: "Rechercher une mise à jour",
|
||||
updateChecking: "Recherche d'une mise à jour…",
|
||||
updateLatest: "Vous avez la dernière version.",
|
||||
updateUnavailable:
|
||||
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
|
||||
|
||||
// Tutoriel
|
||||
tutorialMenu: "Les règles (tutoriel)",
|
||||
tutorialProgress: "Étapes du tutoriel",
|
||||
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
|
||||
tutorialSkip: "Quitter",
|
||||
tutorialSkipLabel: "Quitter le tutoriel",
|
||||
tutorialOverline: (n: number, total: number, practice: boolean) =>
|
||||
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
|
||||
tutorialEndOverline: "Les règles · fin",
|
||||
tutorialEndTitle: "Vous connaissez les règles",
|
||||
back: "Retour",
|
||||
next: "Continuer",
|
||||
replay: "Revoir",
|
||||
replayLabel: "Revoir le coup",
|
||||
take: "Prendre",
|
||||
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",
|
||||
/** 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.",
|
||||
computerDoubles: "L'ordinateur double.",
|
||||
|
||||
// Aide
|
||||
helpTutorialTitle: "Nouveau au backgammon ?",
|
||||
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
|
||||
helpTutorialLink: "Apprendre les règles",
|
||||
helpIntro:
|
||||
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
|
||||
seeOnBoard: "Voir sur le plateau",
|
||||
glossarySearch: "Chercher un mot",
|
||||
glossaryNone: "Aucun mot ne correspond.",
|
||||
glossarySeeAlso: "Voir aussi :",
|
||||
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
|
||||
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.",
|
||||
"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.",
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,242 @@
|
||||
// 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
|
||||
// (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.
|
||||
|
||||
import type { SideBoards } from "../domain/game/game.js";
|
||||
import { boardModel, OPENING_BOARDS } from "./board-model.js";
|
||||
import { NO_MARKS, renderBoard } from "./board-view.js";
|
||||
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 { TUTORIAL_DONE } from "./settings.js";
|
||||
import { TEXTS } from "./texts.js";
|
||||
|
||||
export interface HomeViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
/** « Nouvelle partie » (bouton : une action, pas un écran). */
|
||||
readonly newGame: () => void;
|
||||
readonly install: () => void;
|
||||
readonly dismissInstall: () => void;
|
||||
/** Un des deux choix du premier lancement a été suivi. */
|
||||
readonly welcomed: () => void;
|
||||
}
|
||||
|
||||
export interface HomeState {
|
||||
readonly model: HomeModel;
|
||||
/** Le plateau de la partie en cours ou de la dernière partie (null :
|
||||
* jamais joué). */
|
||||
readonly sides: SideBoards | null;
|
||||
/** La dernière étape du tutoriel vue. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Parties jouées et gagnées (null : pas encore lues). */
|
||||
readonly progress: { readonly played: number; readonly won: number } | null;
|
||||
/** Le moteur se charge, ou l'écran est occupé. */
|
||||
readonly busy: boolean;
|
||||
}
|
||||
|
||||
export interface HomeView {
|
||||
render(state: HomeState): void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
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 boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = id;
|
||||
svg.setAttribute("class", `board ${className}`);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
parent.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function logo(parent: HTMLElement): void {
|
||||
for (const [variant, file] of [
|
||||
["logo-light", "logo-horizontal-couleur.svg"],
|
||||
["logo-dark", "logo-horizontal-sombre.svg"],
|
||||
] as const) {
|
||||
const img = element(parent, "img", { className: variant });
|
||||
img.src = `icons/${file}`;
|
||||
img.alt = "Ustagammon";
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
|
||||
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
|
||||
...NO_MARKS,
|
||||
dice: [],
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Une grande carte-lien du premier lancement : le choix et son détail. */
|
||||
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
|
||||
const link = element(parent, "a", { id, className: "choice" });
|
||||
link.href = href;
|
||||
element(link, "strong", { text: label });
|
||||
element(link, "span", { text: detail });
|
||||
return link;
|
||||
}
|
||||
|
||||
export function createHomeView(options: HomeViewOptions): HomeView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
|
||||
// Premier lancement.
|
||||
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
|
||||
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
|
||||
logo(welcomeLogo);
|
||||
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
|
||||
const welcomeText = element(welcome, "div", { className: "welcome-text" });
|
||||
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
|
||||
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
|
||||
const choices = element(welcome, "div", { className: "welcome-choices" });
|
||||
const discover = choice(
|
||||
choices,
|
||||
"welcome-discover",
|
||||
WELCOME_LINKS.discover,
|
||||
HOME_TEXTS.discover,
|
||||
HOME_TEXTS.discoverDetail,
|
||||
);
|
||||
discover.classList.add("primary");
|
||||
const play = choice(
|
||||
choices,
|
||||
"welcome-play",
|
||||
WELCOME_LINKS.play,
|
||||
HOME_TEXTS.knowRules,
|
||||
HOME_TEXTS.knowRulesDetail,
|
||||
);
|
||||
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
|
||||
for (const link of [discover, play]) {
|
||||
link.addEventListener("click", () => options.welcomed());
|
||||
}
|
||||
|
||||
// Ensuite.
|
||||
const main = element(section, "div", { id: "home-main", className: "home-main" });
|
||||
const intro = element(main, "div", { className: "home-intro" });
|
||||
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
|
||||
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
|
||||
|
||||
const card = element(main, "article", { id: "home-game", className: "card home-game" });
|
||||
const cardTop = element(card, "div", { className: "home-game-top" });
|
||||
const miniBox = element(cardTop, "div", { className: "home-mini" });
|
||||
const mini = boardSvg(miniBox, "home-board", "home-board");
|
||||
const facts = element(cardTop, "div", { className: "home-facts" });
|
||||
const gameOverline = element(facts, "span", { className: "overline" });
|
||||
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
|
||||
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
|
||||
const actions = element(card, "div", { className: "home-actions" });
|
||||
const resume = element(actions, "a", {
|
||||
id: "home-resume",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.resumeGame,
|
||||
});
|
||||
resume.href = "#/";
|
||||
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
|
||||
fresh.type = "button";
|
||||
fresh.addEventListener("click", () => options.newGame());
|
||||
|
||||
const install = element(main, "aside", { id: "install-card", className: "install-card" });
|
||||
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
|
||||
const symbol = element(install, "img", { className: "install-symbol" });
|
||||
symbol.src = "icons/symbole-couleur.svg";
|
||||
symbol.alt = "";
|
||||
const installText = element(install, "span", { className: "install-text" });
|
||||
element(installText, "strong", { text: HOME_TEXTS.installTitle });
|
||||
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
|
||||
const installButton = element(install, "button", {
|
||||
id: "install-card-button",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.install,
|
||||
});
|
||||
installButton.type = "button";
|
||||
installButton.addEventListener("click", () => options.install());
|
||||
const later = element(install, "button", { id: "install-later", className: "icon-button" });
|
||||
later.type = "button";
|
||||
later.append(icon("close"));
|
||||
later.setAttribute("aria-label", HOME_TEXTS.installLater);
|
||||
later.title = HOME_TEXTS.installLater;
|
||||
later.addEventListener("click", () => options.dismissInstall());
|
||||
|
||||
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
|
||||
const rulesText = element(rules, "span", { className: "home-rules-text" });
|
||||
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
|
||||
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
|
||||
const rulesAction = element(rules, "span", { className: "pill" });
|
||||
|
||||
const progress = element(main, "p", {
|
||||
id: "home-progress",
|
||||
className: "caption home-progress",
|
||||
});
|
||||
const footer = element(main, "footer", { className: "home-footer" });
|
||||
const footerSymbol = element(footer, "img");
|
||||
footerSymbol.src = "icons/symbole-couleur.svg";
|
||||
footerSymbol.alt = "";
|
||||
element(footer, "span", { text: HOME_TEXTS.footer });
|
||||
|
||||
return {
|
||||
render(state) {
|
||||
const { model } = state;
|
||||
welcome.hidden = !model.welcome;
|
||||
main.hidden = model.welcome;
|
||||
if (model.welcome) {
|
||||
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
|
||||
return;
|
||||
}
|
||||
const sides = state.sides ?? OPENING_BOARDS;
|
||||
drawBoard(mini, sides, miniBox.clientWidth || 132);
|
||||
gameOverline.textContent = model.resume
|
||||
? HOME_TEXTS.gameOverline
|
||||
: HOME_TEXTS.noGameOverline;
|
||||
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
|
||||
// Une seule action principale : « Reprendre la partie », ou
|
||||
// « Nouvelle partie » quand il n'y a rien à reprendre.
|
||||
resume.hidden = !model.resume;
|
||||
fresh.textContent = TEXTS.newGame;
|
||||
fresh.classList.toggle("secondary", model.resume);
|
||||
fresh.disabled = state.busy;
|
||||
install.hidden = !model.installCard;
|
||||
|
||||
const seen = state.tutorialSeen;
|
||||
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
|
||||
rulesAction.textContent =
|
||||
seen === 0
|
||||
? HOME_TEXTS.rulesStart
|
||||
: 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,9 +18,13 @@ export const ICON_PATHS = {
|
||||
chevron: "M9 6l6 6-6 6",
|
||||
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
|
||||
newGame: "M12 5v14M5 12h14",
|
||||
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
|
||||
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
|
||||
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
|
||||
screenOn: "M7 3h10v18H7zM11 18h2",
|
||||
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",
|
||||
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,82 @@
|
||||
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
|
||||
// aide.md, « Installation ») : l'invite du système quand le navigateur en
|
||||
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
|
||||
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
|
||||
// l'application installée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
|
||||
export type InstallPlatform = "ios" | "firefox";
|
||||
|
||||
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
|
||||
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
|
||||
export function installPlatform(userAgent: string): InstallPlatform {
|
||||
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
|
||||
}
|
||||
|
||||
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
|
||||
interface InstallPromptEvent extends Event {
|
||||
readonly prompt: () => Promise<void>;
|
||||
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export interface Installer {
|
||||
/** L'application tourne installée, ou vient de l'être. */
|
||||
readonly installed: boolean;
|
||||
/** Le navigateur a donné son invite d'installation. */
|
||||
readonly canPrompt: boolean;
|
||||
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
|
||||
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
|
||||
* empêche le navigateur de l'afficher de lui-même), et note l'installation
|
||||
* (display-mode: standalone, événement appinstalled). onChange est appelé à
|
||||
* chaque changement.
|
||||
*/
|
||||
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
|
||||
const standalone = window.matchMedia("(display-mode: standalone)");
|
||||
let installedNow = false;
|
||||
let deferred: InstallPromptEvent | null = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
event.preventDefault();
|
||||
deferred = event as InstallPromptEvent;
|
||||
onChange(false);
|
||||
});
|
||||
window.addEventListener("appinstalled", () => {
|
||||
installedNow = true;
|
||||
deferred = null;
|
||||
onChange(true);
|
||||
});
|
||||
standalone.addEventListener("change", () => onChange(false));
|
||||
|
||||
return {
|
||||
get installed() {
|
||||
return installedNow || standalone.matches;
|
||||
},
|
||||
get canPrompt() {
|
||||
return deferred !== null;
|
||||
},
|
||||
async offer() {
|
||||
const event = deferred;
|
||||
if (event === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
// Une invite ne sert qu'une fois.
|
||||
deferred = null;
|
||||
try {
|
||||
await event.prompt();
|
||||
const choice = await event.userChoice;
|
||||
return choice.outcome;
|
||||
} catch {
|
||||
return "unavailable";
|
||||
} finally {
|
||||
onChange(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
|
||||
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
|
||||
// descriptions des niveaux sont ceux de la charte ; le reste est une
|
||||
// première version, à valider par Pierre.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { ComputerLevel } from "../domain/game/level.js";
|
||||
|
||||
function points(n: number): string {
|
||||
return n > 1 ? `${n} points` : `${n} point`;
|
||||
}
|
||||
|
||||
export const MATCH_TEXTS = {
|
||||
// Écran « Nouvelle partie »
|
||||
title: "Nouvelle partie",
|
||||
close: "Fermer sans rien changer",
|
||||
kindLabel: "Type de partie",
|
||||
free: "Partie libre",
|
||||
match: "Match",
|
||||
/** « Le premier à [3] [5] [7] [11] points ». */
|
||||
lengthBefore: "Le premier à",
|
||||
lengthAfter: "points",
|
||||
lengthLabel: (n: number) => `Match en ${n} points`,
|
||||
crawfordRule:
|
||||
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
|
||||
crawfordLink: "règle de Crawford",
|
||||
jacobyRule:
|
||||
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
|
||||
jacobyLink: "règle de Jacoby",
|
||||
levelTitle: "Niveau de l'ordinateur",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Débutant",
|
||||
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
|
||||
},
|
||||
amateur: {
|
||||
name: "Amateur",
|
||||
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
|
||||
},
|
||||
intermediate: {
|
||||
name: "Intermédiaire",
|
||||
text: "Un habitué de la table : solide, sans être implacable.",
|
||||
},
|
||||
advanced: {
|
||||
name: "Avancé",
|
||||
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
|
||||
},
|
||||
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
|
||||
champion: {
|
||||
name: "Champion",
|
||||
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
|
||||
},
|
||||
} satisfies Record<ComputerLevel, { name: string; text: string }>,
|
||||
duringGame: "Pendant la partie",
|
||||
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`,
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
/** « Match en 5 points », et « · partie Crawford ». */
|
||||
matchLine: (length: number, crawford: boolean) =>
|
||||
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
|
||||
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
|
||||
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
|
||||
/** Ce que lit le lecteur d'écran. */
|
||||
scoreSpoken: (length: number, computer: number, player: number) =>
|
||||
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
|
||||
/** Paysage : « sur 5 ». */
|
||||
outOf: (length: number) => `sur ${length}`,
|
||||
playerScore: (n: number) => `Votre score : ${n}`,
|
||||
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
|
||||
|
||||
// Fin d'une partie
|
||||
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
|
||||
* après un match). */
|
||||
replay: "Rejouer",
|
||||
changeSettings: "Changer les réglages",
|
||||
nextGame: "Partie suivante",
|
||||
nextGameStarted: "Partie suivante du match.",
|
||||
matchWonTitle: (winner: "player" | "computer") =>
|
||||
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
|
||||
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
|
||||
matchDetail: (
|
||||
winner: "player" | "computer",
|
||||
n: number,
|
||||
length: number,
|
||||
player: number,
|
||||
computer: number,
|
||||
over: boolean,
|
||||
) =>
|
||||
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
|
||||
over ? "Score final" : `Match en ${length} points`
|
||||
} : vous ${player}, l'ordinateur ${computer}.`,
|
||||
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
|
||||
};
|
||||
@@ -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++;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// La navigation entre les écrans : l'adresse de la page est la source de
|
||||
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
|
||||
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
|
||||
// dessine l'écran que désigne l'adresse.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
|
||||
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
|
||||
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
|
||||
// l'onglet garde, dans history.state, la suite des adresses visitées
|
||||
// jusqu'à elle. Elle survit au rechargement de la page.
|
||||
|
||||
import { parseRoute, type Route, stepsBack } from "./router.js";
|
||||
|
||||
/** Au plus autant d'adresses gardées par entrée de l'historique. */
|
||||
const TRAIL_MAX = 50;
|
||||
|
||||
export interface Navigation {
|
||||
/** L'écran que désigne l'adresse actuelle. */
|
||||
readonly route: Route;
|
||||
/** Va à une adresse, comme un lien (empile une entrée). */
|
||||
go(hash: string): void;
|
||||
/** Remplace l'adresse actuelle, sans empiler. */
|
||||
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;
|
||||
}
|
||||
|
||||
interface TrailState {
|
||||
readonly ustagammonTrail: readonly string[];
|
||||
}
|
||||
|
||||
function trailOf(state: unknown): readonly string[] | null {
|
||||
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
|
||||
return Array.isArray(trail) ? trail : null;
|
||||
}
|
||||
|
||||
/** Le fragment actuel, « #/ » s'il est vide. */
|
||||
function currentHash(): string {
|
||||
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche la navigation sur la page : onChange est appelé à chaque
|
||||
* changement d'adresse (pas au démarrage : l'appelant lit route).
|
||||
*/
|
||||
export function startNavigation(onChange: (route: Route) => void): Navigation {
|
||||
let trail: readonly string[] = [];
|
||||
// Un retour dans l'historique est en cours (history.go est asynchrone) :
|
||||
// une adresse demandée entre-temps attend son arrivée.
|
||||
let leaving: ReturnType<typeof setTimeout> | null = null;
|
||||
let queued: string | null = null;
|
||||
|
||||
const remember = (next: readonly string[]) => {
|
||||
trail = next.slice(-TRAIL_MAX);
|
||||
try {
|
||||
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
|
||||
} catch {
|
||||
// Historique indisponible (page dans un cadre) : la navigation
|
||||
// marche quand même, « Retour » ramène alors à la partie.
|
||||
}
|
||||
};
|
||||
|
||||
// L'entrée actuelle : déjà connue (rechargement), ou la première.
|
||||
const known = trailOf(history.state);
|
||||
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const hash = currentHash();
|
||||
const saved = trailOf(history.state);
|
||||
if (saved !== null && saved.at(-1) === hash) {
|
||||
// Retour ou avance dans l'historique : une entrée déjà connue.
|
||||
trail = saved;
|
||||
} else {
|
||||
// Un lien suivi : une nouvelle entrée.
|
||||
remember([...trail, hash]);
|
||||
}
|
||||
onChange(parseRoute(hash));
|
||||
if (leaving !== null) {
|
||||
clearTimeout(leaving);
|
||||
leaving = null;
|
||||
const next = queued;
|
||||
queued = null;
|
||||
if (next !== null) {
|
||||
go(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function go(hash: string): void {
|
||||
if (leaving !== null) {
|
||||
queued = hash;
|
||||
} else if (hash !== currentHash()) {
|
||||
location.hash = hash;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(hash: string): void {
|
||||
if (hash === currentHash()) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(location.href);
|
||||
url.hash = hash;
|
||||
const next = [...trail.slice(0, -1), hash];
|
||||
try {
|
||||
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
|
||||
} catch {
|
||||
location.replace(url);
|
||||
return;
|
||||
}
|
||||
trail = next;
|
||||
// replaceState ne déclenche pas hashchange.
|
||||
onChange(parseRoute(hash));
|
||||
}
|
||||
|
||||
return {
|
||||
get route() {
|
||||
return parseRoute(currentHash());
|
||||
},
|
||||
go,
|
||||
replace,
|
||||
leave(stay) {
|
||||
if (leaving !== null) {
|
||||
return; // déjà en route
|
||||
}
|
||||
const steps = stepsBack(trail, stay);
|
||||
if (steps === null) {
|
||||
replace("#/");
|
||||
return;
|
||||
}
|
||||
// Au cas où le navigateur ne reviendrait pas (historique
|
||||
// indisponible), on n'attend pas plus d'une seconde.
|
||||
leaving = setTimeout(() => {
|
||||
leaving = null;
|
||||
queued = null;
|
||||
}, 1000);
|
||||
history.go(steps);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
|
||||
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
|
||||
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
|
||||
* du milieu ou une touche, il reste un lien ordinaire.
|
||||
*/
|
||||
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigation.replace(new URL(link.href).hash);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
|
||||
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
|
||||
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
|
||||
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
|
||||
// en cours ; seul « Commencer » en lance une.
|
||||
|
||||
import { COMPUTER_LEVELS } from "../domain/game/level.js";
|
||||
import { MATCH_LENGTHS } from "../domain/game/match.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { routeHash } from "./router.js";
|
||||
import type { Settings } from "./settings.js";
|
||||
|
||||
export interface NewGameViewOptions {
|
||||
readonly section: HTMLElement;
|
||||
readonly settings: () => Settings;
|
||||
/** Un choix a changé : le garder. */
|
||||
readonly change: (changes: Partial<Settings>) => void;
|
||||
/** « Commencer la partie » ou « Commencer le match ». */
|
||||
readonly start: () => void;
|
||||
}
|
||||
|
||||
export interface NewGameView {
|
||||
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
|
||||
readonly closeLink: HTMLAnchorElement;
|
||||
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
|
||||
render(busy: boolean): void;
|
||||
/** Le premier élément à recevoir le focus à l'ouverture. */
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
|
||||
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
|
||||
const button = element(parent, "button", {
|
||||
id,
|
||||
className,
|
||||
...(text === undefined ? {} : { text }),
|
||||
});
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "radio");
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
|
||||
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
|
||||
const p = element(parent, "p", { id, className: "new-game-rule" });
|
||||
p.append(`${text} `);
|
||||
const a = element(p, "a", { text: link });
|
||||
a.href = routeHash({ screen: "glossary", term });
|
||||
p.append(".");
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
const { section } = options;
|
||||
section.replaceChildren();
|
||||
section.setAttribute("aria-labelledby", "new-game-title");
|
||||
|
||||
const head = element(section, "div", { className: "new-game-head" });
|
||||
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
|
||||
closeLink.href = "#/";
|
||||
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
|
||||
closeLink.append(icon("close"));
|
||||
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
|
||||
|
||||
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
|
||||
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
|
||||
const body = element(section, "div", { className: "new-game-body" });
|
||||
const kindColumn = element(body, "div", { className: "new-game-column" });
|
||||
const levelColumn = element(body, "div", { className: "new-game-column" });
|
||||
|
||||
// 1. Type de partie.
|
||||
const kinds = element(kindColumn, "div", { className: "segmented" });
|
||||
kinds.setAttribute("role", "radiogroup");
|
||||
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
|
||||
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
|
||||
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
|
||||
free.addEventListener("click", () => options.change({ gameKind: "free" }));
|
||||
match.addEventListener("click", () => options.change({ gameKind: "match" }));
|
||||
|
||||
const matchOptions = element(kindColumn, "div", {
|
||||
id: "new-game-match-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
const lengths = element(matchOptions, "div", { className: "lengths" });
|
||||
lengths.setAttribute("role", "radiogroup");
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
|
||||
const lengthButtons = MATCH_LENGTHS.map((n) => {
|
||||
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
|
||||
button.addEventListener("click", () => options.change({ matchLength: n }));
|
||||
return button;
|
||||
});
|
||||
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
|
||||
rule(
|
||||
matchOptions,
|
||||
"new-game-crawford",
|
||||
MATCH_TEXTS.crawfordRule,
|
||||
MATCH_TEXTS.crawfordLink,
|
||||
"regle-de-crawford",
|
||||
);
|
||||
const freeOptions = element(kindColumn, "div", {
|
||||
id: "new-game-free-options",
|
||||
className: "new-game-options",
|
||||
});
|
||||
rule(
|
||||
freeOptions,
|
||||
"new-game-jacoby",
|
||||
MATCH_TEXTS.jacobyRule,
|
||||
MATCH_TEXTS.jacobyLink,
|
||||
"regle-de-jacoby",
|
||||
);
|
||||
|
||||
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
|
||||
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
|
||||
const bars = element(levelColumn, "div", { className: "level-bars" });
|
||||
bars.setAttribute("role", "radiogroup");
|
||||
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
|
||||
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
|
||||
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
|
||||
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
|
||||
button.dataset.level = level;
|
||||
element(button, "span", { className: "level-bar" });
|
||||
button.addEventListener("click", () => options.change({ level }));
|
||||
return button;
|
||||
});
|
||||
const levelName = element(levelColumn, "p", {
|
||||
id: "new-game-level-name",
|
||||
className: "level-name",
|
||||
});
|
||||
const levelText = element(levelColumn, "p", {
|
||||
id: "new-game-level-text",
|
||||
className: "level-text",
|
||||
});
|
||||
|
||||
// 3. Pendant la partie.
|
||||
element(levelColumn, "h3", {
|
||||
className: "overline during-game",
|
||||
text: MATCH_TEXTS.duringGame,
|
||||
});
|
||||
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
|
||||
advice.type = "button";
|
||||
advice.setAttribute("role", "switch");
|
||||
const adviceLabel = element(advice, "span", { className: "item-label" });
|
||||
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
|
||||
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
|
||||
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
|
||||
advice.addEventListener("click", () =>
|
||||
options.change({ masterAdvice: !options.settings().masterAdvice }),
|
||||
);
|
||||
|
||||
// 4. Barre d'action collée en bas.
|
||||
const actions = element(section, "div", { className: "new-game-actions" });
|
||||
const start = element(actions, "button", { id: "new-game-start" });
|
||||
start.type = "button";
|
||||
start.addEventListener("click", () => options.start());
|
||||
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
|
||||
|
||||
return {
|
||||
closeLink,
|
||||
render(busy) {
|
||||
const s = options.settings();
|
||||
const isMatch = s.gameKind === "match";
|
||||
free.setAttribute("aria-checked", String(!isMatch));
|
||||
match.setAttribute("aria-checked", String(isMatch));
|
||||
matchOptions.hidden = !isMatch;
|
||||
freeOptions.hidden = isMatch;
|
||||
for (const [i, button] of lengthButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
|
||||
}
|
||||
const chosen = COMPUTER_LEVELS.indexOf(s.level);
|
||||
for (const [i, button] of levelButtons.entries()) {
|
||||
button.setAttribute("aria-checked", String(i === chosen));
|
||||
button.classList.toggle("reached", i <= chosen);
|
||||
}
|
||||
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.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,99 @@
|
||||
// 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 % ». */
|
||||
export function percent(chance: number): string {
|
||||
return `${Math.round(chance * 100)} %`;
|
||||
}
|
||||
|
||||
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
|
||||
export function signed(value: number): string {
|
||||
const text = Math.abs(value).toFixed(3).replace(".", ",");
|
||||
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,264 @@
|
||||
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
|
||||
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
|
||||
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
|
||||
// testées sous Node (app/tests/router.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** Un écran de l'application, tel que le désigne son adresse. */
|
||||
export type Route =
|
||||
/** #/ : la partie en cours. */
|
||||
| { readonly screen: "game" }
|
||||
/** #/accueil. */
|
||||
| { 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 }
|
||||
/** #/a-propos. */
|
||||
| { readonly screen: "about" }
|
||||
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
|
||||
* #/tutoriel/<n>/a-vous. */
|
||||
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
|
||||
/** #/tutoriel/fin : la fin du tutoriel. */
|
||||
| { readonly screen: "tutorialEnd" }
|
||||
/** #/installer : la marche à suivre pour installer l'application. */
|
||||
| { readonly screen: "install" }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { readonly screen: "games"; readonly game: string | null }
|
||||
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
|
||||
| { readonly screen: "summary"; readonly game: string }
|
||||
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
|
||||
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
|
||||
* #/parties/<identifiant>/analyse. */
|
||||
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
|
||||
| { readonly screen: "newGame" }
|
||||
/** #/explication/<événement> : la feuille d'explication du maître sur
|
||||
* une décision de la partie en cours (étape 1d). */
|
||||
| { readonly screen: "explanation"; readonly event: string }
|
||||
/** #/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. */
|
||||
| { readonly screen: "unknown"; readonly hash: string };
|
||||
|
||||
export type Screen = Route["screen"];
|
||||
|
||||
export const GAME_ROUTE: Route = { screen: "game" };
|
||||
|
||||
/** Longueur d'un Position ID de gnubg. */
|
||||
const POSITION_ID_LENGTH = 14;
|
||||
|
||||
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
|
||||
* chiffres et tirets. */
|
||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** L'écran que désigne un fragment (avec ou sans # au début). */
|
||||
export function parseRoute(hash: string): Route {
|
||||
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
if (fragment === "" || fragment === "/") {
|
||||
return GAME_ROUTE;
|
||||
}
|
||||
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
|
||||
if (!fragment.startsWith("/")) {
|
||||
return unknown;
|
||||
}
|
||||
const [head = "", ...rest] = fragment.slice(1).split("/");
|
||||
// Une barre finale (#/aide/) ne change rien.
|
||||
if (rest.at(-1) === "") {
|
||||
rest.pop();
|
||||
}
|
||||
if (head === "position") {
|
||||
return positionRoute(rest.join("/")) ?? unknown;
|
||||
}
|
||||
if (head === "tutoriel") {
|
||||
return tutorialRoute(rest) ?? unknown;
|
||||
}
|
||||
if (head === "parties" && rest.length > 1) {
|
||||
return gameReviewRoute(rest) ?? unknown;
|
||||
}
|
||||
if (rest.length > 1) {
|
||||
return unknown;
|
||||
}
|
||||
const name = rest[0] ?? null;
|
||||
if (name !== null && !NAME.test(name)) {
|
||||
return unknown;
|
||||
}
|
||||
switch (head) {
|
||||
case "":
|
||||
return name === null ? GAME_ROUTE : unknown;
|
||||
case "accueil":
|
||||
return name === null ? { screen: "home" } : unknown;
|
||||
case "aide":
|
||||
return { screen: "help", question: name };
|
||||
case "glossaire":
|
||||
return { screen: "glossary", term: name };
|
||||
case "a-propos":
|
||||
return name === null ? { screen: "about" } : unknown;
|
||||
case "menu":
|
||||
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":
|
||||
return name === null ? { screen: "install" } : unknown;
|
||||
default:
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/** #/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;
|
||||
if (name === undefined) {
|
||||
return { screen: "tutorial", step: null, practice: false };
|
||||
}
|
||||
if (more.length > 0) {
|
||||
return null;
|
||||
}
|
||||
if (name === "fin") {
|
||||
return part === undefined ? { screen: "tutorialEnd" } : null;
|
||||
}
|
||||
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
|
||||
return null;
|
||||
}
|
||||
return { screen: "tutorial", 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
|
||||
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
|
||||
* comprise aussi : le texte est décodé en entier, puis découpé à la
|
||||
* longueur du Position ID. Leur validité est l'affaire du moteur.
|
||||
*/
|
||||
function positionRoute(text: string): Route | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === "") {
|
||||
return null;
|
||||
}
|
||||
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
|
||||
const match = decoded.slice(POSITION_ID_LENGTH + 1);
|
||||
return {
|
||||
screen: "position",
|
||||
position: decoded.slice(0, POSITION_ID_LENGTH),
|
||||
match: match === "" ? null : match,
|
||||
};
|
||||
}
|
||||
return { screen: "position", position: decoded, match: null };
|
||||
}
|
||||
|
||||
/** Le fragment d'un écran, # compris. */
|
||||
export function routeHash(route: Route): string {
|
||||
switch (route.screen) {
|
||||
case "game":
|
||||
return "#/";
|
||||
case "home":
|
||||
return "#/accueil";
|
||||
case "help":
|
||||
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
|
||||
case "glossary":
|
||||
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
|
||||
case "about":
|
||||
return "#/a-propos";
|
||||
case "tutorial":
|
||||
return route.step === null
|
||||
? "#/tutoriel"
|
||||
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
|
||||
case "tutorialEnd":
|
||||
return "#/tutoriel/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
|
||||
? position
|
||||
: `${position}/${encodeURIComponent(route.match)}`;
|
||||
}
|
||||
case "unknown":
|
||||
return route.hash;
|
||||
}
|
||||
}
|
||||
|
||||
/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */
|
||||
export type HelpRoute = Extract<Route, { screen: "help" | "glossary" | "about" }>;
|
||||
|
||||
/** L'adresse est-elle celle d'un écran de l'aide ? */
|
||||
export function isHelpScreen(route: Route): route is HelpRoute {
|
||||
return route.screen === "help" || route.screen === "glossary" || route.screen === "about";
|
||||
}
|
||||
|
||||
/** L'identifiant d'une question de l'aide : son titre, en minuscules sans
|
||||
* accents, les mots séparés par des tirets. */
|
||||
export function slug(title: string): string {
|
||||
return title
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Où revenir en quittant un écran (« Retour », Échap, la croix d'une
|
||||
* feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas
|
||||
* de cet écran. trail est la suite des adresses de l'onglet jusqu'à
|
||||
* l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou
|
||||
* null si aucune adresse ne convient : il faut alors remplacer l'adresse
|
||||
* par celle de la partie, sans rien empiler.
|
||||
*/
|
||||
export function stepsBack(
|
||||
trail: readonly string[],
|
||||
stay: (route: Route) => boolean,
|
||||
): number | null {
|
||||
for (let i = trail.length - 2; i >= 0; i--) {
|
||||
const hash = trail[i];
|
||||
if (hash !== undefined && !stay(parseRoute(hash))) {
|
||||
return i - (trail.length - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+124
-5
@@ -3,6 +3,14 @@
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
|
||||
// toujours une valeur par défaut, et un réglage enregistré par une version
|
||||
// précédente se relit sans perte.
|
||||
|
||||
import type { NewGame } from "../domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, type ComputerLevel, ENGINE_LEVELS } from "../domain/game/level.js";
|
||||
import { firstGameOf, MATCH_LENGTHS } from "../domain/game/match.js";
|
||||
|
||||
export type Theme = "auto" | "light" | "dark";
|
||||
|
||||
@@ -15,18 +23,60 @@ export interface Settings {
|
||||
/** Parties commencées : la consigne est longue pendant les trois
|
||||
* premières, courte ensuite. */
|
||||
readonly gamesStarted: number;
|
||||
/** Parties terminées (l'invitation à installer vient après la
|
||||
* première). */
|
||||
readonly gamesFinished: number;
|
||||
/** Le choix du premier lancement (« Je découvre », « Je connais les
|
||||
* règles ») a été fait : il ne revient plus. */
|
||||
readonly welcomed: boolean;
|
||||
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
|
||||
* la fin). La barre de progression ne mène qu'aux étapes vues. */
|
||||
readonly tutorialSeen: number;
|
||||
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
|
||||
* le nombre de parties terminées à la dernière fois. */
|
||||
readonly installDismissals: number;
|
||||
readonly installDismissedAt: number;
|
||||
/** L'application a été installée (événement appinstalled). */
|
||||
readonly installed: boolean;
|
||||
/** Les choix de l'écran « Nouvelle partie », gardés pour la suivante :
|
||||
* partie libre ou match, longueur du match, niveau de l'ordinateur,
|
||||
* conseil du maître. */
|
||||
readonly gameKind: GameKind;
|
||||
readonly matchLength: number;
|
||||
readonly level: ComputerLevel;
|
||||
readonly masterAdvice: boolean;
|
||||
}
|
||||
|
||||
/** Partie libre ou match. */
|
||||
export type GameKind = "free" | "match";
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
vibrations: true,
|
||||
keepScreenOn: true,
|
||||
theme: "auto",
|
||||
gamesStarted: 0,
|
||||
gamesFinished: 0,
|
||||
welcomed: false,
|
||||
tutorialSeen: 0,
|
||||
installDismissals: 0,
|
||||
installDismissedAt: 0,
|
||||
installed: false,
|
||||
gameKind: "free",
|
||||
matchLength: 5,
|
||||
level: "intermediate",
|
||||
masterAdvice: true,
|
||||
};
|
||||
|
||||
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
|
||||
export const LONG_PROMPT_GAMES = 3;
|
||||
|
||||
/** tutorialSeen quand la fin du tutoriel a été vue. */
|
||||
export const TUTORIAL_DONE = 99;
|
||||
|
||||
/** Parties terminées de plus avant que l'invitation à installer, masquée
|
||||
* par « Plus tard », revienne (une seule fois). */
|
||||
export const INSTALL_AGAIN_AFTER = 3;
|
||||
|
||||
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
|
||||
* valeur illisible garde sa valeur par défaut. */
|
||||
export function parseSettings(text: string | null): Settings {
|
||||
@@ -39,19 +89,54 @@ export function parseSettings(text: string | null): Settings {
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (key: "vibrations" | "keepScreenOn") =>
|
||||
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
|
||||
const bool = (
|
||||
key: "vibrations" | "keepScreenOn" | "welcomed" | "installed" | "masterAdvice",
|
||||
) => (typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]);
|
||||
const count = (
|
||||
key:
|
||||
| "gamesStarted"
|
||||
| "gamesFinished"
|
||||
| "tutorialSeen"
|
||||
| "installDismissals"
|
||||
| "installDismissedAt",
|
||||
) => {
|
||||
const value = raw[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
||||
? value
|
||||
: DEFAULT_SETTINGS[key];
|
||||
};
|
||||
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
|
||||
const games = raw.gamesStarted;
|
||||
return {
|
||||
vibrations: bool("vibrations"),
|
||||
keepScreenOn: bool("keepScreenOn"),
|
||||
theme,
|
||||
gamesStarted:
|
||||
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
|
||||
gamesStarted: count("gamesStarted"),
|
||||
gamesFinished: count("gamesFinished"),
|
||||
welcomed: bool("welcomed"),
|
||||
tutorialSeen: count("tutorialSeen"),
|
||||
installDismissals: count("installDismissals"),
|
||||
installDismissedAt: count("installDismissedAt"),
|
||||
installed: bool("installed"),
|
||||
gameKind: raw.gameKind === "match" ? "match" : "free",
|
||||
matchLength: (MATCH_LENGTHS as readonly unknown[]).includes(raw.matchLength)
|
||||
? (raw.matchLength as number)
|
||||
: DEFAULT_SETTINGS.matchLength,
|
||||
level: (COMPUTER_LEVELS as readonly unknown[]).includes(raw.level)
|
||||
? (raw.level as ComputerLevel)
|
||||
: DEFAULT_SETTINGS.level,
|
||||
masterAdvice: bool("masterAdvice"),
|
||||
};
|
||||
}
|
||||
|
||||
/** La partie que lancent les choix de l'écran « Nouvelle partie » : le
|
||||
* niveau de gnubg du niveau choisi, et la première partie d'un match. */
|
||||
export function chosenGame(settings: Settings): NewGame {
|
||||
const level = ENGINE_LEVELS[settings.level];
|
||||
return settings.gameKind === "match"
|
||||
? { level, match: firstGameOf(settings.matchLength) }
|
||||
: { level };
|
||||
}
|
||||
|
||||
/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */
|
||||
export function nextTheme(theme: Theme): Theme {
|
||||
return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto";
|
||||
@@ -61,3 +146,37 @@ export function nextTheme(theme: Theme): Theme {
|
||||
export function longPrompts(settings: Settings): boolean {
|
||||
return settings.gamesStarted <= LONG_PROMPT_GAMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier lancement : rien n'a encore été fait dans l'application (le choix
|
||||
* d'accueil, une partie terminée, une étape du tutoriel). La partie
|
||||
* commencée d'office au chargement compte pour une : jusqu'à une partie
|
||||
* commencée, c'est encore le premier lancement.
|
||||
*/
|
||||
export function isFirstLaunch(settings: Settings): boolean {
|
||||
return (
|
||||
!settings.welcomed &&
|
||||
settings.gamesStarted <= 1 &&
|
||||
settings.gamesFinished === 0 &&
|
||||
settings.tutorialSeen === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
|
||||
* l'application installée ; à partir de la première partie terminée ;
|
||||
* « Plus tard » la masque, et elle ne revient qu'une fois, après
|
||||
* INSTALL_AGAIN_AFTER parties terminées de plus.
|
||||
*/
|
||||
export function installCardShown(settings: Settings, installed: boolean): boolean {
|
||||
if (installed || settings.installed || settings.gamesFinished < 1) {
|
||||
return false;
|
||||
}
|
||||
if (settings.installDismissals === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
settings.installDismissals === 1 &&
|
||||
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// 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 { 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;
|
||||
}
|
||||
|
||||
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;
|
||||
panel.replaceChildren();
|
||||
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 });
|
||||
// L'emplacement de « Demander à une IA » (étape 1f).
|
||||
const slot = element(links, "div", { className: "ask-ai-slot" });
|
||||
slot.dataset.slot = "ask-ai";
|
||||
}
|
||||
|
||||
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;
|
||||
panel.replaceChildren();
|
||||
delete panel.dataset.ready;
|
||||
drawn = "";
|
||||
}
|
||||
},
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
};
|
||||
}
|
||||
+60
-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"}${
|
||||
@@ -153,8 +156,56 @@ export const TEXTS = {
|
||||
|
||||
// Aide
|
||||
helpTitle: "Aide",
|
||||
glossaryTitle: "Glossaire",
|
||||
helpTabs: "Rubriques de l'aide",
|
||||
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
|
||||
glossaryTitle: "Les mots du jeu",
|
||||
aboutTitle: "À propos",
|
||||
helpLoading: "Chargement de l'aide…",
|
||||
helpFailed: "L'aide n'a pas pu se charger.",
|
||||
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
|
||||
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
|
||||
|
||||
// Adresses (ADR 0013)
|
||||
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
|
||||
|
||||
// Lien vers une position (ADR 0013)
|
||||
copyPositionLink: "Copier le lien de la position",
|
||||
linkCopied: "Lien de la position copié.",
|
||||
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
|
||||
positionTitle: "Position partagée",
|
||||
unreadablePositionTitle: "Lien illisible",
|
||||
unreadablePosition:
|
||||
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
|
||||
backToGame: "Retour à ma partie",
|
||||
playFromHere: "Jouer à partir d'ici",
|
||||
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
|
||||
/** Ce que montre la vue d'une position, sous le plateau. */
|
||||
positionDetail: (
|
||||
pips: Readonly<Record<"player" | "computer", number>>,
|
||||
cube: { value: number; owner: "player" | "computer" | null },
|
||||
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
|
||||
winner: "player" | "computer" | null,
|
||||
) =>
|
||||
[
|
||||
winner === null
|
||||
? "Vous avez la main : vos pions ivoire, en bas."
|
||||
: `La partie est finie dans cette position : ${
|
||||
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
|
||||
}.`,
|
||||
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
|
||||
cube.value > 1 || cube.owner !== null
|
||||
? `Videau à ${cube.value}, ${
|
||||
cube.owner === null
|
||||
? "au centre"
|
||||
: cube.owner === "player"
|
||||
? "chez vous"
|
||||
: "chez l'ordinateur"
|
||||
}.`
|
||||
: "",
|
||||
match === null
|
||||
? ""
|
||||
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
|
||||
]
|
||||
.filter((part) => part !== "")
|
||||
.join(" "),
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
|
||||
// de progression (charte : « les segments sont des liens vers les étapes
|
||||
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Route } from "./router.js";
|
||||
import { TUTORIAL_DONE } from "./settings.js";
|
||||
import type { Tutorial } from "./tutorial-text.js";
|
||||
|
||||
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
|
||||
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
|
||||
|
||||
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
|
||||
* étape hors du tutoriel mène à la première, un « à vous » absent à
|
||||
* l'explication de son étape. */
|
||||
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];
|
||||
if (step === undefined) {
|
||||
return { step: 1, practice: false };
|
||||
}
|
||||
return { step: step.number, practice: route.practice && step.practice !== null };
|
||||
}
|
||||
|
||||
/** L'adresse d'un écran du tutoriel. */
|
||||
export function placeRoute(place: TutorialPlace): Route {
|
||||
return place === "end"
|
||||
? { screen: "tutorialEnd" }
|
||||
: { screen: "tutorial", 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") {
|
||||
return "end";
|
||||
}
|
||||
const step = tutorial.steps[place.step - 1];
|
||||
if (!place.practice && step?.practice != null) {
|
||||
return { step: place.step, practice: true };
|
||||
}
|
||||
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
|
||||
}
|
||||
|
||||
/** L'écran précédent (null avant la première étape). */
|
||||
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
|
||||
if (place === "end") {
|
||||
const last = tutorial.steps.at(-1);
|
||||
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
|
||||
}
|
||||
if (place.practice) {
|
||||
return { step: place.step, practice: false };
|
||||
}
|
||||
const before = tutorial.steps[place.step - 2];
|
||||
return before === undefined
|
||||
? null
|
||||
: { step: before.number, practice: before.practice !== null };
|
||||
}
|
||||
|
||||
/** Ce que retient la progression quand on arrive sur un écran : la
|
||||
* dernière étape vue (TUTORIAL_DONE à la fin). */
|
||||
export function seenAfter(seen: number, place: TutorialPlace): number {
|
||||
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
|
||||
}
|
||||
|
||||
/** Un segment de la barre de progression. */
|
||||
export interface ProgressSegment {
|
||||
readonly step: number;
|
||||
/** Étape faite ou en cours : le segment est en brique. */
|
||||
readonly done: boolean;
|
||||
/** Étape déjà vue : le segment est un lien vers elle. */
|
||||
readonly link: boolean;
|
||||
readonly current: boolean;
|
||||
}
|
||||
|
||||
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
|
||||
* seen ont déjà été vues. */
|
||||
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
|
||||
const current = place === "end" ? total + 1 : place.step;
|
||||
const reached = Math.max(seen, current);
|
||||
return Array.from({ length: total }, (_, i) => {
|
||||
const step = i + 1;
|
||||
return {
|
||||
step,
|
||||
done: step <= current,
|
||||
link: step <= reached && step !== current,
|
||||
current: step === current,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
|
||||
// docs/tutoriel/README.md), copié tel quel dans le site par make site
|
||||
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
|
||||
* glossaire (nom de fichier sans .md). */
|
||||
export interface Inline {
|
||||
readonly text: string;
|
||||
readonly strong?: boolean;
|
||||
readonly term?: string;
|
||||
}
|
||||
|
||||
export type Paragraph = readonly Inline[];
|
||||
|
||||
/** Un écran du tutoriel : explication, ou « à vous ». */
|
||||
export interface TutorialScreen {
|
||||
/** Identifiant de position de gnubg, vu du camp au trait. */
|
||||
readonly position: string;
|
||||
readonly onRoll: "player" | "computer";
|
||||
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
|
||||
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. */
|
||||
readonly path: boolean;
|
||||
/** « À vous » : les coups justes, en notation de gnubg. */
|
||||
readonly expected: readonly string[];
|
||||
/** « À vous » de videau : la bonne réponse au double. */
|
||||
readonly answer: "take" | "pass" | null;
|
||||
readonly right: Paragraph;
|
||||
readonly wrong: Paragraph;
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
export interface TutorialStep {
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly explain: TutorialScreen;
|
||||
readonly practice: TutorialScreen | null;
|
||||
}
|
||||
|
||||
export interface Tutorial {
|
||||
readonly steps: readonly TutorialStep[];
|
||||
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
|
||||
}
|
||||
|
||||
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
|
||||
export function parseInline(text: string): Paragraph {
|
||||
const parts: Inline[] = [];
|
||||
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) {
|
||||
parts.push({ text: text.slice(last, index) });
|
||||
}
|
||||
if (match[1] !== undefined && match[2] !== undefined) {
|
||||
parts.push({ text: match[1], term: match[2] });
|
||||
} else {
|
||||
parts.push({ text: match[3] ?? "", strong: true });
|
||||
}
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push({ text: text.slice(last) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface RawScreen {
|
||||
keys: Map<string, string[]>;
|
||||
paragraphs: string[];
|
||||
}
|
||||
|
||||
function emptyScreen(): RawScreen {
|
||||
return { keys: new Map(), paragraphs: [] };
|
||||
}
|
||||
|
||||
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
|
||||
if (text === undefined) {
|
||||
return null;
|
||||
}
|
||||
const match = /^([1-6])-([1-6])$/.exec(text);
|
||||
if (match === null) {
|
||||
throw new Error(`${where} : jet 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");
|
||||
if (position === undefined) {
|
||||
throw new Error(`${where} : position absente`);
|
||||
}
|
||||
const trait = one("trait");
|
||||
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
|
||||
throw new Error(`${where} : trait inconnu « ${trait} »`);
|
||||
}
|
||||
const answer = one("réponse");
|
||||
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
|
||||
throw new Error(`${where} : réponse inconnue « ${answer} »`);
|
||||
}
|
||||
return {
|
||||
position,
|
||||
onRoll: trait === "ordinateur" ? "computer" : "player",
|
||||
roll: rollOf(one("jet"), where),
|
||||
move: one("coup") ?? null,
|
||||
path: one("chemin") === "oui",
|
||||
expected: raw.keys.get("attendu") ?? [],
|
||||
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
|
||||
right: parseInline(one("juste") ?? ""),
|
||||
wrong: parseInline(one("refus") ?? ""),
|
||||
paragraphs: raw.paragraphs.map(parseInline),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
|
||||
* étapes ne se suivent pas. */
|
||||
export function parseTutorial(text: string): Tutorial {
|
||||
const steps: {
|
||||
number: number;
|
||||
title: string;
|
||||
explain: RawScreen;
|
||||
practice: RawScreen | null;
|
||||
}[] = [];
|
||||
let end: RawScreen | null = null;
|
||||
let current: RawScreen | null = null;
|
||||
let paragraph: string[] = [];
|
||||
|
||||
const endParagraph = () => {
|
||||
if (paragraph.length > 0 && current !== null) {
|
||||
current.paragraphs.push(paragraph.join(" "));
|
||||
}
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const step = /^## (\d+)\. (.+)$/.exec(line);
|
||||
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
|
||||
if (line === "") {
|
||||
endParagraph();
|
||||
} else if (step !== null) {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
steps.push({
|
||||
number: Number(step[1]),
|
||||
title: step[2] ?? "",
|
||||
explain: current,
|
||||
practice: null,
|
||||
});
|
||||
} else if (line === "## Fin") {
|
||||
endParagraph();
|
||||
current = emptyScreen();
|
||||
end = current;
|
||||
} else if (line === "### À vous") {
|
||||
endParagraph();
|
||||
const last = steps.at(-1);
|
||||
if (last === undefined) {
|
||||
throw new Error("« À vous » hors d'une étape");
|
||||
}
|
||||
current = emptyScreen();
|
||||
last.practice = current;
|
||||
} else if (current === null) {
|
||||
// Notice avant la première étape : non affichée.
|
||||
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
|
||||
const values = current.keys.get(key[1] ?? "") ?? [];
|
||||
values.push((key[2] ?? "").trim());
|
||||
current.keys.set(key[1] ?? "", values);
|
||||
} else {
|
||||
paragraph.push(line);
|
||||
}
|
||||
}
|
||||
endParagraph();
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
if (step.number !== i + 1) {
|
||||
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
|
||||
}
|
||||
});
|
||||
if (end === null) {
|
||||
throw new Error("écran de fin absent");
|
||||
}
|
||||
const finish: RawScreen = end;
|
||||
return {
|
||||
steps: steps.map((step) => ({
|
||||
number: step.number,
|
||||
title: step.title,
|
||||
explain: screenOf(step.explain, `étape ${step.number}`),
|
||||
practice:
|
||||
step.practice === null
|
||||
? null
|
||||
: screenOf(step.practice, `étape ${step.number}, à vous`),
|
||||
})),
|
||||
end: {
|
||||
action: finish.keys.get("action")?.[0] ?? "",
|
||||
paragraphs: finish.paragraphs.map(parseInline),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
|
||||
// #/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 »).
|
||||
//
|
||||
// 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,
|
||||
// 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.
|
||||
|
||||
import type { MoveSteps } from "../domain/game/game.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
import type { Roll } from "../domain/game/roll.js";
|
||||
import type { Side } from "../domain/game/side.js";
|
||||
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
moveTrace,
|
||||
onlyHalfMove,
|
||||
type Place,
|
||||
placeOf as placeOfChecker,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
slideChecker,
|
||||
} from "./board-view.js";
|
||||
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
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";
|
||||
|
||||
/** Le coup illustré se joue tout seul après une seconde. */
|
||||
const AUTOPLAY_MS = 1000;
|
||||
const SLIDE_MS = 160;
|
||||
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";
|
||||
|
||||
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
|
||||
export interface PracticeSession {
|
||||
board(): Promise<{
|
||||
player: TutorialBoard["sides"]["player"];
|
||||
computer: TutorialBoard["sides"]["computer"];
|
||||
}>;
|
||||
nextHalfMoves(): Promise<HalfMove[]>;
|
||||
play(halfMove: HalfMove): Promise<PracticeVerdict>;
|
||||
reset(): void;
|
||||
readonly diceToPlay: readonly { value: number; used: boolean }[];
|
||||
}
|
||||
|
||||
/** Ce que le tutoriel demande au domaine et au moteur. */
|
||||
export interface TutorialAccess {
|
||||
board(position: string, onRoll: Side): Promise<TutorialBoard>;
|
||||
illustrate(
|
||||
position: string,
|
||||
roll: Roll,
|
||||
move: string,
|
||||
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
|
||||
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
|
||||
shouldTake(position: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Un geste juste : la vibration « pion posé ». */
|
||||
readonly placed: () => void;
|
||||
readonly reducedMotion: MediaQueryList;
|
||||
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
|
||||
readonly landscape: () => boolean;
|
||||
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
|
||||
readonly replace: (hash: string) => void;
|
||||
}
|
||||
|
||||
export interface TutorialView {
|
||||
show(route: Route): Promise<void>;
|
||||
/** La taille de l'écran a changé : redessiner le plateau. */
|
||||
relayout(): void;
|
||||
/** L'écran est quitté : arrêter le coup illustré. */
|
||||
hide(): void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
|
||||
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
|
||||
target.replaceChildren();
|
||||
for (const part of paragraph) {
|
||||
if (part.term !== undefined) {
|
||||
const link = document.createElement("a");
|
||||
link.href = routeHash({ screen: "glossary", term: part.term });
|
||||
link.textContent = part.text;
|
||||
target.append(link);
|
||||
} else if (part.strong === true) {
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = part.text;
|
||||
target.append(strong);
|
||||
} else {
|
||||
target.append(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
|
||||
export function createTutorialView(options: TutorialViewOptions): TutorialView {
|
||||
const { section, access } = options;
|
||||
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" });
|
||||
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" });
|
||||
const boardBox = element(section, "div", { className: "tutorial-board-box" });
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.id = "tutorial-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" });
|
||||
replay.type = "button";
|
||||
replay.append(icon("replay"));
|
||||
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
|
||||
replay.title = HOME_TEXTS.replayLabel;
|
||||
// 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 feedback = element(body, "p", {
|
||||
id: "tutorial-feedback",
|
||||
className: "tutorial-feedback",
|
||||
});
|
||||
feedback.setAttribute("role", "status");
|
||||
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
|
||||
const drop = element(cube, "button", {
|
||||
id: "tutorial-drop",
|
||||
className: "secondary",
|
||||
text: HOME_TEXTS.drop,
|
||||
});
|
||||
drop.type = "button";
|
||||
const take = element(cube, "button", {
|
||||
id: "tutorial-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",
|
||||
className: "button secondary",
|
||||
text: HOME_TEXTS.back,
|
||||
});
|
||||
const next = element(nav, "a", {
|
||||
id: "tutorial-next",
|
||||
className: "button",
|
||||
text: HOME_TEXTS.next,
|
||||
});
|
||||
|
||||
let tutorial: Promise<Tutorial> | 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;
|
||||
let autoplay: ReturnType<typeof setTimeout> | null = null;
|
||||
// Un coup illustré se joue, ou un geste est jugé : les touchers
|
||||
// attendent (data-busy, pour les tests dans un navigateur).
|
||||
let busy = false;
|
||||
const setBusy = (value: boolean) => {
|
||||
busy = value;
|
||||
section.toggleAttribute("data-busy", value);
|
||||
};
|
||||
|
||||
// L'écran affiché.
|
||||
let screen: TutorialScreen | null = null;
|
||||
let place: TutorialPlace | null = null;
|
||||
let boards: TutorialBoard | null = null;
|
||||
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
|
||||
let illustrated = false;
|
||||
let practice: PracticeSession | null = null;
|
||||
let targets = new Map<Place, Map<Place, HalfMove>>();
|
||||
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 {
|
||||
const box = svg.getBoundingClientRect();
|
||||
if (options.landscape()) {
|
||||
const width = box.width || window.innerWidth;
|
||||
const height = box.height || window.innerHeight;
|
||||
return boardGeometry("landscape", width / height, height / 520);
|
||||
}
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
|
||||
}
|
||||
|
||||
function shownDice(): ShownDie[] {
|
||||
if (practice !== null) {
|
||||
return practice.diceToPlay.map((die) => ({
|
||||
value: die.value,
|
||||
side: "player",
|
||||
blocked: false,
|
||||
used: die.used,
|
||||
}));
|
||||
}
|
||||
const roll = screen?.move != null ? screen.roll : null;
|
||||
if (roll === null || roll === undefined) {
|
||||
return [];
|
||||
}
|
||||
const [a, b] = roll;
|
||||
return [a, b].map((value) => ({
|
||||
value,
|
||||
side: "player" as const,
|
||||
blocked: false,
|
||||
used: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Redessine le plateau dans son état courant. */
|
||||
function draw(sides: TutorialBoard["sides"] | null = null): void {
|
||||
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
|
||||
if (current === undefined || current === null) {
|
||||
return;
|
||||
}
|
||||
const g = geometry();
|
||||
const decor: BoardDecor = {
|
||||
...NO_MARKS,
|
||||
dice: shownDice(),
|
||||
cube: { value: 1, owner: null },
|
||||
band: null,
|
||||
controls: null,
|
||||
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
trace:
|
||||
illustrated && illustration !== null
|
||||
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
|
||||
: null,
|
||||
};
|
||||
renderBoard(svg, g, boardModel(current), decor);
|
||||
if (screen?.path === true) {
|
||||
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer(): void {
|
||||
if (autoplay !== null) {
|
||||
clearTimeout(autoplay);
|
||||
autoplay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
|
||||
* puis ses traces ; sans mouvement si le système le demande. */
|
||||
async function playIllustration(id: number): Promise<void> {
|
||||
const shownMove = illustration;
|
||||
if (shownMove === null || id !== shown) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
replay.disabled = true;
|
||||
illustrated = false;
|
||||
try {
|
||||
if (!options.reducedMotion.matches) {
|
||||
const { steps } = shownMove;
|
||||
for (const [i, halfMove] of steps.halfMoves.entries()) {
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (i > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
}
|
||||
const from = placeOfChecker("player", halfMove.from);
|
||||
const to = placeOfChecker("player", halfMove.to);
|
||||
draw(steps.boards[i] ?? null);
|
||||
const start = checkerSpot(svg, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
draw(steps.boards[i + 1] ?? null);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
illustrated = true;
|
||||
replay.disabled = false;
|
||||
draw();
|
||||
const result = shownMove.after.result;
|
||||
feedback.textContent =
|
||||
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
|
||||
feedback.dataset.verdict = result === null ? "" : "info";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTargets(): Promise<void> {
|
||||
targets =
|
||||
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
|
||||
if (selected !== null && !targets.has(selected)) {
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
|
||||
feedback.dataset.verdict = verdict;
|
||||
fillParagraph(feedback, words);
|
||||
if (verdict === "right") {
|
||||
feedback.prepend(icon("verdictGood"));
|
||||
}
|
||||
}
|
||||
|
||||
function markSolved(): void {
|
||||
solved = true;
|
||||
section.toggleAttribute("data-solved", true);
|
||||
next.classList.remove("secondary");
|
||||
take.disabled = true;
|
||||
drop.disabled = true;
|
||||
options.placed();
|
||||
}
|
||||
|
||||
async function playHalfMove(halfMove: HalfMove): Promise<void> {
|
||||
const session = practice;
|
||||
const current = screen;
|
||||
if (session === null || current === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
selected = null;
|
||||
try {
|
||||
const verdict = await session.play(halfMove);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(await session.board());
|
||||
if (verdict === "right") {
|
||||
markSolved();
|
||||
setVerdict("right", current.right);
|
||||
} else if (verdict === "wrong") {
|
||||
// Le pion revient, et une phrase dit pourquoi.
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
session.reset();
|
||||
setVerdict("wrong", current.wrong);
|
||||
} else {
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
}
|
||||
await refreshTargets();
|
||||
} finally {
|
||||
if (id === shown) {
|
||||
setBusy(false);
|
||||
draw(practice === null ? null : await practice.board());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (practice === null || busy || solved) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as Element | null;
|
||||
const placeName = target
|
||||
?.closest("[data-place]")
|
||||
?.getAttribute("data-place") as Place | null;
|
||||
if (placeName == null) {
|
||||
return;
|
||||
}
|
||||
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
|
||||
if (halfMove !== undefined) {
|
||||
void playHalfMove(halfMove);
|
||||
return;
|
||||
}
|
||||
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
|
||||
if (only !== null) {
|
||||
void playHalfMove(only);
|
||||
return;
|
||||
}
|
||||
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
});
|
||||
|
||||
replay.addEventListener("click", () => {
|
||||
clearTimer();
|
||||
void playIllustration(shown);
|
||||
});
|
||||
|
||||
async function answer(takes: boolean): Promise<void> {
|
||||
const current = screen;
|
||||
if (current === null || solved || busy) {
|
||||
return;
|
||||
}
|
||||
const id = shown;
|
||||
setBusy(true);
|
||||
try {
|
||||
const right = (await access.shouldTake(current.position)) === takes;
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
if (right) {
|
||||
markSolved();
|
||||
}
|
||||
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
take.addEventListener("click", () => void answer(true));
|
||||
drop.addEventListener("click", () => void answer(false));
|
||||
|
||||
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
|
||||
bar.replaceChildren();
|
||||
for (const segment of progress(total, at, options.seen())) {
|
||||
const stepTitle = steps[segment.step - 1]?.title ?? "";
|
||||
const label = HOME_TEXTS.tutorialStepLink(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.setAttribute("aria-label", label);
|
||||
} else {
|
||||
item.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (segment.current) {
|
||||
item.setAttribute("aria-current", "step");
|
||||
}
|
||||
bar.append(item);
|
||||
}
|
||||
bar.setAttribute(
|
||||
"aria-description",
|
||||
at === "end"
|
||||
? HOME_TEXTS.tutorialEndOverline
|
||||
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async show(route) {
|
||||
const id = ++shown;
|
||||
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
|
||||
delete section.dataset.ready;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
practice = null;
|
||||
illustration = null;
|
||||
illustrated = false;
|
||||
screen = null;
|
||||
boards = null;
|
||||
selected = null;
|
||||
targets = new Map();
|
||||
solved = false;
|
||||
section.toggleAttribute("data-solved", false);
|
||||
feedback.textContent = "";
|
||||
feedback.dataset.verdict = "";
|
||||
task.hidden = true;
|
||||
cube.hidden = true;
|
||||
replay.hidden = true;
|
||||
take.disabled = false;
|
||||
drop.disabled = false;
|
||||
let loaded: Tutorial;
|
||||
try {
|
||||
if (tutorial === null) {
|
||||
title.textContent = HOME_TEXTS.tutorialLoading;
|
||||
text.replaceChildren();
|
||||
svg.replaceChildren();
|
||||
}
|
||||
loaded = await load();
|
||||
} catch {
|
||||
if (id === shown) {
|
||||
title.textContent = HOME_TEXTS.tutorialFailed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
const at = placeOf(loaded, route);
|
||||
const wanted = routeHash(placeRoute(at));
|
||||
if (routeHash(route) !== wanted) {
|
||||
options.replace(wanted);
|
||||
return;
|
||||
}
|
||||
place = at;
|
||||
options.visit(at);
|
||||
const total = loaded.steps.length;
|
||||
renderProgress(total, at, loaded.steps);
|
||||
const before = previousPlace(loaded, at);
|
||||
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
|
||||
if (at === "end") {
|
||||
overline.textContent = HOME_TEXTS.tutorialEndOverline;
|
||||
title.textContent = HOME_TEXTS.tutorialEndTitle;
|
||||
text.replaceChildren();
|
||||
for (const paragraph of loaded.end.paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
next.textContent = loaded.end.action;
|
||||
next.href = "#/";
|
||||
next.classList.remove("secondary");
|
||||
screen = loaded.steps[0]?.explain ?? null;
|
||||
} else {
|
||||
const step = loaded.steps[at.step - 1];
|
||||
const current = at.practice ? step?.practice : step?.explain;
|
||||
if (step === undefined || current == null) {
|
||||
return;
|
||||
}
|
||||
screen = { ...current, path: current.path && !at.practice };
|
||||
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
|
||||
title.textContent = step.title;
|
||||
text.replaceChildren();
|
||||
const paragraphs = at.practice ? [] : current.paragraphs;
|
||||
for (const paragraph of paragraphs) {
|
||||
fillParagraph(element(text, "p"), paragraph);
|
||||
}
|
||||
if (at.practice) {
|
||||
task.hidden = false;
|
||||
task.replaceChildren();
|
||||
for (const [i, paragraph] of current.paragraphs.entries()) {
|
||||
if (i > 0) {
|
||||
task.append(" ");
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
fillParagraph(span, paragraph);
|
||||
task.append(span);
|
||||
}
|
||||
}
|
||||
const after = nextPlace(loaded, at);
|
||||
next.textContent = HOME_TEXTS.next;
|
||||
next.href = routeHash(placeRoute(after));
|
||||
next.classList.toggle("secondary", at.practice);
|
||||
}
|
||||
const currentScreen = screen;
|
||||
if (currentScreen === null) {
|
||||
return;
|
||||
}
|
||||
// Le plateau : la position, puis le coup illustré ou le geste à
|
||||
// faire, tous lus par le moteur.
|
||||
try {
|
||||
await options.engineReady;
|
||||
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
boards = shownBoard;
|
||||
if (
|
||||
at !== "end" &&
|
||||
at.practice &&
|
||||
currentScreen.roll !== null &&
|
||||
currentScreen.expected.length > 0
|
||||
) {
|
||||
practice = await access.practice(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.expected,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
await refreshTargets();
|
||||
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
|
||||
cube.hidden = false;
|
||||
feedback.textContent = HOME_TEXTS.computerDoubles;
|
||||
feedback.dataset.verdict = "info";
|
||||
} else if (
|
||||
at !== "end" &&
|
||||
currentScreen.move !== null &&
|
||||
currentScreen.roll !== null
|
||||
) {
|
||||
illustration = await access.illustrate(
|
||||
currentScreen.position,
|
||||
currentScreen.roll,
|
||||
currentScreen.move,
|
||||
);
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
replay.hidden = false;
|
||||
replay.disabled = true;
|
||||
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
|
||||
}
|
||||
if (id !== shown) {
|
||||
return;
|
||||
}
|
||||
draw(practice === null ? null : await practice.board());
|
||||
section.dataset.ready = String(id);
|
||||
} catch (error) {
|
||||
if (id === shown) {
|
||||
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
relayout() {
|
||||
if (place === null || busy) {
|
||||
return;
|
||||
}
|
||||
if (practice !== null) {
|
||||
void practice.board().then((sides) => draw(sides));
|
||||
} else {
|
||||
draw();
|
||||
}
|
||||
},
|
||||
hide() {
|
||||
shown++;
|
||||
clearTimer();
|
||||
setBusy(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// La mise à jour de l'application : le service worker (web/sw.js) installe
|
||||
// chaque nouvelle version publiée dans son propre cache, puis attend
|
||||
// l'accord du joueur. L'application affiche alors, entre deux tours, le
|
||||
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
|
||||
// active la nouvelle version et recharge la page, qui reprend la partie
|
||||
// enregistrée.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
export interface Updates {
|
||||
/** Une nouvelle version est installée et attend l'accord du joueur. */
|
||||
readonly available: boolean;
|
||||
/** « Rechercher une mise à jour » : demande au navigateur de relire le
|
||||
* service worker publié. found : une nouvelle version attend (elle a pu
|
||||
* demander de télécharger tout le site) ; latest : rien de nouveau ;
|
||||
* unavailable : pas de service worker (http hors localhost, navigateur
|
||||
* qui le refuse) ou réseau absent. */
|
||||
check(): Promise<"found" | "latest" | "unavailable">;
|
||||
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
|
||||
* la main. */
|
||||
apply(): void;
|
||||
}
|
||||
|
||||
/** Message au service worker en attente : prendre la main (sw.js). */
|
||||
export const SKIP_WAITING = "skip-waiting";
|
||||
|
||||
/**
|
||||
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
|
||||
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
|
||||
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
|
||||
* pure, testée sous Node.
|
||||
*/
|
||||
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
|
||||
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
|
||||
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
|
||||
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
|
||||
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
|
||||
*/
|
||||
export function updateBannerShown(
|
||||
available: boolean,
|
||||
betweenTwoTurns: boolean,
|
||||
otherBanner: boolean,
|
||||
): boolean {
|
||||
return available && betweenTwoTurns && !otherBanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre le service worker une fois ready résolue (le moteur chargé :
|
||||
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
|
||||
* nouvelle version attend.
|
||||
*/
|
||||
export function startUpdates(
|
||||
script: URL,
|
||||
ready: Promise<unknown>,
|
||||
onAvailable: () => void,
|
||||
): Updates {
|
||||
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let waiting: ServiceWorker | null = null;
|
||||
|
||||
/** Une version installée attend, et une version plus ancienne a la
|
||||
* main : c'est une mise à jour (à la toute première installation,
|
||||
* aucune page n'est encore contrôlée, et rien n'attend). */
|
||||
const notice = (worker: ServiceWorker | null) => {
|
||||
if (worker !== null && worker.state === "installed" && container?.controller) {
|
||||
waiting = worker;
|
||||
onAvailable();
|
||||
}
|
||||
};
|
||||
|
||||
/** Suit une version en cours d'installation ; résolue quand elle est
|
||||
* installée (vrai) ou abandonnée (faux). */
|
||||
const follow = (worker: ServiceWorker): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const settle = () => {
|
||||
if (worker.state === "installed" || worker.state === "activated") {
|
||||
notice(worker);
|
||||
resolve(true);
|
||||
} else if (worker.state === "redundant") {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
worker.addEventListener("statechange", settle);
|
||||
settle();
|
||||
});
|
||||
|
||||
if (container !== null) {
|
||||
ready
|
||||
.then(() => container.register(script))
|
||||
.then((reg) => {
|
||||
registration = reg;
|
||||
notice(reg.waiting);
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
reg.addEventListener("updatefound", () => {
|
||||
if (reg.installing !== null) {
|
||||
void follow(reg.installing);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Refusé (http hors localhost) : l'application marche sans.
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get available() {
|
||||
return waiting !== null;
|
||||
},
|
||||
async check() {
|
||||
const reg = registration;
|
||||
if (reg === null) {
|
||||
return "unavailable";
|
||||
}
|
||||
try {
|
||||
await reg.update();
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
if (reg.waiting !== null) {
|
||||
notice(reg.waiting);
|
||||
return "found";
|
||||
}
|
||||
if (reg.installing !== null) {
|
||||
return (await follow(reg.installing)) ? "found" : "latest";
|
||||
}
|
||||
return "latest";
|
||||
},
|
||||
apply() {
|
||||
const worker = waiting ?? registration?.waiting ?? null;
|
||||
if (container === null || worker === null) {
|
||||
return;
|
||||
}
|
||||
let reloading = false;
|
||||
container.addEventListener("controllerchange", () => {
|
||||
if (!reloading) {
|
||||
reloading = true;
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
worker.postMessage({ type: SKIP_WAITING });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,172 @@
|
||||
// Tests du domaine : les branches d'une partie et son export SGF, sur des
|
||||
// journaux écrits à la main (sans moteur, sauf un faux pour une position de
|
||||
// départ particulière). La relecture des SGF par le vrai GNU Backgammon est
|
||||
// faite par tests/test_web.sh (tests/sgf/check.py).
|
||||
//
|
||||
// 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 { branches, switchToBranch } from "../src/domain/game/branches.js";
|
||||
import type { Board, Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
type NewGameEvent,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { exportSgf } from "../src/domain/game/sgf.js";
|
||||
|
||||
function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal {
|
||||
return events.reduce(appendEvent, journal);
|
||||
}
|
||||
|
||||
const start: NewGameEvent = {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
};
|
||||
const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({
|
||||
type: "MovePlayed",
|
||||
side,
|
||||
halfMoves: parseHalfMoves(text),
|
||||
position,
|
||||
});
|
||||
const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({
|
||||
type: "DiceRolled",
|
||||
side,
|
||||
roll: [a, b],
|
||||
});
|
||||
|
||||
/**
|
||||
* Une partie et ses branches :
|
||||
* 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4,
|
||||
* 5 coup 24/18 13/9, 6 dés joueur 2-1 ;
|
||||
* rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même
|
||||
* ouverture, autre coup) : 8 coup 24/23 13/10.
|
||||
*/
|
||||
function branchedGame(): GameJournal {
|
||||
let j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 3, 1),
|
||||
move("player", "8/5 6/5"),
|
||||
roll("computer", 6, 4),
|
||||
move("computer", "24/18 13/9"),
|
||||
roll("player", 2, 1),
|
||||
);
|
||||
j = add(goBackTo(j, "3"), roll("computer", 5, 5));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
return j;
|
||||
}
|
||||
|
||||
test("les branches : feuilles de l'arbre, avec leur point de départ", () => {
|
||||
const j = branchedGame();
|
||||
const all = branches(j);
|
||||
assert.deepEqual(
|
||||
all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]),
|
||||
[
|
||||
["6", null, 0, 2, false],
|
||||
["7", "3", 1, 1, false],
|
||||
["8", "2", 0, 1, true],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(branches(EMPTY_JOURNAL), []);
|
||||
|
||||
// Passer d'une branche à l'autre ne perd rien.
|
||||
const back = switchToBranch(j, "6");
|
||||
assert.equal(back.events, j.events);
|
||||
assert.deepEqual(
|
||||
currentLine(back).map((e) => e.id),
|
||||
["1", "2", "3", "4", "5", "6"],
|
||||
);
|
||||
assert.throws(() => switchToBranch(j, "4"), /branche introuvable/);
|
||||
});
|
||||
|
||||
test("une branche rejouée puis continuée garde son ordre de création", () => {
|
||||
// La ligne d'origine continue après la création d'une branche : elle
|
||||
// reste la première.
|
||||
let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"));
|
||||
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
|
||||
j = add(goBackTo(j, "3"), roll("computer", 6, 4));
|
||||
assert.deepEqual(
|
||||
branches(j).map((b) => [b.head, b.forkedFrom]),
|
||||
[
|
||||
["5", null],
|
||||
["4", "2"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/** Un faux moteur qui ne sait que décoder un plateau. */
|
||||
function boardEngine(board: Board): Engine {
|
||||
return { board: async () => board } as unknown as Engine;
|
||||
}
|
||||
const noEngine = boardEngine({} as Board);
|
||||
const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" };
|
||||
|
||||
test("SGF : les coups, les variantes, la branche courante d'abord", async () => {
|
||||
const sgf = await exportSgf(noEngine, branchedGame(), names);
|
||||
assert.equal(
|
||||
sgf,
|
||||
"(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" +
|
||||
"PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" +
|
||||
// Branche courante : même ouverture, autre coup.
|
||||
"\n;B[31ablo])(" +
|
||||
// Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)).
|
||||
"\n;B[31qtst](" +
|
||||
// Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et
|
||||
// des dés lancés, pas joués.
|
||||
"\n;W[64xrmi]\n;PL[B]DI[21])(" +
|
||||
"\n;PL[W]DI[55])))\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : barre, sortie, coup impossible, videau et résultat", async () => {
|
||||
const j = add(
|
||||
EMPTY_JOURNAL,
|
||||
start,
|
||||
roll(null, 1, 6),
|
||||
move("computer", "bar/19 6/off"),
|
||||
{ type: "CubeOffered", side: "player" },
|
||||
{ type: "CubeTaken", side: "computer" },
|
||||
roll("player", 6, 6),
|
||||
move("player", ""),
|
||||
{ type: "CubeOffered", side: "computer" },
|
||||
{ type: "CubeDropped", side: "player" },
|
||||
{ type: "GameEnded", winner: "computer", kind: "single", points: 2 },
|
||||
);
|
||||
const sgf = await exportSgf(noEngine, j, names);
|
||||
assert.ok(sgf.includes("RE[W+2]"));
|
||||
assert.ok(
|
||||
sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"),
|
||||
sgf,
|
||||
);
|
||||
});
|
||||
|
||||
test("SGF : une position de départ particulière est posée", async () => {
|
||||
// Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ;
|
||||
// l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur.
|
||||
const points = (entries: Record<number, number>) =>
|
||||
Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0);
|
||||
const engine = boardEngine({
|
||||
onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 },
|
||||
opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 },
|
||||
swappedPosition: "X",
|
||||
result: null,
|
||||
});
|
||||
const j = add(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: "ABC",
|
||||
cube: { value: 4, owner: "computer" },
|
||||
firstTurn: "player",
|
||||
});
|
||||
const sgf = await exportSgf(engine, j, names);
|
||||
assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf);
|
||||
await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/);
|
||||
});
|
||||
@@ -52,13 +52,13 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
|
||||
return actions;
|
||||
}
|
||||
|
||||
for (const [seed, ply] of [
|
||||
[1, 0],
|
||||
[7, 2],
|
||||
for (const [seed, level] of [
|
||||
[1, "0ply"],
|
||||
[7, "2ply"],
|
||||
] as const) {
|
||||
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
|
||||
test(`partie complète, dés ${seed}, ordinateur au niveau ${level}`, async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const game = Game.start(engine, { dice: seededDice(seed), ply });
|
||||
const game = Game.start(engine, { dice: seededDice(seed), level });
|
||||
await playToTheEnd(game, engine);
|
||||
|
||||
const events = game.events;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// 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 { 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);
|
||||
});
|
||||
+221
-16
@@ -10,14 +10,24 @@ import type {
|
||||
Board,
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
Move,
|
||||
} from "../src/domain/game/engine.js";
|
||||
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
EMPTY_JOURNAL,
|
||||
EVENT_VERSION,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { replay } from "../src/domain/game/state.js";
|
||||
import { DEFAULT_LEVEL, replay } from "../src/domain/game/state.js";
|
||||
|
||||
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
|
||||
* textes sans signification. */
|
||||
@@ -51,7 +61,19 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
board: async () => notOver,
|
||||
levelMove: async () => move,
|
||||
levelCubeDecision: async () => noDouble,
|
||||
levelDouble: async () => false,
|
||||
levelTake: async () => true,
|
||||
matchAfterGame: async (match, winner, points) => {
|
||||
const score = { ...match.score };
|
||||
score[winner] += points;
|
||||
return {
|
||||
score,
|
||||
over: score[winner] >= match.length,
|
||||
crawford: score[winner] === match.length - 1,
|
||||
};
|
||||
},
|
||||
analyseMove: async () => null,
|
||||
rankMoves: async () => [],
|
||||
analyseCube: async () => ({
|
||||
analysed: false,
|
||||
equities: null,
|
||||
@@ -59,6 +81,17 @@ 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,
|
||||
score: [0, 0],
|
||||
cubeValue: 1,
|
||||
cubeOwner: -1,
|
||||
playerOnRoll: 0,
|
||||
crawford: false,
|
||||
jacoby: true,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -227,10 +260,8 @@ test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () =>
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: true }),
|
||||
});
|
||||
test("l'ordinateur double si le moteur le dit à son niveau ; le joueur prend", async () => {
|
||||
const engine = fakeEngine({ levelDouble: async () => true });
|
||||
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled");
|
||||
@@ -241,22 +272,18 @@ test("l'ordinateur double si le moteur le recommande ; le joueur prend", async (
|
||||
});
|
||||
|
||||
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: true }),
|
||||
});
|
||||
const engine = fakeEngine({ levelDouble: async () => true });
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
|
||||
});
|
||||
await game.playComputerTurn();
|
||||
game.drop();
|
||||
await game.drop();
|
||||
assert.equal(game.state.phase, "over");
|
||||
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
|
||||
});
|
||||
|
||||
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
|
||||
const engine = fakeEngine({
|
||||
cubeDecision: async () => ({ available: true, double: true, take: false }),
|
||||
});
|
||||
test("le joueur double : l'ordinateur passe si le moteur le dit à son niveau", async () => {
|
||||
const engine = fakeEngine({ levelTake: async () => false });
|
||||
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
|
||||
await game.double();
|
||||
assert.deepEqual(
|
||||
@@ -273,7 +300,7 @@ test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async (
|
||||
const game = Game.start(engine, {
|
||||
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
|
||||
});
|
||||
await assert.rejects(game.double(), /videau/);
|
||||
await assert.rejects(game.double(), /pas doubler/);
|
||||
assert.equal(game.events.length, 1);
|
||||
});
|
||||
|
||||
@@ -329,7 +356,7 @@ test("les événements sont immuables et versionnés ; le rejeu est déterminist
|
||||
});
|
||||
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
|
||||
const [started, rolled] = currentLine(journal);
|
||||
assert.equal(started?.version, 1);
|
||||
assert.equal(started?.version, 2);
|
||||
assert.equal(started?.parent, null);
|
||||
assert.equal(rolled?.parent, started?.id);
|
||||
assert.throws(() => {
|
||||
@@ -403,3 +430,181 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () =
|
||||
assert.equal(swapped.player.pipCount, 2);
|
||||
assert.equal(swapped.computer.pipCount, 1);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Match (1c)
|
||||
|
||||
test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => {
|
||||
const cubes: EngineCube[] = [];
|
||||
const levels: Level[] = [];
|
||||
const engine = fakeEngine({
|
||||
levelDouble: async (_position, cube, level) => {
|
||||
cubes.push(cube);
|
||||
levels.push(level);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false };
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1, 4, 2]),
|
||||
level: "intermediate",
|
||||
match,
|
||||
});
|
||||
const start = game.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "intermediate");
|
||||
assert.deepEqual(start.match, match);
|
||||
assert.equal(game.state.level, "intermediate");
|
||||
// L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite
|
||||
// l'ordinateur pense à doubler, et le match lui est donné vu de lui.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.deepEqual(levels, ["intermediate"]);
|
||||
assert.deepEqual(cubes[0]?.match, {
|
||||
length: 5,
|
||||
score: { onRoll: 3, opponent: 1 },
|
||||
crawford: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => {
|
||||
const winning: Board = {
|
||||
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
|
||||
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
|
||||
swappedPosition: "SWAPPED",
|
||||
result: { winner: "onRoll", kind: "gammon", points: 2 },
|
||||
};
|
||||
const engine = fakeEngine({ board: async () => winning });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([5, 2]),
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
assert.equal(game.nextGame, null);
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const end = game.events.at(-1);
|
||||
assert.ok(end?.type === "GameEnded");
|
||||
assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true });
|
||||
const next = game.nextGame;
|
||||
assert.deepEqual(next, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 2, computer: 0 }, crawford: true },
|
||||
});
|
||||
|
||||
// La partie Crawford, gagnée : le match est fini, plus de partie suivante.
|
||||
const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) });
|
||||
await crawford.playHalfMove({ from: 8, to: 5 });
|
||||
await crawford.playHalfMove({ from: 6, to: 5 });
|
||||
await crawford.confirmMove();
|
||||
assert.deepEqual(crawford.state.matchAfter, {
|
||||
score: { player: 4, computer: 0 },
|
||||
over: true,
|
||||
crawford: false,
|
||||
});
|
||||
assert.equal(crawford.nextGame, null);
|
||||
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
|
||||
// 0, au même niveau.
|
||||
assert.deepEqual(crawford.rematch, {
|
||||
level: "advanced",
|
||||
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("passer un double dans un match : le moteur compte le match", async () => {
|
||||
const engine = fakeEngine({ levelDouble: async () => true });
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([2, 5, 3, 1]),
|
||||
match: { length: 3, score: { player: 0, computer: 1 }, crawford: false },
|
||||
});
|
||||
// L'ordinateur joue son jet d'ouverture, le joueur son coup, puis
|
||||
// l'ordinateur double.
|
||||
await game.playComputerTurn();
|
||||
await game.roll();
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
assert.equal(game.state.phase, "doubled");
|
||||
await game.drop();
|
||||
assert.deepEqual(game.state.matchAfter, {
|
||||
score: { player: 0, computer: 2 },
|
||||
over: false,
|
||||
crawford: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("une partie libre n'a ni match ni partie suivante", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, DEFAULT_LEVEL);
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
|
||||
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
|
||||
assert.deepEqual(game.rematch, { level: "0ply" });
|
||||
});
|
||||
|
||||
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
|
||||
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
|
||||
// sans level ni match, un coup joué, une branche.
|
||||
const v1 = {
|
||||
events: [
|
||||
{
|
||||
id: "1",
|
||||
parent: null,
|
||||
version: 1,
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
},
|
||||
{ id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] },
|
||||
{
|
||||
id: "3",
|
||||
parent: "2",
|
||||
version: 1,
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 6, to: 5 },
|
||||
],
|
||||
position: "SWAPPED",
|
||||
},
|
||||
{ id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] },
|
||||
],
|
||||
head: "3",
|
||||
} as unknown as GameJournal;
|
||||
const before = replay(currentLine(v1));
|
||||
const migrated = migrateJournal(v1);
|
||||
assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION));
|
||||
assert.equal(migrated.head, "3");
|
||||
const start = migrated.events[0];
|
||||
assert.ok(start?.type === "GameStarted");
|
||||
assert.equal(start.level, "2ply");
|
||||
assert.equal(start.match, undefined);
|
||||
// Rien d'autre ne change : mêmes événements, même arbre, même état.
|
||||
const strip = (journal: GameJournal) =>
|
||||
journal.events.map(({ version: _, ...event }) => {
|
||||
const { level: _level, ...rest } = event as typeof event & { level?: unknown };
|
||||
return rest;
|
||||
});
|
||||
assert.deepEqual(strip(migrated), strip(v1));
|
||||
assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" });
|
||||
// Une version courante passe telle quelle.
|
||||
assert.equal(migrateJournal(migrated), migrated);
|
||||
|
||||
const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1)));
|
||||
assert.equal(resumed.state.level, "2ply");
|
||||
assert.equal(resumed.state.match, null);
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
// La partie continue en version 2.
|
||||
await resumed.playComputerTurn();
|
||||
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
|
||||
// premier lancement, règles d'apparition de la carte d'installation,
|
||||
// réglages enregistrés par une version précédente.
|
||||
//
|
||||
// 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 { installPlatform } from "../src/ui/install.js";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
INSTALL_AGAIN_AFTER,
|
||||
installCardShown,
|
||||
isFirstLaunch,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
TUTORIAL_DONE,
|
||||
} from "../src/ui/settings.js";
|
||||
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../src/ui/tutorial-text.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
|
||||
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
|
||||
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
|
||||
assert.equal(old.welcomed, false);
|
||||
assert.equal(old.gamesFinished, 0);
|
||||
assert.equal(old.tutorialSeen, 0);
|
||||
assert.equal(old.installed, false);
|
||||
const saved = parseSettings(
|
||||
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
|
||||
'"installDismissedAt": 2, "installed": true}',
|
||||
);
|
||||
assert.deepEqual(saved, {
|
||||
...DEFAULT_SETTINGS,
|
||||
welcomed: true,
|
||||
gamesFinished: 2,
|
||||
tutorialSeen: 5,
|
||||
installDismissals: 1,
|
||||
installDismissedAt: 2,
|
||||
installed: true,
|
||||
});
|
||||
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
|
||||
});
|
||||
|
||||
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
|
||||
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
|
||||
// La partie commencée d'office au chargement ne compte pas.
|
||||
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
|
||||
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
|
||||
});
|
||||
|
||||
test("invitation à installer : après la première partie terminée, une seule relance", () => {
|
||||
const at = (changes: Partial<Settings>, installed = false) =>
|
||||
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
|
||||
assert.ok(!at({}), "jamais avant la première partie terminée");
|
||||
assert.ok(at({ gamesFinished: 1 }));
|
||||
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
|
||||
assert.ok(!at({ gamesFinished: 1, installed: true }));
|
||||
// « Plus tard » : masquée, puis de retour après trois parties de plus.
|
||||
const later = { installDismissals: 1, installDismissedAt: 1 };
|
||||
assert.equal(INSTALL_AGAIN_AFTER, 3);
|
||||
assert.ok(!at({ ...later, gamesFinished: 1 }));
|
||||
assert.ok(!at({ ...later, gamesFinished: 3 }));
|
||||
assert.ok(at({ ...later, gamesFinished: 4 }));
|
||||
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
|
||||
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
|
||||
});
|
||||
|
||||
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
const explain = (step: number) => ({ step, practice: false });
|
||||
const practice = (step: number) => ({ step, practice: true });
|
||||
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
|
||||
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
|
||||
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
|
||||
assert.equal(nextPlace(tutorial, practice(8)), "end");
|
||||
assert.equal(previousPlace(tutorial, explain(1)), null);
|
||||
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
|
||||
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
|
||||
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
|
||||
explain(2),
|
||||
);
|
||||
assert.deepEqual(
|
||||
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
|
||||
explain(1),
|
||||
);
|
||||
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
|
||||
assert.equal(seenAfter(5, explain(3)), 5);
|
||||
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
|
||||
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
|
||||
const bar = progress(8, explain(3), 5);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.link).map((s) => s.step),
|
||||
[1, 2, 4, 5],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.done).map((s) => s.step),
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bar.filter((s) => s.current).map((s) => s.step),
|
||||
[3],
|
||||
);
|
||||
});
|
||||
|
||||
test("la marche à suivre pour installer dépend du navigateur", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
const chrome =
|
||||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/130.0 Mobile Safari/537.36";
|
||||
assert.equal(installPlatform(iphone), "ios");
|
||||
assert.equal(installPlatform(ipad), "ios");
|
||||
assert.equal(installPlatform(firefox), "firefox");
|
||||
// Sans invite du système, Chrome suit la marche à suivre de Firefox
|
||||
// (menu ⋮, « Installer »), la plus proche de la sienne.
|
||||
assert.equal(installPlatform(chrome), "firefox");
|
||||
});
|
||||
@@ -218,14 +218,18 @@ function sources(dir: string): string[] {
|
||||
}
|
||||
|
||||
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
|
||||
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
|
||||
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
|
||||
for (const path of sources("app/src/")) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
|
||||
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
|
||||
`${path} appelle une boîte native`,
|
||||
);
|
||||
}
|
||||
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
|
||||
});
|
||||
|
||||
test("les deux blocs du thème sombre de app.css sont identiques", () => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Tests du choix de la partie et du match, sans navigateur : réglages de
|
||||
// l'écran « Nouvelle partie », correspondance des niveaux avec ceux de
|
||||
// GNU Backgammon, résumé d'une partie de match enregistrée, adaptateur du
|
||||
// moteur (Match ID d'un match) sur 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 { Game } from "../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, computerLevelOf, ENGINE_LEVELS } from "../src/domain/game/level.js";
|
||||
import { firstGameOf, MATCH_LENGTHS, nextGameOf } from "../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { levelOf, summarizeGame } from "../src/domain/game/saved-game.js";
|
||||
import { chosenGame, DEFAULT_SETTINGS, parseSettings } from "../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
test("les six niveaux montrés au joueur, du plus faible au plus fort, et leur niveau de gnubg", () => {
|
||||
assert.deepEqual(COMPUTER_LEVELS, [
|
||||
"beginner",
|
||||
"amateur",
|
||||
"intermediate",
|
||||
"advanced",
|
||||
"expert",
|
||||
"champion",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
COMPUTER_LEVELS.map((level) => ENGINE_LEVELS[level]),
|
||||
["beginner", "casual", "intermediate", "advanced", "0ply", "2ply"],
|
||||
);
|
||||
assert.equal(computerLevelOf("casual"), "amateur");
|
||||
assert.equal(computerLevelOf("3ply"), null);
|
||||
});
|
||||
|
||||
test("réglages de la nouvelle partie : partie libre, Intermédiaire, conseil activé par défaut ; valeurs illisibles ignorées", () => {
|
||||
assert.equal(DEFAULT_SETTINGS.gameKind, "free");
|
||||
assert.equal(DEFAULT_SETTINGS.level, "intermediate");
|
||||
assert.equal(DEFAULT_SETTINGS.masterAdvice, true);
|
||||
// Réglages d'avant l'étape 1c : les valeurs par défaut.
|
||||
const old = parseSettings('{"vibrations": false, "gamesStarted": 4}');
|
||||
assert.equal(old.gameKind, "free");
|
||||
assert.equal(old.matchLength, 5);
|
||||
assert.equal(old.level, "intermediate");
|
||||
const saved = parseSettings(
|
||||
'{"gameKind": "match", "matchLength": 7, "level": "champion", "masterAdvice": false}',
|
||||
);
|
||||
assert.deepEqual(
|
||||
[saved.gameKind, saved.matchLength, saved.level, saved.masterAdvice],
|
||||
["match", 7, "champion", false],
|
||||
);
|
||||
const wrong = parseSettings('{"gameKind": "x", "matchLength": 4, "level": "4ply"}');
|
||||
assert.deepEqual([wrong.gameKind, wrong.matchLength, wrong.level], ["free", 5, "intermediate"]);
|
||||
assert.deepEqual([...MATCH_LENGTHS], [3, 5, 7, 11]);
|
||||
});
|
||||
|
||||
test("la partie choisie : le niveau de gnubg, et la première partie d'un match à 0-0", () => {
|
||||
assert.deepEqual(chosenGame(DEFAULT_SETTINGS), { level: "intermediate" });
|
||||
assert.deepEqual(chosenGame({ ...DEFAULT_SETTINGS, gameKind: "match", level: "expert" }), {
|
||||
level: "0ply",
|
||||
match: { length: 5, score: { player: 0, computer: 0 }, crawford: false },
|
||||
});
|
||||
assert.throws(() => firstGameOf(0), /longueur/);
|
||||
const match = firstGameOf(3);
|
||||
assert.equal(
|
||||
nextGameOf(match, { score: { player: 3, computer: 0 }, over: true, crawford: false }),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
nextGameOf(match, { score: { player: 2, computer: 0 }, over: false, crawford: true }),
|
||||
{ length: 3, score: { player: 2, computer: 0 }, crawford: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("le résumé d'une partie de match enregistrée porte le match ; celui d'une partie libre n'en a pas", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const times = { startedAt: 1, updatedAt: 2 };
|
||||
const free = Game.start(engine, { dice: fixedDice([3, 1]), level: "beginner" });
|
||||
assert.equal(levelOf(free.journal), "beginner");
|
||||
const freeSummary = summarizeGame("a", free.journal, { level: "beginner" }, times);
|
||||
assert.equal(freeSummary.match, undefined);
|
||||
assert.equal(freeSummary.level, "beginner");
|
||||
|
||||
const match = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
level: "2ply",
|
||||
match: { length: 5, score: { player: 4, computer: 2 }, crawford: true },
|
||||
});
|
||||
const summary = summarizeGame("b", match.journal, { level: levelOf(match.journal) }, times);
|
||||
assert.deepEqual(summary.match, {
|
||||
length: 5,
|
||||
score: { player: 4, computer: 2 },
|
||||
crawford: true,
|
||||
over: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("adaptateur : le Match ID d'un match code la longueur, le score vu du joueur au trait et Crawford", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const id = await engine.encodeMatch({
|
||||
value: 1,
|
||||
owner: "centered",
|
||||
match: { length: 5, score: { onRoll: 4, opponent: 2 }, crawford: true },
|
||||
});
|
||||
const decoded = await engine.decodeMatch(id);
|
||||
assert.deepEqual(
|
||||
[decoded.matchLength, decoded.score, decoded.crawford, decoded.jacoby],
|
||||
[5, [4, 2], true, false],
|
||||
);
|
||||
// En partie Crawford, le moteur refuse le videau au joueur au trait.
|
||||
const crawford = await engine.cubeDecision(
|
||||
START_POSITION,
|
||||
{
|
||||
value: 1,
|
||||
owner: "centered",
|
||||
match: { length: 5, score: { onRoll: 2, opponent: 4 }, crawford: true },
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert.equal(crawford.available, false);
|
||||
const after = await engine.matchAfterGame(
|
||||
{ length: 5, score: { onRoll: 3, opponent: 2 }, crawford: false },
|
||||
"onRoll",
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(after, { score: { onRoll: 4, opponent: 2 }, over: false, crawford: true });
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Tests des liens de position (app/src/domain/game/position-link.ts, ADR
|
||||
// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau
|
||||
// vu du joueur au trait, identifiants illisibles.
|
||||
//
|
||||
// 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 { EngineCube } from "../src/domain/game/engine.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import {
|
||||
linkOf,
|
||||
readPosition,
|
||||
setupOf,
|
||||
UnreadableLinkError,
|
||||
} from "../src/domain/game/position-link.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { parseRoute, routeHash } from "../src/ui/router.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const cases: [EngineCube, -1 | 0 | 1][] = [
|
||||
[{ value: 1, owner: "centered" }, -1],
|
||||
[{ value: 2, owner: "onRoll" }, 0],
|
||||
[{ value: 4, owner: "opponent" }, 1],
|
||||
];
|
||||
for (const [cube, owner] of cases) {
|
||||
const id = await engine.encodeMatch(cube);
|
||||
assert.equal(id.length, 12);
|
||||
const match = await engine.decodeMatch(id);
|
||||
assert.equal(match.matchLength, 0);
|
||||
assert.equal(match.playerOnRoll, 0);
|
||||
assert.equal(match.cubeValue, cube.value);
|
||||
assert.equal(match.cubeOwner, owner);
|
||||
}
|
||||
});
|
||||
|
||||
test("sans Match ID : partie libre, videau au centre", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const shared = await readPosition(engine, { position: START_POSITION, matchId: null });
|
||||
assert.deepEqual(shared.cube, { value: 1, owner: null });
|
||||
assert.equal(shared.match, null);
|
||||
assert.equal(shared.result, null);
|
||||
assert.equal(shared.sides.player.pipCount, 167);
|
||||
assert.equal(shared.sides.computer.pipCount, 167);
|
||||
assert.deepEqual(setupOf(shared), {
|
||||
position: START_POSITION,
|
||||
firstTurn: "player",
|
||||
cube: { value: 1, owner: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("des identifiants illisibles donnent une erreur claire", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const link of [
|
||||
{ position: "abc", matchId: null },
|
||||
{ position: "4HPwATDgc/ABM*", matchId: null },
|
||||
{ position: START_POSITION, matchId: "pas un id" },
|
||||
{ position: START_POSITION, matchId: "" },
|
||||
]) {
|
||||
await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link));
|
||||
}
|
||||
});
|
||||
|
||||
test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Videau chez l'ordinateur, à 2 : une partie commencée ainsi.
|
||||
const game = Game.start(engine, {
|
||||
dice: fixedDice([3, 1]),
|
||||
setup: {
|
||||
position: "jGfkASjg8wcBMA",
|
||||
firstTurn: "player",
|
||||
cube: { value: 2, owner: "computer" },
|
||||
},
|
||||
});
|
||||
const link = await linkOf(engine, game.state);
|
||||
const route = parseRoute(
|
||||
routeHash({ screen: "position", position: link.position, match: link.matchId }),
|
||||
);
|
||||
assert.ok(route.screen === "position");
|
||||
const shared = await readPosition(engine, { position: route.position, matchId: route.match });
|
||||
assert.deepEqual(shared.sides, await game.displayedBoard());
|
||||
assert.deepEqual(shared.cube, { value: 2, owner: "computer" });
|
||||
});
|
||||
|
||||
test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
// Jet d'ouverture 1-6 : l'ordinateur commence.
|
||||
const game = Game.start(engine, { dice: fixedDice([1, 6]) });
|
||||
assert.equal(game.state.turn, "computer");
|
||||
const link = await linkOf(engine, game.state);
|
||||
assert.equal(link.position, game.state.position);
|
||||
const shared = await readPosition(engine, link);
|
||||
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
|
||||
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de
|
||||
// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran
|
||||
// redonne son adresse.
|
||||
//
|
||||
// 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 {
|
||||
isHelpScreen,
|
||||
parseRoute,
|
||||
type Route,
|
||||
routeHash,
|
||||
slug,
|
||||
stepsBack,
|
||||
} from "../src/ui/router.js";
|
||||
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
const MATCH = "cAkAAAAAAAAA";
|
||||
|
||||
/** Les adresses du tableau de l'ADR, et l'écran de chacune. */
|
||||
const TABLE: [string, Route][] = [
|
||||
["#/", { screen: "game" }],
|
||||
["#/accueil", { screen: "home" }],
|
||||
["#/aide", { screen: "help", question: null }],
|
||||
["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }],
|
||||
["#/glossaire", { screen: "glossary", term: null }],
|
||||
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
|
||||
["#/a-propos", { screen: "about" }],
|
||||
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
|
||||
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
|
||||
["#/tutoriel/fin", { screen: "tutorialEnd" }],
|
||||
["#/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" }],
|
||||
[
|
||||
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
|
||||
{ screen: "position", position: START, match: MATCH },
|
||||
],
|
||||
["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }],
|
||||
];
|
||||
|
||||
test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => {
|
||||
for (const [hash, route] of TABLE) {
|
||||
assert.deepEqual(parseRoute(hash), route, hash);
|
||||
assert.equal(routeHash(route), hash, hash);
|
||||
}
|
||||
});
|
||||
|
||||
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" });
|
||||
assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null });
|
||||
assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" });
|
||||
});
|
||||
|
||||
test("une adresse inconnue est reconnue comme telle", () => {
|
||||
for (const hash of [
|
||||
"#/inconnu",
|
||||
"#aide",
|
||||
"#/aide/a/b",
|
||||
"#/aide/Majuscules",
|
||||
"#/menu/plus",
|
||||
"#/tutoriel/0",
|
||||
"#/tutoriel/x",
|
||||
"#/tutoriel/3/b",
|
||||
"#/tutoriel/3/a-vous/x",
|
||||
"#/tutoriel/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",
|
||||
]) {
|
||||
assert.equal(parseRoute(hash).screen, "unknown", hash);
|
||||
}
|
||||
assert.equal(routeHash(parseRoute("#/inconnu")), "#/inconnu");
|
||||
});
|
||||
|
||||
test("un lien de position collé sans encodage (+ et / du base64) est compris", () => {
|
||||
assert.deepEqual(parseRoute(`#/position/${START}/${MATCH}`), {
|
||||
screen: "position",
|
||||
position: START,
|
||||
match: MATCH,
|
||||
});
|
||||
assert.deepEqual(parseRoute("#/position/jGfkASjg8wcBMA/cAkAAAAAAAAA"), {
|
||||
screen: "position",
|
||||
position: "jGfkASjg8wcBMA",
|
||||
match: MATCH,
|
||||
});
|
||||
// + reste un + (pas une espace), encodé ou non.
|
||||
const plus = "AAAA+AAAAAAAAA";
|
||||
assert.equal(
|
||||
routeHash({ screen: "position", position: plus, match: null }),
|
||||
"#/position/AAAA%2BAAAAAAAAA",
|
||||
);
|
||||
assert.deepEqual(parseRoute("#/position/AAAA%2BAAAAAAAAA"), {
|
||||
screen: "position",
|
||||
position: plus,
|
||||
match: null,
|
||||
});
|
||||
assert.deepEqual(parseRoute(`#/position/${plus}`), {
|
||||
screen: "position",
|
||||
position: plus,
|
||||
match: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("un identifiant de position de mauvaise longueur arrive tel quel au moteur, qui jugera", () => {
|
||||
assert.deepEqual(parseRoute("#/position/abc"), {
|
||||
screen: "position",
|
||||
position: "abc",
|
||||
match: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("l'identifiant d'une question : le titre sans accents ni ponctuation", () => {
|
||||
assert.equal(slug("Jouer un coup, puis l'annuler"), "jouer-un-coup-puis-l-annuler");
|
||||
assert.equal(slug(" Été : déjà vu ? "), "ete-deja-vu");
|
||||
assert.equal(
|
||||
slug("Rejouer d'ici sans perdre l'original"),
|
||||
"rejouer-d-ici-sans-perdre-l-original",
|
||||
);
|
||||
});
|
||||
|
||||
test("les écrans de l'aide : jouer, les mots du jeu, à propos", () => {
|
||||
assert.ok(isHelpScreen(parseRoute("#/aide/x")));
|
||||
assert.ok(isHelpScreen(parseRoute("#/glossaire")));
|
||||
assert.ok(isHelpScreen(parseRoute("#/a-propos")));
|
||||
assert.ok(!isHelpScreen(parseRoute("#/menu")));
|
||||
});
|
||||
|
||||
test("quitter un écran revient à la dernière adresse qui n'en est pas", () => {
|
||||
const help = (route: Route) => isHelpScreen(route);
|
||||
// Partie, aide, une question : « Retour » remonte de deux pas.
|
||||
assert.equal(stepsBack(["#/", "#/aide", "#/aide/q"], help), -2);
|
||||
// Le menu ouvert depuis l'aide : on revient à l'aide.
|
||||
const menu = (route: Route) => route.screen === "menu";
|
||||
assert.equal(stepsBack(["#/", "#/aide", "#/menu"], menu), -1);
|
||||
// Ouvert directement dans un nouvel onglet : rien avant, on remplace.
|
||||
assert.equal(stepsBack(["#/aide/q"], help), null);
|
||||
assert.equal(stepsBack(["#/aide", "#/glossaire/blot"], help), null);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
|
||||
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
|
||||
// installer passent par les modules de l'interface qui les décident
|
||||
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
|
||||
// vrai moteur, le tutoriel par son texte source.
|
||||
//
|
||||
// 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 { Game } from "../../src/domain/game/game.js";
|
||||
import {
|
||||
dismissInstall,
|
||||
homeModel,
|
||||
installMenuShown,
|
||||
launchHash,
|
||||
WELCOME_LINKS,
|
||||
welcomed,
|
||||
} from "../../src/ui/home-model.js";
|
||||
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
|
||||
import { installPlatform } from "../../src/ui/install.js";
|
||||
import { parseRoute } from "../../src/ui/router.js";
|
||||
import { parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { placeOf } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial } from "../../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { seededDice, step } from "../support/play.js";
|
||||
|
||||
const root = new URL("../../../../", import.meta.url);
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let game: Game | null = null;
|
||||
let installed = false;
|
||||
let route = "";
|
||||
let installSteps: readonly string[] = [];
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Ce que montre l'accueil, la partie étant celle du scénario. */
|
||||
function home() {
|
||||
const playing = game === null ? false : game.state.phase !== "over";
|
||||
return homeModel(settings, playing, installed);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- Premier lancement
|
||||
|
||||
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
game = null;
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
|
||||
assert.equal(launchHash(""), "#/accueil");
|
||||
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
|
||||
assert.ok(home().welcome);
|
||||
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
|
||||
});
|
||||
|
||||
When("vous choisissez de découvrir les règles", () => {
|
||||
settings = { ...settings, ...welcomed() };
|
||||
route = WELCOME_LINKS.discover;
|
||||
});
|
||||
|
||||
Then("le tutoriel s'ouvre à sa première étape", () => {
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
|
||||
});
|
||||
|
||||
Then("ce choix ne vous est plus proposé", () => {
|
||||
assert.ok(!home().welcome);
|
||||
});
|
||||
|
||||
Given("vous avez choisi de jouer tout de suite", () => {
|
||||
settings = { ...parseSettings(null), ...welcomed() };
|
||||
installed = false;
|
||||
});
|
||||
|
||||
Given("une partie est en cours", () => {
|
||||
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
|
||||
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
|
||||
});
|
||||
|
||||
When("vous rouvrez Ustagammon", () => {
|
||||
route = launchHash("") ?? "";
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose de reprendre votre partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(home().resume);
|
||||
});
|
||||
|
||||
When("la partie se termine", async () => {
|
||||
assert.ok(game !== null);
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
assert.ok(i < 2000, "la partie ne finit pas");
|
||||
await step(game, engine);
|
||||
}
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose une nouvelle partie", () => {
|
||||
assert.equal(parseRoute(route).screen, "home");
|
||||
assert.ok(!home().resume);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Installation
|
||||
|
||||
Given("votre navigateur ne propose pas lui-même l'installation", () => {
|
||||
installSteps = [];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application avec Firefox", () => {
|
||||
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
|
||||
});
|
||||
|
||||
When("vous demandez à installer l'application sur iPhone", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
||||
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /menu du navigateur/);
|
||||
});
|
||||
|
||||
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
|
||||
assert.equal(installSteps.length, 3);
|
||||
assert.match(installSteps[0] ?? "", /bouton de partage/);
|
||||
});
|
||||
|
||||
Then("l'accueil ne vous propose pas d'installer l'application", () => {
|
||||
assert.ok(!home().installCard);
|
||||
});
|
||||
|
||||
Then("l'accueil vous propose d'installer l'application", () => {
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous terminez une partie", () => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
|
||||
});
|
||||
|
||||
When("vous terminez {int} parties de plus", (n: number) => {
|
||||
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
|
||||
});
|
||||
|
||||
When("vous choisissez « Plus tard »", () => {
|
||||
settings = { ...settings, ...dismissInstall(settings) };
|
||||
});
|
||||
|
||||
Then("le menu vous le propose toujours", () => {
|
||||
assert.ok(installMenuShown(settings, installed));
|
||||
});
|
||||
|
||||
Given("vous avez terminé une partie", () => {
|
||||
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
|
||||
installed = false;
|
||||
assert.ok(home().installCard);
|
||||
});
|
||||
|
||||
When("vous installez l'application", () => {
|
||||
// L'événement appinstalled du navigateur, retenu dans les réglages.
|
||||
installed = true;
|
||||
settings = { ...settings, installed: true };
|
||||
});
|
||||
|
||||
Then("le menu non plus", () => {
|
||||
assert.ok(!installMenuShown(settings, installed));
|
||||
// Même dans un onglet ouvert plus tard, hors de l'application installée.
|
||||
assert.ok(!installMenuShown(settings, false));
|
||||
assert.ok(!homeModel(settings, true, false).installCard);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
|
||||
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
|
||||
// chaque geste est jugé par le domaine branché sur le vrai moteur, 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 {
|
||||
MovePractice,
|
||||
type PracticeVerdict,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../../src/domain/learning/tutorial.js";
|
||||
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
|
||||
import { parseTutorial, type Tutorial, 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 tutorial: Tutorial;
|
||||
let seen = 0;
|
||||
let place: TutorialPlace = { step: 1, practice: false };
|
||||
let screen: TutorialScreen;
|
||||
let practice: MovePractice | null = null;
|
||||
let verdict: PracticeVerdict = "continue";
|
||||
let answeredRight = false;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
});
|
||||
|
||||
Given("vous commencez le tutoriel", () => {
|
||||
seen = 0;
|
||||
place = { step: 1, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
});
|
||||
|
||||
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
|
||||
});
|
||||
|
||||
Then("chaque position du tutoriel se lit sur le plateau", async () => {
|
||||
for (const step of tutorial.steps) {
|
||||
for (const current of [step.explain, step.practice]) {
|
||||
if (current !== null) {
|
||||
const board = await tutorialBoard(engine, current.position, current.onRoll);
|
||||
assert.equal(board.sides.player.points.length, 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
When("vous arrivez à l'étape {int}", (n: number) => {
|
||||
for (let step = 1; step <= n; step++) {
|
||||
place = { step, practice: false };
|
||||
seen = seenAfter(seen, place);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
|
||||
const links = progress(tutorial.steps.length, place, seen)
|
||||
.filter((segment) => segment.link)
|
||||
.map((segment) => segment.step);
|
||||
assert.deepEqual(links, [1, 2]);
|
||||
});
|
||||
|
||||
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
|
||||
const found = tutorial.steps[n - 1]?.practice;
|
||||
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
|
||||
screen = found;
|
||||
practice =
|
||||
screen.roll === null || screen.expected.length === 0
|
||||
? null
|
||||
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
|
||||
verdict = "continue";
|
||||
answeredRight = false;
|
||||
});
|
||||
|
||||
When(
|
||||
"vous déplacez 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 une phrase vous dit pourquoi", () => {
|
||||
assert.equal(verdict, "wrong");
|
||||
assert.deepEqual(practice?.pendingHalfMoves, []);
|
||||
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
|
||||
});
|
||||
|
||||
When("vous réunissez deux pions sur la flèche 5", async () => {
|
||||
assert.ok(practice !== null);
|
||||
verdict = await practice.play({ from: 8, to: 5 });
|
||||
assert.equal(verdict, "continue");
|
||||
verdict = await practice.play({ from: 6, to: 5 });
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre coup", () => {
|
||||
assert.equal(verdict, "right");
|
||||
assert.ok(screen.right.length > 0);
|
||||
});
|
||||
|
||||
When("vous répondez « Passer »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === false;
|
||||
});
|
||||
|
||||
When("vous répondez « Prendre »", async () => {
|
||||
answeredRight = (await shouldTake(engine, screen.position)) === true;
|
||||
});
|
||||
|
||||
Then("le maître dit qu'il aurait pris", () => {
|
||||
assert.ok(!answeredRight);
|
||||
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
|
||||
});
|
||||
|
||||
Then("le tutoriel salue votre réponse", () => {
|
||||
assert.ok(answeredRight);
|
||||
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
|
||||
});
|
||||
@@ -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,324 @@
|
||||
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
|
||||
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
|
||||
// (settings.ts) et la session d'enregistrement ; les parties et le match,
|
||||
// par le domaine branché sur le vrai moteur, avec des dés imposés.
|
||||
//
|
||||
// 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 type { Engine } from "../../src/domain/game/engine.js";
|
||||
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
|
||||
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
|
||||
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
|
||||
import { START_POSITION } from "../../src/domain/game/position.js";
|
||||
import { fixedDice } from "../../src/domain/game/roll.js";
|
||||
import { createGameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
|
||||
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playToTheEnd, seededDice } from "../support/play.js";
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
|
||||
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
|
||||
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
|
||||
// 6-5, le joueur au trait gagne un gammon.
|
||||
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
|
||||
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
|
||||
// gagne une partie simple avec 6-5.
|
||||
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
|
||||
|
||||
let engine: Engine;
|
||||
let settings: Settings;
|
||||
let session: GameSession;
|
||||
let game: Game;
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
|
||||
async function playFirstMove(g: Game): Promise<void> {
|
||||
await g.roll();
|
||||
const state = g.state;
|
||||
assert.ok(state.roll !== null);
|
||||
const [move] = await engine.legalMoves(state.position, state.roll);
|
||||
assert.ok(move !== undefined);
|
||||
for (const halfMove of move.halfMoves) {
|
||||
assert.ok(await g.playHalfMove(halfMove));
|
||||
}
|
||||
await g.confirmMove();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Le niveau
|
||||
|
||||
Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => {
|
||||
settings = parseSettings(null);
|
||||
session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), {
|
||||
start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
});
|
||||
});
|
||||
|
||||
Then(
|
||||
"une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée",
|
||||
() => {
|
||||
assert.equal(settings.gameKind, "free");
|
||||
assert.equal(settings.level, "intermediate");
|
||||
assert.equal(settings.masterAdvice, true);
|
||||
assert.deepEqual(chosenGame(settings), { level: "intermediate" });
|
||||
},
|
||||
);
|
||||
|
||||
When("vous choisissez le niveau Champion et commencez la partie", () => {
|
||||
settings = { ...settings, level: "champion" };
|
||||
game = session.newGame(chosenGame(settings));
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => {
|
||||
assert.equal(game.state.level, `${plies}ply`);
|
||||
// Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue
|
||||
// le coup que le moteur donne pour ce niveau.
|
||||
await game.playHalfMove({ from: 8, to: 5 });
|
||||
await game.playHalfMove({ from: 6, to: 5 });
|
||||
await game.confirmMove();
|
||||
const before = game.state;
|
||||
await game.playComputerTurn();
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
assert.ok(played?.type === "MovePlayed" && played.side === "computer");
|
||||
const roll = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
assert.ok(roll?.type === "DiceRolled");
|
||||
const expected = await engine.levelMove(
|
||||
before.position,
|
||||
engineCube(before, "computer"),
|
||||
roll.roll,
|
||||
"2ply",
|
||||
);
|
||||
assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []);
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("la partie reprise garde ce niveau", async () => {
|
||||
const resumed = await session.restore();
|
||||
assert.ok(resumed !== null);
|
||||
assert.equal(resumed.state.level, "2ply");
|
||||
});
|
||||
|
||||
Then("vos choix sont proposés à la prochaine nouvelle partie", () => {
|
||||
const again = parseSettings(JSON.stringify(settings));
|
||||
assert.equal(again.level, "champion");
|
||||
assert.equal(chosenGame(again).level, "2ply");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- Un match entier
|
||||
|
||||
// Les parties du match, dans l'ordre.
|
||||
let games: Game[] = [];
|
||||
|
||||
Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => {
|
||||
settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" };
|
||||
games = [];
|
||||
});
|
||||
|
||||
When("le match se joue jusqu'au bout, partie après partie", async () => {
|
||||
// Des dés reproductibles, partagés par toutes les parties du match.
|
||||
const dice = seededDice(3);
|
||||
let next: NewGame | null = chosenGame(settings);
|
||||
while (next !== null) {
|
||||
assert.ok(games.length < 20, "le match ne finit pas");
|
||||
const g = Game.start(engine, { dice, ...next });
|
||||
await playToTheEnd(g, engine);
|
||||
games.push(g);
|
||||
next = g.nextGame;
|
||||
}
|
||||
});
|
||||
|
||||
Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => {
|
||||
const last = games.at(-1)?.state;
|
||||
assert.ok(last?.matchAfter);
|
||||
assert.equal(last.matchAfter.over, true);
|
||||
const { player, computer } = last.matchAfter.score;
|
||||
assert.ok(Math.max(player, computer) >= n);
|
||||
// Avant la dernière partie, personne n'avait encore les points.
|
||||
for (const g of games.slice(0, -1)) {
|
||||
assert.equal(g.state.matchAfter?.over, false);
|
||||
const score = g.state.matchAfter?.score;
|
||||
assert.ok(score !== undefined && Math.max(score.player, score.computer) < n);
|
||||
}
|
||||
});
|
||||
|
||||
Then("chaque partie commence avec le score laissé par la précédente", () => {
|
||||
assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 });
|
||||
for (let i = 1; i < games.length; i++) {
|
||||
assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score);
|
||||
}
|
||||
});
|
||||
|
||||
Then("la partie Crawford s'est jouée sans videau", () => {
|
||||
const crawford = games.filter((g) => g.state.match?.crawford === true);
|
||||
assert.equal(crawford.length, 1, "une partie Crawford, et une seule");
|
||||
for (const g of crawford) {
|
||||
assert.ok(!g.events.some((e) => e.type === "CubeOffered"));
|
||||
assert.equal(g.state.cube.value, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- Rejouer
|
||||
|
||||
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
|
||||
function engineLevelNamed(name: string) {
|
||||
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
|
||||
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
|
||||
return ENGINE_LEVELS[level];
|
||||
}
|
||||
|
||||
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.phase, "over");
|
||||
assert.equal(game.nextGame, null);
|
||||
});
|
||||
|
||||
Given(
|
||||
"un match en {int} points contre un ordinateur {word} vient de se terminer",
|
||||
async (n: number, name: string) => {
|
||||
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
|
||||
// fini.
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
level: engineLevelNamed(name),
|
||||
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
assert.equal(game.state.matchAfter?.over, true);
|
||||
assert.equal(game.nextGame, null);
|
||||
},
|
||||
);
|
||||
|
||||
When("vous rejouez", () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
|
||||
});
|
||||
|
||||
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
|
||||
assert.equal(game.state.phase, "moving");
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.equal(game.state.match, null);
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
});
|
||||
|
||||
Then(
|
||||
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
|
||||
(n: number, name: string) => {
|
||||
assert.equal(game.state.position, START_POSITION);
|
||||
assert.deepEqual(game.state.match, firstGameOf(n));
|
||||
assert.equal(game.state.level, engineLevelNamed(name));
|
||||
},
|
||||
);
|
||||
|
||||
// ------------------------------------------------------- La partie Crawford
|
||||
|
||||
let crawfordGame: Game;
|
||||
|
||||
Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" },
|
||||
level: "beginner",
|
||||
match: firstGameOf(3),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
When("l'ordinateur gagne la partie", async () => {
|
||||
await game.playComputerTurn();
|
||||
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
|
||||
});
|
||||
|
||||
Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => {
|
||||
assert.deepEqual(game.state.matchAfter?.score, { player, computer });
|
||||
});
|
||||
|
||||
Then("la partie suivante est la partie Crawford", () => {
|
||||
assert.equal(game.nextGame?.match?.crawford, true);
|
||||
});
|
||||
|
||||
When("la partie Crawford commence", () => {
|
||||
const next = game.nextGame;
|
||||
assert.ok(next?.match !== undefined);
|
||||
crawfordGame = Game.start(engine, {
|
||||
...next,
|
||||
setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
});
|
||||
|
||||
Then("personne ne peut doubler pendant cette partie", async () => {
|
||||
assert.equal(await crawfordGame.canDouble(), false);
|
||||
// L'ordinateur non plus, dans la même position vue de lui.
|
||||
const state = crawfordGame.state;
|
||||
const board = await engine.board(state.position, engineCube(state, "player"));
|
||||
assert.equal(
|
||||
await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
When("vous gagnez la partie Crawford", async () => {
|
||||
await playFirstMove(crawfordGame);
|
||||
assert.deepEqual(crawfordGame.state.matchAfter, {
|
||||
score: { player: 1, computer: 2 },
|
||||
over: false,
|
||||
crawford: false,
|
||||
});
|
||||
});
|
||||
|
||||
Then("vous pouvez de nouveau doubler dans la partie suivante", async () => {
|
||||
const next = crawfordGame.nextGame;
|
||||
assert.ok(next?.match !== undefined);
|
||||
const match: Match = next.match;
|
||||
assert.equal(match.crawford, false);
|
||||
const after = Game.start(engine, {
|
||||
...next,
|
||||
setup: { position: START_POSITION, firstTurn: "player" },
|
||||
});
|
||||
assert.equal(await after.canDouble(), true);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- La règle Jacoby
|
||||
|
||||
Given("vous allez gagner un gammon sans que personne ait doublé", () => {
|
||||
// La position est choisie à chaque partie, ci-dessous.
|
||||
});
|
||||
|
||||
When("vous gagnez une partie libre", async () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
});
|
||||
|
||||
When("vous gagnez la même partie dans un match", async () => {
|
||||
game = Game.start(engine, {
|
||||
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
|
||||
match: firstGameOf(5),
|
||||
dice: fixedDice([6, 5]),
|
||||
});
|
||||
await playFirstMove(game);
|
||||
});
|
||||
|
||||
Then("vous marquez {int} point(s)", (points: number) => {
|
||||
assert.equal(game.state.result?.winner, "player");
|
||||
assert.equal(game.state.result?.kind, "gammon");
|
||||
assert.equal(game.state.result?.points, points);
|
||||
});
|
||||
@@ -426,8 +426,8 @@ When("vous prenez", () => {
|
||||
game.take();
|
||||
});
|
||||
|
||||
When("vous passez", () => {
|
||||
game.drop();
|
||||
When("vous passez", async () => {
|
||||
await game.drop();
|
||||
});
|
||||
|
||||
Then("la partie continue à valeur doublée", () => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles
|
||||
// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le
|
||||
// stockage des parties sur un localStorage simulé en mémoire. Fermer puis
|
||||
// rouvrir l'application revient à rouvrir ce stockage et à reprendre la
|
||||
// partie en cours, comme main.ts au démarrage.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Before, Given, Then, When } from "@cucumber/cucumber";
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import type { GameLibrary } from "../../src/storage/game-library.js";
|
||||
import type { KeyValueStorage } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, seededDice } from "../support/play.js";
|
||||
|
||||
let engine: Engine;
|
||||
let storage: KeyValueStorage;
|
||||
let library: GameLibrary;
|
||||
let game: Game;
|
||||
let shownId: string;
|
||||
/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */
|
||||
let played: { id: string; journal: GameJournal }[];
|
||||
/** La partie d'origine, avant « Rejouer d'ici ». */
|
||||
let original: { head: string; ids: string[] };
|
||||
/** Le coup du joueur d'où l'on rejoue. */
|
||||
let replayed: MovePlayed;
|
||||
let sgf: string;
|
||||
let nextSeed = 1;
|
||||
|
||||
Before(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
storage = memoryStorage();
|
||||
library = (await openGameLibrary({ localStorage: storage })).library;
|
||||
played = [];
|
||||
});
|
||||
|
||||
/** Commence une partie, comme l'application : un nouvel identifiant. */
|
||||
function startGame(): void {
|
||||
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
|
||||
shownId = `partie-${nextSeed++}`;
|
||||
}
|
||||
|
||||
/** Joue des coups, en enregistrant après chacun comme l'application. */
|
||||
async function play(moves: number): Promise<void> {
|
||||
for (let i = 0; i < moves; i++) {
|
||||
await playMoves(game, engine, 1);
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(): Promise<void> {
|
||||
library = (await openGameLibrary({ localStorage: storage })).library;
|
||||
const saved = await library.current();
|
||||
assert.ok(saved !== null, "aucune partie en cours");
|
||||
game = Game.resume(engine, saved.journal, { level: "0ply" });
|
||||
shownId = saved.summary.id;
|
||||
}
|
||||
|
||||
/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses
|
||||
* dés déjà lancés). Renvoie ce coup. */
|
||||
function goBackToSecondMove(): MovePlayed {
|
||||
original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) };
|
||||
const second = game.events.filter(
|
||||
(e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player",
|
||||
)[1];
|
||||
assert.ok(second?.parent != null, "moins de deux coups du joueur");
|
||||
game.goBackTo(second.parent);
|
||||
return second;
|
||||
}
|
||||
|
||||
/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */
|
||||
async function playAnotherMove(first: MovePlayed): Promise<void> {
|
||||
const state = game.state;
|
||||
assert.ok(state.roll !== null);
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
// Move.position est vue de celui qui joue, MovePlayed.position de son
|
||||
// adversaire : on compare les demi-coups.
|
||||
const same = (m: (typeof moves)[number]) =>
|
||||
m.halfMoves.length === first.halfMoves.length &&
|
||||
m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from);
|
||||
const move = moves.find((m) => !same(m));
|
||||
assert.ok(move !== undefined, "un seul coup possible");
|
||||
for (const halfMove of move.halfMoves) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await library.save(shownId, game.journal, { level: "0ply" });
|
||||
const last = game.events.at(-1);
|
||||
assert.ok(last?.type === "MovePlayed" && last.position !== first.position);
|
||||
}
|
||||
|
||||
// ------------------------------------------- Reprendre une partie parmi plusieurs
|
||||
|
||||
Given("vous avez joué deux parties, l'une après l'autre", async () => {
|
||||
for (const moves of [5, 3]) {
|
||||
startGame();
|
||||
await play(moves);
|
||||
played.push({ id: shownId, journal: game.journal });
|
||||
}
|
||||
});
|
||||
|
||||
When("vous fermez l'application puis la rouvrez", reopen);
|
||||
|
||||
Then("la dernière partie jouée est à l'écran", () => {
|
||||
const last = played.at(-1);
|
||||
assert.equal(shownId, last?.id);
|
||||
assert.deepEqual(game.journal, last?.journal);
|
||||
});
|
||||
|
||||
Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => {
|
||||
const ids = (await library.list()).map((s) => s.id);
|
||||
assert.deepEqual(ids, played.map((p) => p.id).reverse());
|
||||
});
|
||||
|
||||
When("vous reprenez la première partie", async () => {
|
||||
await library.setCurrent(played[0]?.id as string);
|
||||
await reopen();
|
||||
});
|
||||
|
||||
Then("elle est à l'écran, telle que vous l'aviez laissée", () => {
|
||||
assert.equal(shownId, played[0]?.id);
|
||||
assert.deepEqual(game.journal, played[0]?.journal);
|
||||
});
|
||||
|
||||
// ------------------------------------------- Rejouer d'ici sans perdre l'original
|
||||
|
||||
Given("vous avez joué plusieurs coups", async () => {
|
||||
startGame();
|
||||
await play(8);
|
||||
});
|
||||
|
||||
When("vous rejouez d'ici à partir de votre deuxième coup", () => {
|
||||
// Le retour seul : le coup suivant ouvrira la branche.
|
||||
replayed = goBackToSecondMove();
|
||||
});
|
||||
|
||||
When("vous choisissez un autre coup", async () => {
|
||||
await playAnotherMove(replayed);
|
||||
});
|
||||
|
||||
Then("la partie a deux branches", () => {
|
||||
assert.equal(game.branches.length, 2);
|
||||
});
|
||||
|
||||
Then("la partie d'origine est intacte", () => {
|
||||
const origin = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.equal(origin?.head, original.head);
|
||||
const copy = Game.resume(engine, game.journal);
|
||||
copy.switchBranch(original.head);
|
||||
assert.deepEqual(
|
||||
copy.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
Then("vous êtes sur la nouvelle branche", () => {
|
||||
const current = game.branches.find((b) => b.containsHead);
|
||||
assert.ok(current !== undefined && current.forkedFrom !== null);
|
||||
assert.equal(current.head, game.journal.head);
|
||||
});
|
||||
|
||||
Then("vous pouvez revenir à la partie d'origine, au coup près", () => {
|
||||
const branch = game.branches.find((b) => b.forkedFrom === null);
|
||||
assert.ok(branch !== undefined);
|
||||
game.switchBranch(branch.head);
|
||||
assert.deepEqual(
|
||||
game.events.map((e) => e.id),
|
||||
original.ids,
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Exporter une partie
|
||||
|
||||
Given("vous avez rejoué d'ici une autre suite", async () => {
|
||||
await playAnotherMove(goBackToSecondMove());
|
||||
await play(2);
|
||||
});
|
||||
|
||||
When("vous exportez la partie", async () => {
|
||||
sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ustagammon",
|
||||
});
|
||||
});
|
||||
|
||||
Then("vous obtenez un fichier SGF de GNU Backgammon", () => {
|
||||
assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40));
|
||||
assert.ok(sgf.trimEnd().endsWith(")"));
|
||||
});
|
||||
|
||||
Then("il contient vos coups, et l'autre branche en variante", () => {
|
||||
// Un nœud par coup joué, toutes branches comprises (B ou W, puis les
|
||||
// dés), et deux variantes à l'embranchement.
|
||||
const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length;
|
||||
assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves);
|
||||
assert.equal(sgf.match(/\(\n;/g)?.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008,
|
||||
// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite
|
||||
// puis relue), la position par le domaine et le vrai moteur WebAssembly, et
|
||||
// les parties par le même enregistrement que 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, Then, When } from "@cucumber/cucumber";
|
||||
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 {
|
||||
linkOf,
|
||||
readPosition,
|
||||
type SharedPosition,
|
||||
setupOf,
|
||||
UnreadableLinkError,
|
||||
} from "../../src/domain/game/position-link.js";
|
||||
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { parseRoute, routeHash } from "../../src/ui/router.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, seededDice, step } from "../support/play.js";
|
||||
|
||||
let engine: Engine;
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
let game: Game;
|
||||
let gameId: string | null;
|
||||
let journalBefore: GameJournal;
|
||||
let address: string;
|
||||
let shared: SharedPosition | null;
|
||||
let failure: unknown;
|
||||
|
||||
/** Une position de milieu de partie (gnubg), vue du joueur au trait. */
|
||||
const MIDDLE_GAME = "jGfkASjg8wcBMA";
|
||||
|
||||
BeforeAll(async () => {
|
||||
engine = await loadNodeEngine();
|
||||
});
|
||||
|
||||
Before(() => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
session = createGameSession(library, {
|
||||
start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }),
|
||||
resume: (journal) => Game.resume(engine, journal, { level: "0ply" }),
|
||||
});
|
||||
shared = null;
|
||||
failure = null;
|
||||
});
|
||||
|
||||
/** Ouvre une adresse comme l'application : le routeur la lit, le moteur
|
||||
* lit la position. */
|
||||
async function open(hash: string): Promise<void> {
|
||||
const route = parseRoute(hash);
|
||||
assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`);
|
||||
if (route.screen !== "position") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
shared = await readPosition(engine, { position: route.position, matchId: route.match });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
|
||||
Given("votre partie en cours compte quelques coups", async () => {
|
||||
game = session.newGame();
|
||||
// Des coups jusqu'à ce que ce soit à vous de lancer les dés.
|
||||
await playMoves(game, engine, 4);
|
||||
const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling";
|
||||
while (!yourRoll() && game.state.phase !== "over") {
|
||||
await step(game, engine);
|
||||
}
|
||||
assert.ok(yourRoll(), "partie finie trop tôt");
|
||||
session.save(game.journal);
|
||||
gameId = session.shownId;
|
||||
journalBefore = game.journal;
|
||||
});
|
||||
|
||||
When("vous copiez le lien de la position", async () => {
|
||||
const link = await linkOf(engine, game.state);
|
||||
address = routeHash({ screen: "position", position: link.position, match: link.matchId });
|
||||
});
|
||||
|
||||
Then("le lien contient l'identifiant de la position et celui du match", () => {
|
||||
const route = parseRoute(address);
|
||||
assert.ok(route.screen === "position");
|
||||
assert.equal(route.position, game.state.position);
|
||||
assert.equal(route.match?.length, 12);
|
||||
assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/);
|
||||
});
|
||||
|
||||
When("vous ouvrez ce lien", async () => {
|
||||
await open(address);
|
||||
});
|
||||
|
||||
Then("vous voyez exactement la même position, avec le même videau", async () => {
|
||||
assert.ok(shared !== null, `lien illisible : ${failure}`);
|
||||
assert.deepEqual(shared.sides, await game.displayedBoard());
|
||||
assert.deepEqual(shared.cube, game.state.cube);
|
||||
assert.equal(shared.match, null);
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => {
|
||||
const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" });
|
||||
address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId });
|
||||
await open(address);
|
||||
});
|
||||
|
||||
Then("vous voyez cette position, avec la main", async () => {
|
||||
assert.ok(shared !== null, `lien illisible : ${failure}`);
|
||||
const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" });
|
||||
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
|
||||
assert.equal(shared.result, null);
|
||||
});
|
||||
|
||||
Then("votre partie n'a pas changé", () => {
|
||||
assert.equal(session.shownId, gameId);
|
||||
assert.deepEqual(game.journal, journalBefore);
|
||||
});
|
||||
|
||||
When("vous choisissez « Jouer à partir d'ici »", () => {
|
||||
assert.ok(shared !== null);
|
||||
game = session.newGame({ setup: setupOf(shared) });
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => {
|
||||
assert.notEqual(session.shownId, gameId);
|
||||
assert.equal(game.state.position, MIDDLE_GAME);
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "rolling");
|
||||
});
|
||||
|
||||
Then("le videau est à 2, chez l'ordinateur", () => {
|
||||
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
|
||||
});
|
||||
|
||||
Then("votre partie précédente est toujours enregistrée", async () => {
|
||||
assert.ok(gameId !== null);
|
||||
const saved = await library.load(gameId);
|
||||
assert.deepEqual(saved?.journal, journalBefore);
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => {
|
||||
// Coupé d'un caractère, puis un caractère hors du base64.
|
||||
shared = null;
|
||||
failure = null;
|
||||
await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA");
|
||||
});
|
||||
|
||||
When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => {
|
||||
shared = null;
|
||||
failure = null;
|
||||
await open("#/position/4HPwATDgc%2FABMA/cAk*");
|
||||
});
|
||||
|
||||
Then("le lien est déclaré illisible", () => {
|
||||
assert.equal(shared, null);
|
||||
assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`);
|
||||
});
|
||||
@@ -13,10 +13,14 @@ 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 { createGameStore } from "../../src/storage/game-store.js";
|
||||
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
|
||||
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
|
||||
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
|
||||
import { memoryStorage } from "../../src/storage/open-library.js";
|
||||
import { BANNER_MS } from "../../src/ui/app.js";
|
||||
import { createHaptics } from "../../src/ui/haptics.js";
|
||||
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
|
||||
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
|
||||
setDefaultTimeout(30_000);
|
||||
@@ -34,30 +38,37 @@ BeforeAll(async () => {
|
||||
|
||||
// -------------------------------------------------- Nouvelle partie annulable
|
||||
|
||||
// Comme dans l'application : chaque partie est enregistrée (localStorage,
|
||||
// ici un stockage en mémoire), et l'ancienne est reprise depuis son journal.
|
||||
const data = new Map<string, string>();
|
||||
const store = createGameStore({
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => void data.set(key, value),
|
||||
});
|
||||
// Comme dans l'application : chaque partie est enregistrée par la session
|
||||
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
|
||||
// stockage par son identifiant.
|
||||
let library: GameLibrary;
|
||||
let session: GameSession;
|
||||
let oldId: string | null;
|
||||
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]) });
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
|
||||
session = createGameSession(library, {
|
||||
start: () => Game.start(engine, { dice }),
|
||||
resume: (journal) => Game.resume(engine, journal, { dice }),
|
||||
});
|
||||
game = session.newGame();
|
||||
dice = () => 1 + Math.floor(Math.random() * 6);
|
||||
for (const halfMove of parseHalfMoves("8/5 6/5")) {
|
||||
assert.ok(await game.playHalfMove(halfMove));
|
||||
}
|
||||
await game.confirmMove();
|
||||
await game.playComputerTurn();
|
||||
store.save(game.journal);
|
||||
session.save(game.journal);
|
||||
oldId = session.shownId;
|
||||
assert.equal(game.state.turn, "player");
|
||||
});
|
||||
|
||||
When("vous commencez une nouvelle partie depuis le menu", async () => {
|
||||
oldJournal = game.journal;
|
||||
oldBoard = await game.displayedBoard();
|
||||
game = Game.start(engine);
|
||||
store.save(game.journal);
|
||||
game = session.newGame();
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("une nouvelle partie commence aussitôt", () => {
|
||||
@@ -69,13 +80,21 @@ Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: nu
|
||||
assert.equal(BANNER_MS, seconds * 1000);
|
||||
});
|
||||
|
||||
When("vous reprenez l'ancienne partie", () => {
|
||||
game = Game.resume(engine, oldJournal);
|
||||
store.save(game.journal);
|
||||
When("vous reprenez l'ancienne partie", async () => {
|
||||
const old = await session.resumePrevious();
|
||||
assert.ok(old !== null);
|
||||
game = old;
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
Then("vous retrouvez exactement la partie quittée", async () => {
|
||||
assert.deepEqual(store.load(), oldJournal);
|
||||
assert.equal(session.shownId, oldId);
|
||||
assert.deepEqual((await library.current())?.journal, oldJournal);
|
||||
// La nouvelle partie, abandonnée, est supprimée.
|
||||
assert.deepEqual(
|
||||
(await library.list()).map((s) => s.id),
|
||||
[oldId],
|
||||
);
|
||||
assert.deepEqual(game.journal, oldJournal);
|
||||
assert.deepEqual(await game.displayedBoard(), oldBoard);
|
||||
assert.equal(game.state.turn, "player");
|
||||
@@ -141,3 +160,47 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
|
||||
}
|
||||
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ Mise à jour
|
||||
|
||||
// Le bandeau dépend de deux choses : une version attend (le service worker
|
||||
// l'a installée), et la partie est entre deux tours. La partie est celle du
|
||||
// domaine, sur le vrai moteur.
|
||||
let updateAvailable = false;
|
||||
|
||||
Given("une nouvelle version d'Ustagammon est disponible", () => {
|
||||
updateAvailable = true;
|
||||
});
|
||||
|
||||
const shownNow = (otherBanner = false) =>
|
||||
updateBannerShown(
|
||||
updateAvailable,
|
||||
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
|
||||
otherBanner,
|
||||
);
|
||||
|
||||
Given("vous avez des dés à jouer", () => {
|
||||
// Jet d'ouverture 3-1 : vous commencez.
|
||||
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
|
||||
assert.equal(game.state.turn, "player");
|
||||
assert.equal(game.state.phase, "moving");
|
||||
});
|
||||
|
||||
When("vous déplacez un pion de votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
|
||||
});
|
||||
|
||||
Then("l'application ne vous propose pas encore de mettre à jour", () => {
|
||||
assert.ok(!shownNow());
|
||||
});
|
||||
|
||||
When("vous avez joué votre coup", async () => {
|
||||
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
|
||||
await game.confirmMove();
|
||||
});
|
||||
|
||||
Then("l'application vous propose de mettre à jour", () => {
|
||||
assert.ok(shownNow());
|
||||
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
|
||||
assert.ok(!shownNow(true));
|
||||
});
|
||||
|
||||
@@ -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,263 @@
|
||||
// Tests du stockage des parties (src/storage/), sans navigateur : la
|
||||
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
|
||||
// de localStorage), la migration de la partie des versions précédentes et
|
||||
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
|
||||
// navigateur (tests/browser/play.mjs).
|
||||
//
|
||||
// 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 { branches } from "../src/domain/game/branches.js";
|
||||
import type { Engine } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
EMPTY_JOURNAL,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
} from "../src/domain/game/events.js";
|
||||
import { Game } from "../src/domain/game/game.js";
|
||||
import { START_POSITION } from "../src/domain/game/position.js";
|
||||
import { fixedDice } from "../src/domain/game/roll.js";
|
||||
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
|
||||
import { createGameSession } from "../src/storage/game-session.js";
|
||||
import {
|
||||
createKeyValueBackend,
|
||||
type KeyValueStorage,
|
||||
migrateLegacyGame,
|
||||
} from "../src/storage/key-value-backend.js";
|
||||
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
|
||||
|
||||
/** Une partie commencée, et éventuellement quelques événements de plus. */
|
||||
function journal(extraRolls = 0): GameJournal {
|
||||
let j = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
for (let i = 0; i < extraRolls; i++) {
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/** Une horloge qui avance d'une seconde à chaque lecture. */
|
||||
function clock(start = 1_000_000): () => number {
|
||||
let t = start;
|
||||
return () => {
|
||||
t += 1000;
|
||||
return t;
|
||||
};
|
||||
}
|
||||
|
||||
function library(storage: KeyValueStorage = memoryStorage()) {
|
||||
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
|
||||
}
|
||||
|
||||
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
|
||||
const games = library();
|
||||
assert.deepEqual(await games.list(), []);
|
||||
assert.equal(await games.current(), null);
|
||||
|
||||
const a = await games.save("a", journal(), { level: "2ply" });
|
||||
await games.save("b", journal(1), { level: "beginner" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b", "a"],
|
||||
);
|
||||
assert.equal(a.status, "playing");
|
||||
assert.equal(a.result, null);
|
||||
assert.equal(a.moveCount, 0);
|
||||
assert.equal(a.branchCount, 1);
|
||||
|
||||
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
|
||||
const again = await games.save("a", journal(2), { level: "2ply" });
|
||||
assert.equal(again.startedAt, a.startedAt);
|
||||
assert.ok(again.updatedAt > a.updatedAt);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.deepEqual((await games.load("a"))?.journal, journal(2));
|
||||
assert.equal((await games.load("b"))?.summary.level, "beginner");
|
||||
assert.equal(await games.load("inconnue"), null);
|
||||
});
|
||||
|
||||
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
|
||||
const games = library();
|
||||
await games.save("ancienne", journal(), { level: "2ply" });
|
||||
await games.save("nouvelle", journal(1), { level: "2ply" });
|
||||
assert.equal((await games.current())?.summary.id, "nouvelle");
|
||||
|
||||
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
|
||||
// être modifiée ; la nouvelle peut être supprimée.
|
||||
const before = await games.load("ancienne");
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("nouvelle");
|
||||
assert.deepEqual(await games.current(), before);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["ancienne"],
|
||||
);
|
||||
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
|
||||
|
||||
// La partie en cours supprimée : la dernière jouée la remplace.
|
||||
await games.save("autre", journal(), { level: "2ply" });
|
||||
await games.setCurrent("ancienne");
|
||||
await games.remove("ancienne");
|
||||
assert.equal((await games.current())?.summary.id, "autre");
|
||||
});
|
||||
|
||||
test("les opérations sont faites dans l'ordre des appels", async () => {
|
||||
const games = library();
|
||||
// Lancées sans attendre, comme l'enregistrement après chaque action.
|
||||
void games.save("a", journal(), { level: "2ply" });
|
||||
void games.save("a", journal(3), { level: "2ply" });
|
||||
void games.remove("a");
|
||||
void games.save("b", journal(), { level: "2ply" });
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("les branches et le résultat sont résumés", async () => {
|
||||
const games = library();
|
||||
let j = journal(2);
|
||||
j = goBackTo(j, "2");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
|
||||
j = appendEvent(j, {
|
||||
type: "MovePlayed",
|
||||
side: "player",
|
||||
halfMoves: [],
|
||||
position: START_POSITION,
|
||||
});
|
||||
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
|
||||
const summary = await games.save("x", j, { level: "2ply" });
|
||||
assert.equal(summary.branchCount, 2);
|
||||
assert.equal(summary.moveCount, 1);
|
||||
assert.equal(summary.status, "over");
|
||||
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
|
||||
});
|
||||
|
||||
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
|
||||
const storage = memoryStorage();
|
||||
const games = library(storage);
|
||||
await games.save("a", journal(), { level: "2ply" });
|
||||
storage.setItem("ustagammon.game.a", "{pas du JSON");
|
||||
assert.equal(await games.load("a"), null);
|
||||
assert.equal(isJournal({ events: [], head: 3 }), false);
|
||||
assert.equal(isJournal(null), false);
|
||||
|
||||
const full = library({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
|
||||
// La bibliothèque reste utilisable après un échec.
|
||||
assert.deepEqual(await full.list(), []);
|
||||
});
|
||||
|
||||
test("la partie des versions précédentes est migrée, une seule fois", async () => {
|
||||
const storage = memoryStorage();
|
||||
const old = journal(1);
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(old));
|
||||
const games = library(storage);
|
||||
const id = await migrateLegacyGame(storage, games, 42);
|
||||
assert.ok(id !== null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
const current = await games.current();
|
||||
assert.equal(current?.summary.id, id);
|
||||
assert.deepEqual(current?.journal, old);
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
|
||||
// Un ancien enregistrement illisible est seulement effacé.
|
||||
storage.setItem("ustagammon.current-game", "{pas du JSON");
|
||||
assert.equal(await migrateLegacyGame(storage, games), null);
|
||||
assert.equal(storage.getItem("ustagammon.current-game"), null);
|
||||
assert.equal((await games.list()).length, 1);
|
||||
});
|
||||
|
||||
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
|
||||
const storage = memoryStorage();
|
||||
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
|
||||
const opened = await openGameLibrary({ localStorage: storage });
|
||||
assert.equal(opened.kind, "localStorage");
|
||||
assert.equal((await opened.library.list()).length, 1);
|
||||
|
||||
const refused: KeyValueStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("interdit");
|
||||
},
|
||||
};
|
||||
const memory = await openGameLibrary({ localStorage: refused });
|
||||
assert.equal(memory.kind, "memory");
|
||||
await memory.library.save("a", journal(), { level: "2ply" });
|
||||
assert.equal((await memory.library.current())?.summary.id, "a");
|
||||
|
||||
// IndexedDB qui échoue à l'ouverture : repli.
|
||||
const broken = {
|
||||
open: () => {
|
||||
throw new Error("indisponible");
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
|
||||
});
|
||||
|
||||
test("les identifiants de partie sont distincts", () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
|
||||
assert.ok(ids.size > 95);
|
||||
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
|
||||
});
|
||||
|
||||
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.
|
||||
const engine = {} as Engine;
|
||||
const session = createGameSession(games, {
|
||||
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
|
||||
resume: (j) => Game.resume(engine, j),
|
||||
});
|
||||
assert.equal(await session.restore(), null);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
const first = session.newGame();
|
||||
const firstId = session.shownId as string;
|
||||
session.save(first.journal);
|
||||
const second = session.newGame();
|
||||
session.save(second.journal);
|
||||
assert.notEqual(session.shownId, firstId);
|
||||
|
||||
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
|
||||
const back = await session.resumePrevious();
|
||||
assert.deepEqual(back?.journal, first.journal);
|
||||
assert.equal(session.shownId, firstId);
|
||||
assert.deepEqual(
|
||||
(await games.list()).map((s) => s.id),
|
||||
[firstId],
|
||||
);
|
||||
assert.equal(await session.resumePrevious(), null);
|
||||
|
||||
// Une branche reprise devient la ligne courante enregistrée.
|
||||
let j = goBackTo(first.journal, "1");
|
||||
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
|
||||
await games.save(firstId, j, { level: "2ply" });
|
||||
const original = branches(j).find((b) => b.forkedFrom === null);
|
||||
assert.ok(original !== undefined);
|
||||
const opened = await session.open(firstId, original.head);
|
||||
assert.equal(opened.journal.head, original.head);
|
||||
assert.equal((await games.current())?.journal.head, original.head);
|
||||
await assert.rejects(session.open("inconnue"), /introuvable/);
|
||||
});
|
||||
@@ -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,86 @@
|
||||
// Jouer une partie du domaine sans interface, pour les tests, les
|
||||
// scénarios et les parties d'exemple : les deux camps, avec des dés
|
||||
// reproductibles.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { Engine } from "../../src/domain/game/engine.js";
|
||||
import type { Game } from "../../src/domain/game/game.js";
|
||||
import type { DiceSource } from "../../src/domain/game/roll.js";
|
||||
|
||||
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
|
||||
export function seededDice(seed: number): DiceSource {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return 1 + (Math.floor(state / 65536) % 6);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
|
||||
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
|
||||
* doubles si take, et double quand il le peut si double.
|
||||
*/
|
||||
export async function step(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const state = game.state;
|
||||
if (state.turn === "computer" && state.phase === "doubled") {
|
||||
if (strategy.take ?? true) {
|
||||
game.take();
|
||||
} else {
|
||||
await game.drop();
|
||||
}
|
||||
} else if (state.turn === "computer") {
|
||||
await game.playComputerTurn();
|
||||
} else if (state.phase === "rolling") {
|
||||
if (strategy.double === true && (await game.canDouble())) {
|
||||
await game.double();
|
||||
} else {
|
||||
await game.roll();
|
||||
}
|
||||
} else if (state.phase === "moving" && state.roll !== null) {
|
||||
const moves = await engine.legalMoves(state.position, state.roll);
|
||||
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
|
||||
if (move === undefined) {
|
||||
await game.skipTurn();
|
||||
return;
|
||||
}
|
||||
for (const halfMove of move.halfMoves) {
|
||||
if (!(await game.playHalfMove(halfMove))) {
|
||||
throw new Error("coup légal refusé");
|
||||
}
|
||||
}
|
||||
await game.confirmMove();
|
||||
} else {
|
||||
throw new Error(`phase inattendue : ${state.phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */
|
||||
export async function playMoves(
|
||||
game: Game,
|
||||
engine: Engine,
|
||||
count: number,
|
||||
choice = 0,
|
||||
): Promise<void> {
|
||||
let played = 0;
|
||||
while (played < count && game.state.phase !== "over") {
|
||||
const before = game.events.length;
|
||||
await step(game, engine, { choice });
|
||||
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
|
||||
for (let i = 0; game.state.phase !== "over"; i++) {
|
||||
if (i > 2000) {
|
||||
throw new Error("la partie ne finit pas");
|
||||
}
|
||||
await step(game, engine, { choice });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
|
||||
// Backgammon de référence (tests/test_sgf_reference.sh).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
|
||||
//
|
||||
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
|
||||
// parties variées : complètes, avec branches, avec videau, depuis une
|
||||
// position particulière. Pour chaque branche de chaque partie, écrit
|
||||
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
|
||||
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
|
||||
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Game } from "../../src/domain/game/game.js";
|
||||
import { exportSgf } from "../../src/domain/game/sgf.js";
|
||||
import { loadNodeEngine } from "../support/node-engine.js";
|
||||
import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js";
|
||||
|
||||
/** Le dernier événement « coup joué » de la ligne courante. */
|
||||
function lastMoveId(game: Game, skip = 0): string {
|
||||
const moves = game.events.filter((event) => event.type === "MovePlayed");
|
||||
const move = moves.at(-1 - skip);
|
||||
if (move === undefined) {
|
||||
throw new Error("aucun coup joué");
|
||||
}
|
||||
return move.id;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const out = process.argv[2];
|
||||
if (out === undefined) {
|
||||
throw new Error("usage : sgf-samples.js <dossier>");
|
||||
}
|
||||
mkdirSync(out, { recursive: true });
|
||||
const engine = await loadNodeEngine();
|
||||
const games: [string, Game][] = [];
|
||||
|
||||
// Une partie complète, puis une autre où le joueur joue autrement.
|
||||
for (const [seed, choice] of [
|
||||
[3, 0],
|
||||
[11, 2],
|
||||
] as const) {
|
||||
const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" });
|
||||
await playToTheEnd(game, engine, choice);
|
||||
games.push([`complete-${seed}`, game]);
|
||||
}
|
||||
|
||||
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
|
||||
// jet du joueur (même jet, autre coup), et une branche d'une branche.
|
||||
{
|
||||
const game = Game.start(engine, { dice: seededDice(5), level: "0ply" });
|
||||
await playMoves(game, engine, 14);
|
||||
const original = game.events.at(-1)?.id as string;
|
||||
game.goBackTo(lastMoveId(game, 6));
|
||||
await playMoves(game, engine, 8);
|
||||
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
|
||||
if (rolled === undefined) {
|
||||
throw new Error("aucun jet du joueur");
|
||||
}
|
||||
game.goBackTo(rolled.id);
|
||||
await playMoves(game, engine, 5, 1);
|
||||
game.switchBranch(original);
|
||||
await playToTheEnd(game, engine);
|
||||
games.push(["branches", game]);
|
||||
}
|
||||
|
||||
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
|
||||
{
|
||||
const game = Game.start(engine, { dice: seededDice(9), level: "0ply" });
|
||||
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
|
||||
await step(game, engine, { double: true });
|
||||
}
|
||||
games.push(["player-doubles", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière : l'ordinateur, au trait, double
|
||||
// (position de référence « cube-double-take ») ; le joueur prend dans
|
||||
// une branche, passe dans une autre.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(13),
|
||||
level: "0ply",
|
||||
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
|
||||
});
|
||||
await step(game, engine);
|
||||
const offered = game.events.at(-1)?.id as string;
|
||||
await playMoves(game, engine, 6);
|
||||
game.goBackTo(offered);
|
||||
await step(game, engine, { take: false });
|
||||
games.push(["setup-cube", game]);
|
||||
}
|
||||
|
||||
// Depuis une position particulière, videau déjà doublé chez le joueur,
|
||||
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(17),
|
||||
level: "0ply",
|
||||
setup: {
|
||||
position: "t+0OAADg/wcAQA",
|
||||
firstTurn: "player",
|
||||
cube: { value: 2, owner: "player" },
|
||||
},
|
||||
});
|
||||
await playMoves(game, engine, 4);
|
||||
await game.roll();
|
||||
games.push(["setup-unfinished", game]);
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
for (const [name, game] of games) {
|
||||
const original = game.journal;
|
||||
for (const [i, branch] of game.branches.entries()) {
|
||||
game.switchBranch(branch.head);
|
||||
const sgf = await exportSgf(engine, game.journal, {
|
||||
playerName: "Vous",
|
||||
computerName: "Ordinateur",
|
||||
date: "2026-11-03",
|
||||
});
|
||||
// Après chaque coup, la position vue de celui qui a alors la main,
|
||||
// et le résultat.
|
||||
const expected = {
|
||||
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
|
||||
result: game.state.result,
|
||||
};
|
||||
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
|
||||
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
|
||||
files++;
|
||||
}
|
||||
game.goBackTo(original.head as string);
|
||||
}
|
||||
console.log(`${files} parties exportées dans ${out}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,212 @@
|
||||
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
|
||||
// chaque position est valide pour le vrai moteur, chaque coup illustré et
|
||||
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
|
||||
// justes et refuse les autres, et la réponse au double est celle du moteur.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { Move } from "../src/domain/game/engine.js";
|
||||
import {
|
||||
canStillLeadTo,
|
||||
illustratedMove,
|
||||
MovePractice,
|
||||
shouldTake,
|
||||
tutorialBoard,
|
||||
} from "../src/domain/learning/tutorial.js";
|
||||
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
|
||||
import { loadNodeEngine } from "./support/node-engine.js";
|
||||
|
||||
const root = new URL("../../../", import.meta.url);
|
||||
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
|
||||
|
||||
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
|
||||
assert.equal(tutorial.steps.length, 8);
|
||||
assert.deepEqual(
|
||||
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
|
||||
[3, 4, 6, 8],
|
||||
);
|
||||
assert.ok(tutorial.end.action !== "");
|
||||
assert.ok(tutorial.end.paragraphs.length > 0);
|
||||
for (const step of tutorial.steps) {
|
||||
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
|
||||
const practice = step.practice;
|
||||
if (practice !== null) {
|
||||
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
|
||||
assert.ok(
|
||||
practice.right.length > 0 && practice.wrong.length > 0,
|
||||
`étape ${step.number} : phrases juste et refus absentes`,
|
||||
);
|
||||
assert.ok(
|
||||
practice.expected.length > 0 || practice.answer !== null,
|
||||
`étape ${step.number} : ni coup attendu, ni réponse au double`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
|
||||
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
|
||||
const inlines = [
|
||||
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
|
||||
...tutorial.end.paragraphs.flat(),
|
||||
];
|
||||
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
|
||||
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
|
||||
for (const term of terms) {
|
||||
assert.ok(
|
||||
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
|
||||
`fiche ${term}.md absente`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les liens et le gras du texte sont lus", () => {
|
||||
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
|
||||
{ text: "Un " },
|
||||
{ text: "blot", term: "blot" },
|
||||
{ text: " " },
|
||||
{ text: "seul", strong: true },
|
||||
{ text: ", voilà." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("un texte mal formé est refusé", () => {
|
||||
assert.throws(() =>
|
||||
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
|
||||
);
|
||||
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
|
||||
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
|
||||
});
|
||||
|
||||
/** Tous les écrans, avec leur nom pour les messages. */
|
||||
function screens(): [string, TutorialScreen][] {
|
||||
return tutorial.steps.flatMap((s) => [
|
||||
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
|
||||
...(s.practice === null
|
||||
? []
|
||||
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
|
||||
]);
|
||||
}
|
||||
|
||||
test("chaque position du tutoriel est valide pour le moteur", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
|
||||
const count = (side: "player" | "computer") => {
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.move === null) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
|
||||
const { steps, after } = await illustratedMove(
|
||||
engine,
|
||||
screen.position,
|
||||
screen.roll,
|
||||
screen.move,
|
||||
);
|
||||
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
|
||||
if (name === "étape 7") {
|
||||
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
|
||||
} else {
|
||||
assert.equal(after.result, null, name);
|
||||
}
|
||||
if (name === "étape 4") {
|
||||
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
for (const [name, screen] of screens()) {
|
||||
if (screen.expected.length === 0) {
|
||||
continue;
|
||||
}
|
||||
assert.ok(screen.roll !== null, `${name} : pas de jet`);
|
||||
const legal = await engine.legalMoves(screen.position, screen.roll);
|
||||
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}`);
|
||||
}
|
||||
// Un coup attendu au moins, et un coup refusé au moins.
|
||||
assert.ok(
|
||||
legal.some((m) => !screen.expected.includes(m.notation)),
|
||||
`${name} : rien à refuser`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("le joueur peut revenir en arrière après un coup refusé", async () => {
|
||||
const engine = await loadNodeEngine();
|
||||
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
|
||||
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
|
||||
practice.reset();
|
||||
assert.deepEqual(practice.pendingHalfMoves, []);
|
||||
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
|
||||
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
|
||||
});
|
||||
|
||||
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()) {
|
||||
if (screen.answer === null) {
|
||||
continue;
|
||||
}
|
||||
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
|
||||
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
|
||||
}
|
||||
});
|
||||
|
||||
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
|
||||
const move = (notation: string, halfMoves: [number, number][]): Move => ({
|
||||
notation,
|
||||
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
|
||||
position: notation,
|
||||
});
|
||||
const expected = [
|
||||
move("8/5 6/5", [
|
||||
[8, 5],
|
||||
[6, 5],
|
||||
]),
|
||||
];
|
||||
assert.ok(canStillLeadTo([], expected));
|
||||
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
|
||||
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
|
||||
assert.ok(
|
||||
!canStillLeadTo(
|
||||
[
|
||||
{ from: 8, to: 5 },
|
||||
{ from: 8, to: 5 },
|
||||
],
|
||||
expected,
|
||||
),
|
||||
);
|
||||
});
|
||||
+84
-45
@@ -1,6 +1,6 @@
|
||||
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
|
||||
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
|
||||
// l'aide, l'enregistrement de la partie.
|
||||
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
@@ -9,9 +9,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import type { BoardSide } from "../src/domain/game/engine.js";
|
||||
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
|
||||
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
|
||||
import { createGameStore } from "../src/storage/game-store.js";
|
||||
import { parseHalfMoves } from "../src/domain/game/position.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -21,7 +19,15 @@ import {
|
||||
pointColor,
|
||||
pointSlot,
|
||||
} from "../src/ui/board-model.js";
|
||||
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
|
||||
import { FEATURES } from "../src/ui/help.js";
|
||||
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
|
||||
import {
|
||||
matchesSearch,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "../src/ui/help-text.js";
|
||||
import { slug } from "../src/ui/router.js";
|
||||
import { TEXTS } from "../src/ui/texts.js";
|
||||
|
||||
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
|
||||
@@ -145,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
|
||||
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
|
||||
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
|
||||
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
|
||||
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
|
||||
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
|
||||
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
|
||||
assert.deepEqual(feature.scenarios[0]?.steps, [
|
||||
"Quand vous commencez une nouvelle partie",
|
||||
"Alors les pions sont en position de départ",
|
||||
"Et le premier jet de dés désigne qui commence",
|
||||
]);
|
||||
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
|
||||
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
|
||||
const refused = feature.scenarios.find(
|
||||
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
|
||||
);
|
||||
assert.match(
|
||||
refused?.description[0] ?? "",
|
||||
/^Une flèche où l'ordinateur a au moins deux pions/,
|
||||
);
|
||||
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
|
||||
assert.equal(feature.scenarios[2]?.position, null);
|
||||
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
|
||||
for (const scenario of feature.scenarios) {
|
||||
assert.ok(scenario.steps.length >= 2, scenario.title);
|
||||
}
|
||||
});
|
||||
|
||||
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
|
||||
const listed = read("docs/scenarios/README.md");
|
||||
const ids = new Set<string>();
|
||||
for (const url of FEATURES) {
|
||||
assert.ok(
|
||||
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
|
||||
`${url} hors de l'index`,
|
||||
);
|
||||
const feature = parseFeature(read(url));
|
||||
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
|
||||
for (const scenario of feature.scenarios) {
|
||||
// La réponse se lit seule : pas de vocabulaire technique.
|
||||
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
|
||||
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
|
||||
assert.ok(
|
||||
!scenario.description.join(" ").includes(word),
|
||||
`${scenario.title} : « ${word} » dans la réponse`,
|
||||
);
|
||||
}
|
||||
const id = slug(scenario.title);
|
||||
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
|
||||
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
|
||||
for (const title of Object.values(HELP_QUESTIONS)) {
|
||||
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
|
||||
}
|
||||
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
|
||||
assert.equal(helpHash(null), "#/aide");
|
||||
});
|
||||
|
||||
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
|
||||
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
|
||||
assert.ok(entry !== null);
|
||||
assert.equal(entry.english, "Home board");
|
||||
assert.deepEqual(
|
||||
entry.seeAlso.map((s) => s.name),
|
||||
["sortie", "barre", "point"],
|
||||
);
|
||||
assert.ok(matchesSearch(entry, "camp"));
|
||||
assert.ok(matchesSearch(entry, "HOME"));
|
||||
assert.ok(matchesSearch(entry, "fleches sortir"));
|
||||
assert.ok(!matchesSearch(entry, "videau"));
|
||||
});
|
||||
|
||||
test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
|
||||
assert.ok(files.includes("videau.md"));
|
||||
@@ -169,49 +231,26 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
|
||||
assert.ok(entry !== null, file);
|
||||
assert.ok(entry.brief.length > 10, file);
|
||||
}
|
||||
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
|
||||
term: "Videau",
|
||||
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
});
|
||||
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
|
||||
assert.equal(videau?.term, "Videau");
|
||||
assert.equal(videau?.english, "Cube");
|
||||
assert.equal(
|
||||
videau?.brief,
|
||||
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
|
||||
);
|
||||
assert.equal(parseGlossaryEntry("pas une fiche"), null);
|
||||
});
|
||||
|
||||
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
|
||||
const data = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
};
|
||||
const store = createGameStore(storage);
|
||||
assert.equal(store.load(), null);
|
||||
const journal = appendEvent(EMPTY_JOURNAL, {
|
||||
type: "GameStarted",
|
||||
position: START_POSITION,
|
||||
cube: { value: 1, owner: null },
|
||||
firstTurn: null,
|
||||
});
|
||||
store.save(journal);
|
||||
assert.deepEqual(store.load(), journal);
|
||||
for (const key of data.keys()) {
|
||||
data.set(key, "{pas du JSON");
|
||||
}
|
||||
assert.equal(store.load(), null);
|
||||
// Un stockage plein ou interdit ne casse pas la partie.
|
||||
const full = createGameStore({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("plein");
|
||||
},
|
||||
});
|
||||
assert.doesNotThrow(() => full.save(journal));
|
||||
});
|
||||
|
||||
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.",
|
||||
|
||||
@@ -94,3 +94,93 @@ les justifie pas.
|
||||
dépendances seront fixés avec le socle, à la fin de l'étape 0.
|
||||
- Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y
|
||||
renvoie.
|
||||
|
||||
## Amendements
|
||||
|
||||
### 2026-11-03 — Le stockage des parties est un format durable
|
||||
|
||||
Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base
|
||||
« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les
|
||||
clés `localStorage` de l'application (`ustagammon.settings`,
|
||||
`ustagammon.last-board`, et le repli des parties) rejoignent le journal
|
||||
d'événements parmi les formats durables : toute modification passe par une
|
||||
montée de version avec migration testée, jamais par une réécriture qui
|
||||
perdrait les parties des joueurs.
|
||||
|
||||
### 2026-11-09 — Version 2 des événements : niveau et match
|
||||
|
||||
Avec le choix du niveau et les matchs (étape 1c), le format des événements
|
||||
passe à la version 2 (`EVENT_VERSION`, `app/src/domain/game/events.ts`) :
|
||||
|
||||
- `GameStarted` porte le niveau de l'ordinateur (`level`, un niveau de gnubg)
|
||||
et, pour une partie de match, le match à son début (`match` : longueur,
|
||||
score, partie Crawford) ;
|
||||
- `GameEnded` porte, dans un match, le match après la partie (score, fin du
|
||||
match, partie Crawford suivante), tel que le moteur l'a calculé.
|
||||
|
||||
Chaque partie d'un match est une partie à part entière, avec son journal et
|
||||
son enregistrement ; la suivante reprend le match que la précédente a laissé.
|
||||
La migration (`migrateJournal`, testée dans `app/tests/game.test.ts`) passe un
|
||||
journal de la version 1 à la version 2 au moment de le reprendre : le niveau,
|
||||
implicite, devient explicite (2 plis, le seul qui existait) ; rien d'autre ne
|
||||
change, une partie de la version 1 étant une partie libre. Le schéma IndexedDB
|
||||
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 »).
|
||||
|
||||
@@ -85,3 +85,77 @@ unique pour tous les écrans, avant sa première mise en œuvre.
|
||||
- Une adresse inconnue mène à la partie en cours avec un message clair.
|
||||
- L'adresse de l'écran courant est la source de vérité de la navigation :
|
||||
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
|
||||
|
||||
### 2026-11-09 — Adresses du tutoriel et de l'installation
|
||||
|
||||
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
|
||||
| Fin du tutoriel | `#/tutoriel/fin` |
|
||||
| Marche à suivre pour installer l'application | `#/installer` |
|
||||
|
||||
Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste
|
||||
la partie en cours, pour que les liens existants ne changent pas.
|
||||
|
||||
### 2026-11-09 — Adresse de l'écran « Nouvelle partie »
|
||||
|
||||
Ajoutée avec le choix de la partie et le match (étape 1c), dans le même
|
||||
schéma :
|
||||
|
||||
| Écran | Adresse |
|
||||
| --- | --- |
|
||||
| Nouvelle partie : type de partie, niveau, conseil du maître | `#/nouvelle-partie` |
|
||||
|
||||
Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Commencer »
|
||||
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.
|
||||
|
||||
+24
-15
@@ -18,10 +18,10 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| Sous-étape | Contenu | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| 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 | 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 | 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, gaffe) 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 |
|
||||
| 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 (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
|
||||
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
|
||||
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
|
||||
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
|
||||
@@ -36,21 +36,30 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
|
||||
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
|
||||
|
||||
## Questions pour Pierre (ne bloquent pas le démarrage)
|
||||
## Décisions de Pierre (2026-11-09)
|
||||
|
||||
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
|
||||
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
|
||||
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
|
||||
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
|
||||
les règles » qui oriente vers le tutoriel ou la partie.
|
||||
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
|
||||
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
|
||||
l'étape 1.
|
||||
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
|
||||
(fait en 0e.5).
|
||||
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
|
||||
mauvais 0,06, très mauvais 0,12 d'équité, `arSkillLevel`), exposés par le
|
||||
moteur depuis 1a ; à calibrer sur de vraies parties.
|
||||
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
|
||||
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
|
||||
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
|
||||
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
|
||||
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
|
||||
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
|
||||
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
|
||||
par un test, et le texte n'affirme rien que le moteur ne confirme.
|
||||
- **Plein écran par défaut dans l'app installée** : non (pas de
|
||||
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
|
||||
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
|
||||
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
|
||||
des phrases du maître (section « Les phrases du maître » de
|
||||
`docs/identite/charte/ecrans-etape-1.md`).
|
||||
|
||||
## Ordre de réalisation
|
||||
|
||||
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
|
||||
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
|
||||
sous-étape a son plan dans `docs/plans/` et sa merge request.
|
||||
|
||||
@@ -23,15 +23,13 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
|
||||
## Étapes suivantes
|
||||
|
||||
| Étape | Contenu | État |
|
||||
| --- | --- | --- |
|
||||
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
|
||||
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
|
||||
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
|
||||
|
||||
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
|
||||
|
||||
@@ -40,9 +40,12 @@ 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,10 +55,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Identifiant de match](identifiant-de-match.md) | Match ID |
|
||||
| [Identifiant de position](identifiant-de-position.md) | Position ID |
|
||||
| [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 |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Partie enregistrée](partie-enregistree.md) | Saved game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
| [Pion](pion.md) | Checker |
|
||||
| [Plateau](plateau.md) | Board |
|
||||
@@ -64,5 +69,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Prendre](prendre.md) | Take |
|
||||
| [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,9 +1,14 @@
|
||||
---
|
||||
term: Branche
|
||||
english: Branch
|
||||
code: Branch
|
||||
---
|
||||
# Branche
|
||||
|
||||
**En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md)
|
||||
Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à
|
||||
l'autre depuis « Mes parties », et l'export SGF les contient toutes, en
|
||||
variantes.
|
||||
|
||||
Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
term: Identifiant de match
|
||||
english: Match ID
|
||||
code: MatchId
|
||||
---
|
||||
# Identifiant de match
|
||||
|
||||
**En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon.
|
||||
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md)
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md)
|
||||
|
||||
@@ -7,4 +7,4 @@ code: PositionId
|
||||
|
||||
**En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon.
|
||||
|
||||
Voir aussi : [Identifiant de match](identifiant-de-match.md)
|
||||
Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
term: Lien de position
|
||||
english: Position link
|
||||
code: PositionLink
|
||||
---
|
||||
# Lien de position
|
||||
|
||||
**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match.
|
||||
|
||||
Le lien montre la position du point de vue du joueur qui a la main : c'est
|
||||
vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis
|
||||
cette position, sans toucher à votre partie en cours. Le lien ne contient que
|
||||
la position : aucune donnée personnelle.
|
||||
|
||||
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md)
|
||||
@@ -1,9 +1,16 @@
|
||||
---
|
||||
term: Match
|
||||
english: Match
|
||||
code: Match
|
||||
---
|
||||
# Match
|
||||
|
||||
**En bref** : suite de parties jouée jusqu'à ce qu'un joueur atteigne un nombre de points fixé à l'avance, par exemple 5 points.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md)
|
||||
Chaque partie rapporte ses points à son gagnant : 1 pour une partie simple,
|
||||
2 pour un gammon, 3 pour un backgammon, multipliés par la valeur du videau.
|
||||
Le premier qui atteint la longueur du match le gagne. Dans Ustagammon, un
|
||||
match se joue en 3, 5, 7 ou 11 points ; le score, la fin du match et la
|
||||
partie Crawford sont calculés par GNU Backgammon.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md), [Identifiant de match](identifiant-de-match.md)
|
||||
|
||||
@@ -15,4 +15,19 @@ la position seule : dans la même position, avec le même jet, l'ordinateur
|
||||
joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent
|
||||
de 0 à 4 plis.
|
||||
|
||||
L'écran « Nouvelle partie » en propose six :
|
||||
|
||||
| Nom montré | Niveau de GNU Backgammon |
|
||||
| --- | --- |
|
||||
| Débutant | beginner (0 pli, bruit 0,060) |
|
||||
| Amateur | casual play (0 pli, bruit 0,050) |
|
||||
| Intermédiaire | intermediate (0 pli, bruit 0,040) |
|
||||
| Avancé | advanced (0 pli, bruit 0,015) |
|
||||
| Expert | 0 pli sans bruit, l'ancien « expert » de gnubg |
|
||||
| Champion | 2 plis, l'ancien « world class » de gnubg |
|
||||
|
||||
Champion est le plus fort qui joue sans attente sur le téléphone ; 3 et
|
||||
4 plis ne sont pas proposés. L'ordinateur double, prend et passe comme celui
|
||||
de GNU Backgammon, au même niveau.
|
||||
|
||||
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
term: Partie enregistrée
|
||||
english: Saved game
|
||||
code: SavedGameSummary
|
||||
---
|
||||
# Partie enregistrée
|
||||
|
||||
**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ».
|
||||
|
||||
Chaque partie est enregistrée après chacune de vos actions : fermer
|
||||
l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous
|
||||
exportez une partie.
|
||||
|
||||
Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md)
|
||||
@@ -6,4 +6,12 @@ english: Crawford rule
|
||||
|
||||
**En bref** : en match, dans la partie qui suit le moment où un joueur arrive à un point de la victoire, personne ne peut doubler.
|
||||
|
||||
Cette partie s'appelle la partie Crawford. Elle protège celui qui arrive
|
||||
au bord de la victoire : sans elle, son adversaire doublerait aussitôt,
|
||||
puisqu'il n'a plus rien à perdre. Après la partie Crawford, le videau
|
||||
revient, et celui qui est mené peut de nouveau doubler. La règle ne joue
|
||||
qu'une fois par match ; si les deux joueurs arrivent à un point de la
|
||||
victoire, il n'y a pas de partie Crawford. Dans Ustagammon, elle s'applique
|
||||
toujours aux matchs, comme dans GNU Backgammon.
|
||||
|
||||
Voir aussi : [Match](match.md), [Doubler](doubler.md)
|
||||
|
||||
@@ -6,4 +6,9 @@ english: Jacoby rule
|
||||
|
||||
**En bref** : en partie libre, hors match, un gammon ou un backgammon ne compte que si le videau a été utilisé.
|
||||
|
||||
Voir aussi : [Gammon](gammon.md), [Videau](videau.md)
|
||||
Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un
|
||||
point. La règle encourage à doubler une position forte plutôt qu'à jouer le
|
||||
gammon sans risque. Elle s'applique toujours aux parties libres d'Ustagammon,
|
||||
jamais aux matchs.
|
||||
|
||||
Voir aussi : [Gammon](gammon.md), [Videau](videau.md), [Match](match.md)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user