Compare commits
27
Commits
@@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
|
||||
# docs/identite/logo/ (une seule source, ADR 0010) ;
|
||||
# - l'application compilée (build/web/src/) sous app/ ;
|
||||
# - le moteur sous wasm/ ;
|
||||
# - les copies du glossaire, des scénarios et du tutoriel sous docs/, lues par
|
||||
# l'aide et le tutoriel de l'application (ADR 0008 : jamais commitées
|
||||
# - les copies du glossaire, des scénarios, du tutoriel 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).
|
||||
@@ -141,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 docs/tutoriel $(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,166 @@
|
||||
// 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 { 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 events.filter((event) => {
|
||||
switch (event.type) {
|
||||
case "MovePlayed":
|
||||
return event.side === "player" && event.halfMoves.length > 0;
|
||||
case "DiceRolled":
|
||||
case "CubeOffered":
|
||||
case "CubeTaken":
|
||||
case "CubeDropped":
|
||||
return event.side === "player";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
|
||||
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
|
||||
* possible, videau inaccessible ou position de départ pour gnubg).
|
||||
*/
|
||||
export async function analyseDecision(
|
||||
engine: Engine,
|
||||
journal: GameJournal,
|
||||
eventId: string,
|
||||
ply: Ply = ANALYSIS_PLY,
|
||||
): Promise<Advice | null> {
|
||||
const line = currentLine({ ...journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event === undefined || playerDecisions([event]).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, "player");
|
||||
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
|
||||
// même position vue du joueur.
|
||||
const after = await engine.board(event.position, engineCube(before, "computer"));
|
||||
const analysis = await engine.analyseMove(
|
||||
before.position,
|
||||
cube,
|
||||
before.roll,
|
||||
after.swappedPosition,
|
||||
ply,
|
||||
);
|
||||
if (analysis === null) {
|
||||
return null;
|
||||
}
|
||||
const ranking = 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: "player" | "computer" = "player";
|
||||
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 = "computer";
|
||||
}
|
||||
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,267 @@
|
||||
// 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 } from "../game/engine.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",
|
||||
];
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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). */
|
||||
@@ -75,8 +104,8 @@ export interface Engine {
|
||||
/** La chance d'un jet pour le joueur au trait. */
|
||||
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
|
||||
|
||||
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
|
||||
* de lui, sans dés. */
|
||||
/** 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. */
|
||||
@@ -212,10 +241,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,80 @@
|
||||
// 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 { 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 = 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) };
|
||||
}
|
||||
|
||||
interface EventBase {
|
||||
/** Identifiant, unique dans la partie. */
|
||||
@@ -31,12 +97,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 +152,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 +179,14 @@ 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. */
|
||||
export interface GameJournal {
|
||||
readonly events: readonly GameEvent[];
|
||||
readonly head: string | null;
|
||||
readonly advice?: Readonly<Record<string, Advice>>;
|
||||
}
|
||||
|
||||
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
|
||||
@@ -120,7 +199,16 @@ 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 } };
|
||||
}
|
||||
|
||||
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
|
||||
@@ -144,5 +232,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 };
|
||||
}
|
||||
|
||||
+176
-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,11 +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,
|
||||
@@ -19,16 +21,19 @@ import {
|
||||
type GameEvent,
|
||||
type GameJournal,
|
||||
goBackTo,
|
||||
migrateJournal,
|
||||
type NewGameEvent,
|
||||
withAdvice,
|
||||
} 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;
|
||||
|
||||
@@ -62,12 +67,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +90,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
|
||||
@@ -88,11 +99,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;
|
||||
@@ -101,6 +112,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) });
|
||||
@@ -112,11 +125,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");
|
||||
}
|
||||
@@ -141,6 +156,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);
|
||||
@@ -182,7 +220,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,36 +229,59 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,10 +321,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;
|
||||
@@ -349,62 +411,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
|
||||
@@ -445,8 +506,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 };
|
||||
@@ -474,10 +535,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,
|
||||
@@ -485,28 +543,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -576,10 +660,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 };
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import type { Cube } from "./cube.js";
|
||||
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
|
||||
import type { GameSetup, SideBoards } from "./game.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";
|
||||
@@ -100,19 +100,16 @@ export async function readPosition(engine: Engine, link: PositionLink): Promise<
|
||||
|
||||
/**
|
||||
* 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 (partie libre, sans
|
||||
* dés). Pendant le jet d'ouverture, la position de départ, vue du joueur.
|
||||
* 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";
|
||||
const cube: EngineCube =
|
||||
state.cube.owner === null
|
||||
? { value: state.cube.value, owner: "centered" }
|
||||
: {
|
||||
value: state.cube.value,
|
||||
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
|
||||
};
|
||||
return { position: state.position, matchId: await engine.encodeMatch(cube) };
|
||||
return {
|
||||
position: state.position,
|
||||
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
|
||||
};
|
||||
}
|
||||
|
||||
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
import { branches } from "./branches.js";
|
||||
import type { Level } from "./engine.js";
|
||||
import { currentLine, type GameJournal } from "./events.js";
|
||||
import { type GameResult, replay } from "./state.js";
|
||||
import type { Side } from "./side.js";
|
||||
import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js";
|
||||
|
||||
/**
|
||||
* Le résumé d'une partie enregistrée.
|
||||
@@ -33,6 +34,18 @@ export interface SavedGameSummary {
|
||||
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
|
||||
@@ -41,6 +54,12 @@ 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(
|
||||
@@ -60,5 +79,15 @@ export function summarizeGame(
|
||||
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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,11 +78,18 @@ export async function exportSgf(
|
||||
return properties === null ? below : [{ properties, children: below }];
|
||||
});
|
||||
|
||||
// La partie telle que la voit gnubg : la ligne courante.
|
||||
const result = replay(currentLine(journal)).result;
|
||||
// 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]",
|
||||
"MI[length:0][game:0][ws:0][bs:0]",
|
||||
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}]`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export class MovePractice {
|
||||
const game = Game.start(engine, {
|
||||
setup: { position, firstTurn: "player" },
|
||||
dice: fixedDice(roll),
|
||||
ply: 0,
|
||||
level: "0ply",
|
||||
});
|
||||
await game.roll();
|
||||
return new MovePractice(game, expected, roll);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CubeDecision,
|
||||
Engine,
|
||||
EngineCube,
|
||||
EngineMatch,
|
||||
EvaluatedMove,
|
||||
HalfMovesCheck,
|
||||
Level,
|
||||
@@ -75,13 +76,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;
|
||||
@@ -129,6 +140,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,
|
||||
@@ -158,6 +195,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,
|
||||
|
||||
+34
-11
@@ -5,6 +5,7 @@
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { analyseDecision } from "./domain/analysis/analyse.js";
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
tutorialBoard,
|
||||
} from "./domain/learning/tutorial.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
|
||||
import { createGameSession } from "./storage/game-session.js";
|
||||
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
|
||||
import { createSettingsStore } from "./storage/settings-store.js";
|
||||
@@ -27,6 +28,30 @@ import { startUpdates } from "./ui/updates.js";
|
||||
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
const engine = createWasmEngine(worker.run);
|
||||
|
||||
// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un
|
||||
// second moteur, dans son propre Web Worker. Le premier ne traite qu'une
|
||||
// commande à la fois : une analyse y retarderait le coup de l'ordinateur.
|
||||
// Le second démarre à la première analyse demandée, une fois le premier
|
||||
// chargé et le service worker installé (au plus 20 s d'attente) : si deux
|
||||
// téléchargements des données du moteur se croisent, Chromium fait échouer
|
||||
// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les
|
||||
// données viennent du cache.
|
||||
let analysisWorker: Promise<EngineWorker> | null = null;
|
||||
function startAnalysisWorker(): Promise<EngineWorker> {
|
||||
analysisWorker ??= worker.ready
|
||||
.then(() => {
|
||||
const installed =
|
||||
"serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve();
|
||||
return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]);
|
||||
})
|
||||
.then(() => {
|
||||
const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
|
||||
return started.ready.then(() => started);
|
||||
});
|
||||
return analysisWorker;
|
||||
}
|
||||
const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args));
|
||||
|
||||
// Chaque accès au stockage du navigateur peut lever une exception (stockage
|
||||
// interdit) : on fait alors sans.
|
||||
const localStorage = attempt(() => window.localStorage);
|
||||
@@ -40,15 +65,12 @@ const opening = (async () => {
|
||||
indexedDB: attempt(() => window.indexedDB),
|
||||
localStorage,
|
||||
});
|
||||
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
|
||||
const session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
// 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 };
|
||||
})();
|
||||
@@ -68,7 +90,7 @@ startApp({
|
||||
stored = opened;
|
||||
}),
|
||||
]),
|
||||
newGame: (setup) => storage().session.newGame(setup),
|
||||
newGame: (newGame) => storage().session.newGame(newGame),
|
||||
positions: {
|
||||
read: (link) => readPosition(engine, link),
|
||||
linkOf: (game) => linkOf(engine, game.state),
|
||||
@@ -120,6 +142,7 @@ startApp({
|
||||
// network error »), et l'application n'est pas disponible hors ligne.
|
||||
// Cela évite aussi de télécharger deux fois ces données à la première
|
||||
// visite.
|
||||
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
|
||||
startUpdates: (onAvailable) =>
|
||||
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
|
||||
});
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
// quelle, sur un stockage en mémoire.
|
||||
|
||||
import { switchToBranch } from "../domain/game/branches.js";
|
||||
import type { Level } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, GameSetup } from "../domain/game/game.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: (setup?: GameSetup) => Game;
|
||||
readonly start: (newGame?: NewGame) => Game;
|
||||
readonly resume: (journal: GameJournal) => Game;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ export interface GameSession {
|
||||
/** 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 (depuis la position de setup si elle est donnée) ;
|
||||
/** 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(setup?: GameSetup): Game;
|
||||
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;
|
||||
@@ -42,11 +42,7 @@ export interface GameSession {
|
||||
open(id: string, branchHead?: string): Promise<Game>;
|
||||
}
|
||||
|
||||
export function createGameSession(
|
||||
library: GameLibrary,
|
||||
games: GameFactory,
|
||||
level: Level,
|
||||
): GameSession {
|
||||
export function createGameSession(library: GameLibrary, games: GameFactory): GameSession {
|
||||
let shownId: string | null = null;
|
||||
let previousId: string | null = null;
|
||||
|
||||
@@ -80,14 +76,14 @@ export function createGameSession(
|
||||
return null; // partie illisible : on en commencera une nouvelle
|
||||
}
|
||||
},
|
||||
newGame(setup) {
|
||||
newGame(newGame) {
|
||||
previousId = shownId;
|
||||
shownId = newGameId();
|
||||
return games.start(setup);
|
||||
return games.start(newGame);
|
||||
},
|
||||
save(journal) {
|
||||
if (shownId !== null) {
|
||||
library.save(shownId, journal, { level }).catch(() => {});
|
||||
library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {});
|
||||
}
|
||||
},
|
||||
async resumePrevious() {
|
||||
@@ -123,7 +119,7 @@ export function createGameSession(
|
||||
if (branchHead === undefined) {
|
||||
await library.setCurrent(id);
|
||||
} else {
|
||||
await library.save(id, journal, { level: saved.summary.level });
|
||||
await library.save(id, journal, { level: levelOf(journal) });
|
||||
}
|
||||
return game;
|
||||
},
|
||||
|
||||
@@ -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,409 @@
|
||||
// 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, MoveSteps } from "../domain/game/game.js";
|
||||
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.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";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
const SLIDE_MS = 160;
|
||||
const PAUSE_MS = 120;
|
||||
|
||||
/** 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;
|
||||
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
|
||||
let playing = 0;
|
||||
|
||||
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. */
|
||||
function boardSvg(box: HTMLElement): SVGSVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.id = "explain-board";
|
||||
svg.setAttribute("class", "board explain-board");
|
||||
svg.setAttribute("role", "img");
|
||||
box.append(svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): 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,
|
||||
},
|
||||
);
|
||||
// Les flèches des traces renvoient à leur pointe par identifiant :
|
||||
// celui du plateau de jeu serait pris à sa place.
|
||||
for (const marker of svg.querySelectorAll("marker[id]")) {
|
||||
marker.id = `explain-${marker.id}`;
|
||||
}
|
||||
for (const path of svg.querySelectorAll("[marker-end]")) {
|
||||
const ref = path.getAttribute("marker-end") ?? "";
|
||||
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Joue un coup pion par pion (sans mouvement si le système le
|
||||
* demande), puis laisse ses traces. */
|
||||
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
|
||||
const id = ++playing;
|
||||
if (!options.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);
|
||||
draw(svg, steps, i, false);
|
||||
const start = checkerSpot(svg, from);
|
||||
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
|
||||
draw(svg, steps, i + 1, false);
|
||||
await Promise.all([
|
||||
start && slideChecker(svg, to, start, SLIDE_MS),
|
||||
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (id === playing) {
|
||||
draw(svg, steps, steps.halfMoves.length, true);
|
||||
}
|
||||
}
|
||||
|
||||
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 = boardSvg(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);
|
||||
draw(svg, mine, 0, false);
|
||||
void animate(svg, 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;
|
||||
const steps = shown === "mine" ? mine : best;
|
||||
draw(svg, steps, 0, false);
|
||||
void animate(svg, steps);
|
||||
};
|
||||
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);
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
|
||||
draw(svg, steps, 0, false);
|
||||
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++;
|
||||
playing++;
|
||||
if (!veil.hidden) {
|
||||
veil.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+336
-38
@@ -21,13 +21,24 @@
|
||||
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
|
||||
// changement. Les actions de jeu restent des boutons.
|
||||
|
||||
import {
|
||||
type MasterPhrases,
|
||||
masterLine,
|
||||
masterWords,
|
||||
parsePhrases,
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import type { GameState } from "../domain/game/state.js";
|
||||
import { ADVICE_TEXTS } from "./advice-texts.js";
|
||||
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
|
||||
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
|
||||
import {
|
||||
boardModel,
|
||||
moveTargets,
|
||||
@@ -39,9 +50,11 @@ import {
|
||||
placeOf,
|
||||
} from "./board-model.js";
|
||||
import {
|
||||
type BoardAdvice,
|
||||
type BoardDecor,
|
||||
checkerSpot,
|
||||
type DiceAction,
|
||||
type MatchScore,
|
||||
NO_MARKS,
|
||||
renderBoard,
|
||||
type ShownDie,
|
||||
@@ -58,9 +71,18 @@ import { HOME_TEXTS } from "./home-texts.js";
|
||||
import { createHomeView } from "./home-view.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { installPlatform, startInstaller } from "./install.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import {
|
||||
chosenGame,
|
||||
isFirstLaunch,
|
||||
longPrompts,
|
||||
nextTheme,
|
||||
parseSettings,
|
||||
type Settings,
|
||||
} from "./settings.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
import { seenAfter } from "./tutorial-nav.js";
|
||||
import { createTutorialView, type TutorialAccess } from "./tutorial-view.js";
|
||||
@@ -84,9 +106,9 @@ export interface CachedBoard {
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
|
||||
* une position donnée (setup). */
|
||||
readonly newGame: (setup?: GameSetup) => Game;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré) : niveau,
|
||||
* match, ou position de départ (setup). */
|
||||
readonly newGame: (newGame?: NewGame) => Game;
|
||||
/** Les liens vers une position (ADR 0013). */
|
||||
readonly positions: PositionsAccess;
|
||||
/** La partie enregistrée, ou null s'il n'y en a pas. */
|
||||
@@ -108,8 +130,14 @@ export interface AppOptions {
|
||||
/** Les mises à jour de l'application (service worker) : onAvailable est
|
||||
* appelé quand une nouvelle version attend. */
|
||||
readonly startUpdates: (onAvailable: () => void) => Updates;
|
||||
/** Le conseil du maître : l'analyse d'une décision du joueur, sur un
|
||||
* moteur à part (elle ne retarde jamais la partie). */
|
||||
readonly analyse: Analyse;
|
||||
}
|
||||
|
||||
/** La source des phrases du maître, copiée dans le site (make site). */
|
||||
const PHRASES_SOURCE = "docs/maitre/phrases.md";
|
||||
|
||||
/** Ce que « Mes parties » demande au stockage. */
|
||||
export interface SavedGamesAccess {
|
||||
/** Les parties, la dernière jouée d'abord. */
|
||||
@@ -173,6 +201,7 @@ export function startApp(options: AppOptions): void {
|
||||
strip,
|
||||
who: child<HTMLElement>(strip, ".who"),
|
||||
lead: child<HTMLElement>(strip, ".lead"),
|
||||
score: child<HTMLElement>(strip, ".match-score"),
|
||||
count: child<HTMLButtonElement>(strip, ".count"),
|
||||
};
|
||||
};
|
||||
@@ -182,6 +211,9 @@ export function startApp(options: AppOptions): void {
|
||||
board: byId("board") as unknown as SVGSVGElement,
|
||||
boardBox: child<HTMLElement>(document.documentElement, ".board-box"),
|
||||
info: byId("info"),
|
||||
adviceLine: byId<HTMLAnchorElement>("advice-line"),
|
||||
explainVeil: byId("explain-veil"),
|
||||
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
|
||||
prompt: byId("prompt"),
|
||||
strips: { computer: stripOf("strip-computer"), player: stripOf("strip-player") },
|
||||
bubble: byId("pip-bubble"),
|
||||
@@ -216,7 +248,9 @@ export function startApp(options: AppOptions): void {
|
||||
menu: byId("menu"),
|
||||
menuTitle: byId("menu-title"),
|
||||
menuClose: byId<HTMLButtonElement>("menu-close"),
|
||||
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
|
||||
menuNewGame: byId<HTMLAnchorElement>("menu-new-game"),
|
||||
newGameSection: byId("new-game"),
|
||||
matchLine: byId("match-line"),
|
||||
menuGames: byId<HTMLAnchorElement>("menu-games"),
|
||||
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
|
||||
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
|
||||
@@ -231,6 +265,7 @@ export function startApp(options: AppOptions): void {
|
||||
panelVeil: byId("panel-veil"),
|
||||
panelText: byId("panel-text"),
|
||||
panelPrimary: byId<HTMLButtonElement>("panel-primary"),
|
||||
panelSettings: byId<HTMLAnchorElement>("panel-settings"),
|
||||
panelSecondary: byId<HTMLButtonElement>("panel-secondary"),
|
||||
banner: byId("banner"),
|
||||
bannerText: byId("banner-text"),
|
||||
@@ -304,7 +339,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.confirm.textContent = TEXTS.confirm;
|
||||
label(ui.cancel, TEXTS.cancel, "undo");
|
||||
ui.skip.textContent = TEXTS.skip;
|
||||
ui.again.textContent = TEXTS.newGame;
|
||||
ui.again.textContent = MATCH_TEXTS.replay;
|
||||
ui.moveHint.textContent = TEXTS.moveHint;
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
@@ -326,6 +361,8 @@ export function startApp(options: AppOptions): void {
|
||||
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
|
||||
ui.menuTheme.prepend(icon("theme"));
|
||||
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
|
||||
ui.menuAdvice.prepend(icon("verdictGood"));
|
||||
child(ui.menuAdvice, ".item-label").textContent = ADVICE_TEXTS.menuSwitch;
|
||||
ui.menuVibrations.prepend(icon("vibrate"));
|
||||
child(ui.menuVibrations, ".item-label").textContent = TEXTS.vibrations;
|
||||
ui.menuScreenOn.prepend(icon("screenOn"));
|
||||
@@ -419,6 +456,36 @@ export function startApp(options: AppOptions): void {
|
||||
// Les parties jouées et gagnées, lues dans « Mes parties » pour
|
||||
// l'accueil.
|
||||
let homeProgress: { played: number; won: number } | null = null;
|
||||
// Le conseil du maître : ses phrases (lues une fois), les analyses en
|
||||
// cours et la ligne à montrer.
|
||||
let phrases: MasterPhrases | null = null;
|
||||
const phrasesLoading: Promise<MasterPhrases> = fetch(PHRASES_SOURCE)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`phrases du maître : ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
phrases = parsePhrases(text);
|
||||
render();
|
||||
return phrases;
|
||||
});
|
||||
phrasesLoading.catch(() => undefined);
|
||||
const adviceTracker = createAdviceTracker({
|
||||
analyse: options.analyse,
|
||||
// Le conseil rattaché au journal est enregistré avec la partie, si
|
||||
// elle est toujours à l'écran.
|
||||
recorded: (g) => {
|
||||
if (g === game) {
|
||||
save();
|
||||
}
|
||||
},
|
||||
changed: () => render(),
|
||||
});
|
||||
// La ligne du maître dessinée (identifiant de sa décision) : elle n'est
|
||||
// reconstruite, en fondu, que si elle change.
|
||||
let adviceDrawn: string | null = null;
|
||||
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
|
||||
const portraitQuery = window.matchMedia("(orientation: portrait)");
|
||||
@@ -510,7 +577,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (!animating) {
|
||||
renderBoard(ui.board, g, model, {
|
||||
dice: shownDice(game, stuck),
|
||||
cube: state.cube,
|
||||
// Partie Crawford : pas de videau.
|
||||
cube: state.match?.crawford === true ? null : state.cube,
|
||||
movable: new Set(targets.keys()),
|
||||
selected,
|
||||
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
|
||||
@@ -521,14 +589,22 @@ export function startApp(options: AppOptions): void {
|
||||
diceAction: action,
|
||||
canDouble: !busy && canDouble,
|
||||
band:
|
||||
landscape && !panelShown ? (text === null ? TEXTS.gameOver : text.short) : null,
|
||||
landscape && !panelShown
|
||||
? text === null
|
||||
? TEXTS.gameOver
|
||||
: bandText(text.short, state.match?.crawford === true)
|
||||
: null,
|
||||
controls: landscape ? controls() : null,
|
||||
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
|
||||
});
|
||||
}
|
||||
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
|
||||
renderMatch(state);
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
const advice = shownAdvice();
|
||||
renderAdviceLine(notice === null && !stuck ? advice : null);
|
||||
if (notice !== null) {
|
||||
ui.info.textContent = notice;
|
||||
} else if (stuck && state.roll !== null) {
|
||||
@@ -561,6 +637,7 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.take, doubled);
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
ui.again.textContent = afterGameLabel(game);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
@@ -573,7 +650,6 @@ export function startApp(options: AppOptions): void {
|
||||
ui.skip,
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.menuNewGame,
|
||||
ui.positionPlay,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
@@ -591,6 +667,69 @@ export function startApp(options: AppOptions): void {
|
||||
void keepScreenOn(isPlaying(game) || state.phase === "doubled");
|
||||
}
|
||||
|
||||
/** Le conseil du maître à montrer : le réglage le permet, ses phrases
|
||||
* sont lues, et l'analyse attendue est prête. */
|
||||
function shownAdvice(): {
|
||||
advice: Advice;
|
||||
line: string;
|
||||
words: ReturnType<typeof masterWords>;
|
||||
} | null {
|
||||
const advice = adviceTracker.shown;
|
||||
if (!settings.masterAdvice || phrases === null || advice === null) {
|
||||
return null;
|
||||
}
|
||||
const words = masterWords(phrases, advice);
|
||||
return { advice, line: masterLine(words), words };
|
||||
}
|
||||
|
||||
/** Portrait : la ligne du maître à la place du dernier coup (charte 1d) :
|
||||
* pictogramme, mot en couleur, phrase en italique, chevron ; un lien vers
|
||||
* la feuille d'explication. */
|
||||
function renderAdviceLine(shown: ReturnType<typeof shownAdvice>): void {
|
||||
ui.info.hidden = shown !== null;
|
||||
ui.adviceLine.hidden = shown === null;
|
||||
const id = shown?.advice.eventId ?? null;
|
||||
if (shown === null || id === adviceDrawn) {
|
||||
adviceDrawn = id;
|
||||
return;
|
||||
}
|
||||
adviceDrawn = id;
|
||||
const link = ui.adviceLine;
|
||||
link.replaceChildren();
|
||||
link.href = routeHash({ screen: "explanation", event: shown.advice.eventId });
|
||||
link.dataset.verdict = shown.words.verdict;
|
||||
link.setAttribute("aria-label", ADVICE_TEXTS.lineLabel(shown.line));
|
||||
link.append(icon(VERDICT_ICONS[shown.words.verdict]));
|
||||
const text = document.createElement("span");
|
||||
text.className = "advice-text";
|
||||
const word = document.createElement("strong");
|
||||
word.className = "advice-word";
|
||||
word.textContent = `${shown.words.word}.`;
|
||||
const phrase = document.createElement("em");
|
||||
phrase.className = "advice-phrase";
|
||||
phrase.textContent = shown.words.phrase;
|
||||
text.append(word, " ", phrase);
|
||||
link.append(text, icon("chevron"));
|
||||
// Le fondu d'apparition repart à chaque nouvelle ligne.
|
||||
link.classList.remove("appear");
|
||||
void link.offsetWidth;
|
||||
link.classList.add("appear");
|
||||
}
|
||||
|
||||
/** Paysage : la pastille du conseil dans la bande centrale. */
|
||||
function boardAdvice(shown: ReturnType<typeof shownAdvice>): BoardAdvice | null {
|
||||
if (shown === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
href: routeHash({ screen: "explanation", event: shown.advice.eventId }),
|
||||
verdict: shown.words.verdict,
|
||||
icon: VERDICT_ICONS[shown.words.verdict],
|
||||
word: shown.words.word,
|
||||
label: ADVICE_TEXTS.chipLabel(shown.line),
|
||||
};
|
||||
}
|
||||
|
||||
/** Avant que le moteur réponde : le dernier plateau affiché (ou la
|
||||
* position de départ), non jouable, et les zones à leur place. */
|
||||
function renderLoading(g: Geometry): void {
|
||||
@@ -624,7 +763,6 @@ export function startApp(options: AppOptions): void {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.moveHint.hidden = true;
|
||||
ui.menuNewGame.disabled = true;
|
||||
ui.menuCopyLink.disabled = true;
|
||||
ui.positionPlay.disabled = true;
|
||||
if (notice !== null) {
|
||||
@@ -646,9 +784,42 @@ export function startApp(options: AppOptions): void {
|
||||
back: replay ? "replay" : "undo",
|
||||
backEnabled: !busy && (replay || undoable),
|
||||
menuEnabled: true,
|
||||
match: game === null ? null : matchScore(game.state),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le match : la ligne « Match en N points » sous la barre haute, et le
|
||||
* score de chaque camp dans son bandeau (portrait). */
|
||||
function renderMatch(state: GameState): void {
|
||||
const score = matchScore(state);
|
||||
ui.matchLine.hidden = score === null;
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
const badge = ui.strips[side].score;
|
||||
badge.hidden = score === null;
|
||||
badge.textContent = score === null ? "" : String(score[side]);
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
score === null
|
||||
? ""
|
||||
: side === "player"
|
||||
? MATCH_TEXTS.playerScore(score.player)
|
||||
: MATCH_TEXTS.computerScore(score.computer),
|
||||
);
|
||||
}
|
||||
if (score === null || state.match === null) {
|
||||
ui.matchLine.textContent = "";
|
||||
ui.matchLine.removeAttribute("aria-label");
|
||||
return;
|
||||
}
|
||||
ui.matchLine.textContent = MATCH_TEXTS.matchLine(score.length, state.match.crawford);
|
||||
ui.matchLine.setAttribute(
|
||||
"aria-label",
|
||||
`${MATCH_TEXTS.scoreSpoken(score.length, score.computer, score.player)}${
|
||||
state.match.crawford ? ", partie Crawford" : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Les bandeaux des joueurs : compte de points, qui mène, qui a la main. */
|
||||
function renderStrips(pips: Readonly<Record<Side, number>>, turn: Side | null): void {
|
||||
const lead = pipLead(pips);
|
||||
@@ -681,7 +852,8 @@ export function startApp(options: AppOptions): void {
|
||||
ui.panelText.textContent = end.title;
|
||||
detail.textContent = end.detail;
|
||||
ui.panelSecondary.textContent = TEXTS.reviewGame;
|
||||
ui.panelPrimary.textContent = TEXTS.newGame;
|
||||
ui.panelPrimary.textContent = afterGameLabel(g);
|
||||
ui.panelSettings.hidden = false;
|
||||
ui.panelVeil.dataset.kind = "over";
|
||||
} else if (ask) {
|
||||
overline.textContent = "";
|
||||
@@ -689,6 +861,7 @@ export function startApp(options: AppOptions): void {
|
||||
detail.textContent = "";
|
||||
ui.panelSecondary.textContent = TEXTS.drop;
|
||||
ui.panelPrimary.textContent = TEXTS.take;
|
||||
ui.panelSettings.hidden = true;
|
||||
ui.panelVeil.dataset.kind = "doubled";
|
||||
}
|
||||
ui.panelPrimary.disabled = busy;
|
||||
@@ -703,6 +876,7 @@ export function startApp(options: AppOptions): void {
|
||||
);
|
||||
child(ui.menuTheme, ".item-value").textContent = TEXTS.themes[settings.theme];
|
||||
ui.menuVibrations.setAttribute("aria-checked", String(settings.vibrations));
|
||||
ui.menuAdvice.setAttribute("aria-checked", String(settings.masterAdvice));
|
||||
ui.menuScreenOn.setAttribute("aria-checked", String(settings.keepScreenOn));
|
||||
}
|
||||
|
||||
@@ -736,6 +910,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (navigation.route.screen === "home") {
|
||||
renderHome();
|
||||
}
|
||||
if (navigation.route.screen === "newGame") {
|
||||
newGameView.render(busy || game === null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Le moment de la partie, pour ouvrir l'aide sur la bonne question. */
|
||||
@@ -850,7 +1027,7 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const decor = {
|
||||
dice: shownDice(game, false),
|
||||
cube: game.state.cube,
|
||||
cube: game.state.match?.crawford === true ? null : game.state.cube,
|
||||
...NO_MARKS,
|
||||
band: null,
|
||||
controls: null,
|
||||
@@ -915,11 +1092,19 @@ export function startApp(options: AppOptions): void {
|
||||
bubbleSide = null;
|
||||
notice = null;
|
||||
ui.bubble.hidden = true;
|
||||
// L'action suivante du joueur efface la ligne du maître.
|
||||
adviceTracker.clear();
|
||||
render();
|
||||
const before = game;
|
||||
const lineBefore = game.events;
|
||||
const wasOver = game.state.phase === "over";
|
||||
try {
|
||||
await action(game);
|
||||
// Ses décisions partent à l'analyse, sans être attendues : le
|
||||
// tour de l'ordinateur suit tout de suite.
|
||||
if (game === before) {
|
||||
adviceTracker.track(game, lineBefore);
|
||||
}
|
||||
save();
|
||||
await computerTurns();
|
||||
// Une partie vient de se terminer (pas une partie finie
|
||||
@@ -935,24 +1120,27 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
function startNewGame(setup?: GameSetup): void {
|
||||
game = options.newGame(setup);
|
||||
/** Commence une partie : celle des choix de l'écran « Nouvelle partie »
|
||||
* si rien d'autre n'est demandé. */
|
||||
function startNewGame(chosen: NewGame = chosenGame(settings)): void {
|
||||
game = options.newGame(chosen);
|
||||
panelDismissed = false;
|
||||
announcedEnd = null;
|
||||
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
|
||||
const opening = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
info =
|
||||
setup !== undefined
|
||||
chosen.setup !== undefined
|
||||
? TEXTS.startedFromPosition
|
||||
: opening?.type === "DiceRolled"
|
||||
? TEXTS.opening(opening.roll[0], opening.roll[1])
|
||||
: "";
|
||||
}
|
||||
|
||||
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
|
||||
* position) : tout de suite, sans confirmation ; une partie en cours
|
||||
* reste reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(setup?: GameSetup): void {
|
||||
/** Une nouvelle partie (« Commencer » de l'écran « Nouvelle partie »,
|
||||
* « Jouer à partir d'ici » depuis une position, « Partie suivante » d'un
|
||||
* match) : tout de suite, sans confirmation ; une partie en cours reste
|
||||
* reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(chosen?: NewGame): void {
|
||||
closeOverlays();
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
@@ -961,7 +1149,16 @@ export function startApp(options: AppOptions): void {
|
||||
if (old !== null) {
|
||||
showBanner(old);
|
||||
}
|
||||
void act(() => startNewGame(setup));
|
||||
void act(() => startNewGame(chosen));
|
||||
}
|
||||
|
||||
/** Après une partie finie : la partie suivante du match s'il continue,
|
||||
* sinon « Rejouer », une nouvelle partie aux mêmes réglages (un match de
|
||||
* même longueur après un match), tout de suite. */
|
||||
function afterGame(): void {
|
||||
if (game !== null) {
|
||||
newGame(game.nextGame ?? game.rematch);
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(journal: GameJournal): void {
|
||||
@@ -1012,7 +1209,10 @@ export function startApp(options: AppOptions): void {
|
||||
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
|
||||
const navigation = startNavigation((route) => applyRoute(route));
|
||||
const isSheet = (route: Route) =>
|
||||
route.screen === "menu" || route.screen === "games" || route.screen === "install";
|
||||
route.screen === "menu" ||
|
||||
route.screen === "games" ||
|
||||
route.screen === "install" ||
|
||||
route.screen === "explanation";
|
||||
|
||||
// « Mes parties » : reprendre une partie (ou une branche) en change à
|
||||
// l'écran, sans recharger la page.
|
||||
@@ -1036,6 +1236,15 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
|
||||
|
||||
// La feuille d'explication du maître (#/explication/<événement>).
|
||||
const adviceView = createAdviceView({
|
||||
veil: ui.explainVeil,
|
||||
game: () => game,
|
||||
phrases: () => phrasesLoading,
|
||||
reducedMotion,
|
||||
close: () => leaveSheet(),
|
||||
});
|
||||
|
||||
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
|
||||
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
|
||||
function playHalfMove(halfMove: HalfMove): void {
|
||||
@@ -1101,8 +1310,8 @@ export function startApp(options: AppOptions): void {
|
||||
drop: () => {
|
||||
const g = canAct();
|
||||
if (g?.state.phase === "doubled" && g.state.turn === "computer") {
|
||||
void act((g) => {
|
||||
g.drop();
|
||||
void act(async (g) => {
|
||||
await g.drop();
|
||||
info = "";
|
||||
});
|
||||
}
|
||||
@@ -1129,7 +1338,7 @@ export function startApp(options: AppOptions): void {
|
||||
selected = null;
|
||||
void settle(true);
|
||||
},
|
||||
again: () => newGame(),
|
||||
again: () => afterGame(),
|
||||
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
|
||||
// lien) ; le refermer, revenir à l'écran d'avant.
|
||||
menu: () => {
|
||||
@@ -1161,6 +1370,8 @@ export function startApp(options: AppOptions): void {
|
||||
navigation.leave(isHelpScreen);
|
||||
} else if (route.screen === "position") {
|
||||
navigation.leave((r) => r.screen === "position");
|
||||
} else if (route.screen === "newGame") {
|
||||
navigation.leave((r) => r.screen === "newGame");
|
||||
} else if (route.screen === "tutorial" || route.screen === "tutorialEnd") {
|
||||
navigation.leave((r) => r.screen === "tutorial" || r.screen === "tutorialEnd");
|
||||
}
|
||||
@@ -1203,6 +1414,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuVeil.hidden = true;
|
||||
ui.installVeil.hidden = true;
|
||||
gamesView?.hide();
|
||||
adviceView.hide();
|
||||
if (menuWasOpen) {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
@@ -1233,9 +1445,16 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.take();
|
||||
} else {
|
||||
newGame();
|
||||
afterGame();
|
||||
}
|
||||
};
|
||||
// « Changer les réglages » : un lien vers l'écran « Nouvelle partie » ;
|
||||
// le panneau se ferme, le résultat reste sur l'écran de jeu.
|
||||
ui.panelSettings.textContent = MATCH_TEXTS.changeSettings;
|
||||
ui.panelSettings.addEventListener("click", () => {
|
||||
panelDismissed = true;
|
||||
render();
|
||||
});
|
||||
ui.panelSecondary.onclick = () => {
|
||||
if (ui.panelVeil.dataset.kind === "doubled") {
|
||||
actions.drop();
|
||||
@@ -1250,12 +1469,12 @@ export function startApp(options: AppOptions): void {
|
||||
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
|
||||
// brancher. Les liens du menu remplacent son adresse : le retour du
|
||||
// téléphone ne rouvre pas le menu.
|
||||
replacingLink(ui.menuNewGame, navigation);
|
||||
replacingLink(ui.menuGames, navigation);
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
replacingLink(ui.menuHome, navigation);
|
||||
replacingLink(ui.menuTutorial, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuNewGame.onclick = () => newGame();
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
closeOverlays();
|
||||
@@ -1266,6 +1485,7 @@ export function startApp(options: AppOptions): void {
|
||||
ui.menuFullscreen.onclick = actions.fullscreen;
|
||||
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
|
||||
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
|
||||
ui.menuAdvice.onclick = () => saveSettings({ masterAdvice: !settings.masterAdvice });
|
||||
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
|
||||
ui.fullscreenButton.onclick = actions.fullscreen;
|
||||
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
|
||||
@@ -1303,7 +1523,8 @@ export function startApp(options: AppOptions): void {
|
||||
const position = below.screen === "position";
|
||||
const home = below.screen === "home";
|
||||
const tutorial = below.screen === "tutorial" || below.screen === "tutorialEnd";
|
||||
const page = help || position || home || tutorial;
|
||||
const choosing = below.screen === "newGame";
|
||||
const page = help || position || home || tutorial || choosing;
|
||||
const wasPage = ui.game.hidden;
|
||||
const wasScreen = ui.root.dataset.screen;
|
||||
ui.game.hidden = page;
|
||||
@@ -1311,9 +1532,16 @@ export function startApp(options: AppOptions): void {
|
||||
ui.position.hidden = !position;
|
||||
ui.home.hidden = !home;
|
||||
ui.tutorial.hidden = !tutorial;
|
||||
const opening = choosing && ui.newGameSection.hidden;
|
||||
ui.newGameSection.hidden = !choosing;
|
||||
ui.root.classList.toggle("page-open", page);
|
||||
// Le tutoriel et le premier lancement ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || (home && isFirstLaunch(settings)));
|
||||
// Le tutoriel, le premier lancement et l'écran « Nouvelle partie »
|
||||
// ont leur propre en-tête.
|
||||
ui.root.classList.toggle("bare", tutorial || choosing || (home && isFirstLaunch(settings)));
|
||||
if (opening) {
|
||||
newGameView.render(busy || game === null);
|
||||
newGameView.focus();
|
||||
}
|
||||
ui.root.dataset.screen = below.screen;
|
||||
if ((wasPage && !ui.game.hidden) || (page && wasScreen !== below.screen)) {
|
||||
window.scrollTo(0, 0);
|
||||
@@ -1354,6 +1582,15 @@ export function startApp(options: AppOptions): void {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
}
|
||||
// La feuille d'explication du maître, sur une décision de la partie
|
||||
// à l'écran.
|
||||
if (route.screen === "explanation") {
|
||||
closeTransient();
|
||||
ui.menuVeil.hidden = true;
|
||||
void adviceView.show(route.event);
|
||||
} else {
|
||||
adviceView.hide();
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
const expanded = route.game;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
@@ -1397,18 +1634,29 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
const homeView = createHomeView({
|
||||
section: ui.home,
|
||||
newGame: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.go("#/");
|
||||
newGame();
|
||||
},
|
||||
// « Nouvelle partie » : l'écran des choix, qui la commence.
|
||||
newGame: () => navigation.go(routeHash({ screen: "newGame" })),
|
||||
install: () => void install(false),
|
||||
dismissInstall: () => saveSettings(dismissInstall(settings)),
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
change: (changes) => saveSettings(changes),
|
||||
start: () => {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
|
||||
let progressLoading = false;
|
||||
function renderHome(): void {
|
||||
const playing = game === null ? null : game.state.phase !== "over";
|
||||
@@ -1616,7 +1864,7 @@ export function startApp(options: AppOptions): void {
|
||||
// La nouvelle partie est l'écran de jeu, à la place de la position
|
||||
// (le retour du téléphone ne rouvre pas la position).
|
||||
navigation.replace("#/");
|
||||
newGame(setupOf(shared));
|
||||
newGame({ level: chosenGame(settings).level, setup: setupOf(shared) });
|
||||
};
|
||||
ui.positionCopy.onclick = () => {
|
||||
if (shownPosition !== null) {
|
||||
@@ -1855,6 +2103,10 @@ export function startApp(options: AppOptions): void {
|
||||
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
|
||||
name = null;
|
||||
}
|
||||
// La feuille d'explication ouverte : le jeu attend qu'on la ferme.
|
||||
if (!ui.explainVeil.hidden && name !== null && name !== "close") {
|
||||
name = null;
|
||||
}
|
||||
if (name !== null) {
|
||||
event.preventDefault();
|
||||
actions[name]();
|
||||
@@ -1885,6 +2137,12 @@ export function startApp(options: AppOptions): void {
|
||||
announcedEnd = game.journal.head;
|
||||
}
|
||||
}
|
||||
// Ouverte par son adresse avant que la partie soit là : la
|
||||
// feuille d'explication la retrouve maintenant.
|
||||
const route = navigation.route;
|
||||
if (route.screen === "explanation") {
|
||||
void adviceView.show(route.event);
|
||||
}
|
||||
// Comme après une action du joueur : l'ordinateur joue si c'est
|
||||
// à lui (partie nouvelle ou reprise pendant son tour). Une partie
|
||||
// reprise juste après son coup le montre, sans l'animer.
|
||||
@@ -2032,12 +2290,52 @@ function endParts(game: Game): { title: string; detail: string } {
|
||||
}
|
||||
const { winner, kind, points } = result;
|
||||
const dropped = game.events.some((e) => e.type === "CubeDropped");
|
||||
const { match, matchAfter } = game.state;
|
||||
if (match !== null && matchAfter !== null) {
|
||||
const { player, computer } = matchAfter.score;
|
||||
const detail = MATCH_TEXTS.matchDetail(
|
||||
winner,
|
||||
points,
|
||||
match.length,
|
||||
player,
|
||||
computer,
|
||||
matchAfter.over,
|
||||
);
|
||||
return {
|
||||
title: matchAfter.over
|
||||
? MATCH_TEXTS.matchWonTitle(winner)
|
||||
: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: matchAfter.crawford ? `${detail} ${MATCH_TEXTS.crawfordNext}` : detail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: TEXTS.endTitle(winner, kind, dropped),
|
||||
detail: TEXTS.endDetail(winner, points, game.state.cube.value),
|
||||
};
|
||||
}
|
||||
|
||||
/** Le score du match à montrer : avant la partie, puis après une fois
|
||||
* qu'elle est finie (null en partie libre). */
|
||||
function matchScore(state: GameState): MatchScore | null {
|
||||
if (state.match === null) {
|
||||
return null;
|
||||
}
|
||||
const score = state.matchAfter?.score ?? state.match.score;
|
||||
return { length: state.match.length, computer: score.computer, player: score.player };
|
||||
}
|
||||
|
||||
/** Paysage : la consigne de la bande centrale ; pendant la partie
|
||||
* Crawford, elle le dit d'abord (la ligne du match n'y est pas). */
|
||||
function bandText(prompt: string, crawford: boolean): string {
|
||||
return crawford && prompt !== "" ? MATCH_TEXTS.crawfordPrompt(prompt) : prompt;
|
||||
}
|
||||
|
||||
/** Le bouton qui suit une partie finie : « Partie suivante » dans un match
|
||||
* qui continue, sinon « Rejouer » (mêmes réglages). */
|
||||
function afterGameLabel(game: Game): string {
|
||||
return game.nextGame === null ? MATCH_TEXTS.replay : MATCH_TEXTS.nextGame;
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, sur une ligne (consigne, une fois le
|
||||
* panneau fermé). */
|
||||
function resultText(game: Game): string {
|
||||
|
||||
+150
-10
@@ -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. */
|
||||
@@ -177,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);
|
||||
}
|
||||
@@ -197,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);
|
||||
}
|
||||
@@ -485,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,
|
||||
@@ -506,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,
|
||||
@@ -557,6 +694,9 @@ function drawControls(
|
||||
}
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -29,6 +29,8 @@ import { TEXTS } from "./texts.js";
|
||||
/** Les scénarios montrés dans l'aide, dans l'ordre. */
|
||||
export const FEATURES = [
|
||||
"docs/scenarios/jouer-une-partie.feature",
|
||||
"docs/scenarios/jouer-un-match.feature",
|
||||
"docs/scenarios/comprendre-mes-coups.feature",
|
||||
"docs/scenarios/apprendre-les-regles.feature",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,11 @@ export type Route =
|
||||
| { readonly screen: "games"; readonly game: string | 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. */
|
||||
@@ -86,6 +91,10 @@ export function parseRoute(hash: string): Route {
|
||||
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":
|
||||
@@ -166,6 +175,10 @@ export function routeHash(route: Route): string {
|
||||
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
|
||||
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
|
||||
|
||||
+38
-2
@@ -8,6 +8,10 @@
|
||||
// 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";
|
||||
|
||||
export interface Settings {
|
||||
@@ -34,8 +38,18 @@ export interface Settings {
|
||||
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,
|
||||
@@ -47,6 +61,10 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
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. */
|
||||
@@ -71,8 +89,9 @@ export function parseSettings(text: string | null): Settings {
|
||||
} catch {
|
||||
// Réglages illisibles : valeurs par défaut.
|
||||
}
|
||||
const bool = (key: "vibrations" | "keepScreenOn" | "welcomed" | "installed") =>
|
||||
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"
|
||||
@@ -98,9 +117,26 @@ export function parseSettings(text: string | null): Settings {
|
||||
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";
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+210
-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,
|
||||
@@ -237,10 +259,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");
|
||||
@@ -251,22 +271,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(
|
||||
@@ -283,7 +299,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);
|
||||
});
|
||||
|
||||
@@ -339,7 +355,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(() => {
|
||||
@@ -413,3 +429,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,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 });
|
||||
});
|
||||
@@ -36,6 +36,8 @@ const TABLE: [string, Route][] = [
|
||||
["#/parties", { screen: "games", game: null }],
|
||||
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
|
||||
["#/menu", { screen: "menu" }],
|
||||
["#/nouvelle-partie", { screen: "newGame" }],
|
||||
["#/explication/12", { screen: "explanation", event: "12" }],
|
||||
[
|
||||
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
|
||||
{ screen: "position", position: START, match: MATCH },
|
||||
|
||||
@@ -83,7 +83,7 @@ Given("vous avez choisi de jouer tout de suite", () => {
|
||||
});
|
||||
|
||||
Given("une partie est en cours", () => {
|
||||
game = Game.start(engine, { dice: seededDice(11), ply: 0 });
|
||||
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
|
||||
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ Before(async () => {
|
||||
|
||||
/** Commence une partie, comme l'application : un nouvel identifiant. */
|
||||
function startGame(): void {
|
||||
game = Game.start(engine, { dice: seededDice(nextSeed), ply: 0 });
|
||||
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
|
||||
shownId = `partie-${nextSeed++}`;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ 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, { ply: 0 });
|
||||
game = Game.resume(engine, saved.journal, { level: "0ply" });
|
||||
shownId = saved.summary.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,10 @@ BeforeAll(async () => {
|
||||
|
||||
Before(() => {
|
||||
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
|
||||
session = createGameSession(
|
||||
library,
|
||||
{
|
||||
start: (setup) =>
|
||||
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
|
||||
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
|
||||
},
|
||||
"0ply",
|
||||
);
|
||||
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;
|
||||
});
|
||||
@@ -131,7 +126,7 @@ Then("votre partie n'a pas changé", () => {
|
||||
|
||||
When("vous choisissez « Jouer à partir d'ici »", () => {
|
||||
assert.ok(shared !== null);
|
||||
game = session.newGame(setupOf(shared));
|
||||
game = session.newGame({ setup: setupOf(shared) });
|
||||
session.save(game.journal);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,14 +48,10 @@ let oldId: string | null;
|
||||
Given("une partie est en cours depuis quelques coups", async () => {
|
||||
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 }),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
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")) {
|
||||
@@ -185,7 +181,7 @@ const shownNow = (otherBanner = false) =>
|
||||
|
||||
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]), ply: 0 });
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -226,14 +226,10 @@ test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une b
|
||||
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),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export async function step(
|
||||
if (strategy.take ?? true) {
|
||||
game.take();
|
||||
} else {
|
||||
game.drop();
|
||||
await game.drop();
|
||||
}
|
||||
} else if (state.turn === "computer") {
|
||||
await game.playComputerTurn();
|
||||
|
||||
@@ -44,7 +44,7 @@ async function main(): Promise<void> {
|
||||
[3, 0],
|
||||
[11, 2],
|
||||
] as const) {
|
||||
const game = Game.start(engine, { dice: seededDice(seed), ply: 0 });
|
||||
const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" });
|
||||
await playToTheEnd(game, engine, choice);
|
||||
games.push([`complete-${seed}`, game]);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ async function main(): Promise<void> {
|
||||
// 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), ply: 0 });
|
||||
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));
|
||||
@@ -70,7 +70,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
|
||||
{
|
||||
const game = Game.start(engine, { dice: seededDice(9), ply: 0 });
|
||||
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 });
|
||||
}
|
||||
@@ -83,7 +83,7 @@ async function main(): Promise<void> {
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(13),
|
||||
ply: 0,
|
||||
level: "0ply",
|
||||
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
|
||||
});
|
||||
await step(game, engine);
|
||||
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
|
||||
{
|
||||
const game = Game.start(engine, {
|
||||
dice: seededDice(17),
|
||||
ply: 0,
|
||||
level: "0ply",
|
||||
setup: {
|
||||
position: "t+0OAADg/wcAQA",
|
||||
firstTurn: "player",
|
||||
|
||||
@@ -106,3 +106,47 @@ clés `localStorage` de l'application (`ustagammon.settings`,
|
||||
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.
|
||||
|
||||
@@ -98,3 +98,35 @@ Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
|
||||
|
||||
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).
|
||||
|
||||
+22
-13
@@ -19,8 +19,8 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| --- | --- | --- |
|
||||
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
|
||||
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
|
||||
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
|
||||
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
|
||||
| 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 | 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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -29,7 +29,7 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
| É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.
|
||||
|
||||
@@ -43,6 +43,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [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 |
|
||||
| [Demi-coup](demi-coup.md) | Half-move |
|
||||
| [Course](course.md) | Race |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,6 +11,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Une seule action principale (brique) visible à la fois.
|
||||
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
|
||||
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
|
||||
- [ ] Aucun élément interactif visible caché, même en partie, sous une barre d'action collée en bas, sur aucun écran (vérifié sur chaque capture par `tests/browser/recette.mjs`).
|
||||
- [ ] Textes à lire : 13 px au moins ; numéros des flèches : 12 px au moins.
|
||||
- [ ] Thème sombre : aucune couleur codée en dur hors `tokens` ; le cadre se distingue du fond.
|
||||
- [ ] Anneau de focus `focus` visible au clavier sur chaque élément interactif.
|
||||
@@ -34,7 +35,7 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
|
||||
- [ ] Comptes en pastilles aux deux bouts de la barre, 13 px, couleur du camp ; ils ne chevauchent pas les pions sur la barre.
|
||||
- [ ] Consigne et dés dans la bande centrale, sans chevaucher un pion.
|
||||
- [ ] Rotation portrait ↔ paysage en cours de partie : la position, la sélection et les dés sont conservés.
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Nouvelle partie ».
|
||||
- [ ] Fin de partie : panneau centré sur un voile, « Revoir la partie » et « Rejouer » (« Partie suivante » dans un match qui continue), lien « Changer les réglages » dessous.
|
||||
|
||||
## Comportement
|
||||
|
||||
|
||||
@@ -163,3 +163,38 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
|
||||
Pierre sur le téléphone.
|
||||
- Réglage en main des vibrations sur le Fairphone 4.
|
||||
|
||||
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
|
||||
|
||||
**Fait**
|
||||
- Retours du premier test sur téléphone, traités et publiés :
|
||||
- saisie du coup (MR !28) ;
|
||||
- révision UX arbitrée par la session « identité » (MR !35) ;
|
||||
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
|
||||
- Fusionnés :
|
||||
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
|
||||
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
|
||||
- Décisions techniques :
|
||||
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
|
||||
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
|
||||
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
|
||||
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
|
||||
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
|
||||
- « Grosse erreur » plutôt que « gaffe » ;
|
||||
- pas d'indice en V1 ;
|
||||
- pas de relecture préalable des leçons ;
|
||||
- pas de plein écran par défaut ;
|
||||
- seuils de gnubg ;
|
||||
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
|
||||
- Nouvelles demandes de Pierre, intégrées aux plans :
|
||||
- une adresse pour chaque écran ;
|
||||
- la mise à jour forcée de l'app (0e.5) ;
|
||||
- un coach bienveillant, mis en avant mais discret.
|
||||
|
||||
**Reste à faire**
|
||||
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
|
||||
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
|
||||
- Étape 1 :
|
||||
- 1c en cours ;
|
||||
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
|
||||
- enfin le plan de test 1j.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Les phrases du maître
|
||||
|
||||
Les mots et les phrases du conseil du maître (étape 1d), en une seule source
|
||||
(ADR 0008) : `make site` copie ce dossier dans le site publié
|
||||
(`docs/maitre/`), l'application le lit à l'exécution
|
||||
(`app/src/domain/analysis/phrases.ts`), et `make test` rend chaque gabarit
|
||||
sur de vraies analyses du moteur (`app/tests/advice.test.ts`).
|
||||
|
||||
Texte sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
|
||||
d'après la charte (`docs/identite/charte/ecrans-etape-1.md`, « Les phrases du
|
||||
maître »), **à relire** par la session « identité » puis par Pierre.
|
||||
|
||||
## Règles
|
||||
|
||||
- Le maître parle à la première personne et vouvoie : « J'aurais joué… »,
|
||||
jamais « Vous auriez dû… ».
|
||||
- Il ne cite que des faits donnés par le moteur : le coup recommandé, le rang
|
||||
du coup joué, le nombre de coups possibles, les pions frappés et les points
|
||||
faits (lus sur les plateaux décodés par le moteur), la décision de videau
|
||||
qu'il préfère. Aucune explication stratégique.
|
||||
- La ligne (le mot et la phrase) fait moins de 140 caractères ; pas de point
|
||||
d'exclamation, pas d'émoji, pas de superlatif.
|
||||
- Quand une section a plusieurs gabarits, ils tournent d'un coup à l'autre
|
||||
(l'identifiant de l'événement choisit), pour ne pas lasser.
|
||||
|
||||
## Format
|
||||
|
||||
Une section `## clé` par emploi, suivie d'un gabarit par ligne (`- …`). Les
|
||||
mots entre accolades sont remplis avec les données du moteur :
|
||||
|
||||
| Repère | Ce qu'il devient |
|
||||
| --- | --- |
|
||||
| `{best}` | le coup du maître, en notation de gnubg (« 8/5 6/5 ») |
|
||||
| `{played}` | votre coup, même notation |
|
||||
| `{move}` | le coup dont parle le fait (le vôtre ou celui du maître) |
|
||||
| `{rank}` | le rang de votre coup dans le classement du moteur (2 ou 3 seulement) |
|
||||
| `{points}` | « votre point 5 » ou « vos points 5 et 7 » |
|
||||
| `{roll}` | le jet, « 3-1 » |
|
||||
| `{hits}` | le nombre de pions frappés, en chiffres |
|
||||
|
||||
Un fait (sections `fait.…`) s'ajoute à la phrase seulement si la ligne reste
|
||||
sous 140 caractères.
|
||||
|
||||
Tout ce qui précède la première section `## mot.…` n'est pas lu.
|
||||
|
||||
## mot.coup.bon
|
||||
|
||||
- Bon coup
|
||||
|
||||
## mot.videau.bon
|
||||
|
||||
- Bonne décision
|
||||
|
||||
## mot.douteux
|
||||
|
||||
- Imprécision
|
||||
|
||||
## mot.mauvais
|
||||
|
||||
- Erreur
|
||||
|
||||
## mot.tres-mauvais
|
||||
|
||||
- Grosse erreur
|
||||
|
||||
## coup.meilleur
|
||||
|
||||
- C'est le coup du maître.
|
||||
- Exactement ce que j'aurais joué.
|
||||
- Bien vu.
|
||||
- C'est aussi mon choix.
|
||||
|
||||
## coup.seul
|
||||
|
||||
- C'était le seul coup possible.
|
||||
|
||||
## coup.proche
|
||||
|
||||
« Bon coup » sans être le meilleur, votre coup étant le 2e ou le 3e choix du
|
||||
moteur. Le rang n'est cité qu'ici et dans coup.douteux : il mesure mal la
|
||||
gravité d'une erreur (un 2e choix peut être loin du premier), et le
|
||||
classement complet est dans « Voir les chiffres du moteur ».
|
||||
|
||||
- Tout près : j'aurais joué {best}, votre coup était le {rank}e choix.
|
||||
|
||||
## coup.douteux
|
||||
|
||||
Imprécision, votre coup étant le 2e ou le 3e choix.
|
||||
|
||||
- J'aurais joué {best}. Votre coup était le {rank}e choix, tout près.
|
||||
|
||||
## coup.loin
|
||||
|
||||
« Bon coup » non meilleur ou imprécision, votre coup étant au-delà du 3e
|
||||
choix : le rang n'est pas cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.mauvais
|
||||
|
||||
Erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## coup.tres-mauvais
|
||||
|
||||
Grosse erreur : le rang n'est jamais cité.
|
||||
|
||||
- J'aurais joué {best}.
|
||||
|
||||
## fait.frappe
|
||||
|
||||
- {move} frappe un pion.
|
||||
|
||||
## fait.frappe-plusieurs
|
||||
|
||||
- {move} frappe {hits} pions.
|
||||
|
||||
## fait.point
|
||||
|
||||
- {move} fait {points}.
|
||||
|
||||
## fait.frappe-et-point
|
||||
|
||||
- {move} frappe un pion et fait {points}.
|
||||
|
||||
## fait.maitre-frappe
|
||||
|
||||
- Mon coup frappe un pion.
|
||||
|
||||
## fait.maitre-point
|
||||
|
||||
- Mon coup fait {points}.
|
||||
|
||||
## fait.joueur-frappe
|
||||
|
||||
- Votre coup frappe un pion, le mien non.
|
||||
|
||||
## videau.bon.double
|
||||
|
||||
- Doubler était le bon choix.
|
||||
|
||||
## videau.bon.pas-de-double
|
||||
|
||||
- Ne pas doubler était le bon choix.
|
||||
|
||||
## videau.bon.prendre
|
||||
|
||||
- Prendre était le bon choix.
|
||||
|
||||
## videau.bon.passer
|
||||
|
||||
- Passer était le bon choix.
|
||||
|
||||
## videau.proche.double
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais doublé.
|
||||
|
||||
## videau.proche.pas-de-double
|
||||
|
||||
- Les deux choix étaient très proches. Je n'aurais pas doublé.
|
||||
|
||||
## videau.proche.prendre
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais pris.
|
||||
|
||||
## videau.proche.passer
|
||||
|
||||
- Les deux choix étaient très proches. J'aurais passé.
|
||||
|
||||
## videau.autre.double
|
||||
|
||||
- Ici, j'aurais doublé.
|
||||
|
||||
## videau.autre.pas-de-double
|
||||
|
||||
- Ici, je n'aurais pas doublé.
|
||||
|
||||
## videau.autre.prendre
|
||||
|
||||
- Ici, j'aurais pris.
|
||||
|
||||
## videau.autre.passer
|
||||
|
||||
- Ici, j'aurais passé.
|
||||
|
||||
## titre.coup.meme
|
||||
|
||||
- C'est aussi le coup que j'aurais joué
|
||||
|
||||
## titre.coup.autre
|
||||
|
||||
- J'aurais joué {best}
|
||||
|
||||
## titre.videau.meme
|
||||
|
||||
- C'est aussi ce que j'aurais fait
|
||||
|
||||
## titre.videau.double
|
||||
|
||||
- J'aurais doublé
|
||||
|
||||
## titre.videau.pas-de-double
|
||||
|
||||
- Je n'aurais pas doublé
|
||||
|
||||
## titre.videau.prendre
|
||||
|
||||
- J'aurais pris
|
||||
|
||||
## titre.videau.passer
|
||||
|
||||
- J'aurais passé
|
||||
|
||||
## contexte.coup
|
||||
|
||||
- votre {roll}
|
||||
|
||||
## contexte.coup.ouverture
|
||||
|
||||
- votre {roll} d'ouverture
|
||||
|
||||
## contexte.videau.double
|
||||
|
||||
- votre double
|
||||
|
||||
## contexte.videau.pas-de-double
|
||||
|
||||
- avant votre jet
|
||||
|
||||
## contexte.videau.reponse
|
||||
|
||||
- le double de l'ordinateur
|
||||
@@ -1,60 +0,0 @@
|
||||
# Conseil du maître (1d)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1d (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après 1c)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Le coach bienveillant, « mis en avant mais discret » (Pierre, 2026-11-03) :
|
||||
après chaque coup et chaque décision de videau du joueur, jamais avant
|
||||
(ADR 0011), une appréciation et une phrase du maître fondées sur les chiffres
|
||||
du moteur.
|
||||
|
||||
## Références
|
||||
|
||||
- ADR 0011 ; maquette « conseil du maître après le coup » du canevas
|
||||
https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe ; règles de la charte
|
||||
(`docs/identite/charte/`, dont interaction.md) ; la ligne réservée de 44 px
|
||||
de la révision UX.
|
||||
- Moteur : `analyseMove`, `analyseCube`, `luck`, seuils `SKILL_THRESHOLDS`
|
||||
(gnubg : 0,03 / 0,06 / 0,12).
|
||||
|
||||
## Consignes
|
||||
|
||||
1. **Analyse en arrière-plan** : après la validation d'un coup (ou une
|
||||
décision de videau), l'analyse à 2 plis part dans le Worker sans retarder le
|
||||
tour de l'ordinateur ; son résultat est rattaché au coup dans le journal
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
|
||||
elles ne citent que des faits du moteur (coup recommandé, rang, pions
|
||||
frappés, écart) : aucune explication inventée. Première version à faire
|
||||
valider par Pierre (ton).
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
6. Scénarios Gherkin (bon coup, erreur avec meilleur coup montré, conseil
|
||||
désactivé, décision de videau commentée) avec positions et dés imposés ;
|
||||
tests unitaires des gabarits (chaque gabarit rendu sur des analyses réelles
|
||||
du moteur). `make lint`, `make test`, `make test-browser`, captures pour la
|
||||
recette design.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Après une erreur volontaire dans une position connue, la ligne du maître dit
|
||||
« Erreur. », nomme le coup recommandé par le moteur et le montre sur demande ;
|
||||
le tour de l'ordinateur n'est pas retardé ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -18,7 +18,7 @@ Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque l
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : chaque réponse attendue est le meilleur coup du moteur, vérifié par un test, et le texte n'affirme rien que le moteur ne confirme.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Match, niveaux et réglages (1c)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 1c (`docs/etape-1.md`)
|
||||
- **Statut** : prévu (après la révision UX et 1b)
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Choisir sa partie : partie libre ou match en N points, avec les règles de
|
||||
Crawford et de Jacoby, contre l'ordinateur au niveau voulu.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008. Le moteur expose depuis 1a les niveaux de gnubg
|
||||
(`levelMove`, `levelCubeDecision`) et accepte les matchs dans son API C.
|
||||
|
||||
1. **Domaine** : longueur de match, score, règle de Crawford (partie Crawford
|
||||
et suivantes, calculées par le moteur via le Match ID), règle de Jacoby en
|
||||
partie libre ; enchaînement des manches d'un match jusqu'à sa fin ; le
|
||||
journal d'événements reste rétrocompatible (les parties existantes se
|
||||
rechargent).
|
||||
2. **Adaptateur** : `EngineCube` étendu au match (score, longueur, Crawford)
|
||||
ou remplacé par le Match ID, sans casser les appels existants.
|
||||
3. **Niveau de l'ordinateur** : ses coups et ses décisions de videau au niveau
|
||||
choisi. Reprendre de `ComputerTurn` (gnubg, `play.c`) les règles de
|
||||
déroulement non reprises en 1a : pré-filtre « fenêtre de marché » et double
|
||||
facultatif à 0 pli, avec citation des sources.
|
||||
4. **Écran de nouvelle partie** (maquettes de la session « identité » si
|
||||
disponibles, sinon sobre et conforme à la charte) : type de partie, niveau
|
||||
(noms montrés au joueur à proposer simplement : Débutant, Amateur,
|
||||
Intermédiaire, Avancé, Expert, Champion — correspondance avec les niveaux
|
||||
de gnubg documentée), conseil du maître activé ou non. Par défaut : partie
|
||||
libre, Intermédiaire, conseil activé. Le réglage est mémorisé.
|
||||
5. Affichage du score de match et de la partie Crawford sur le plateau.
|
||||
6. Scénarios Gherkin (match en 3 points jusqu'au bout avec dés imposés,
|
||||
Crawford, Jacoby) et fiches du glossaire (match, Crawford, Jacoby si
|
||||
absentes). `make lint`, `make test`, `make test-browser` (un match court).
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un match en 3 points se joue jusqu'au bout, au niveau choisi, avec Crawford
|
||||
appliqué ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -56,6 +56,8 @@ Règles d'écriture :
|
||||
| Fichier | Tâche | Depuis |
|
||||
| --- | --- | --- |
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c |
|
||||
| [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d |
|
||||
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
|
||||
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Comprendre mes coups avec le conseil du maître
|
||||
|
||||
Après chacun de vos coups, et après chacune de vos décisions de videau,
|
||||
le maître dit en une ligne ce qu'il en pense : « Bon coup. »,
|
||||
« Imprécision. », « Erreur. » ou « Grosse erreur. », puis une phrase,
|
||||
par exemple le coup qu'il aurait joué. Il ne parle jamais avant que vous
|
||||
ayez joué, et la ligne s'efface dès que vous lancez les dés ou touchez un
|
||||
pion. Touchez-la pour ouvrir l'explication. Le maître, c'est GNU
|
||||
Backgammon : il ne dit que ce que le programme a calculé.
|
||||
|
||||
Scénario: Que dit le maître après un bon coup ?
|
||||
« Bon coup. », avec une phrase qui change d'une fois à l'autre et, s'il
|
||||
y en a, ce que votre coup fait sur le plateau : un pion frappé, un point
|
||||
fait. Avec 3-1 à l'ouverture, 8/5 6/5 est le meilleur coup.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 8/5 6/5
|
||||
Alors le maître dit « Bon coup. »
|
||||
Et sa phrase dit que 8/5 6/5 fait votre point 5
|
||||
|
||||
Scénario: Comment voir le coup que le maître aurait joué ?
|
||||
Après une imprécision ou une erreur, la ligne nomme le coup du maître.
|
||||
Touchez-la : l'explication montre, sur le plateau, votre coup et le coup
|
||||
du maître joués pion par pion depuis la même position. Les chiffres,
|
||||
repliés en bas, donnent l'écart entre les coups si vous les voulez.
|
||||
|
||||
Étant donné que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître dit « Grosse erreur. »
|
||||
Et sa phrase commence par « J'aurais joué 8/5 6/5. »
|
||||
Et l'explication peut montrer votre coup et le coup du maître sur le plateau
|
||||
|
||||
Scénario: Comment faire taire le maître ?
|
||||
Coupez « Conseil du maître » dans le menu, ou sur l'écran « Nouvelle
|
||||
partie ». La ligne reste alors vide, et rien d'autre ne bouge. Vos coups
|
||||
restent analysés pour le bilan de fin de partie.
|
||||
|
||||
Étant donné que vous avez coupé le conseil du maître
|
||||
Et que vous avez 3-1 à jouer à la position de départ
|
||||
Quand vous validez le coup 24/23 13/10
|
||||
Alors le maître ne dit rien
|
||||
|
||||
Scénario: Le maître commente-t-il le videau ?
|
||||
Oui : après votre double, votre réponse à un double de l'ordinateur, ou
|
||||
quand vous avez lancé les dés alors qu'il aurait doublé, le maître dit
|
||||
si vous avez bien fait.
|
||||
|
||||
Étant donné que l'ordinateur vous propose de doubler dans une position où il faut prendre
|
||||
Quand vous passez son double
|
||||
Alors le maître dit « Erreur. »
|
||||
Et sa phrase dit « Ici, j'aurais pris. »
|
||||
@@ -0,0 +1,76 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Choisir sa partie et jouer un match
|
||||
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
|
||||
Scénario: Comment choisir le niveau de l'ordinateur ?
|
||||
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
|
||||
GNU Backgammon à son meilleur niveau sur ce téléphone. La première fois,
|
||||
c'est une partie libre contre un ordinateur Intermédiaire, avec le
|
||||
conseil du maître. L'ordinateur garde le niveau choisi toute la partie,
|
||||
même si vous fermez l'application.
|
||||
|
||||
Étant donné que vous ouvrez l'écran « Nouvelle partie » pour la première fois
|
||||
Alors une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée
|
||||
Quand vous choisissez le niveau Champion et commencez la partie
|
||||
Alors l'ordinateur joue au niveau 2 plis de GNU Backgammon
|
||||
Et la partie reprise garde ce niveau
|
||||
Et vos choix sont proposés à la prochaine nouvelle partie
|
||||
|
||||
Scénario: Comment jouer un match ?
|
||||
Un match se joue en plusieurs parties : chacune rapporte ses points au
|
||||
gagnant (le double pour un gammon, le triple pour un backgammon, fois la
|
||||
valeur du videau), et le premier qui atteint le nombre de points du
|
||||
match le gagne. À la fin de chaque partie, « Partie suivante » lance la
|
||||
suivante, avec le score du match. Le match fini, « Rejouer » en commence
|
||||
un autre de même longueur.
|
||||
|
||||
Étant donné que vous commencez un match en 3 points contre un ordinateur Débutant
|
||||
Quand le match se joue jusqu'au bout, partie après partie
|
||||
Alors le match se termine dès qu'un joueur atteint 3 points
|
||||
Et chaque partie commence avec le score laissé par la précédente
|
||||
Et la partie Crawford s'est jouée sans videau
|
||||
|
||||
Scénario: Comment rejouer avec les mêmes réglages ?
|
||||
À la fin d'une partie, « Rejouer » en commence tout de suite une autre,
|
||||
avec les mêmes réglages : le même niveau, et après un match, un nouveau
|
||||
match de même longueur, à 0 à 0. Tant qu'un match n'est pas fini, c'est
|
||||
« Partie suivante » qui le continue. Pour changer de niveau ou de type
|
||||
de partie, touchez « Changer les réglages », sous les boutons.
|
||||
|
||||
Étant donné qu'une partie libre contre un ordinateur Expert vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors une partie libre commence, contre un ordinateur Expert
|
||||
Étant donné qu'un match en 3 points contre un ordinateur Avancé vient de se terminer
|
||||
Quand vous rejouez
|
||||
Alors un match en 3 points commence, à 0 à 0, contre un ordinateur Avancé
|
||||
|
||||
Scénario: Qu'est-ce que la partie Crawford ?
|
||||
Dans un match, quand un joueur arrive à un point de la victoire, la
|
||||
partie suivante se joue sans videau : personne ne peut doubler. C'est la
|
||||
règle de Crawford ; l'écran l'annonce par « partie Crawford ». Dans les
|
||||
parties d'après, celui qui est mené peut de nouveau doubler.
|
||||
|
||||
Étant donné qu'un match en 3 points commence et que l'ordinateur va gagner un gammon
|
||||
Quand l'ordinateur gagne la partie
|
||||
Alors l'ordinateur mène le match 2 à 0
|
||||
Et la partie suivante est la partie Crawford
|
||||
Quand la partie Crawford commence
|
||||
Alors personne ne peut doubler pendant cette partie
|
||||
Quand vous gagnez la partie Crawford
|
||||
Alors vous pouvez de nouveau doubler dans la partie suivante
|
||||
|
||||
Scénario: Pourquoi mon gammon ne m'a-t-il rapporté qu'un point ?
|
||||
En partie libre, la règle de Jacoby s'applique : tant que personne n'a
|
||||
doublé, un gammon ou un backgammon ne rapporte qu'un point. Elle
|
||||
encourage à doubler plutôt qu'à jouer le gammon sans risque. En match,
|
||||
elle ne s'applique pas : le gammon compte toujours double.
|
||||
|
||||
Étant donné que vous allez gagner un gammon sans que personne ait doublé
|
||||
Quand vous gagnez une partie libre
|
||||
Alors vous marquez 1 point
|
||||
Quand vous gagnez la même partie dans un match
|
||||
Alors vous marquez 2 points
|
||||
+116
-18
@@ -19,9 +19,19 @@
|
||||
* vérifie des demi-coups saisis un par
|
||||
* un (ex. « 8/5 6/5 », « bar/22 »,
|
||||
* « 6/off »)
|
||||
* matchid VIDEAU PROPRIÉTAIRE Match ID d'une partie libre avec la
|
||||
* règle Jacoby, joueur 0 au trait ;
|
||||
* matchid VIDEAU PROPRIÉTAIRE [LONGUEUR SCORE0 SCORE1 CRAWFORD]
|
||||
* Match ID joueur 0 au trait : partie
|
||||
* libre avec la règle Jacoby, ou match
|
||||
* en LONGUEUR points (CRAWFORD : 1
|
||||
* pour la partie Crawford, sinon 0) ;
|
||||
* PROPRIÉTAIRE : -1 (centre), 0 ou 1
|
||||
* double POSITION_ID MATCH_ID (avec --level) l'ordinateur au trait
|
||||
* double-t-il ? (ComputerTurn de gnubg)
|
||||
* take POSITION_ID MATCH_ID (avec --level) l'ordinateur prend-il
|
||||
* le double du joueur au trait ?
|
||||
* aftergame MATCH_ID GAGNANT POINTS le match après une partie : score,
|
||||
* fin du match, partie Crawford
|
||||
* suivante
|
||||
* reference POSITION_ID MATCH_ID [DÉS]
|
||||
* un niveau au format des positions de
|
||||
* référence (tests/reference/README.md) :
|
||||
@@ -42,8 +52,9 @@
|
||||
*
|
||||
* --ply : niveau de gnubg, de 0 (défaut) à 4 (meilleur coup comme « hint »,
|
||||
* analyses comme l'analyse de partie de gnubg).
|
||||
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves
|
||||
* et cube : coups classés comme l'ordinateur de gnubg les joue, bruit compris.
|
||||
* --level : niveau de jeu de l'ordinateur (voir levels), pour bestmove, moves,
|
||||
* cube, double et take : coups classés et videau comme l'ordinateur de gnubg
|
||||
* les joue, bruit compris.
|
||||
* --data : dossier des données ; par défaut « data » à côté du programme.
|
||||
*
|
||||
* En WebAssembly (engine-wasm/), le même programme sert d'interface : le
|
||||
@@ -71,13 +82,13 @@
|
||||
|
||||
/* Liste des commandes, pour les messages d'erreur. */
|
||||
#define COMMANDS "bestmove, moves, legal, cube, eval, position, halfmoves, matchid, reference, " \
|
||||
"levels, analysemove, analysecube ou luck"
|
||||
"levels, analysemove, analysecube, luck, double, take ou aftergame"
|
||||
|
||||
/* Coups gardés dans le classement de la commande reference (comme hint). */
|
||||
#define REFERENCE_MAX_MOVES 10
|
||||
|
||||
/* Arguments de la ligne de commande, une fois lus. */
|
||||
#define MAX_ARGS 4
|
||||
#define MAX_ARGS 6
|
||||
|
||||
typedef struct {
|
||||
int ply;
|
||||
@@ -125,6 +136,8 @@ status_code(engine_status status)
|
||||
return "invalid_move";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "invalid_cube_action";
|
||||
case ENGINE_ERROR_RESULT:
|
||||
return "invalid_result";
|
||||
}
|
||||
return "unknown_error";
|
||||
}
|
||||
@@ -475,24 +488,45 @@ run_half_moves_command(const options *opt)
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* matchid : Match ID d'une partie libre avec la règle Jacoby, joueur 0 au
|
||||
* trait. */
|
||||
/* Lit un entier ; renvoie 0 si le texte n'en est pas un. */
|
||||
static int
|
||||
parse_int(const char *text, int *value)
|
||||
{
|
||||
char *end;
|
||||
|
||||
*value = (int) strtol(text, &end, 10);
|
||||
return !*end && end != text;
|
||||
}
|
||||
|
||||
/* matchid : Match ID joueur 0 au trait, sans dés. Partie libre avec la
|
||||
* règle Jacoby, ou match (LONGUEUR SCORE0 SCORE1 CRAWFORD, ce dernier à 0
|
||||
* ou 1), sans Jacoby. */
|
||||
static int
|
||||
run_match_id_command(const options *opt)
|
||||
{
|
||||
engine_match match = { .cube_value = 1, .cube_owner = -1, .jacoby = 1 };
|
||||
char match_id[13];
|
||||
char *end;
|
||||
engine_status status;
|
||||
|
||||
if (opt->arg_count != 2)
|
||||
return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE");
|
||||
match.cube_value = (int) strtol(opt->args[0], &end, 10);
|
||||
if (*end || end == opt->args[0])
|
||||
if (opt->arg_count != 2 && opt->arg_count != 6)
|
||||
return usage_error("arguments attendus : VIDEAU PROPRIÉTAIRE "
|
||||
"[LONGUEUR SCORE0 SCORE1 CRAWFORD]");
|
||||
if (!parse_int(opt->args[0], &match.cube_value))
|
||||
return usage_error("VIDEAU attend un nombre");
|
||||
match.cube_owner = (int) strtol(opt->args[1], &end, 10);
|
||||
if (*end || end == opt->args[1])
|
||||
if (!parse_int(opt->args[1], &match.cube_owner))
|
||||
return usage_error("PROPRIÉTAIRE attend -1, 0 ou 1");
|
||||
if (opt->arg_count == 6) {
|
||||
if (!parse_int(opt->args[2], &match.match_length) || match.match_length < 1
|
||||
|| !parse_int(opt->args[3], &match.score[0]) || match.score[0] < 0
|
||||
|| !parse_int(opt->args[4], &match.score[1]) || match.score[1] < 0
|
||||
|| match.score[0] >= match.match_length || match.score[1] >= match.match_length)
|
||||
return usage_error("LONGUEUR attend un nombre positif, SCORE0 et SCORE1 des "
|
||||
"scores inférieurs à la longueur");
|
||||
if (!parse_int(opt->args[5], &match.crawford) || match.crawford < 0
|
||||
|| match.crawford > 1)
|
||||
return usage_error("CRAWFORD attend 0 ou 1");
|
||||
match.jacoby = 0;
|
||||
}
|
||||
|
||||
status = engine_encode_match(&match, match_id);
|
||||
if (status != ENGINE_OK)
|
||||
@@ -503,8 +537,67 @@ run_match_id_command(const options *opt)
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* double et take (avec --level) : le videau de l'ordinateur, comme
|
||||
* ComputerTurn de gnubg (voir engine.h). */
|
||||
static int
|
||||
run_level_cube_command(const options *opt)
|
||||
{
|
||||
const char *position_id = opt->args[0], *match_id = opt->args[1];
|
||||
engine_status status;
|
||||
|
||||
if (opt->arg_count != 2)
|
||||
return usage_error("arguments attendus : POSITION_ID MATCH_ID");
|
||||
if (opt->level < 0)
|
||||
return usage_error("double et take demandent --level");
|
||||
if (strcmp(opt->command, "double") == 0) {
|
||||
engine_level_double d;
|
||||
status = engine_level_double_decision(position_id, match_id, opt->level, &d);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
|
||||
print_level_or_ply(opt);
|
||||
printf(", \"available\": %s, \"market_window\": %s, \"double\": %s, \"decision\": ",
|
||||
d.available ? "true" : "false", d.market_window ? "true" : "false",
|
||||
d.doubles ? "true" : "false");
|
||||
if (d.decision)
|
||||
printf("\"%s\"}\n", d.decision);
|
||||
else
|
||||
printf("null}\n");
|
||||
} else {
|
||||
int takes;
|
||||
const char *decision;
|
||||
status = engine_level_take_decision(position_id, match_id, opt->level, &takes, &decision);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"position_id\": \"%s\", \"match_id\": \"%s\"", position_id, match_id);
|
||||
print_level_or_ply(opt);
|
||||
printf(", \"take\": %s, \"decision\": \"%s\"}\n", takes ? "true" : "false", decision);
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/* aftergame : le match après une partie (voir engine_match_after_game). */
|
||||
static int
|
||||
run_after_game_command(const options *opt)
|
||||
{
|
||||
engine_match_after after;
|
||||
engine_status status;
|
||||
int winner, points;
|
||||
|
||||
if (opt->arg_count != 3)
|
||||
return usage_error("arguments attendus : MATCH_ID GAGNANT POINTS");
|
||||
if (!parse_int(opt->args[1], &winner) || !parse_int(opt->args[2], &points))
|
||||
return usage_error("GAGNANT et POINTS attendent des nombres");
|
||||
status = engine_match_after_game(opt->args[0], winner, points, &after);
|
||||
if (status != ENGINE_OK)
|
||||
return print_engine_error(status);
|
||||
printf("{\"score\": [%d, %d], \"match_over\": %s, \"crawford\": %s}\n", after.score[0],
|
||||
after.score[1], after.match_over ? "true" : "false", after.crawford ? "true" : "false");
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/*
|
||||
* reference : un niveau d'un fichier de résultat de tests/reference/, de
|
||||
* reference :un niveau d'un fichier de résultat de tests/reference/, de
|
||||
* même structure que plies["<n>"] (voir tests/reference/README.md).
|
||||
*/
|
||||
static int
|
||||
@@ -731,8 +824,9 @@ parse_options(int argc, char **argv, options *opt)
|
||||
if (opt->level >= 0 && has_ply)
|
||||
return "--ply et --level ne vont pas ensemble";
|
||||
if (opt->level >= 0 && strcmp(opt->command, "bestmove") != 0
|
||||
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0)
|
||||
return "--level ne s'applique qu'à bestmove, moves et cube";
|
||||
&& strcmp(opt->command, "moves") != 0 && strcmp(opt->command, "cube") != 0
|
||||
&& strcmp(opt->command, "double") != 0 && strcmp(opt->command, "take") != 0)
|
||||
return "--level ne s'applique qu'à bestmove, moves, cube, double et take";
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -772,6 +866,10 @@ main(int argc, char **argv)
|
||||
result = run_half_moves_command(&opt);
|
||||
else if (strcmp(opt.command, "matchid") == 0)
|
||||
result = run_match_id_command(&opt);
|
||||
else if (strcmp(opt.command, "double") == 0 || strcmp(opt.command, "take") == 0)
|
||||
result = run_level_cube_command(&opt);
|
||||
else if (strcmp(opt.command, "aftergame") == 0)
|
||||
result = run_after_game_command(&opt);
|
||||
else if (strcmp(opt.command, "levels") == 0)
|
||||
result = run_levels_command(&opt);
|
||||
else if (strcmp(opt.command, "analysemove") == 0)
|
||||
|
||||
+154
@@ -156,6 +156,8 @@ engine_status_message(engine_status status)
|
||||
return "coup joué absent des coups légaux";
|
||||
case ENGINE_ERROR_CUBE_ACTION:
|
||||
return "décision de videau inconnue";
|
||||
case ENGINE_ERROR_RESULT:
|
||||
return "résultat de partie invalide";
|
||||
}
|
||||
return "erreur inconnue";
|
||||
}
|
||||
@@ -667,6 +669,158 @@ engine_level_cube_decision(const char *position_id, const char *match_id, int le
|
||||
return cube_decision(&pos, &context, cube);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- Videau de l'ordinateur */
|
||||
|
||||
/* Valeur maximale du videau (MAX_CUBE dans backgammon.h de gnubg, non
|
||||
* repris ici). */
|
||||
#define MAX_CUBE_VALUE (1 << 12)
|
||||
|
||||
/* La décision de videau de gnubg pour une position, avec ces réglages. */
|
||||
static engine_status
|
||||
find_cube_decision(position *pos, const evalcontext *context, cubedecision *decision)
|
||||
{
|
||||
float output[2][NUM_ROLLOUT_OUTPUTS];
|
||||
float equities[NUM_CUBEFUL_OUTPUTS];
|
||||
|
||||
if (GeneralCubeDecisionE(output, (ConstTanBoard) pos->board, &pos->cube, context, NULL) < 0)
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
*decision = FindCubeDecision(equities, output, &pos->cube);
|
||||
if ((size_t) *decision >= sizeof cube_decision_names / sizeof cube_decision_names[0])
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Voir engine.h : ComputerTurn, play.c l. 1312-1398. */
|
||||
engine_status
|
||||
engine_level_double_decision(const char *position_id, const char *match_id, int level,
|
||||
engine_level_double *result)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context, market_context;
|
||||
float output[NUM_ROLLOUT_OUTPUTS];
|
||||
cubedecision decision;
|
||||
engine_status status;
|
||||
int available = 0;
|
||||
|
||||
memset(result, 0, sizeof *result);
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
|
||||
/* « if (ms.fCubeUse && !ms.anDice[0] && ms.nCube < MAX_CUBE && GetDPEq(...)) » */
|
||||
GetDPEq(&available, NULL, &pos.cube);
|
||||
if (!available || pos.cube.nCube >= MAX_CUBE_VALUE)
|
||||
return ENGINE_OK;
|
||||
result->available = 1;
|
||||
|
||||
/* Fenêtre de marché : évaluation sans videau, un pli de moins. */
|
||||
market_context = context;
|
||||
market_context.fCubeful = FALSE;
|
||||
if (market_context.nPlies)
|
||||
market_context.nPlies--;
|
||||
if (EvaluatePosition(NULL, (ConstTanBoard) pos.board, output, &pos.cube, &market_context))
|
||||
return ENGINE_ERROR_INTERNAL;
|
||||
if (output[0] < GetDoublePointDeadCube(output, &pos.cube))
|
||||
return ENGINE_OK;
|
||||
result->market_window = 1;
|
||||
|
||||
status = find_cube_decision(&pos, &context, &decision);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
result->decision = cube_decision_names[decision];
|
||||
switch (decision) {
|
||||
case DOUBLE_TAKE:
|
||||
case REDOUBLE_TAKE:
|
||||
case DOUBLE_PASS:
|
||||
case REDOUBLE_PASS:
|
||||
case DOUBLE_BEAVER:
|
||||
result->doubles = 1;
|
||||
break;
|
||||
case OPTIONAL_DOUBLE_BEAVER:
|
||||
case OPTIONAL_DOUBLE_TAKE:
|
||||
case OPTIONAL_REDOUBLE_TAKE:
|
||||
case OPTIONAL_DOUBLE_PASS:
|
||||
case OPTIONAL_REDOUBLE_PASS:
|
||||
/* « double if 0-ply except when about to lose game » */
|
||||
result->doubles = context.nPlies == 0 && output[0] > 0.001f;
|
||||
break;
|
||||
default:
|
||||
/* « better leave cube where it is » */
|
||||
break;
|
||||
}
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* Voir engine.h : ComputerTurn, play.c l. 1139-1235. */
|
||||
engine_status
|
||||
engine_level_take_decision(const char *position_id, const char *match_id, int level,
|
||||
int *takes, const char **decision_name)
|
||||
{
|
||||
position pos;
|
||||
evalcontext context;
|
||||
cubedecision decision;
|
||||
engine_status status;
|
||||
|
||||
status = decode_position(position_id, match_id, &pos);
|
||||
if (status == ENGINE_OK)
|
||||
status = eval_context_for_level(level, &context);
|
||||
if (status == ENGINE_OK)
|
||||
status = find_cube_decision(&pos, &context, &decision);
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
switch (decision) {
|
||||
case DOUBLE_PASS:
|
||||
case TOOGOOD_PASS:
|
||||
case REDOUBLE_PASS:
|
||||
case TOOGOODRE_PASS:
|
||||
case OPTIONAL_DOUBLE_PASS:
|
||||
case OPTIONAL_REDOUBLE_PASS:
|
||||
*takes = 0;
|
||||
break;
|
||||
default:
|
||||
*takes = 1;
|
||||
break;
|
||||
}
|
||||
*decision_name = cube_decision_names[decision];
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Match */
|
||||
|
||||
/* Voir engine.h : play.c l. 303, 1817-1820 et 4277. */
|
||||
engine_status
|
||||
engine_match_after_game(const char *match_id, int winner, int points,
|
||||
engine_match_after *after)
|
||||
{
|
||||
engine_match m;
|
||||
engine_status status = engine_decode_match(match_id, &m);
|
||||
int length, post_crawford;
|
||||
|
||||
if (status != ENGINE_OK)
|
||||
return status;
|
||||
if (winner < 0 || winner > 1 || points < 1)
|
||||
return ENGINE_ERROR_RESULT;
|
||||
memset(after, 0, sizeof *after);
|
||||
length = m.match_length;
|
||||
after->score[0] = m.score[0];
|
||||
after->score[1] = m.score[1];
|
||||
after->score[winner] += points;
|
||||
if (length == 0)
|
||||
return ENGINE_OK;
|
||||
after->match_over = after->score[winner] >= length;
|
||||
if (after->match_over)
|
||||
return ENGINE_OK;
|
||||
/* Partie Crawford déjà jouée, lue comme gnubg (play.c l. 303). */
|
||||
post_crawford = !m.crawford && (m.score[0] == length - 1 || m.score[1] == length - 1);
|
||||
/* play.c l. 1817-1820. */
|
||||
post_crawford |= m.crawford && after->score[winner] < length;
|
||||
after->crawford = !post_crawford && !m.crawford && after->score[winner] == length - 1
|
||||
&& after->score[!winner] != length - 1;
|
||||
return ENGINE_OK;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- Analyse d'une décision de videau */
|
||||
|
||||
/* Le plus petit de deux nombres. */
|
||||
|
||||
+67
-1
@@ -51,7 +51,8 @@ typedef enum {
|
||||
ENGINE_ERROR_INTERNAL, /* échec signalé par gnubg */
|
||||
ENGINE_ERROR_LEVEL, /* niveau de jeu inconnu */
|
||||
ENGINE_ERROR_MOVE, /* coup joué absent des coups légaux */
|
||||
ENGINE_ERROR_CUBE_ACTION /* décision de videau inconnue */
|
||||
ENGINE_ERROR_CUBE_ACTION, /* décision de videau inconnue */
|
||||
ENGINE_ERROR_RESULT /* résultat de partie invalide */
|
||||
} engine_status;
|
||||
|
||||
#define ENGINE_MAX_PLY 4
|
||||
@@ -374,6 +375,71 @@ engine_status engine_level_best_moves(const char *position_id, const char *match
|
||||
engine_status engine_level_cube_decision(const char *position_id, const char *match_id,
|
||||
int level, engine_cube *cube);
|
||||
|
||||
/*
|
||||
* Le videau de l'ordinateur à ce niveau, comme le joue gnubg de bureau
|
||||
* (ComputerTurn dans play.c, l. 1312-1398, « Consider doubling ») :
|
||||
* - il ne pense à doubler que s'il a accès au videau (GetDPEq : pas en
|
||||
* partie Crawford, pas avec un videau mort…) et que le videau vaut moins
|
||||
* de 4096 (MAX_CUBE) ;
|
||||
* - pré-filtre « fenêtre de marché » : ses chances de gain, évaluées sans
|
||||
* videau à un pli de moins que le niveau (bruit compris), doivent atteindre
|
||||
* le point de double à videau mort (GetDoublePointDeadCube) ; sinon il ne
|
||||
* double pas, sans évaluer la décision complète ;
|
||||
* - dans la fenêtre, il double sur DOUBLE_TAKE, REDOUBLE_TAKE, DOUBLE_PASS,
|
||||
* REDOUBLE_PASS et DOUBLE_BEAVER ; sur un double « facultatif »
|
||||
* (OPTIONAL_*), seulement s'il évalue à 0 pli et que ses chances de gain
|
||||
* dépassent 0,001 (« double if 0-ply except when about to lose game ») ;
|
||||
* sinon il garde le videau.
|
||||
*/
|
||||
typedef struct {
|
||||
int available; /* le joueur au trait a accès au videau */
|
||||
int market_window; /* le pré-filtre est passé : décision évaluée */
|
||||
int doubles; /* l'ordinateur double */
|
||||
const char *decision; /* décision complète (noms d'engine_cube.decision)
|
||||
* si elle a été évaluée, sinon NULL */
|
||||
} engine_level_double;
|
||||
|
||||
engine_status engine_level_double_decision(const char *position_id, const char *match_id,
|
||||
int level, engine_level_double *result);
|
||||
|
||||
/*
|
||||
* L'ordinateur, adversaire du joueur au trait qui vient de doubler, prend-il
|
||||
* ? Comme ComputerTurn (play.c, l. 1139-1235, « normal double by
|
||||
* opponent ») : la décision de videau est évaluée du point de vue de celui
|
||||
* qui double, avec les réglages du niveau de l'ordinateur ; il passe sur
|
||||
* DOUBLE_PASS, TOOGOOD_PASS, REDOUBLE_PASS, TOOGOODRE_PASS et les
|
||||
* OPTIONAL_*_PASS, et prend sinon (Ustagammon ne joue pas les castors :
|
||||
* gnubg prend aussi quand il ne peut pas en faire). position_id et match_id
|
||||
* sont ceux du joueur qui double ; *decision reçoit le nom de la décision.
|
||||
*/
|
||||
engine_status engine_level_take_decision(const char *position_id, const char *match_id,
|
||||
int level, int *takes, const char **decision);
|
||||
|
||||
/* ------------------------------------------------------------------ Match */
|
||||
|
||||
/* Le match après une partie. */
|
||||
typedef struct {
|
||||
int score[2]; /* score après la partie */
|
||||
int match_over; /* le gagnant a atteint la longueur du match */
|
||||
int crawford; /* la partie suivante est la partie Crawford */
|
||||
} engine_match_after;
|
||||
|
||||
/*
|
||||
* Le match après une partie gagnée par winner (0 ou 1, numérotation du
|
||||
* Match ID) pour points points. match_id est celui de la partie jouée : sa
|
||||
* longueur, son score avant la partie et son drapeau Crawford comptent, le
|
||||
* reste est ignoré. Comme gnubg de bureau à la fin d'une partie (play.c,
|
||||
* l. 1817-1820, règle de Crawford automatique) : la partie suivante est la
|
||||
* partie Crawford si le gagnant arrive à un point du but, que son adversaire
|
||||
* n'y est pas et qu'il n'y a pas encore eu de partie Crawford. Qu'une partie
|
||||
* Crawford ait déjà eu lieu se lit dans le Match ID comme gnubg le lit
|
||||
* (play.c, l. 303 et l. 4277) : pas de drapeau Crawford alors qu'un joueur
|
||||
* est à un point du but. En partie libre (longueur 0), le score s'ajoute
|
||||
* seulement. ENGINE_ERROR_RESULT si winner ou points sont invalides.
|
||||
*/
|
||||
engine_status engine_match_after_game(const char *match_id, int winner, int points,
|
||||
engine_match_after *after);
|
||||
|
||||
/* ------------------------------------------------------------- Analyse */
|
||||
|
||||
/* Appréciation de gnubg pour une perte d'équité (0 ou négative). */
|
||||
|
||||
+23
-4
@@ -11,6 +11,7 @@ set -euo pipefail
|
||||
|
||||
CAPROVER_MACHINE="${CAPROVER_MACHINE:-sans-pub}"
|
||||
CAPROVER_APP="${CAPROVER_APP:-ustagammon}"
|
||||
SITE_URL="${SITE_URL:-https://ustagammon.sans.pub}"
|
||||
|
||||
die() {
|
||||
echo "Erreur : $1" >&2
|
||||
@@ -33,9 +34,14 @@ git fetch -q origin main
|
||||
[ "$(git rev-parse main)" = "$(git rev-parse origin/main)" ] \
|
||||
|| die "main diffère d'origin/main : git pull (ou fusionner la MR) d'abord"
|
||||
|
||||
# Le jeton CapRover expire ; caprover ls ne le vérifie pas.
|
||||
caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false >/dev/null 2>&1 \
|
||||
|| die "session CapRover expirée : lancer caprover login dans un terminal"
|
||||
# Le jeton CapRover expire ; caprover ls ne le vérifie pas. Sans terminal
|
||||
# (stdin fermé), la CLI ne peut pas demander le mot de passe : elle doit
|
||||
# échouer, et sa sortie ne doit pas contenir de demande de mot de passe (le
|
||||
# 2026-11-09, elle a demandé le mot de passe puis rendu 0 sans rien publier).
|
||||
if ! session=$(caprover api -n "$CAPROVER_MACHINE" -t /user/system/info -m GET -d '{}' -o false </dev/null 2>&1) \
|
||||
|| grep -qi "password\|not valid anymore" <<<"$session"; then
|
||||
die "session CapRover expirée : lancer caprover login dans un terminal"
|
||||
fi
|
||||
|
||||
echo "Vérification avant publication : make lint et make test"
|
||||
make lint
|
||||
@@ -43,4 +49,17 @@ make test
|
||||
|
||||
make package
|
||||
echo "Publication de main ($(git rev-parse --short main)) sur l'app $CAPROVER_APP"
|
||||
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar
|
||||
caprover deploy -n "$CAPROVER_MACHINE" -a "$CAPROVER_APP" -t build/ustagammon.tar </dev/null
|
||||
|
||||
# La CLI peut rendre 0 sans avoir publié : on vérifie le résultat réel, le
|
||||
# commit servi par le site (version.json), pendant deux minutes au plus.
|
||||
commit=$(git rev-parse main)
|
||||
for _ in $(seq 24); do
|
||||
served=$(curl -fsS "$SITE_URL/version.json?verif=$RANDOM" 2>/dev/null || true)
|
||||
if grep -q "\"$commit\"" <<<"$served"; then
|
||||
echo "Publié : $SITE_URL sert $(git rev-parse --short main)."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
die "le site ne sert pas le commit $(git rev-parse --short main) après 2 minutes : la publication a échoué (version servie : ${served:-aucune})"
|
||||
|
||||
+34
-12
@@ -4,9 +4,15 @@
|
||||
# construite en local, comme tests/test_site.sh. Les captures d'écran vont
|
||||
# dans build/browser-shots/. Puis l'accueil, le tutoriel, l'aide,
|
||||
# l'installation et la mise à jour (tests/browser/accueil.mjs), avec leurs
|
||||
# captures dans build/browser-shots/accueil-tutoriel/.
|
||||
# captures dans build/browser-shots/accueil-tutoriel/. Enfin l'écran « Nouvelle
|
||||
# partie » et un match en 3 points joué jusqu'au bout (tests/browser/match.mjs),
|
||||
# captures dans build/browser-shots/match/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play) restreignent le test.
|
||||
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
|
||||
# build/browser-shots/maitre/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre) restreignent le
|
||||
# test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
@@ -19,7 +25,7 @@ cd "$(dirname "$0")/.."
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -56,7 +62,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs "$tools/"
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.mjs "$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
@@ -66,13 +73,28 @@ for browser in $browsers; do
|
||||
-e TRACE -v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node play.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
[[ " $scripts " == *" accueil "* ]] || continue
|
||||
# Accueil, tutoriel, aide, installation et mise à jour ; captures de la
|
||||
# recette dans build/browser-shots/accueil-tutoriel/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
|
||||
-w /tools "$image" \
|
||||
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
|
||||
"http://localhost:$update_port/" /site || status=1
|
||||
if [[ " $scripts " == *" accueil "* ]]; then
|
||||
# Accueil, tutoriel, aide, installation et mise à jour ; captures de
|
||||
# la recette dans build/browser-shots/accueil-tutoriel/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -v "$PWD/$updates:/site" \
|
||||
-w /tools "$image" \
|
||||
node accueil.mjs "http://localhost:$port/" "$browser" /shots \
|
||||
"http://localhost:$update_port/" /site || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" match "* ]]; then
|
||||
# Écran « Nouvelle partie » et match en 3 points ; captures de la
|
||||
# recette dans build/browser-shots/match/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node match.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" maitre "* ]]; then
|
||||
# Conseil du maître : ligne, feuille d'explication, videau, réglage ;
|
||||
# captures de la recette dans build/browser-shots/maitre/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node maitre.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -114,6 +115,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await sleep(250);
|
||||
await settle();
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true });
|
||||
captures++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
|
||||
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
|
||||
// de production (étape 1d, docs/etape-1.md ; charte, ecrans-etape-1.md).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node maitre.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Chaque cas part d'une position connue
|
||||
// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés
|
||||
// de l'application viennent de Math.random, remplacé ici par une file de
|
||||
// valeurs choisies (window.__dice), puis par un générateur à graine.
|
||||
// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse
|
||||
// erreur), la feuille d'explication et son adresse, une décision de videau
|
||||
// commentée, le conseil coupé dans le menu, l'absence d'erreur et de
|
||||
// violation de la politique de sécurité. Captures de la recette design
|
||||
// (Chromium) dans <dossier des captures>/maitre/, en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node maitre.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/maitre`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const START = "4HPwATDgc/ABMA";
|
||||
// Une position de double et prise (videau au centre) : doubler y est juste.
|
||||
const DOUBLE_TAKE = "6O5uAAC4uzsAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
// Sans animation : les captures montrent les coups posés, avec leurs
|
||||
// traces.
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
|
||||
window.__dice = [];
|
||||
let state = 11;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 90_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
// Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture.
|
||||
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** Une nouvelle partie à partir d'une position, joueur au trait. */
|
||||
async function startFrom(position) {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until("partie commencée", () =>
|
||||
location.hash === "#/" && !document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden);
|
||||
}
|
||||
|
||||
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion
|
||||
* de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */
|
||||
async function play(dice, halfMoves) {
|
||||
await page.evaluate((d) => {
|
||||
window.__dice.push(...d);
|
||||
}, dice);
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
for (const [from, to] of halfMoves) {
|
||||
await page.click(`#board .hit[data-place="p${from}"]`);
|
||||
await idle();
|
||||
const land = page.locator(`#board .hit.can-land[data-place="p${to}"]`);
|
||||
if ((await land.count()) > 0) {
|
||||
await land.click();
|
||||
await idle();
|
||||
}
|
||||
}
|
||||
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
|
||||
await page.click("#confirm");
|
||||
}
|
||||
|
||||
/** La ligne du maître telle qu'elle s'affiche. */
|
||||
async function adviceLine() {
|
||||
return page.evaluate(() => {
|
||||
const line = document.getElementById("advice-line");
|
||||
return {
|
||||
shown: !line.hidden,
|
||||
word: line.querySelector(".advice-word")?.textContent ?? "",
|
||||
phrase: line.querySelector(".advice-phrase")?.textContent ?? "",
|
||||
href: line.getAttribute("href"),
|
||||
infoShown: !document.getElementById("info").hidden,
|
||||
height: line.getBoundingClientRect().height,
|
||||
chip: document.getElementById("advice-chip") !== null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitAdvice() {
|
||||
await until("ligne du maître", () =>
|
||||
!document.getElementById("advice-line").hidden &&
|
||||
!document.body.classList.contains("busy"));
|
||||
return adviceLine();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Bon coup, et ses voisins
|
||||
|
||||
const cases = [
|
||||
{ name: "1-bon-coup", dice: [3, 1], moves: [[8, 5], [6, 5]], word: "Bon coup." },
|
||||
{ name: "2-imprecision", dice: [2, 1], moves: [[13, 11], [11, 10]], word: "Imprécision." },
|
||||
{ name: "3-erreur", dice: [2, 1], moves: [[24, 22], [6, 5]], word: "Erreur." },
|
||||
{ name: "4-grosse-erreur", dice: [3, 1], moves: [[24, 23], [13, 10]], word: "Grosse erreur." },
|
||||
];
|
||||
for (const c of cases) {
|
||||
await startFrom(START);
|
||||
// Rien pendant que le joueur réfléchit.
|
||||
check(!(await adviceLine()).shown, `${c.name} : ligne avant le coup`);
|
||||
await play(c.dice, c.moves);
|
||||
const line = await waitAdvice();
|
||||
check(line.word === c.word, `${c.name} : mot « ${line.word} », attendu « ${c.word} »`);
|
||||
check(line.phrase.length > 0 && !line.phrase.includes("!"), `${c.name} : phrase « ${line.phrase} »`);
|
||||
check(!line.infoShown, `${c.name} : la ligne du dernier coup reste visible`);
|
||||
check(line.height <= 44, `${c.name} : ligne de ${line.height} px`);
|
||||
check(/^#\/explication\/\d+$/.test(line.href), `${c.name} : lien ${line.href}`);
|
||||
if (c.word !== "Bon coup.") {
|
||||
check(line.phrase.startsWith("J'aurais joué") || line.phrase.startsWith("Tout près"),
|
||||
`${c.name} : le coup du maître n'est pas nommé : « ${line.phrase} »`);
|
||||
}
|
||||
await capture(c.name);
|
||||
if (full) {
|
||||
// En paysage, la pastille du mot mène à la même feuille.
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
check((await adviceLine()).chip, `${c.name} : pas de pastille en paysage`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
}
|
||||
log(`${c.name} : « ${line.word} ${line.phrase} »`);
|
||||
}
|
||||
|
||||
// La ligne s'efface à l'action suivante du joueur.
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
check(!(await adviceLine()).shown, "la ligne reste après le lancer suivant");
|
||||
|
||||
// ------------------------------------------------- Feuille d'explication
|
||||
|
||||
await startFrom(START);
|
||||
await play([3, 1], [[24, 23], [13, 10]]);
|
||||
await waitAdvice();
|
||||
await page.click("#advice-line");
|
||||
await until("feuille ouverte", () =>
|
||||
/^#\/explication\/\d+$/.test(location.hash) &&
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
document.getElementById("explain-board") !== null &&
|
||||
document.querySelector("#explain-board .board-frame") !== null);
|
||||
const sheet = await page.evaluate(() => ({
|
||||
verdict: document.getElementById("explain-verdict").textContent,
|
||||
title: document.getElementById("explain-title").textContent,
|
||||
mine: document.getElementById("explain-mine")?.getAttribute("aria-checked"),
|
||||
best: document.getElementById("explain-best")?.getAttribute("aria-checked"),
|
||||
numbersOpen: document.getElementById("explain-numbers").open,
|
||||
}));
|
||||
check(sheet.verdict === "Grosse erreur · votre 3-1 d'ouverture", `feuille : ${JSON.stringify(sheet)}`);
|
||||
check(sheet.title === "J'aurais joué 8/5 6/5", `titre : ${sheet.title}`);
|
||||
check(sheet.best === "true" && sheet.mine === "false", "le coup du maître n'est pas montré d'abord");
|
||||
check(!sheet.numbersOpen, "les chiffres du moteur ne sont pas repliés");
|
||||
await capture("5-feuille-explication");
|
||||
await page.click("#explain-mine");
|
||||
await sleep(300);
|
||||
await page.click("#explain-numbers summary");
|
||||
const rows = await page.evaluate(() => document.querySelector(".explain-table .strong")?.textContent);
|
||||
check(rows !== undefined, "votre coup n'est pas en gras dans les chiffres");
|
||||
await capture("5b-feuille-votre-coup-chiffres");
|
||||
// L'adresse se recharge : la feuille revient, le conseil vient du journal.
|
||||
await page.reload();
|
||||
await until("feuille rouverte après rechargement", () =>
|
||||
!document.getElementById("explain-veil").hidden &&
|
||||
document.getElementById("explain-title")?.textContent === "J'aurais joué 8/5 6/5");
|
||||
await page.click("#explain-done");
|
||||
await until("feuille fermée", () =>
|
||||
location.hash === "#/" && document.getElementById("explain-veil").hidden);
|
||||
log("feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée");
|
||||
|
||||
// ------------------------------------------------- Décision de videau
|
||||
|
||||
await startFrom(DOUBLE_TAKE);
|
||||
check(!(await page.locator("#double").isHidden()), "doubler n'est pas proposé");
|
||||
await page.click("#double");
|
||||
await page.click("#double-confirm");
|
||||
const cube = await waitAdvice();
|
||||
check(cube.word === "Bonne décision." && cube.phrase === "Doubler était le bon choix.",
|
||||
`videau : « ${cube.word} ${cube.phrase} »`);
|
||||
await capture("6-decision-de-videau");
|
||||
log(`décision de videau : « ${cube.word} ${cube.phrase} »`);
|
||||
|
||||
// ------------------------------------------------- Conseil coupé
|
||||
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
check((await page.getAttribute("#menu-advice", "aria-checked")) === "true", "conseil coupé par défaut");
|
||||
await page.click("#menu-advice");
|
||||
check((await page.getAttribute("#menu-advice", "aria-checked")) === "false", "interrupteur sans effet");
|
||||
await page.click("#menu-close");
|
||||
await startFrom(START);
|
||||
await play([3, 1], [[24, 23], [13, 10]]);
|
||||
await idle();
|
||||
// Laisse le temps à l'analyse : la ligne ne doit pas venir.
|
||||
await sleep(3000);
|
||||
const off = await adviceLine();
|
||||
check(!off.shown && off.infoShown, `conseil coupé : ${JSON.stringify(off)}`);
|
||||
await capture("7-conseil-desactive");
|
||||
// On le rallume : le conseil déjà prêt réapparaît.
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-advice");
|
||||
await page.click("#menu-close");
|
||||
check((await waitAdvice()).word === "Grosse erreur.", "conseil rallumé : la ligne ne revient pas");
|
||||
log("conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient");
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`conseil du maître : tout est conforme (${captures} captures)`);
|
||||
@@ -0,0 +1,446 @@
|
||||
// match.mjs -- l'écran « Nouvelle partie » et un match en 3 points joué
|
||||
// jusqu'au bout par l'interface, dans un vrai navigateur sans écran
|
||||
// (Playwright), sur le site servi par l'image de production
|
||||
// (docs/plans/2026-11-03-match-et-reglages.md, point 6).
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node match.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Les dés de l'application viennent de
|
||||
// Math.random : il est remplacé ici par un générateur à graine, pour que le
|
||||
// match soit le même à chaque fois (il passe par une partie Crawford). Le
|
||||
// joueur joue toujours le premier pion jouable vers sa première case
|
||||
// d'arrivée, prend les doubles et ne double jamais. Captures de la recette
|
||||
// design (Chromium) dans <dossier des captures>/match/ : écran « Nouvelle
|
||||
// partie » (aussi en 844×320), score du match et partie Crawford, en
|
||||
// 412×915 et 915×412. Sur chaque capture, rien d'interactif ne doit être
|
||||
// caché sous la barre d'action collée (recette.mjs).
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node match.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/match`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
const SMALL_LANDSCAPE = { width: 844, height: 320 };
|
||||
// La graine des dés : un match qui passe par une partie Crawford.
|
||||
const SEED = 7;
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
// Sans animation : le match va plus vite, rien d'autre ne change.
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript((seed) => {
|
||||
let state = seed;
|
||||
Math.random = () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
}, SEED);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 60_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what}`);
|
||||
}
|
||||
|
||||
/** Une capture de la recette, dans chaque taille (Chromium seulement),
|
||||
* après la règle commune : rien d'interactif sous la barre d'action. */
|
||||
async function capture(name, sizes = [PORTRAIT, LANDSCAPE]) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of sizes) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
/** Ce que le joueur a à faire (comme tests/browser/play.mjs). */
|
||||
async function todo() {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const what = await page.evaluate(() => {
|
||||
if (document.body.classList.contains("busy")) {
|
||||
return null;
|
||||
}
|
||||
const panel = document.getElementById("panel-veil");
|
||||
if (!panel.hidden) {
|
||||
return panel.dataset.kind;
|
||||
}
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return document.querySelector(".hit.can-move") ? "move" : null;
|
||||
});
|
||||
if (what !== null) {
|
||||
return what;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`rien à faire depuis 60 s : « ${await page.textContent("#info")} »`);
|
||||
}
|
||||
|
||||
/** Le match tel que l'écran le montre (portrait). */
|
||||
async function shownMatch() {
|
||||
return page.evaluate(() => ({
|
||||
line: document.getElementById("match-line").textContent,
|
||||
lineShown: !document.getElementById("match-line").hidden,
|
||||
spoken: document.getElementById("match-line").getAttribute("aria-label"),
|
||||
computer: document.querySelector("#strip-computer .match-score").textContent,
|
||||
player: document.querySelector("#strip-player .match-score").textContent,
|
||||
cube: document.querySelectorAll("#board .cube").length,
|
||||
double: !document.getElementById("double").hidden,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Écran « Nouvelle partie »
|
||||
|
||||
await page.goto(`${base}#/nouvelle-partie`);
|
||||
await until("moteur chargé", () => !document.getElementById("new-game-start").disabled);
|
||||
const screen = () =>
|
||||
page.evaluate(() => ({
|
||||
free: document.getElementById("new-game-free").getAttribute("aria-checked"),
|
||||
match: document.getElementById("new-game-match").getAttribute("aria-checked"),
|
||||
lengthsShown: !document.getElementById("new-game-match-options").hidden,
|
||||
jacobyShown: !document.getElementById("new-game-free-options").hidden,
|
||||
level: document.querySelector(".level-bars [aria-checked=true]")?.dataset.level,
|
||||
reached: document.querySelectorAll(".level-bars .reached").length,
|
||||
name: document.getElementById("new-game-level-name").textContent,
|
||||
advice: document.getElementById("new-game-advice").getAttribute("aria-checked"),
|
||||
start: document.getElementById("new-game-start").textContent,
|
||||
topbar: document.querySelector(".topbar").getClientRects().length > 0,
|
||||
game: !document.getElementById("game").hidden,
|
||||
}));
|
||||
let s = await screen();
|
||||
check(s.free === "true" && s.match === "false" && !s.lengthsShown && s.jacobyShown,
|
||||
`par défaut, partie libre : ${JSON.stringify(s)}`);
|
||||
check(s.level === "intermediate" && s.reached === 3 && s.name === "Intermédiaire",
|
||||
`par défaut, Intermédiaire : ${JSON.stringify(s)}`);
|
||||
check(s.advice === "true" && s.start === "Commencer la partie", `conseil, bouton : ${JSON.stringify(s)}`);
|
||||
check(!s.topbar && !s.game, "l'écran « Nouvelle partie » laisse voir la barre haute ou la partie");
|
||||
check((await page.getAttribute("#new-game-jacoby a", "href")) === "#/glossaire/regle-de-jacoby",
|
||||
"lien vers la règle de Jacoby");
|
||||
// Les cibles tactiles : 44 px au moins.
|
||||
const small = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("#new-game button, #new-game a")]
|
||||
.filter((el) => el.getClientRects().length > 0 && el.closest(".new-game-rule") === null)
|
||||
.map((el) => el.getBoundingClientRect())
|
||||
.filter((r) => r.width < 44 || r.height < 44).length,
|
||||
);
|
||||
check(small === 0, `${small} cibles tactiles de moins de 44 px sur l'écran « Nouvelle partie »`);
|
||||
|
||||
await page.click("#new-game-match");
|
||||
await page.click("#new-game-length-3");
|
||||
await page.click("#new-game-level-beginner");
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
|
||||
`match en 3 points : ${JSON.stringify(s)}`);
|
||||
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
|
||||
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
|
||||
"lien vers la règle de Crawford");
|
||||
check((await page.textContent("#new-game-level-text")).startsWith("Joue simplement"),
|
||||
"description du niveau Débutant");
|
||||
// Paysage : deux colonnes au-dessus de la barre d'action, tout tient sans
|
||||
// défiler, jusqu'en 844×320 ; le type de partie à gauche, le niveau et le
|
||||
// conseil du maître à droite.
|
||||
for (const size of [LANDSCAPE, SMALL_LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(200);
|
||||
const where = `${size.width}x${size.height}`;
|
||||
const layout = await page.evaluate(() => {
|
||||
const box = (selector) => {
|
||||
const r = document.querySelector(selector).getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
|
||||
};
|
||||
return {
|
||||
scroll: document.scrollingElement.scrollHeight,
|
||||
height: innerHeight,
|
||||
bar: box(".new-game-actions"),
|
||||
kinds: box(".segmented"),
|
||||
lengths: box(".lengths"),
|
||||
rule: box("#new-game-crawford"),
|
||||
bars: box(".level-bars"),
|
||||
name: box("#new-game-level-name"),
|
||||
text: box("#new-game-level-text"),
|
||||
advice: box("#new-game-advice"),
|
||||
};
|
||||
});
|
||||
check(layout.scroll <= layout.height + 1,
|
||||
`${where} : l'écran « Nouvelle partie » défile (${layout.scroll} px pour ${layout.height})`);
|
||||
for (const part of ["kinds", "lengths", "rule", "bars", "name", "text", "advice"]) {
|
||||
check(layout[part].top >= 0 && layout[part].bottom <= layout.bar.top + 1,
|
||||
`${where} : ${part} n'est pas au-dessus de la barre d'action : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
for (const part of ["bars", "name", "text", "advice"]) {
|
||||
check(layout[part].left >= layout.kinds.right,
|
||||
`${where} : ${part} n'est pas dans la colonne de droite : ${JSON.stringify(layout)}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("1-nouvelle-partie", [PORTRAIT, LANDSCAPE, SMALL_LANDSCAPE]);
|
||||
// Fermer ne change rien : on revient à la partie, et les choix sont gardés.
|
||||
const before = await page.evaluate(() => localStorage.getItem("ustagammon.settings"));
|
||||
await page.click("#new-game-close");
|
||||
await until("retour à la partie", () => location.hash === "#/" && !document.getElementById("game").hidden);
|
||||
const settings = JSON.parse(before);
|
||||
check(settings.gameKind === "match" && settings.matchLength === 3 && settings.level === "beginner",
|
||||
`choix gardés : ${before}`);
|
||||
// Le menu y ramène, avec les choix de la dernière fois.
|
||||
await page.click("#menu-button");
|
||||
await page.waitForSelector("#menu", { state: "visible" });
|
||||
await page.click("#menu-new-game");
|
||||
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden);
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
|
||||
`choix retrouvés : ${JSON.stringify(s)}`);
|
||||
await page.click("#new-game-start");
|
||||
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
|
||||
log("écran « Nouvelle partie » : réglages par défaut, match en 3 points, Débutant, choix gardés");
|
||||
|
||||
// ------------------------------------------------------------- Le match
|
||||
|
||||
let m = await shownMatch();
|
||||
check(m.lineShown && m.line === "Match en 3 points" && m.computer === "0" && m.player === "0",
|
||||
`début du match : ${JSON.stringify(m)}`);
|
||||
check(m.spoken === "Match en 3 points : l'ordinateur 0, vous 0", `lecteur d'écran : « ${m.spoken} »`);
|
||||
|
||||
// Un match ne passe pas toujours par une partie Crawford (un gammon peut
|
||||
// sauter le point d'avant la victoire) : on enchaîne les matchs, avec les
|
||||
// dés de la même graine, jusqu'à en avoir joué une (cinq matchs au plus).
|
||||
let games = 1;
|
||||
let crawfordGames = 0;
|
||||
let scoreShot = false;
|
||||
let crawfordShot = false;
|
||||
let reloaded = false;
|
||||
let actions = 0;
|
||||
let matches = 1;
|
||||
for (;;) {
|
||||
check(actions++ < 20000, "le match ne finit pas");
|
||||
const what = await todo();
|
||||
const now = await shownMatch();
|
||||
const crawford = now.line.endsWith("· partie Crawford");
|
||||
if (crawford && what !== "over") {
|
||||
check(now.cube === 0 && !now.double, `partie Crawford : videau visible ${JSON.stringify(now)}`);
|
||||
}
|
||||
if (what === "over") {
|
||||
const title = await page.textContent("#panel-text");
|
||||
const detail = await page.textContent("#panel-detail");
|
||||
const primary = await page.textContent("#panel-primary");
|
||||
log(`partie ${games}${crawford ? " (Crawford)" : ""} : ${title} ; ${detail}`);
|
||||
const settingsLink = await page.evaluate(() => {
|
||||
const a = document.getElementById("panel-settings");
|
||||
return a.hidden ? null : { text: a.textContent, href: a.getAttribute("href") };
|
||||
});
|
||||
check(settingsLink?.text === "Changer les réglages" && settingsLink.href === "#/nouvelle-partie",
|
||||
`lien « Changer les réglages » : ${JSON.stringify(settingsLink)}`);
|
||||
if (primary === "Rejouer") {
|
||||
check(/gagne(z)? le match$/.test(title) && detail.includes("Score final"),
|
||||
`fin du match : ${title} ; ${detail}`);
|
||||
const [, player, computer] = /vous (\d+), l'ordinateur (\d+)/.exec(detail) ?? [];
|
||||
check(Math.max(Number(player), Number(computer)) >= 3, `score final : ${detail}`);
|
||||
// Paysage : le score dans la colonne de commandes.
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const box = await page.evaluate(() =>
|
||||
document.querySelector("#board .match-box")?.getAttribute("aria-label"));
|
||||
check(/^Match en 3 points : l'ordinateur \d+, vous \d+$/.test(box ?? ""),
|
||||
`score en paysage : « ${box} »`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
// Après le match, « Changer les réglages » mène à l'écran des
|
||||
// choix ; le fermer ramène au résultat, où « Rejouer » relance
|
||||
// tout de suite un match de même longueur.
|
||||
await page.click("#panel-settings");
|
||||
await until("écran « Nouvelle partie » après le match", () =>
|
||||
location.hash === "#/nouvelle-partie" && document.getElementById("panel-veil").hidden);
|
||||
await page.click("#new-game-close");
|
||||
await until("retour au résultat du match", () => location.hash === "#/" &&
|
||||
!document.getElementById("again").hidden);
|
||||
check((await page.textContent("#again")) === "Rejouer",
|
||||
`après le match, barre d'actions : « ${await page.textContent("#again")} »`);
|
||||
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
|
||||
await page.click("#again");
|
||||
await until("match rejoué", () => location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === "Match en 3 points" && again.computer === "0" && again.player === "0",
|
||||
`« Rejouer » après le match : ${JSON.stringify(again)}`);
|
||||
if (crawfordGames > 0 && scoreShot) {
|
||||
break;
|
||||
}
|
||||
check(matches < 5, `aucune partie Crawford en ${matches} matchs (graine ${SEED})`);
|
||||
matches++;
|
||||
games = 1;
|
||||
continue;
|
||||
}
|
||||
check(primary === "Partie suivante" && /Match en 3 points : vous \d+, l'ordinateur \d+/.test(detail),
|
||||
`fin de partie dans un match : ${primary} ; ${detail}`);
|
||||
await page.click("#panel-primary");
|
||||
games++;
|
||||
await until("partie suivante", () => !document.body.classList.contains("busy"));
|
||||
const next = await shownMatch();
|
||||
if (next.line.endsWith("· partie Crawford")) {
|
||||
crawfordGames++;
|
||||
check(next.cube === 0, "partie Crawford : le videau est dessiné");
|
||||
if (!crawfordShot) {
|
||||
crawfordShot = true;
|
||||
check(next.line === "Match en 3 points · partie Crawford",
|
||||
`partie Crawford, portrait : « ${next.line} »`);
|
||||
await page.setViewportSize(LANDSCAPE);
|
||||
await sleep(200);
|
||||
const band = await page.evaluate(() => {
|
||||
const text = document.getElementById("band-text");
|
||||
const pill = document.querySelector("#board .band-pill");
|
||||
return {
|
||||
text: text?.textContent ?? "",
|
||||
fits: text !== null && pill !== null &&
|
||||
text.getBBox().width <= pill.getBBox().width - 16,
|
||||
};
|
||||
});
|
||||
check(band.text.startsWith("Partie Crawford · "),
|
||||
`partie Crawford, paysage : « ${band.text} »`);
|
||||
check(band.fits, `partie Crawford, paysage : la consigne déborde (« ${band.text} »)`);
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(200);
|
||||
await capture("3-partie-crawford");
|
||||
}
|
||||
} else if (!scoreShot) {
|
||||
scoreShot = true;
|
||||
await capture("2-score-du-match");
|
||||
}
|
||||
if (!reloaded) {
|
||||
// Le match continue après un rechargement de la page.
|
||||
reloaded = true;
|
||||
// L'application enregistre sans attendre : on laisse IndexedDB
|
||||
// finir d'écrire la nouvelle partie (Firefox peut être lent).
|
||||
await until("partie suivante enregistrée", (score) => new Promise((resolve) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onerror = () => resolve(false);
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction(["settings", "games"]);
|
||||
const current = tx.objectStore("settings").get("current");
|
||||
current.onsuccess = () => {
|
||||
const id = current.result?.value;
|
||||
if (typeof id !== "string") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const summary = tx.objectStore("games").get(id);
|
||||
summary.onsuccess = () => {
|
||||
const s = summary.result;
|
||||
resolve(s?.status === "playing" &&
|
||||
`${s.match?.score.computer}-${s.match?.score.player}` === score);
|
||||
open.result.close();
|
||||
};
|
||||
};
|
||||
};
|
||||
}), `${next.computer}-${next.player}`).catch(async (error) => {
|
||||
const stored = await page.evaluate(() => new Promise((resolve) => {
|
||||
const open = indexedDB.open("ustagammon");
|
||||
open.onerror = () => resolve(`IndexedDB : ${open.error}`);
|
||||
open.onsuccess = () => {
|
||||
const all = open.result.transaction(["settings", "games"]);
|
||||
const current = all.objectStore("settings").get("current");
|
||||
const games = all.objectStore("games").getAll();
|
||||
games.onsuccess = () => resolve(JSON.stringify({
|
||||
current: current.result,
|
||||
games: games.result.map((g) => [g.id, g.status, g.match, g.updatedAt]),
|
||||
local: Object.keys(localStorage),
|
||||
}));
|
||||
};
|
||||
}));
|
||||
throw new Error(`${error.message} ; enregistré : ${stored}`);
|
||||
});
|
||||
await page.reload();
|
||||
await until("partie reprise", () => !document.body.classList.contains("busy"));
|
||||
const again = await shownMatch();
|
||||
check(again.line === next.line && again.player === next.player && again.computer === next.computer,
|
||||
`après rechargement : ${JSON.stringify(again)} au lieu de ${JSON.stringify(next)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Les doubles de l'ordinateur sont passés : le match avance d'un point
|
||||
// à la fois, ce qui rend la partie Crawford probable.
|
||||
if (what === "doubled") {
|
||||
await page.click("#panel-secondary");
|
||||
} else if (what === "take") {
|
||||
await page.click("#drop");
|
||||
} else if (what === "skip" || what === "confirm" || what === "roll") {
|
||||
await page.click(`#${what}`);
|
||||
} else if (what === "move") {
|
||||
await page.click(".hit.can-move");
|
||||
if ((await page.locator(".hit.can-land").count()) > 0) {
|
||||
await page.click(".hit.can-land");
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`${matches} match(s) en 3 points joués jusqu'au bout : ${crawfordGames} partie(s) Crawford, ` +
|
||||
`${actions} actions`);
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
check(csp.length === 0, `violations de la politique de sécurité : ${csp.join(", ")}`);
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
console.error(problems.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
log(`aucune erreur dans la page${full ? ` ; ${captures} captures dans match/` : ""}`);
|
||||
+41
-12
@@ -21,6 +21,7 @@
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
@@ -343,6 +344,8 @@ async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const wrapped = await wrappedButtons(page);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -809,6 +812,8 @@ async function checkAddresses() {
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const hidden = await hiddenUnderActionBar(tab);
|
||||
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
|
||||
if (name === "question" || name === "terme") {
|
||||
// La barre haute de l'aide reste en vue, et ce qu'on a
|
||||
// ouvert s'arrête juste sous elle.
|
||||
@@ -923,8 +928,7 @@ async function checkAddresses() {
|
||||
for (const selector of ["#menu-games", "#menu-help"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
|
||||
"« Nouvelle partie » n'est plus un bouton");
|
||||
check(await isLink(tab, "#menu-new-game"), "« Nouvelle partie » n'est pas un lien");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : menu fermé", playing);
|
||||
await tab.click("#menu-button");
|
||||
@@ -985,8 +989,29 @@ async function openMenu() {
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu : effet immédiat, bandeau 10 s. Renvoie
|
||||
* le journal de la partie quittée. */
|
||||
/** L'écran « Nouvelle partie » est ouvert : « Commencer » lance la partie
|
||||
* choisie, et l'adresse redevient celle de la partie. */
|
||||
async function startChosenGame() {
|
||||
await page.waitForSelector("#new-game-start", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#new-game"), "l'écran « Nouvelle partie » ne s'ouvre pas");
|
||||
const hash = await page.evaluate(() => location.hash);
|
||||
check(hash === "#/nouvelle-partie", `écran « Nouvelle partie » à l'adresse ${hash}`);
|
||||
await page.click("#new-game-start");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Commencer » ne ramène pas à la partie");
|
||||
}
|
||||
|
||||
/** « Rejouer » après une partie finie : une nouvelle partie aux mêmes
|
||||
* réglages commence tout de suite, sans passer par l'écran « Nouvelle
|
||||
* partie ». */
|
||||
async function rematch() {
|
||||
await page.click("#again");
|
||||
check((await page.evaluate(() => location.hash)) === "#/" && !(await page.isVisible("#new-game")),
|
||||
"« Rejouer » ouvre un autre écran que la partie");
|
||||
}
|
||||
|
||||
/** Nouvelle partie depuis le menu, par l'écran « Nouvelle partie » : effet
|
||||
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
|
||||
async function newGameFromMenu() {
|
||||
const before = await savedJournal();
|
||||
const started = () =>
|
||||
@@ -995,6 +1020,7 @@ async function newGameFromMenu() {
|
||||
await openMenu();
|
||||
const start = Date.now();
|
||||
await page.click("#menu-new-game");
|
||||
await startChosenGame();
|
||||
check(await page.isVisible("#banner"), "pas de bandeau « Reprendre l'ancienne »");
|
||||
check((await page.textContent("#banner")).includes("Reprendre l'ancienne"),
|
||||
`bandeau : « ${await page.textContent("#banner")} »`);
|
||||
@@ -1513,8 +1539,10 @@ const won = journal.events.at(-1).winner === "player";
|
||||
check((await vibrations()).at(-1) === (won ? "30,50,30,50,60" : "60"),
|
||||
`fin de partie : vibration ${(await vibrations()).at(-1)}`);
|
||||
check((await page.textContent("#panel-secondary")) === "Revoir la partie" &&
|
||||
(await page.textContent("#panel-primary")) === "Nouvelle partie",
|
||||
"fin de partie : panneau sans « Revoir la partie » et « Nouvelle partie »");
|
||||
(await page.textContent("#panel-primary")) === "Rejouer" &&
|
||||
(await page.textContent("#panel-settings")) === "Changer les réglages" &&
|
||||
(await page.getAttribute("#panel-settings", "href")) === "#/nouvelle-partie",
|
||||
"fin de partie : panneau sans « Revoir la partie », « Rejouer » et « Changer les réglages »");
|
||||
await review("6-fin-de-partie");
|
||||
await page.setViewportSize({ width: 844, height: 320 });
|
||||
await sleep(150);
|
||||
@@ -1545,11 +1573,12 @@ log(skips > 0 ? `aucun coup possible ${skips} fois (${skipsByDice} en touchant l
|
||||
log(`vibrations demandées : ${(await vibrations()).length}`);
|
||||
await shot("4-fin-de-partie");
|
||||
|
||||
// « Revoir la partie » ferme le panneau ; « Nouvelle partie » reste dans la
|
||||
// barre d'actions.
|
||||
// « Revoir la partie » ferme le panneau ; « Rejouer » reste dans la barre
|
||||
// d'actions.
|
||||
await page.click("#panel-secondary");
|
||||
check(!(await page.isVisible("#panel")) && (await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Nouvelle partie »");
|
||||
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
|
||||
(await page.isVisible("#again")),
|
||||
"« Revoir la partie » : panneau toujours là, ou pas de « Rejouer »");
|
||||
|
||||
step("mes parties");
|
||||
// Mes parties (IndexedDB), depuis le menu : une deuxième partie commencée,
|
||||
@@ -1557,7 +1586,7 @@ step("mes parties");
|
||||
// recharger la page.
|
||||
{
|
||||
const finished = await savedJournal();
|
||||
await page.click("#again");
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
const second = await savedJournal();
|
||||
check(second.head !== finished.head || second.events.length !== finished.events.length,
|
||||
@@ -1638,7 +1667,7 @@ log("compte de points : bulle avec la fiche du glossaire");
|
||||
|
||||
step("thème sombre");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.click("#again");
|
||||
await rematch();
|
||||
await waitForPlayer();
|
||||
await shot("6-sombre");
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// recette.mjs -- règles de la recette design communes aux tests dans un
|
||||
// navigateur (play.mjs, accueil.mjs, match.mjs), vérifiées sur chaque écran
|
||||
// capturé.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
/**
|
||||
* Les éléments interactifs visibles cachés, même en partie, sous une barre
|
||||
* d'action collée en bas de l'écran (position: sticky, ancrée par bottom) :
|
||||
* un bouton, un lien ou un interrupteur que la barre recouvre ne se voit
|
||||
* pas et ne se touche pas. Un élément entièrement sous le bas de l'écran
|
||||
* n'est pas visible : il n'est pas compté. Ce qui passe devant la barre (le
|
||||
* menu, un panneau) n'est pas caché : seul compte ce que la barre recouvre
|
||||
* vraiment, au point du recouvrement.
|
||||
*
|
||||
* Renvoie la liste des éléments cachés, décrits (vide si tout va bien).
|
||||
*/
|
||||
export async function hiddenUnderActionBar(target) {
|
||||
return target.evaluate(() => {
|
||||
const shown = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(el);
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const bars = [...document.querySelectorAll("body *")].filter((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.position === "sticky" && style.bottom !== "auto" && shown(el);
|
||||
});
|
||||
const describe = (el) =>
|
||||
el.id
|
||||
? `#${el.id}`
|
||||
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")} « ${
|
||||
el.getAttribute("aria-label") ?? el.textContent.trim().slice(0, 40)
|
||||
} »`;
|
||||
const hidden = [];
|
||||
const interactive = document.querySelectorAll(
|
||||
"button, a[href], input, select, textarea, [role=switch], [role=radio], " +
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
);
|
||||
for (const bar of bars) {
|
||||
const b = bar.getBoundingClientRect();
|
||||
for (const el of interactive) {
|
||||
if (bar.contains(el) || el.contains(bar) || !shown(el)) {
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top >= innerHeight) {
|
||||
continue;
|
||||
}
|
||||
const top = Math.max(r.top, b.top);
|
||||
const bottom = Math.min(r.bottom, b.bottom, innerHeight);
|
||||
const left = Math.max(r.left, b.left);
|
||||
const right = Math.min(r.right, b.right);
|
||||
if (bottom - top <= 1 || right - left <= 1) {
|
||||
continue;
|
||||
}
|
||||
const hit = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
|
||||
if (hit !== null && bar.contains(hit)) {
|
||||
hidden.push(
|
||||
`${describe(el)} sous ${describe(bar)} (${Math.round(bottom - top)} px)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
});
|
||||
}
|
||||
@@ -259,6 +259,62 @@ grep -qF '"analysed": false, "cube": null, "equity_loss": 0, "skill": "none"' \
|
||||
<<<"$($cli analysecube "$course" "$(valeur match_id "$($cli matchid 2 1)")" no_double)" \
|
||||
|| echouer "analysecube, videau chez l'adversaire : pas d'analyse attendue"
|
||||
|
||||
# 7. Match et videau de l'ordinateur (étape 1c).
|
||||
|
||||
# Match ID d'un match : longueur, score et Crawford relus à l'identique.
|
||||
sortie="$($cli matchid 1 -1 5 4 2 1)"
|
||||
grep -qF '"match_length": 5, "score": [4, 2], "cube_value": 1, "cube_owner": -1, "player_on_roll": 0, "crawford": true, "jacoby": false' \
|
||||
<<<"$sortie" || echouer "matchid d'un match mal codé : $sortie"
|
||||
[ "$(valeur match_id "$sortie")" = "$(valeur match_id "$($cli matchid 1 -1 5 4 2 1)")" ] \
|
||||
|| echouer "matchid d'un match : pas reproductible"
|
||||
attendre_erreur usage matchid 1 -1 3 3 0 0
|
||||
attendre_erreur usage matchid 1 -1 3 0 0 2
|
||||
|
||||
# Le match après une partie (règle de Crawford de gnubg, play.c).
|
||||
apres() {
|
||||
local id
|
||||
id="$(valeur match_id "$($cli matchid 1 -1 "$1" "$2" "$3" "$4")")"
|
||||
$cli aftergame "$id" "$5" "$6"
|
||||
}
|
||||
while read -r longueur s0 s1 drapeau gagnant points attendu; do
|
||||
sortie="$(apres "$longueur" "$s0" "$s1" "$drapeau" "$gagnant" "$points")"
|
||||
grep -qF "$attendu" <<<"$sortie" \
|
||||
|| echouer "aftergame $longueur $s0-$s1 crawford $drapeau, $gagnant gagne $points : $sortie"
|
||||
done <<-'EOF'
|
||||
3 0 0 0 0 2 {"score": [2, 0], "match_over": false, "crawford": true}
|
||||
3 0 0 0 1 1 {"score": [0, 1], "match_over": false, "crawford": false}
|
||||
3 1 1 0 0 1 {"score": [2, 1], "match_over": false, "crawford": true}
|
||||
3 2 0 1 1 1 {"score": [2, 1], "match_over": false, "crawford": false}
|
||||
3 2 0 1 0 1 {"score": [3, 0], "match_over": true, "crawford": false}
|
||||
3 2 1 0 1 1 {"score": [2, 2], "match_over": false, "crawford": false}
|
||||
3 2 1 0 1 4 {"score": [2, 5], "match_over": true, "crawford": false}
|
||||
5 3 3 0 1 1 {"score": [3, 4], "match_over": false, "crawford": true}
|
||||
5 4 3 0 1 1 {"score": [4, 4], "match_over": false, "crawford": false}
|
||||
EOF
|
||||
grep -qF '{"score": [2, 0], "match_over": false, "crawford": false}' \
|
||||
<<<"$($cli aftergame "$libre_j0" 0 2)" || echouer "aftergame en partie libre"
|
||||
attendre_erreur invalid_result aftergame "$libre_j0" 2 1
|
||||
attendre_erreur invalid_result aftergame "$libre_j0" 0 0
|
||||
|
||||
# L'ordinateur double (ComputerTurn de gnubg) : course gagnée, double et
|
||||
# passe ; partie Crawford, videau inaccessible ; position perdue, hors de la
|
||||
# fenêtre de marché.
|
||||
grep -qF '"available": true, "market_window": true, "double": true, "decision": "DOUBLE_PASS"' \
|
||||
<<<"$($cli --level 2ply double "$course" "$libre_j0")" || echouer "double en course gagnée"
|
||||
grep -qF '"available": false, "market_window": false, "double": false, "decision": null' \
|
||||
<<<"$($cli --level 2ply double "$depart" "$crawford")" || echouer "double en partie Crawford"
|
||||
perdue="$(valeur swapped_position_id "$($cli position "$course" "$libre_j0")")"
|
||||
grep -qF '"available": true, "market_window": false, "double": false, "decision": null' \
|
||||
<<<"$($cli --level beginner double "$perdue" "$libre_j0")" || echouer "double hors fenêtre de marché"
|
||||
grep -qF '"double": false' <<<"$($cli --level 2ply double "$depart" "$libre_j0")" \
|
||||
|| echouer "double à la position de départ"
|
||||
# L'ordinateur répond au double : passe en course perdue, prend au départ.
|
||||
grep -qF '"take": false, "decision": "DOUBLE_PASS"' \
|
||||
<<<"$($cli --level 2ply take "$course" "$libre_j0")" || echouer "take en course perdue"
|
||||
grep -qF '"take": true' <<<"$($cli --level beginner take "$depart" "$libre_j0")" \
|
||||
|| echouer "take au départ"
|
||||
attendre_erreur usage double "$depart" "$libre_j0"
|
||||
|
||||
# Chance : même jet, même chance ; ordre des dés indifférent.
|
||||
[ "$($cli luck "$milieu" "$milieu_mid" 64 | sed 's/"dice": \[[0-9], [0-9]\]//')" \
|
||||
= "$($cli luck "$milieu" "$milieu_mid" 46 | sed 's/"dice": \[[0-9], [0-9]\]//')" ] \
|
||||
|
||||
+588
@@ -835,6 +835,17 @@ button:disabled {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Fin de partie : « Changer les réglages », lien secondaire sous les
|
||||
* boutons. */
|
||||
.panel-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* « Nouvelle partie commencée. Reprendre l'ancienne », 10 s, avec sa
|
||||
* barre de temps. */
|
||||
.banner {
|
||||
@@ -2260,6 +2271,362 @@ html.landscape .welcome {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Match */
|
||||
|
||||
/* « Match en 5 points » sous la barre haute (portrait), hauteur fixe. */
|
||||
.match-line {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Le score de chaque camp, dans son bandeau, juste avant son compte de
|
||||
* points : une pastille carrée. */
|
||||
.strip .match-score {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Paysage : le score dans la colonne de commandes, sous le menu. */
|
||||
.match-box-frame {
|
||||
fill: none;
|
||||
stroke: var(--point-number);
|
||||
stroke-opacity: 0.5;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.match-box-score {
|
||||
font-weight: 700;
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
.match-box-of {
|
||||
fill: var(--point-number);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Nouvelle partie */
|
||||
|
||||
/* La page remplit l'écran : la barre d'action reste collée en bas, même
|
||||
* quand le contenu est plus court que l'écran. */
|
||||
.new-game {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.new-game .new-game-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-game-head {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.new-game-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.new-game-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Portrait : les deux colonnes l'une sous l'autre, comme une seule. */
|
||||
.new-game-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-game-body .overline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Sélecteur à deux segments : fond creusé, segment choisi en relief. */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.segmented .segment {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.segmented .segment[aria-checked="true"] {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.new-game-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lengths {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lengths .length {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lengths .length[aria-checked="true"] {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.new-game-rule {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Six barres de hauteur croissante ; celles jusqu'au niveau choisi sont en
|
||||
* brique, le niveau choisi porte l'anneau de focus. */
|
||||
.level-bars {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.level-bars .level {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
min-width: 44px;
|
||||
height: 84px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.level-bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.level-1 .level-bar { height: 18px; }
|
||||
.level-2 .level-bar { height: 28px; }
|
||||
.level-3 .level-bar { height: 38px; }
|
||||
.level-4 .level-bar { height: 50px; }
|
||||
.level-5 .level-bar { height: 62px; }
|
||||
.level-6 .level-bar { height: 76px; }
|
||||
|
||||
.level-bars .level.reached .level-bar {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.level-bars .level[aria-checked="true"] {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.level-name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.new-game-body .item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.new-game-body .item-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.new-game-body .item small {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Barre d'action collée en bas : le seul bouton principal de l'écran. */
|
||||
.new-game-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
padding: var(--space-3) 0 var(--space-4);
|
||||
background: var(--background);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.new-game-kept {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* En paysage (915 × 412, et jusqu'à 844 × 320), tout tient sans défiler :
|
||||
* deux colonnes au-dessus de la barre d'action, le type de partie à
|
||||
* gauche, le niveau et le conseil du maître à droite ; tout est resserré,
|
||||
* et la barre d'action tient sur une ligne, le bouton à droite sous le
|
||||
* pouce. */
|
||||
html.landscape .new-game {
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-head h2 {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
align-content: start;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .overline {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .during-game {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 48px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 10px; }
|
||||
html.landscape .level-2 .level-bar { height: 17px; }
|
||||
html.landscape .level-3 .level-bar { height: 24px; }
|
||||
html.landscape .level-4 .level-bar { height: 30px; }
|
||||
html.landscape .level-5 .level-bar { height: 36px; }
|
||||
html.landscape .level-6 .level-bar { height: 42px; }
|
||||
|
||||
html.landscape .level-name {
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .level-text {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item-label {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-body .item small {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
html.landscape .new-game-actions {
|
||||
flex-direction: row;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
html.landscape .new-game-kept {
|
||||
order: -1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Un peu plus d'air quand la hauteur le permet (915 × 412). */
|
||||
@media (min-height: 380px) {
|
||||
html.landscape .new-game-column {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
html.landscape .level-bars .level {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
html.landscape .level-1 .level-bar { height: 14px; }
|
||||
html.landscape .level-2 .level-bar { height: 22px; }
|
||||
html.landscape .level-3 .level-bar { height: 30px; }
|
||||
html.landscape .level-4 .level-bar { height: 39px; }
|
||||
html.landscape .level-5 .level-bar { height: 48px; }
|
||||
html.landscape .level-6 .level-bar { height: 58px; }
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
@@ -2304,3 +2671,224 @@ html.landscape .position-view.with-board .position-actions {
|
||||
html.landscape .position-view.with-board .position-actions > * {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Conseil du maître (1d) */
|
||||
|
||||
/* La ligne du maître : à la place du dernier coup, dans la ligne réservée
|
||||
* de 44 px (elle ne pousse rien). Pictogramme et mot en couleur
|
||||
* d'appréciation, phrase en italique tronquée, chevron. */
|
||||
.advice-line {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.advice-line.appear {
|
||||
animation: fade-in var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.advice-line .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.advice-line .icon-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advice-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.advice-word {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-verdict="good"] .icon:not(.icon-chevron),
|
||||
[data-verdict="good"] .advice-word { color: var(--verdict-good); }
|
||||
[data-verdict="doubtful"] .icon:not(.icon-chevron),
|
||||
[data-verdict="doubtful"] .advice-word { color: var(--verdict-doubtful); }
|
||||
[data-verdict="bad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="bad"] .advice-word,
|
||||
[data-verdict="veryBad"] .icon:not(.icon-chevron),
|
||||
[data-verdict="veryBad"] .advice-word { color: var(--verdict-bad); }
|
||||
|
||||
/* Paysage : la pastille du mot, au début de la bande centrale. */
|
||||
.advice-chip-pill {
|
||||
fill: var(--surface);
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.advice-chip-icon {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.advice-chip-text {
|
||||
fill: currentColor;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advice-chip.verdict-good { color: var(--verdict-good); }
|
||||
.advice-chip.verdict-doubtful { color: var(--verdict-doubtful); }
|
||||
.advice-chip.verdict-bad,
|
||||
.advice-chip.verdict-veryBad { color: var(--verdict-bad); }
|
||||
|
||||
/* La feuille d'explication : elle monte du bas, au-dessus du plateau
|
||||
* voilé. */
|
||||
.explain-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.explain-verdict {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-verdict .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.explain-verdict[data-verdict="good"] { color: var(--verdict-good); }
|
||||
.explain-verdict[data-verdict="doubtful"] { color: var(--verdict-doubtful); }
|
||||
.explain-verdict[data-verdict="bad"],
|
||||
.explain-verdict[data-verdict="veryBad"] { color: var(--verdict-bad); }
|
||||
|
||||
.explain-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.explain-phrase {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.explain-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.explain-body .segment {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Le coup montré : le vôtre en couleur du texte, celui du maître en
|
||||
* couleur du bon coup (charte 1d). */
|
||||
.explain-board[data-move="mine"] { --last-move: var(--text); }
|
||||
.explain-board[data-move="best"] { --last-move: var(--verdict-good); }
|
||||
|
||||
.explain-legend {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-numbers {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.explain-numbers summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explain-table {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 4px 12px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.explain-table .strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.explain-note {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.explain-glossary {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.explain-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.explain-actions button {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* En paysage : la feuille prend la hauteur de l'écran ; le plateau à
|
||||
* gauche, le texte à droite. */
|
||||
/* Le plateau tient dans la hauteur, sous le sélecteur et sa légende. */
|
||||
html.landscape .explain-board {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: calc(100vh - 140px);
|
||||
height: calc(100dvh - 140px);
|
||||
aspect-ratio: 640 / 520;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-auto-flow: dense;
|
||||
column-gap: var(--space-4);
|
||||
align-content: start;
|
||||
width: min(100%, 60rem);
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
html.landscape .explain-sheet > .explain-body {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 8;
|
||||
}
|
||||
|
||||
+19
-1
@@ -39,10 +39,13 @@
|
||||
</header>
|
||||
|
||||
<main id="game" class="game">
|
||||
<!-- Dans un match : « Match en 5 points », et la partie Crawford. -->
|
||||
<p id="match-line" class="match-line overline" hidden></p>
|
||||
<div id="strip-computer" class="strip">
|
||||
<span class="mini mini-computer"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<div class="board-box">
|
||||
@@ -57,10 +60,15 @@
|
||||
<span class="mini mini-player"></span>
|
||||
<span class="who"></span>
|
||||
<span class="lead"></span>
|
||||
<span class="match-score" hidden></span>
|
||||
<button type="button" class="count"></button>
|
||||
</div>
|
||||
<!-- Ligne du maître ou du dernier coup : après une décision du joueur,
|
||||
le conseil du maître (un lien vers sa feuille d'explication) prend la
|
||||
place du texte du dernier coup. -->
|
||||
<div class="infoline">
|
||||
<p id="info" class="info" aria-live="polite"></p>
|
||||
<a id="advice-line" class="advice-line" href="#/" hidden></a>
|
||||
<button id="replay" type="button" class="icon-button replay" hidden></button>
|
||||
<button id="copy-link" type="button" class="icon-button copy-link" hidden></button>
|
||||
</div>
|
||||
@@ -89,6 +97,10 @@
|
||||
<section id="home" class="home page" hidden></section>
|
||||
<section id="tutorial" class="tutorial page" hidden></section>
|
||||
|
||||
<!-- Le choix d'une nouvelle partie (#/nouvelle-partie), construit par
|
||||
app/src/ui/new-game-view.ts. -->
|
||||
<section id="new-game" class="new-game page" hidden></section>
|
||||
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
|
||||
@@ -126,7 +138,7 @@
|
||||
<h2 id="menu-title" class="sheet-title"></h2>
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<a id="menu-new-game" href="#/nouvelle-partie" class="item"></a>
|
||||
<a id="menu-games" href="#/parties" class="item"></a>
|
||||
<a id="menu-home" href="#/accueil" class="item"></a>
|
||||
<a id="menu-tutorial" href="#/tutoriel/1" class="item"></a>
|
||||
@@ -135,12 +147,17 @@
|
||||
<a id="menu-help" href="#/aide" class="item"></a>
|
||||
<button id="menu-install" type="button" class="item"></button>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
<button id="menu-advice" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-update" type="button" class="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L'explication du maître (#/explication/<événement>) : une feuille,
|
||||
construite par app/src/ui/advice-view.ts. -->
|
||||
<div id="explain-veil" class="veil sheet-veil" hidden></div>
|
||||
|
||||
<!-- La marche à suivre pour installer (#/installer) : une feuille. -->
|
||||
<div id="install-veil" class="veil sheet-veil" hidden>
|
||||
<div id="install-sheet" class="sheet" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
@@ -178,6 +195,7 @@
|
||||
<button id="panel-secondary" type="button" class="secondary"></button>
|
||||
<button id="panel-primary" type="button"></button>
|
||||
</div>
|
||||
<a id="panel-settings" class="panel-link" href="#/nouvelle-partie" hidden></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user