Compare commits

..
23 Commits
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 da9845466b Fusion de la MR : plateau soigné, glisser-déposer, double toucher, clavier, filet des pions (1i)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:13:22 +01:00
pierre bad7c23287 Merge pull request 'Charte : « flèche », jamais « case »' (#68) from charte-fleche-pas-case into main 2026-11-12 22:57:38 +00:00
Pierre MARTINandClaude Opus 5.5 2382fdd597 Charte : définition du point, pour les deux joueurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:57:31 +01:00
Pierre MARTINandClaude Opus 5.5 67cabcdc5c Charte : « flèche », jamais « case », dans les consignes et les règles de geste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:57:18 +01:00
Pierre MARTINandClaude Opus 5.5 44e535d94b Fusion de main (progression, 1h) dans plateau-soigne
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:31:46 +01:00
Pierre MARTINandClaude Opus 5.5 ee661661fd Fusion de la MR !63 : progression, courbe du taux d'erreur, parties terminées, exercices (1h)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:10:25 +01:00
Pierre MARTINandClaude Opus 5.5 06cc20fb1b Recette 1h : « aucune gagnée », résultat avec gammon et videau
Le résultat d'une partie dit d'où viennent ses points (« Perdue,
gammon, videau à 2, 4 points ») ; la valeur du videau vient du journal
rejoué. Les « Perdue, 2 points » des captures étaient des parties
simples perdues avec un videau à 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:10:11 +01:00
Pierre MARTINandClaude Opus 5.5 c2c5d13651 Recette 1h : courbe, lignes de partie, en-tête, bouton des exercices, carte de l'accueil
Dernier point plus gros, vert seulement si le taux baisse ; valeurs au
premier et au dernier point, axe réduit à la ligne du 0. Une ligne de
partie = un lien vers le bilan, résultat court, date et niveau en
caption. Plus de « Retour », titre en h1 ; « Nouvelle partie » pleine
largeur en portrait ; « Demander des exercices à une IA ». La carte de
l'accueil sans partie en cours ne montre plus la partie finie (testé).
Phrase progression.hausse sans « un peu ». L'écran relit ses parties à
chaque visite sans montrer celles de la précédente. Test navigateur sur
trois courses jouées jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:02:18 +01:00
pierre 432f03878e Merge pull request 'Charte : bilan du match entier' (#67) from charte-bilan-match into main 2026-11-12 18:39:54 +00:00
Pierre MARTINandClaude Opus 5.5 2dcfcf2baf Charte : bilan du match entier, courbe des chances de gagner le match, videau
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:39:48 +01:00
Pierre MARTIN e98a203111 Merge branch 'main' of git.sans.pub:pierre/Ustagammon 2026-11-12 19:30:38 +01:00
pierre f3555d0f11 Merge pull request 'Charte : Crawford sur la barre et abandon (retours des joueurs, 2e lot)' (#66) from charte-crawford-abandon into main 2026-11-12 18:28:44 +00:00
Pierre MARTINandClaude Opus 5.5 b27ca76191 Charte : Crawford sur la barre, abandonner une partie (retours des joueurs, 2e lot)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:28:36 +01:00
Pierre MARTINandClaude Opus 5.5 f70e1a6a74 Fusion de la MR : plans Crawford sur la barre, abandon, analyse du match
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:24:50 +01:00
Pierre MARTINandClaude Opus 5.5 903f9c405b Plans : retours des joueurs (Crawford sur la barre, abandon), analyse du match
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:24:34 +01:00
Pierre MARTIN 2664486541 Merge remote-tracking branch 'origin/main' into progression 2026-11-12 19:09:07 +01:00
pierre 2d787b2c0b Merge pull request 'Charte de la progression : retours de la recette 1h' (#64) from charte-progression into main 2026-11-12 18:08:37 +00:00
Pierre MARTINandClaude Opus 5.5 368d3c9e8f Charte de la progression : point final vert seulement en baisse, lignes de partie en un lien, phrase de hausse sans « un peu »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:04:05 +01:00
Pierre MARTINandClaude Opus 5.5 68d1c3a8c4 Fusion de main (leçons, 1g) dans plateau-soigne
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:37:06 +01:00
Pierre MARTINandClaude Opus 5.5 094f535c84 Double toucher sans coup évident : un toucher simple ; tests navigateur adaptés aux raccourcis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:26:30 +01:00
Pierre MARTINandClaude Opus 5.5 0aec6cb7b1 Plateau soigné : 1i faite, recette des gestes, plan clos ; glisser réservé au plateau de jeu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:01:30 +01:00
Pierre MARTINandClaude Opus 5.5 835a907b9c Plateau : glisser-déposer, double toucher, clavier, raccourcis et filet des pions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:59:34 +01:00
Pierre MARTINandClaude Opus 5.5 6dd0e93d3d Domaine : le coup évident du double toucher, jugé par le moteur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:50:48 +01:00
35 changed files with 1952 additions and 244 deletions
+5 -2
View File
@@ -12,8 +12,8 @@
// n'a pas encore de taux ; l'application l'analyse en arrière-plan.
import type { Level } from "../game/engine.js";
import type { GameJournal } from "../game/events.js";
import type { GameResult } from "../game/state.js";
import { currentLine, type GameJournal } from "../game/events.js";
import { type GameResult, replay } from "../game/state.js";
import { type ErrorRate, readyForSummary, summarizeAnalysis } from "./game-analysis.js";
/** Les parties prises en compte au plus : les plus récentes. Au-delà, la
@@ -43,6 +43,8 @@ export interface ProgressionEntry {
readonly startedAt: number;
readonly level: Level;
readonly result: GameResult | null;
/** La valeur du videau à la fin de la partie (1 : jamais doublé). */
readonly cube: number;
readonly errorRate: ErrorRate | null;
}
@@ -56,6 +58,7 @@ export function progressionEntries(games: readonly FinishedGame[]): ProgressionE
startedAt: game.startedAt,
level: game.level,
result: game.result,
cube: replay(currentLine(game.journal)).cube.value,
errorRate: readyForSummary(game.journal)
? summarizeAnalysis(game.journal).player.errorRate
: null,
+77
View File
@@ -365,6 +365,83 @@ export class Game {
return moves.filter((_, i) => checks[i]?.valid === true);
}
/**
* Le coup évident qui commence par le pion de from (une case du joueur,
* ou la barre : BAR), après les demi-coups already (par défaut, ceux
* déjà saisis) : les demi-coups qui le complètent, ou null s'il n'y en
* a pas
* (charte, ecrans-etape-1.md, 1i : le double toucher). Il existe quand
* un seul coup légal complet commence par ce pion, ou quand le moteur ne
* propose qu'un coup. Deux coups sont différents s'ils mènent à des
* positions différentes, comme dans la liste des coups de gnubg.
*
* Aucune règle n'est jugée ici : chaque demi-coup essayé est soumis au
* moteur (checkHalfMoves), qui dit s'il commence un coup légal et si le
* coup est complet. On parcourt ses réponses et on s'arrête dès que deux
* coups complets différents apparaissent.
*/
async obviousMove(
from: number,
already: readonly HalfMove[] = this.pendingHalfMoves,
): Promise<HalfMove[] | null> {
// Lus avant toute attente : la réponse vaut pour l'état de l'appel.
const state = this.state;
const pending = [...already];
if (state.turn !== "player" || state.phase !== "moving") {
return null;
}
const roll = rollToPlay(state);
const limit = (roll[0] === roll[1] ? 4 : 2) - pending.length;
if (limit <= 0) {
return null;
}
let displayed = state.position;
if (pending.length > 0) {
const check = await this.#engine.checkHalfMoves(state.position, roll, pending);
if (!check.valid || check.position === null) {
return null;
}
displayed = check.position;
}
const legal = await this.#engine.legalMoves(state.position, roll);
// Un seul coup légal : il est évident, quel que soit le pion.
const anyChecker = legal.length === 1;
const found = new Map<PositionId, HalfMove[]>();
const explore = async (sequence: HalfMove[], position: PositionId): Promise<void> => {
if (found.size > 1 || sequence.length >= limit) {
return;
}
const { player } = await this.#sideBoards(position, "player", state);
const starts = sequence.length === 0 && !anyChecker ? [from] : occupiedPoints(player);
const candidates = new Map<string, HalfMove>();
for (const start of starts) {
for (const die of roll) {
const to = Math.max(start - die, OFF);
candidates.set(`${start}/${to}`, { from: start, to });
}
}
for (const halfMove of candidates.values()) {
if (found.size > 1) {
return;
}
const next = [...sequence, halfMove];
const check = await this.#engine.checkHalfMoves(state.position, roll, [
...pending,
...next,
]);
if (!check.valid || check.position === null) {
continue;
}
if (check.complete && !found.has(check.position)) {
found.set(check.position, next);
}
await explore(next, check.position);
}
};
await explore([], displayed);
return found.size === 1 ? ([...found.values()][0] ?? null) : null;
}
/** Le joueur peut-il doubler maintenant ? (Le moteur dit si le videau le
* permet.) */
async canDouble(): Promise<boolean> {
+342 -10
View File
@@ -44,7 +44,7 @@ import type { Cube } from "../domain/game/cube.js";
import type { MoveNotation } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import { BAR, 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";
@@ -64,6 +64,7 @@ import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
import { createAnalysisView } from "./analysis-view.js";
import { createPromptView, type PromptResult } from "./ask-ai.js";
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import { attachDrag, createDoubleTap, nextPlace } from "./board-gestures.js";
import {
boardModel,
moveTargets,
@@ -77,12 +78,16 @@ import {
import {
type BoardAdvice,
type BoardDecor,
type BoardDrag,
checkerSpot,
type DiceAction,
type MatchScore,
moveDraggedChecker,
NO_MARKS,
renderBoard,
returnDraggedChecker,
type ShownDie,
type Spot,
slideChecker,
} from "./board-view.js";
import { GAMES_TEXTS } from "./games-texts.js";
@@ -265,6 +270,20 @@ type ActionName =
| "fullscreen"
| "close";
/** Le premier toucher d'un double toucher : la partie, le pion touché et
* les demi-coups saisis à ce moment-là. */
interface ObviousTap {
readonly game: Game;
readonly place: Place;
readonly pending: readonly HalfMove[];
}
/** Le numéro, pour le domaine, d'un endroit du joueur : la barre (BAR) ou
* une case de 1 à 24. */
function pointOf(place: Place): number {
return place === "bar-player" ? BAR : Number(place.slice(1));
}
/** Démarre l'écran de jeu dans la page (index.html). */
export function startApp(options: AppOptions): void {
const stripOf = (id: string) => {
@@ -418,7 +437,57 @@ 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 = MATCH_TEXTS.replay;
const againLabel = document.createElement("span");
againLabel.textContent = MATCH_TEXTS.replay;
ui.again.replaceChildren(againLabel);
// Les raccourcis du clavier (interaction.md), en petite touche sur
// chaque bouton ; visibles seulement quand un clavier est détecté.
const shortcuts: [HTMLElement, string, string][] = [
[ui.roll, "Espace", "Space"],
[ui.confirm, "Espace", "Space"],
[ui.skip, "Espace", "Space"],
[ui.again, "Espace", "Space"],
[ui.double, "D", "D"],
[ui.take, "P", "P"],
[ui.drop, "N", "N"],
[ui.cancel, "Z", "Z"],
[ui.helpButton, "?", "?"],
[ui.fullscreenButton, "F", "F"],
[ui.menuButton, "M", "M"],
];
for (const [button, key, aria] of shortcuts) {
const kbd = document.createElement("kbd");
kbd.className = "shortcut";
kbd.textContent = key;
kbd.setAttribute("aria-hidden", "true");
button.append(kbd);
button.setAttribute("aria-keyshortcuts", aria);
}
// Un clavier est détecté : sur un ordinateur (pointeur fin, écran
// large), ou dès qu'une touche est frappée ; un toucher du doigt les
// cache de nouveau.
const desktop = window.matchMedia("(hover: hover) and (pointer: fine) and (min-width: 64rem)");
const showShortcuts = (on: boolean) =>
document.documentElement.classList.toggle("keyboard", on);
showShortcuts(desktop.matches);
document.addEventListener(
"keydown",
(event) => {
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key !== "Unidentified") {
showShortcuts(true);
}
},
{ capture: true },
);
document.addEventListener(
"pointerdown",
(event) => {
if (event.pointerType === "touch") {
showShortcuts(false);
}
},
{ capture: true },
);
ui.moveHint.textContent = TEXTS.moveHint;
ui.doubleConfirm.textContent = TEXTS.double;
ui.doubleCancel.textContent = TEXTS.cancel;
@@ -484,6 +553,20 @@ export function startApp(options: AppOptions): void {
let sides: SideBoards | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
// Le pion qu'on fait glisser (charte, 1i), et celui qui vient d'être
// lâché sur une case permise : il reste dessiné là où le doigt l'a
// posé jusqu'à ce que le moteur ait accepté le demi-coup, puis glisse
// à sa place (to).
let drag: BoardDrag | null = null;
let dropped: (BoardDrag & { readonly to: Place }) | null = null;
// Le pion lâché hors d'une case permise rentre à sa place.
let returning = false;
// Clavier : la case d'arrivée choisie aux flèches haut et bas, pour le
// pion choisi (selected) aux flèches gauche et droite.
let keyTarget: Place | null = null;
// Ceux qui attendent que l'écran soit libre (le double toucher qui suit
// un pion parti dès le premier toucher).
let idleWaiters: (() => void)[] = [];
let canDouble = false;
// Le joueur a lancé les dés mais ne peut pas jouer : il doit passer.
let stuck = false;
@@ -680,6 +763,8 @@ export function startApp(options: AppOptions): void {
: null,
controls: landscape ? controls() : null,
advice: landscape && !panelShown ? boardAdvice(shownAdvice()) : null,
drag: drag ?? dropped,
focus: selected !== null && keyTarget !== null ? keyTarget : null,
});
}
renderStrips(model.pipCount, state.phase === "over" ? null : state.turn);
@@ -725,7 +810,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);
againLabel.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);
@@ -1166,15 +1251,40 @@ export function startApp(options: AppOptions): void {
async function settle(animate: boolean): Promise<void> {
try {
await update();
await settleDropped();
if (animate && shownMove !== null) {
await animateMove(shownMove);
}
} finally {
busy = false;
dropped = null;
render();
const waiters = idleWaiters;
idleWaiters = [];
for (const resolve of waiters) {
resolve();
}
}
}
/** Le pion lâché sur une case permise : une fois le demi-coup accepté,
* il glisse de là où le doigt l'a posé jusqu'à sa place (duration-fast),
* ou y apparaît, si le système demande moins de mouvement. */
async function settleDropped(): Promise<void> {
const landing = dropped;
dropped = null;
if (landing === null || reducedMotion.matches) {
return;
}
render();
await slideChecker(ui.board, landing.to, landing.at, SLIDE_MS);
}
/** Résolue quand l'écran ne travaille plus (moteur, animation). */
function whenIdle(): Promise<void> {
return busy ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve();
}
/** Une action du joueur, puis le tour de l'ordinateur. Les touches sont
* ignorées pendant ce temps. */
async function act(action: (game: Game) => Promise<void> | void): Promise<void> {
@@ -1183,6 +1293,8 @@ export function startApp(options: AppOptions): void {
}
busy = true;
selected = null;
keyTarget = null;
drag = null;
targets = new Map();
shownMove = null;
bubbleSide = null;
@@ -1553,10 +1665,7 @@ export function startApp(options: AppOptions): void {
// relues à chaque visite, avec leurs analyses rangées. Celles qui n'en
// ont pas encore sont analysées en arrière-plan, une à une, la plus
// récente d'abord ; rien n'est recalculé pour les autres.
const progressionView = createProgressionView({
section: ui.progression,
back: (link) => leavingLink(link, (route) => route.screen === "progression"),
});
const progressionView = createProgressionView({ section: ui.progression });
let progressionGames: FinishedGame[] | null = null;
// Chaque lecture a son numéro : une lecture dépassée n'est pas dessinée.
let progressionRequest = 0;
@@ -1589,10 +1698,14 @@ export function startApp(options: AppOptions): void {
return games.filter((g): g is FinishedGame => g !== null);
}
/** Ouvre l'écran : ce qui est déjà lu, puis les parties relues. */
/** Ouvre l'écran : les parties relues à chaque visite (une partie a pu
* finir entre-temps) ; d'ici là, l'écran dit qu'il les lit, sans
* montrer la liste de la visite précédente comme si elle était à
* jour. */
function showProgression(): void {
const request = ++progressionRequest;
progressionTried.clear();
progressionGames = null;
renderProgression();
options.engineReady
.then(() => loadProgression())
@@ -1778,10 +1891,64 @@ export function startApp(options: AppOptions): void {
if (await g.playHalfMove(halfMove)) {
const after = (await g.displayedBoard()).computer.bar;
haptic(after > before ? "checkerHit" : "checkerPlaced");
} else {
dropped = null;
}
});
}
/**
* Double toucher (charte, 1i) : joue le coup évident (le domaine le
* cherche, le moteur le juge) qui commence par le pion touché, depuis
* l'état du premier toucher. Ce premier toucher a pu faire partir le
* pion tout de suite (une seule case d'arrivée) : il ne reste alors que
* la suite du coup. Sans coup évident, le second toucher n'est qu'un
* toucher : touchers rapides sur une même pile (un double, par exemple)
* et pion repris (touché deux fois : plus rien de choisi).
* L'appelant a pris l'écran (busy) : le moteur est seul à travailler.
*/
async function playObvious(tap: ObviousTap): Promise<void> {
const g = game;
const now = g?.pendingHalfMoves ?? [];
const same = (a: HalfMove | undefined, b: HalfMove | undefined) =>
a !== undefined && b !== undefined && a.from === b.from && a.to === b.to;
let rest: HalfMove[] = [];
if (
g === tap.game &&
g.state.phase === "moving" &&
now.length >= tap.pending.length &&
tap.pending.every((h, i) => same(h, now[i]))
) {
// Demandé seulement maintenant : un toucher simple ne coûte rien
// au moteur.
const obvious = await g.obviousMove(pointOf(tap.place), tap.pending).catch(() => null);
const done = now.slice(tap.pending.length);
if (obvious !== null && done.every((h, i) => same(h, obvious[i]))) {
rest = obvious.slice(done.length);
}
}
busy = false;
if (game !== g || g === null) {
render();
return;
}
if (rest.length === 0) {
tapPlace(tap.place);
return;
}
void act(async (g) => {
info = "";
const before = (await g.displayedBoard()).computer.bar;
for (const halfMove of rest) {
if (!(await g.playHalfMove(halfMove))) {
break;
}
}
const after = (await g.displayedBoard()).computer.bar;
haptic(after > before ? "checkerHit" : "checkerPlaced");
});
}
const canAct = (): Game | null => (busy || game === null ? null : game);
const actions: Record<ActionName, () => void> = {
@@ -2797,10 +2964,75 @@ export function startApp(options: AppOptions): void {
ui.replay.onclick = actions.replay;
ui.again.onclick = actions.again;
// Glisser un pion (charte, 1i) : il suit le doigt, agrandi et un peu
// au-dessus ; un fantôme reste à son départ et ses cases d'arrivée (du
// moteur) sont marquées. Lâché sur l'une d'elles, il s'y pose ; ailleurs,
// il rentre à sa place, sans vibration ni message.
const dragging = attachDrag(ui.board, {
canStart: (place) =>
!busy && !animating && !returning && game !== null && targets.has(place),
start: (place, at) => {
closeTransient();
selected = place;
keyTarget = null;
shownMove = null;
drag = { place, at };
render();
},
move: (at) => {
if (drag !== null) {
drag = { place: drag.place, at };
moveDraggedChecker(ui.board, at);
}
},
drop: (over, at) => {
if (drag === null) {
return;
}
const from = drag.place;
const halfMove = over === null ? undefined : targets.get(from)?.get(over);
if (halfMove === undefined || over === null || busy) {
void dragBack(at);
return;
}
dropped = { place: from, at, to: over };
drag = null;
playHalfMove(halfMove);
},
cancel: (at) => void dragBack(at),
});
/** Le pion glissé rentre à sa place (duration-fast), puis le plateau
* redevient celui d'avant le glissement. */
async function dragBack(at: Spot): Promise<void> {
if (drag === null) {
return;
}
drag = { place: drag.place, at };
returning = true;
try {
if (!reducedMotion.matches) {
await returnDraggedChecker(ui.board, at, SLIDE_MS);
}
} finally {
returning = false;
drag = null;
selected = null;
render();
}
}
// Double toucher : le premier toucher fait ce que fait un toucher, et
// demande déjà au domaine le coup évident de ce pion ; le second le joue.
const doubleTap = createDoubleTap<ObviousTap | null>();
// Toucher le plateau : une commande (dés, videau, colonne de gauche), ou
// un pion jouable puis une de ses cases d'arrivée (ou aucune, s'il n'en a
// qu'une : il y va tout de suite).
ui.board.addEventListener("click", (event) => {
if (dragging.takeClick()) {
return;
}
const target = event.target as Element | null;
if (!ui.bubble.hidden) {
closeOverlays();
@@ -2816,9 +3048,29 @@ export function startApp(options: AppOptions): void {
return;
}
const place = target?.closest("[data-place]")?.getAttribute("data-place") as Place | null;
if (busy || place == null) {
if (place == null || game === null) {
return;
}
const current = game;
const second = doubleTap.tap(place, performance.now(), () =>
busy || !targets.has(place)
? null
: { game: current, place, pending: [...current.pendingHalfMoves] },
);
if (second !== null) {
doubleTapped(second);
return;
}
if (!busy) {
tapPlace(place);
}
});
/** Un toucher simple sur un endroit du plateau : le pion choisi va sur
* cette case d'arrivée ; ou ce pion part tout de suite, s'il n'a qu'une
* case d'arrivée ; ou il est choisi (touché de nouveau : plus rien). */
function tapPlace(place: Place): void {
keyTarget = null;
const halfMove = selected === null ? undefined : targets.get(selected)?.get(place);
if (halfMove !== undefined) {
playHalfMove(halfMove);
@@ -2834,7 +3086,83 @@ export function startApp(options: AppOptions): void {
shownMove = null; // toucher un pion : le coup de l'ordinateur s'efface
}
render();
});
}
/** Le second toucher d'un double toucher : l'écran le prend tout de
* suite (réponse immédiate), ou dès qu'il a fini l'effet du premier. */
function doubleTapped(tap: ObviousTap): void {
const claim = () => {
if (busy) {
return;
}
busy = true;
render();
void playObvious(tap);
};
if (busy) {
void whenIdle().then(claim);
} else {
claim();
}
}
/**
* Le plateau au clavier (interaction.md) : les flèches gauche et droite
* choisissent un pion jouable (dans le sens de la course), haut et bas
* une de ses cases d'arrivée (anneau focus), Entrée l'y pose. Les cases
* sont celles que le moteur a données. Renvoie vrai si la touche a
* servi.
*/
function boardKey(key: string, onButton: boolean): boolean {
const g = game;
const open =
!ui.game.hidden &&
ui.menuVeil.hidden &&
ui.doubleVeil.hidden &&
ui.explainVeil.hidden &&
g !== null &&
g.state.turn === "player" &&
g.state.phase === "moving";
if (!open || targets.size === 0) {
return false;
}
const landings = selected === null ? undefined : targets.get(selected);
switch (key) {
case "ArrowLeft":
case "ArrowRight": {
if (busy) {
return true;
}
selected = nextPlace(targets.keys(), selected, key === "ArrowRight" ? 1 : -1);
const tos = selected === null ? undefined : targets.get(selected);
keyTarget = tos === undefined ? null : nextPlace(tos.keys(), null, 1);
shownMove = null;
render();
return true;
}
case "ArrowUp":
case "ArrowDown":
if (busy || landings === undefined) {
return landings !== undefined;
}
keyTarget = nextPlace(landings.keys(), keyTarget, key === "ArrowDown" ? 1 : -1);
render();
return true;
case "Enter": {
const halfMove =
keyTarget === null || landings === undefined
? undefined
: landings.get(keyTarget);
if (onButton || halfMove === undefined || busy) {
return false;
}
keyTarget = null;
playHalfMove(halfMove);
return true;
}
}
return false;
}
// Clavier (interaction.md) : les mêmes actions que les touchers.
document.addEventListener("keydown", (event) => {
@@ -2861,6 +3189,10 @@ export function startApp(options: AppOptions): void {
analysisView.step(event.key === "ArrowLeft" ? -1 : 1);
return;
}
if (boardKey(event.key, onButton)) {
event.preventDefault();
return;
}
let name: ActionName | null = null;
switch (event.key) {
case " ":
+212
View File
@@ -0,0 +1,212 @@
// Les gestes sur le plateau : glisser-déposer et double toucher (charte,
// docs/identite/charte/ecrans-etape-1.md, 1i), et l'ordre des pions et des
// cases au clavier (interaction.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un seul code pour la souris, le doigt et le stylet : les Pointer Events.
// Ce module ne connaît ni la partie ni le moteur : il dit où le pion est
// pris, où il passe et où il est lâché ; l'écran décide (app.ts), avec les
// cases d'arrivée que le moteur a données.
import type { Place } from "./board-model.js";
import type { Spot } from "./board-view.js";
/** Au-delà de cette distance (px à l'écran), un appui devient un glissement :
* en deçà, c'est un toucher. */
const DRAG_THRESHOLD_PX = 8;
/** Le pion glissé est dessiné au-dessus du doigt, pour rester visible
* (charte, 1i). */
const LIFT_PX = 12;
/** Deux touchers sur le même pion à moins de tant de ms : un double
* toucher. */
export const DOUBLE_TAP_MS = 350;
/** Après un glissement, le « click » que le navigateur envoie au lâcher
* n'est pas un toucher. */
const CLICK_AFTER_DRAG_MS = 400;
/** Ce que l'écran fait d'un glissement. */
export interface DragHandlers {
/** Ce pion peut-il partir maintenant (pion jouable, moteur libre) ? */
canStart(place: Place): boolean;
/** Le pion de place commence à glisser ; at : son centre, dans le repère
* du dessin. */
start(place: Place, at: Spot): void;
move(at: Spot): void;
/** Le pion est lâché en at, au-dessus de over (null : hors de toute
* zone du plateau). */
drop(over: Place | null, at: Spot): void;
/** Le glissement est interrompu par le navigateur (appel, geste du
* système) : le pion rentre. */
cancel(at: Spot): void;
}
/** L'endroit du plateau sous un élément touché (sa zone data-place). */
function placeOf(target: EventTarget | null): Place | null {
if (!(target instanceof Element)) {
return null;
}
return (target.closest("[data-place]")?.getAttribute("data-place") as Place | null) ?? null;
}
/** Le point du dessin sous le pointeur, remonté de LIFT_PX vers le haut du
* plateau (null si le dessin n'est pas affiché). */
function liftedPoint(svg: SVGSVGElement, event: PointerEvent): Spot | null {
const matrix = svg.getScreenCTM();
if (matrix === null) {
return null;
}
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
const unit = 1 / (Math.hypot(matrix.a, matrix.b) || 1);
return { x: point.x, y: point.y - LIFT_PX * unit };
}
/** La zone du plateau (data-place) qui contient at, dans le repère du
* dessin (null : aucune). */
export function placeAtSpot(svg: SVGSVGElement, at: Spot): Place | null {
for (const hit of svg.querySelectorAll(".hit[data-place]")) {
const number = (name: string) => Number(hit.getAttribute(name));
const x = number("x");
const y = number("y");
if (at.x >= x && at.x <= x + number("width") && at.y >= y && at.y <= y + number("height")) {
return hit.getAttribute("data-place") as Place;
}
}
return null;
}
/**
* Branche le glisser-déposer sur le plateau svg. Renvoie takeClick : vrai
* si le « click » reçu suit un glissement (il faut alors l'ignorer).
*/
export function attachDrag(svg: SVGSVGElement, handlers: DragHandlers): { takeClick(): boolean } {
let press: { id: number; place: Place; x: number; y: number } | null = null;
let dragging = false;
let clickIgnoredUntil = 0;
svg.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0) {
return;
}
const place = placeOf(event.target);
press =
place !== null && handlers.canStart(place)
? { id: event.pointerId, place, x: event.clientX, y: event.clientY }
: null;
dragging = false;
});
svg.addEventListener("pointermove", (event) => {
if (press === null || event.pointerId !== press.id) {
return;
}
const at = liftedPoint(svg, event);
if (at === null) {
return;
}
if (dragging) {
handlers.move(at);
return;
}
const distance = Math.hypot(event.clientX - press.x, event.clientY - press.y);
if (distance < DRAG_THRESHOLD_PX) {
return;
}
if (!handlers.canStart(press.place)) {
press = null;
return;
}
dragging = true;
// Le plateau est redessiné pendant le glissement : c'est lui, et non
// la zone touchée, qui reçoit la suite du geste.
try {
svg.setPointerCapture(event.pointerId);
} catch {
// Sans capture, les événements remontent quand même au plateau.
}
handlers.start(press.place, at);
});
const end = (event: PointerEvent, cancelled: boolean) => {
if (press === null || event.pointerId !== press.id) {
return;
}
const wasDragging = dragging;
press = null;
dragging = false;
if (!wasDragging) {
return;
}
clickIgnoredUntil = performance.now() + CLICK_AFTER_DRAG_MS;
const at = liftedPoint(svg, event) ?? { x: 0, y: 0 };
if (cancelled) {
handlers.cancel(at);
} else {
handlers.drop(placeAtSpot(svg, at), at);
}
};
svg.addEventListener("pointerup", (event) => end(event, false));
svg.addEventListener("pointercancel", (event) => end(event, true));
return {
takeClick: () => {
const ignored = performance.now() < clickIgnoredUntil;
clickIgnoredUntil = 0;
return ignored;
},
};
}
/**
* Reconnaît le double toucher : tap(place, now, first) renvoie ce que first
* a produit au premier toucher quand celui-ci est le second, sur le même
* endroit, à moins de DOUBLE_TAP_MS ; sinon null, et ce toucher devient le
* premier (first() n'est appelé que pour lui).
*/
export function createDoubleTap<T>(): {
tap(place: Place, now: number, first: () => T): T | null;
} {
let last: { place: Place; time: number; data: T } | null = null;
return {
tap(place, now, first) {
if (last !== null && last.place === place && now - last.time < DOUBLE_TAP_MS) {
const data = last.data;
last = null;
return data;
}
last = { place, time: now, data: first() };
return null;
},
};
}
/** Le numéro d'un endroit dans le sens de la course du joueur : la barre
* (25), les cases de 24 à 1, la sortie (0). */
function raceOrder(place: Place): number {
if (place.startsWith("bar-")) {
return 25;
}
if (place.startsWith("off-")) {
return 0;
}
return Number(place.slice(1));
}
/**
* Clavier : l'endroit suivant (step 1) ou précédent (step -1) de places
* après current, dans le sens de la course du joueur (de la barre vers la
* sortie), en bouclant. Sans current (ou s'il n'en fait pas partie), le
* premier dans ce sens. null si places est vide.
*/
export function nextPlace(
places: Iterable<Place>,
current: Place | null,
step: 1 | -1,
): Place | null {
const sorted = [...places].sort((a, b) => raceOrder(b) - raceOrder(a));
if (sorted.length === 0) {
return null;
}
const index = current === null ? -1 : sorted.indexOf(current);
if (index === -1) {
return (step === 1 ? sorted[0] : sorted.at(-1)) ?? null;
}
return sorted[(index + step + sorted.length) % sorted.length] ?? null;
}
+117 -10
View File
@@ -86,8 +86,23 @@ export interface BoardDecor {
/** Paysage : le conseil du maître, une pastille avant la consigne dans
* la bande centrale (absent ou null : rien). */
readonly advice?: BoardAdvice | null;
/** Le pion qu'on fait glisser (absent ou null : aucun). */
readonly drag?: BoardDrag | null;
/** Clavier : l'endroit qui a le focus (anneau focus ; absent ou null :
* aucun). */
readonly focus?: Place | null;
}
/** Un pion qu'on fait glisser : il a quitté place (un fantôme en
* pointillés y reste) et son centre est en at, dans le repère du dessin. */
export interface BoardDrag {
readonly place: Place;
readonly at: Spot;
}
/** Le pion qu'on fait glisser est agrandi (charte, 1i : 115 %). */
export const DRAG_SCALE = 1.15;
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
* la feuille d'explication. */
export interface BoardAdvice {
@@ -203,6 +218,9 @@ export function renderBoard(
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
if (decor.focus != null) {
drawFocus(svg, g, board, decor.focus, decor.targets.has(decor.focus));
}
const advice = decor.band !== null ? (decor.advice ?? null) : null;
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
if (decor.band !== null) {
@@ -235,6 +253,9 @@ export function renderBoard(
if (advice !== null) {
drawAdviceChip(svg, g, hits, advice);
}
if (decor.drag != null) {
drawDrag(svg, g, decor.drag);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -359,18 +380,13 @@ function drawStack(
classes.push("movable");
}
const { x, y } = slotCenter(g, place, i);
const checker = add(svg, "circle", {
class: classes.join(" "),
cx: x,
cy: y,
r: g.radius,
});
const piece = addPiece(svg, g, side, classes.join(" "), { x, y });
if (last) {
checker.setAttribute("data-checker", place);
piece.setAttribute("data-checker", place);
}
if (last && count > limit) {
add(
svg,
piece,
"text",
{
class: `checker-count count-${side}`,
@@ -481,6 +497,23 @@ function drawTray(
});
}
/**
* Un pion vu de dessus, centré en spot : son disque (classes cssClass) et
* son filet intérieur, l'écho du logo (charte, board.js : rayon 12 pour un
* pion de 20). Les deux sont groupés : c'est le groupe qui glisse.
*/
function addPiece(svg: Element, g: Geometry, side: Side, cssClass: string, spot: Spot): SVGElement {
const piece = add(svg, "g", { class: "piece" });
add(piece, "circle", { class: cssClass, cx: spot.x, cy: spot.y, r: g.radius });
add(piece, "circle", {
class: `checker-ring ring-${side}`,
cx: spot.x,
cy: spot.y,
r: g.radius * 0.6,
});
return piece;
}
/** Une tranche de pion sorti, centrée en spot. */
function addSlab(svg: Element, g: Geometry, cssClass: string, spot: Spot): SVGElement {
return add(svg, "rect", {
@@ -810,13 +843,87 @@ export function checkerSpot(svg: SVGSVGElement, place: Place): Spot | null {
if (checker === null) {
return null;
}
const number = (name: string) => Number(checker.getAttribute(name));
if (checker.tagName === "circle") {
return shapeCenter(checker.tagName === "g" ? (checker.firstElementChild ?? checker) : checker);
}
/** Le centre d'un disque (cx, cy) ou d'un rectangle (x, y, largeur, hauteur). */
function shapeCenter(shape: Element): Spot {
const number = (name: string) => Number(shape.getAttribute(name));
if (shape.tagName === "circle") {
return { x: number("cx"), y: number("cy") };
}
return { x: number("x") + number("width") / 2, y: number("y") + number("height") / 2 };
}
/**
* Le pion qu'on fait glisser : le pion de départ devient un fantôme en
* pointillés, et un pion agrandi est dessiné en drag.at, devant les autres.
* Il se déplace par son attribut transform (moveDraggedChecker), sans
* redessiner le plateau.
*/
function drawDrag(svg: SVGSVGElement, g: Geometry, drag: BoardDrag): void {
svg.querySelector(`[data-checker="${drag.place}"]`)?.classList.add("drag-origin");
const group = add(svg, "g", {
class: "drag-piece",
id: "drag-piece",
transform: dragTransform(drag.at, DRAG_SCALE),
});
addPiece(group, g, "player", "checker checker-player dragged", { x: 0, y: 0 });
}
function dragTransform(at: Spot, scale: number): string {
return `translate(${at.x} ${at.y}) scale(${scale})`;
}
/** Déplace le pion qu'on fait glisser en at (repère du dessin). */
export function moveDraggedChecker(svg: SVGSVGElement, at: Spot): void {
svg.querySelector("#drag-piece")?.setAttribute("transform", dragTransform(at, DRAG_SCALE));
}
/**
* Ramène le pion qu'on fait glisser à son départ (le centre du fantôme), en
* ms millisecondes, et à sa taille ; résolue à la fin (animation du
* navigateur, sans bibliothèque).
*/
export async function returnDraggedChecker(
svg: SVGSVGElement,
from: Spot,
ms: number,
): Promise<void> {
const piece = svg.querySelector("#drag-piece");
const origin = svg.querySelector(".drag-origin");
if (piece === null || origin === null || typeof piece.animate !== "function") {
return;
}
const to = shapeCenter(origin.firstElementChild ?? origin);
const animation = piece.animate(
[
{ transform: `translate(${from.x}px, ${from.y}px) scale(${DRAG_SCALE})` },
{ transform: `translate(${to.x}px, ${to.y}px) scale(1)` },
],
{ duration: ms, easing: EASING_STANDARD, fill: "forwards" },
);
await animation.finished.catch(() => undefined);
}
/** Clavier : l'anneau focus autour de l'endroit choisi, sur la case
* d'arrivée (target) ou sur le pion du dessus. */
function drawFocus(
svg: SVGSVGElement,
g: Geometry,
board: BoardModel,
place: Place,
target: boolean,
): void {
const count = countAt(board, "player", place);
const index = target
? place.startsWith("off-")
? count
: Math.min(count, MAX_STACK - 1)
: Math.max(topIndex(g, board, "player", place), 0);
addRing(svg, g, "key-focus", place, slotCenter(g, place, index), g.radius + 5);
}
/** Courbe de tout ce qui se déplace (interaction.md, easing-standard). */
export const EASING_STANDARD = "cubic-bezier(0.2, 0, 0, 1)";
+6
View File
@@ -30,6 +30,11 @@ export interface HomeModel {
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** La miniature du plateau et le compte de points : seulement pour une
* partie à reprendre. Sans partie en cours, la carte ne montre que son
* titre et « Nouvelle partie », jamais la position de la partie finie
* (recette 1h). */
readonly board: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
@@ -45,6 +50,7 @@ export function homeModel(
return {
welcome,
resume: playing !== false,
board: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const HOME_TEXTS = {
progress: (played: number, won: number) =>
played === 0
? "aucune partie jouée"
: `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
: `${plural(played, "partie jouée", "parties jouées")} · ${won === 0 ? "aucune gagnée" : plural(won, "gagnée", "gagnées")}`,
/** La ligne de progression, lien vers l'écran de progression (1h). */
progressLink: (line: string) => `Votre progression : ${line}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
+7 -2
View File
@@ -161,7 +161,7 @@ export function createHomeView(options: HomeViewOptions): HomeView {
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
@@ -243,7 +243,12 @@ export function createHomeView(options: HomeViewOptions): HomeView {
return;
}
const sides = state.sides ?? OPENING_BOARDS;
drawBoard(mini, sides, miniBox.clientWidth || 132);
miniBox.hidden = !model.board;
pips.hidden = !model.board;
againstLine.hidden = !model.board;
if (model.board) {
drawBoard(mini, sides, miniBox.clientWidth || 132);
}
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
+17 -9
View File
@@ -11,14 +11,13 @@ import type { GameResult } from "../domain/game/state.js";
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
const RESULT_KIND = {
single: "partie simple",
single: "",
gammon: "gammon",
backgammon: "backgammon",
} as const;
export const PROGRESSION_TEXTS = {
title: "Votre progression",
back: "Retour",
loading: "Lecture de vos parties…",
failed: "Vos parties n'ont pas pu être lues.",
/** Aucune partie terminée. */
@@ -30,22 +29,31 @@ export const PROGRESSION_TEXTS = {
whatIsIt: "Qu'est-ce que le taux d'erreur ?",
analysing: (n: number) =>
`Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`,
exercises: "Créer des exercices",
exercises: "Demander des exercices à une IA",
listTitle: "Vos parties",
/** « Gagnée, gammon (2 points) ». */
result: (result: GameResult | null) => {
/** Le résultat court, qui dit d'où viennent les points : « Gagnée,
* 1 point », « Perdue, gammon, 2 points », « Perdue, videau à 2,
* 2 points », « Perdue, gammon, videau à 2, 4 points » (jamais
* « partie simple »). cube : la valeur du videau à la fin de la
* partie. */
result: (result: GameResult | null, cube: number) => {
if (result === null) {
return "Partie terminée";
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who}, ${RESULT_KIND[result.kind]} (${plural(result.points, "point", "points")})`;
const parts = [result.winner === "player" ? "Gagnée" : "Perdue"];
if (result.kind !== "single") {
parts.push(RESULT_KIND[result.kind]);
}
if (cube > 1) {
parts.push(`videau à ${cube}`);
}
parts.push(plural(result.points, "point", "points"));
return parts.join(", ");
},
level: (name: string) => `contre ${name}`,
rateLabel: "taux d'erreur",
rateLoading: "en analyse",
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
analysis: "Analyse",
analysisLabel: (date: string) => `Analyse de la partie du ${date}`,
/** La date d'une partie : « 3 nov. 2026 ». */
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
} as const;
+49 -60
View File
@@ -37,8 +37,6 @@ export interface ProgressionData {
export interface ProgressionViewOptions {
readonly section: HTMLElement;
/** Branche le lien « Retour » (il revient dans l'historique). */
readonly back: (link: HTMLAnchorElement) => void;
}
export interface ProgressionView {
@@ -47,12 +45,10 @@ export interface ProgressionView {
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Hauteur de la courbe, et ses marges intérieures (étiquettes de l'axe à
* gauche, valeurs au-dessus des points). */
/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus
* des points, le dernier point plus gros). */
const CURVE_HEIGHT = 168;
const PAD = { left: 34, right: 16, top: 24, bottom: 12 };
/** Au-delà, les valeurs ne s'écrivent plus au-dessus des points. */
const LABELLED_POINTS = 10;
const PAD = { left: 12, right: 12, top: 30, bottom: 12 };
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
@@ -95,17 +91,11 @@ function levelName(entry: ProgressionEntry): string {
export function createProgressionView(options: ProgressionViewOptions): ProgressionView {
const { section } = options;
section.replaceChildren();
const head = element(section, "div", { className: "analysis-head" });
const back = element(head, "a", {
id: "progression-back",
className: "button secondary",
text: PROGRESSION_TEXTS.back,
});
back.href = "#/accueil";
options.back(back);
element(head, "h2", {
// Pas de « Retour » (charte 1h) : la barre du haut et le retour du
// téléphone suffisent, comme sur la liste des leçons.
element(section, "h1", {
id: "progression-title",
className: "analysis-title",
className: "progression-title",
text: PROGRESSION_TEXTS.title,
});
const summary = element(section, "p", {
@@ -135,6 +125,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
entries: data.entries.map((e) => [
e.id,
e.result,
e.cube,
e.level,
e.errorRate?.perDecision ?? null,
]),
@@ -153,7 +144,9 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
}
if (data.entries.length === 0) {
summary.textContent = PROGRESSION_TEXTS.empty;
const actions = element(body, "div", { className: "progression-actions" });
const actions = element(body, "div", {
className: "progression-actions progression-empty",
});
const start = element(actions, "a", {
id: "progression-new-game",
className: "button",
@@ -164,10 +157,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
return;
}
const points = progressionCurve(data.entries);
summary.textContent =
data.phrases === null
? ""
: progressionSentence(data.phrases, progressionTrend(points));
const trend = progressionTrend(points);
summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend);
if (points.length >= CURVE_MIN_GAMES) {
const card = element(body, "section", {
@@ -190,7 +181,10 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
text: PROGRESSION_TEXTS.whatIsIt,
});
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
drawnCurve = () => drawCurve(svg, points);
// Le vert dit « mieux », jamais « dernier » : seulement si le
// taux baisse.
const better = trend.kind === "down";
drawnCurve = () => drawCurve(svg, points, better);
drawnCurve();
}
@@ -203,7 +197,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
}).setAttribute("role", "status");
}
// « Créer des exercices » : la feuille du prompt, étape 1f ; bouton secondaire.
// « Demander des exercices à une IA » : la feuille du prompt, étape
// 1f ; bouton secondaire avec l'icône de copie.
if (points.length > 0) {
const actions = element(body, "div", { className: "progression-actions" });
const exercises = element(actions, "a", {
@@ -228,14 +223,16 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
li.className = "progression-item";
li.dataset.id = entry.id;
const date = PROGRESSION_TEXTS.date(entry.startedAt);
const result = PROGRESSION_TEXTS.result(entry.result);
const result = PROGRESSION_TEXTS.result(entry.result, entry.cube);
// Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis
// lui), chevron à droite.
const game = element(li, "a", { className: "progression-game" });
game.href = routeHash({ screen: "summary", game: entry.id });
game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date));
const text = element(game, "span", { className: "progression-text" });
element(text, "strong", { text: result });
element(text, "span", {
className: "muted",
className: "caption",
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
});
const rate = element(game, "span", { className: "progression-rate" });
@@ -247,12 +244,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) });
element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel });
}
const analysis = element(li, "a", {
className: "button secondary progression-analysis",
text: PROGRESSION_TEXTS.analysis,
});
analysis.href = routeHash({ screen: "analysis", game: entry.id, turn: null });
analysis.setAttribute("aria-label", PROGRESSION_TEXTS.analysisLabel(date));
game.append(icon("chevron"));
return li;
}
@@ -260,58 +252,55 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
}
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
* à droite ; l'axe va de 0 au plus haut taux, en millièmes ; le dernier
* point en verdict-good (charte). */
* à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le
* dernier point est plus gros, à l'encre du texte, en verdict-good si le
* taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du
* premier et du dernier point seulement (charte 1h). */
function drawCurve(
svg: SVGSVGElement,
points: readonly { readonly id: string; readonly perDecision: number }[],
better: boolean,
): void {
const width = Math.round(svg.getBoundingClientRect().width || 360);
const height = CURVE_HEIGHT;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.replaceChildren();
const millis = points.map((p) => errorRateMillis(p.perDecision));
// Le haut de l'axe : le plus haut taux, arrondi à la dizaine au-dessus.
const top = Math.max(10, Math.ceil(Math.max(...millis) / 10) * 10);
const top = Math.max(1, ...millis);
const inner = width - PAD.left - PAD.right;
const step = points.length > 1 ? inner / (points.length - 1) : 0;
const x = (i: number) => PAD.left + i * step;
const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom);
for (const level of [top, 0]) {
svgElement(svg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(level),
y2: y(level),
class: "curve-axis",
});
const label = svgElement(svg, "text", {
x: PAD.left - 6,
y: y(level) + 4,
class: "curve-label",
"text-anchor": "end",
});
label.textContent = String(level);
}
svgElement(svg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(0),
y2: y(0),
class: "curve-axis",
});
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
svgElement(svg, "polyline", { points: line, class: "curve-line" });
const lastIndex = points.length - 1;
points.forEach((point, i) => {
const last = i === points.length - 1;
const last = i === lastIndex;
const cy = y(point.perDecision * 1000);
const dot = svgElement(svg, "circle", {
cx: x(i),
cy: y(point.perDecision * 1000),
r: last ? 6 : 4,
class: last ? "progression-dot last" : "progression-dot",
cy,
r: last ? 7 : 4,
class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot",
"data-id": point.id,
"data-rate": String(point.perDecision),
});
dot.setAttribute("aria-hidden", "true");
if (points.length <= LABELLED_POINTS) {
if (i === 0 || last) {
const value = svgElement(svg, "text", {
x: x(i),
y: y(point.perDecision * 1000) - 10,
class: last ? "progression-value last" : "progression-value",
"text-anchor": "middle",
y: cy - (last ? 14 : 11),
class: last
? `progression-value last${better ? " better" : ""}`
: "progression-value",
"text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle",
});
value.textContent = String(millis[i]);
}
+45
View File
@@ -0,0 +1,45 @@
// Les gestes du plateau qui se vérifient sans navigateur : le double toucher
// et l'ordre des pions et des cases au clavier. Le glisser-déposer lui-même
// est joué dans un navigateur (tests/browser/plateau.mjs).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDoubleTap, DOUBLE_TAP_MS, nextPlace } from "../src/ui/board-gestures.js";
import type { Place } from "../src/ui/board-model.js";
test("double toucher : deux touchers rapprochés sur le même endroit", () => {
const taps = createDoubleTap<string>();
let firsts = 0;
const first = () => {
firsts++;
return `premier ${firsts}`;
};
assert.equal(taps.tap("p8", 0, first), null);
assert.equal(taps.tap("p8", DOUBLE_TAP_MS - 1, first), "premier 1");
// Le troisième toucher recommence un double toucher.
assert.equal(taps.tap("p8", DOUBLE_TAP_MS, first), null);
// Trop lent : un nouveau premier toucher.
assert.equal(taps.tap("p8", 3 * DOUBLE_TAP_MS, first), null);
// Un autre endroit : un nouveau premier toucher.
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 10, first), null);
assert.equal(taps.tap("p6", 3 * DOUBLE_TAP_MS + 20, first), "premier 4");
assert.equal(firsts, 4);
});
test("clavier : les pions dans le sens de la course, en boucle", () => {
const places: Place[] = ["p6", "bar-player", "p13", "p8"];
assert.equal(nextPlace(places, null, 1), "bar-player");
assert.equal(nextPlace(places, null, -1), "p6");
assert.equal(nextPlace(places, "bar-player", 1), "p13");
assert.equal(nextPlace(places, "p8", 1), "p6");
assert.equal(nextPlace(places, "p6", 1), "bar-player");
assert.equal(nextPlace(places, "bar-player", -1), "p6");
// Un endroit qui n'est plus jouable : on repart du début.
assert.equal(nextPlace(places, "p24", 1), "bar-player");
assert.equal(nextPlace([], null, 1), null);
// Les cases d'arrivée, sortie comprise.
assert.equal(nextPlace(["off-player", "p2"], "p2", 1), "off-player");
});
+15
View File
@@ -8,6 +8,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { homeModel } from "../src/ui/home-model.js";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
@@ -45,6 +46,20 @@ test("réglages d'une version précédente : les nouveaux champs prennent leur v
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => {
const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 };
// Partie en cours, ou moteur qui se charge : la miniature et le compte.
assert.deepEqual(
[homeModel(settings, true, false).board, homeModel(settings, true, false).resume],
[true, true],
);
assert.equal(homeModel(settings, null, false).board, true);
// Partie finie : le titre et « Nouvelle partie » seulement (recette 1h).
const over = homeModel(settings, false, false);
assert.equal(over.board, false);
assert.equal(over.resume, false);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
+82
View File
@@ -0,0 +1,82 @@
// Le coup évident du double toucher (charte, ecrans-etape-1.md, 1i), sur le
// vrai moteur WebAssembly : un seul coup légal complet commence par le pion
// touché, ou le moteur ne propose qu'un coup.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Engine } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1.
const SIX_AND_ONE = "BQAACAIAAAAAAA";
// Le joueur, au trait : deux pions, sur ses cases 6 et 5.
const PLAYER_ALMOST_DONE = "/38AAABQAAAAAA";
let engine: Engine | null = null;
async function loaded(): Promise<Engine> {
engine ??= await loadNodeEngine();
return engine;
}
/** Une partie à partir d'une position, joueur au trait, dés lancés. */
async function from(position: string, dice: [number, number]): Promise<Game> {
const game = Game.start(await loaded(), {
setup: { position, firstTurn: "player" },
dice: fixedDice(dice),
});
await game.roll();
return game;
}
function text(halfMoves: readonly HalfMove[] | null): string | null {
return halfMoves === null ? null : formatHalfMoves(halfMoves);
}
test("au départ avec 3-1 : plusieurs coups commencent par chaque pion, rien d'évident", async () => {
const game = Game.start(await loaded(), { dice: fixedDice([3, 1]) });
for (const point of [24, 13, 8, 6]) {
assert.equal(await game.obviousMove(point), null, `pion de la ${point}`);
}
// Rien n'a été saisi.
assert.deepEqual(game.pendingHalfMoves, []);
});
test("6-1 avec des pions sur 6 et 1 : seul le pion de la 1 a un coup évident", async () => {
const game = await from(SIX_AND_ONE, [6, 1]);
// 1/off, puis le 6 sort l'autre pion : un seul coup.
assert.equal(text(await game.obviousMove(1)), "1/off 6/off");
// Depuis la 6 : 6/off 1/off, ou 6/5/off (le pion de la 1 reste).
assert.equal(await game.obviousMove(6), null);
// Une case sans pion n'a pas de coup.
assert.equal(await game.obviousMove(3), null);
});
test("un seul coup légal : il est évident depuis chaque pion, même à moitié saisi", async () => {
const game = await from(PLAYER_ALMOST_DONE, [6, 6]);
assert.equal((await (await loaded()).legalMoves(game.state.position, [6, 6])).length, 1);
const fromFive = await game.obviousMove(5);
const fromSix = await game.obviousMove(6);
assert.ok(fromFive !== null && fromSix !== null);
assert.equal(fromFive.length, 2);
assert.equal(fromSix.length, 2);
// Une fois 6/off saisi, il ne reste que 5/off.
assert.equal(await game.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(text(await game.obviousMove(5)), "5/off");
// Le coup évident se joue tel quel, demi-coup par demi-coup.
for (const halfMove of (await game.obviousMove(5)) ?? []) {
assert.equal(await game.playHalfMove(halfMove), true);
}
assert.equal(game.moveComplete, true);
});
test("hors du tour de jeu du joueur, aucun coup évident", async () => {
const game = Game.start(await loaded(), { dice: fixedDice([1, 3]) });
assert.equal(game.state.turn, "computer");
assert.equal(await game.obviousMove(13), null);
});
+21
View File
@@ -38,6 +38,8 @@ import {
mistakeThemes,
} from "../src/domain/prompts/prompts.js";
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
import { HOME_TEXTS } from "../src/ui/home-texts.js";
import { PROGRESSION_TEXTS } from "../src/ui/progression-texts.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadPhrases } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
@@ -86,6 +88,25 @@ test("l'adresse #/progression et celle de « Créer des exercices »", () => {
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
});
test("le résultat d'une partie dit d'où viennent les points : gammon, videau", () => {
const result = PROGRESSION_TEXTS.result;
const lost = (kind: "single" | "gammon" | "backgammon", points: number) =>
({ winner: "computer", kind, points }) as const;
assert.equal(result({ winner: "player", kind: "single", points: 1 }, 1), "Gagnée, 1 point");
assert.equal(result(lost("single", 1), 1), "Perdue, 1 point");
assert.equal(result(lost("gammon", 2), 1), "Perdue, gammon, 2 points");
assert.equal(result(lost("single", 2), 2), "Perdue, videau à 2, 2 points");
assert.equal(result(lost("gammon", 4), 2), "Perdue, gammon, videau à 2, 4 points");
assert.equal(result(lost("backgammon", 12), 4), "Perdue, backgammon, videau à 4, 12 points");
assert.equal(result(null, 1), "Partie terminée");
});
test("la ligne de l'accueil : « aucune gagnée », comme « aucune leçon terminée »", () => {
assert.equal(HOME_TEXTS.progress(3, 0), "3 parties jouées · aucune gagnée");
assert.equal(HOME_TEXTS.progress(3, 1), "3 parties jouées · 1 gagnée");
assert.equal(HOME_TEXTS.progress(0, 0), "aucune partie jouée");
});
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
assert.equal(errorRateMillis(0.0142), 14);
assert.equal(errorRateMillis(0.0075), 8);
+17
View File
@@ -511,3 +511,20 @@ Then("le résultat indique le gagnant et les points gagnés", () => {
// règle Jacoby, le videau n'a pas bougé.
assert.deepEqual(game.state.result, { winner: "player", kind: "gammon", points: 1 });
});
// ------------------------------------------------- Glisser, double toucher
When("vous demandez le coup évident de votre pion de la flèche {int}", async (point: number) => {
positionBefore = game.displayedPosition;
const obvious = await game.obviousMove(point);
accepted = obvious !== null;
for (const halfMove of obvious ?? []) {
accepted &&= await game.playHalfMove(halfMove);
}
});
Then("aucun pion n'a bougé", () => {
assert.equal(accepted, false);
assert.deepEqual(game.pendingHalfMoves, []);
assert.equal(game.displayedPosition, positionBefore);
});
+8 -7
View File
@@ -226,16 +226,17 @@ Ajoutées avec l'écran de progression (étape 1h), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Progression : courbe du taux d'erreur et parties terminées | `#/progression` |
| Prompt « Créer des exercices », depuis la progression | `#/progression/ia` |
| Prompt « Demander des exercices à une IA », depuis la progression | `#/progression/ia` |
- La ligne de progression de l'accueil est un lien vers `#/progression`.
L'écran est une page, comme l'analyse : « Retour » (et Échap, et le
retour du téléphone) revient à l'écran d'avant, l'accueil s'il a été
ouvert par son adresse.
L'écran est une page, comme la liste des leçons : pas de bouton
« Retour », la barre du haut, Échap et le retour du téléphone suffisent.
Échap revient à l'écran d'avant, l'accueil s'il a été ouvert par son
adresse.
- Chaque partie de la liste est un lien vers son bilan
(`#/parties/<id>/bilan`) ; « Analyse » vers son analyse
(`#/parties/<id>/analyse`). Fermer le bilan ramène à la progression.
- « Créer des exercices » suit la règle des prompts (amendement du
(`#/parties/<id>/bilan`), d'où s'ouvre son analyse. Fermer le bilan
ramène à la progression.
- « Demander des exercices à une IA » suit la règle des prompts (amendement du
2026-11-10) : le bouton ouvre la feuille du prompt, dans sa propre
feuille au-dessus de la progression (« ‹ Progression »), où le texte se
relit avant d'être copié. Rechargée, l'adresse recalcule le prompt à
+3 -1
View File
@@ -25,7 +25,9 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1f. Prompts à copier (fait) | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons (fait) | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 livrés sans relecture préalable, sous les garde-fous du moteur (décision du 2026-11-09) | 0e.5, 1a |
| 1h. Progression (fait) | Historique des parties et courbe du taux d'erreur | 1b, 1e |
| 1i. Plateau soigné | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
| 1i. Plateau soigné (fait) | Dessin du plateau aligné sur la charte (`docs/identite/charte/board.js`), animations des pions, glisser-déposer en plus du toucher, double toucher pour le seul coup évident | 0e.3 |
| 1k. Retours des joueurs, 2e lot | Crawford signalé sur la barre ; abandon (simple, gammon, backgammon) accepté ou refusé par l'ordinateur selon GNU Backgammon (plan `2026-11-12-retours-joueurs-2.md`) | 1c, 1e |
| 1l. Analyse du match | Bilan et courbe d'un match entier (chances de gagner le match), erreurs de videau sur tout le match, prompt et export SGF du match (plan `2026-11-12-analyse-du-match.md`) | 1c, 1e, 1f |
| 1j. Plan de test | `docs/guide-test-etape-1.md` : parcours du débutant et du joueur de club sur le téléphone, avec ce qu'il faut rapporter | toutes |
## Orientation donnée par Pierre
+237 -12
View File
@@ -160,7 +160,7 @@ remplace en place, sans saut.
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
la chance donnés par le moteur, jamais inventée.
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
2. Trois tuiles côte à côte : le nombre de bons coups, d'imprécisions et
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
couleur d'appréciation.
3. Deux jauges sur une ligne chacune :
@@ -255,14 +255,28 @@ Maquette « Progression ».
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
tendance monte, la phrase reste encourageante et sans reproche (« Votre
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
pour reprendre un point. »).
tendance monte, la phrase reste encourageante et sans reproche, sans
minimiser ni dramatiser (« Sur vos 8 dernières parties, votre taux
d'erreur remonte, de 7 à 12. Les leçons sont là pour reprendre un
point. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
vers la fiche « taux d'erreur ».
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
en chiffre gras aligné à droite), chacune ouvrant son bilan.
c'est mieux » dans son titre et un lien vers la fiche « taux d'erreur ».
Le dernier point est plus gros, à l'encre du texte ; il ne passe en
`verdict-good` que si la tendance baisse (le vert dit « mieux », jamais
« dernier »). Les valeurs s'écrivent au-dessus du premier et du dernier
point seulement ; l'axe ne porte que la ligne du 0, sans chiffres qui
doublent ceux des points.
- La liste des parties : chaque ligne est un seul lien vers son bilan
(chevron à droite), sans bouton par ligne ; l'analyse s'ouvre depuis le
bilan. Sur la ligne : le résultat court (« Gagnée, 1 point », « Perdue,
gammon, 2 points », jamais « partie simple »), puis en `caption`
`text-muted` la date et le niveau (« 12 nov. 2026 · contre
Intermédiaire », le niveau sans couleur), et à droite le taux d'erreur en
chiffre gras.
- Sans partie terminée : une phrase et « Nouvelle partie » en action
principale, pleine largeur en portrait.
- Pas de bouton « Retour » dans l'en-tête : la barre du haut et le retour du
téléphone suffisent, comme sur la liste des leçons.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
@@ -270,14 +284,225 @@ Maquette « Progression ».
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
fantôme en pointillés ; les flèches d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une flèche permise : il s'y pose
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
sa place en `duration-fast`, sans vibration ni message.
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
vaut un simple toucher, sans message d'erreur.
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
qu'une case d'arrivée).
- **Toucher simple** : inchangé (pion puis flèche, ou avance directe s'il n'a
qu'une flèche d'arrivée).
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
tutoriel et l'aide en parlent.
## Retours des joueurs, 2e lot : Crawford sur la barre, abandon
Maquettes : rangée « Retours des joueurs, 2e lot » du canevas, et les
artboards « Match en 5 points » et « Match en paysage » de la rangée 1c,
mis à jour.
### Crawford sur la barre
Pendant la partie Crawford, le videau ne peut pas être proposé : c'est ce
que le joueur doit voir, là où il regarde, sur le plateau.
- Le mot **« Crawford »** est écrit à la verticale sur la barre (lecture de
bas en haut), en capitales, `font-weight` 800, espacement 0,12 em, dans
une capsule au contour de 1,5 px. Texte et contour prennent la couleur
des numéros du cadre (`#F3EAD8` en clair, `#CDBFA8` en sombre), soit plus
de 9:1 sur le bois de la barre. Pas de brique : la brique veut dire « à
faire », et Crawford est un état.
- **Taille** : 11 px au moins à l'écran, en portrait comme en paysage. La
capsule fait environ 15 px de large en portrait, dans une barre d'environ
22 px.
- **Place** : dans la moitié haute de la barre, entre le bord du cadre et le
premier pion frappé. En paysage, elle se place sous la pastille du compte
de points de l'ordinateur, sans la toucher (8 px d'écart au moins).
- **Pions frappés** : la capsule est dessinée sous eux. Si la moitié haute
porte plus de pions frappés que la moitié basse, elle passe dans la
moitié basse, symétriquement (au-dessus de la pastille de votre compte en
paysage). Les pions ne bougent jamais pour elle.
- **Le videau** reste à sa place, à 40 % d'opacité pendant cette partie.
Le bouton « Doubler » disparaît de la barre d'actions.
- **La ligne du match** en portrait ne dit plus « partie Crawford » :
« Match en 5 points » suffit, la barre porte l'information. En paysage,
le préfixe de la consigne disparaît aussi.
- **Accessibilité** : `role="note"`, avec le libellé « Partie Crawford : le
videau ne peut pas être proposé pendant cette partie. ». Ce n'est pas une
cible tactile : la capsule ne doit pas gêner les touchers sur les flèches
7 et 18. L'explication vient de l'aide et de la fiche du glossaire.
- **Apparition** : un fondu en `duration-base` (160 ms) au début de la partie
Crawford, et rien d'autre. Elle disparaît à la partie suivante.
### Abandonner une partie
Comme dans gnubg : le joueur propose d'abandonner en concédant une partie
simple, un gammon ou un backgammon, et l'ordinateur accepte ou refuse selon
le moteur.
- **Quand** : à votre tour, avant de lancer les dés, comme pour doubler.
C'est le seul moment où le drapeau est présent ; ailleurs, il n'apparaît
pas. Jamais dans le tutoriel ni dans les leçons.
- **Le drapeau** : une icône de drapeau au trait (20 px, couleur du texte,
jamais en brique), dans un bouton rond de 52 px.
- En portrait, il est à gauche dans la barre d'actions, avant
« Doubler » et « Lancer les dés ».
- En paysage, il est dans la colonne de commandes, au-dessus du bouton
d'annulation.
- Son libellé accessible est « Abandonner la partie ». Le menu propose
aussi la ligne « Abandonner la partie », active au même moment
seulement.
- **La feuille de choix**, qui monte depuis le bas (artboard « Abandon :
feuille de choix ») :
- le titre « Abandonner la partie » ;
- une phrase : « Vous proposez de perdre tout de suite. L'ordinateur
accepte ou refuse, selon le moteur. », complétée par « Le videau est
à 2. » quand il a été doublé ;
- un choix unique en cartes de 56 px : « Une partie simple »,
« Un gammon », « Un backgammon », chacune avec les points en jeu à
droite, videau compris ;
- en match, les points se lisent comme le score qui en résulterait :
« vous 2, l'ordinateur 5 · fin du match » ;
- seuls les choix qui peuvent changer le résultat sont proposés. Ce sont
ceux que le moteur juge possibles ; en partie libre, sans videau
doublé, la règle de Jacoby fait que gammon et backgammon ne comptent
pas, donc seule « Une partie simple » reste, sans liste de choix ;
- le choix par défaut est le plus petit ;
- les actions sont « Continuer la partie » (secondaire) et « Proposer »
(principale) ;
- la feuille elle-même tient lieu de confirmation : pas de deuxième
question.
- **La réponse** arrive après un temps court. Pendant le calcul,
« Proposer » devient « L'ordinateur réfléchit… », désactivé.
- **Il accepte** : la feuille se ferme et la partie se termine comme une
défaite ordinaire, avec la fin de partie puis le bilan. Le résultat
s'écrit « Perdue par abandon, gammon, 2 points » dans la fin de partie,
le bilan et la progression. L'abandon accepté ne s'annule pas : c'est
la seule action du jeu sans retour, et la feuille l'a dit.
- **Il refuse** : la feuille se ferme, la partie continue et le drapeau
reste disponible. Un message du maître s'affiche au-dessus de la barre
d'actions (artboard « Abandon : l'ordinateur refuse ») : le drapeau,
puis « L'ordinateur refuse. » en gras, puis la raison donnée par le
moteur. Le message reste jusqu'au lancer des dés.
- **Ton** : des faits, ni reproche ni encouragement à abandonner. Phrases
à ajouter à `docs/maitre/phrases.md`, puis à valider par Pierre :
- `abandon.refus` : « L'ordinateur refuse. D'après le moteur, il peut
espérer plus que {concession} en continuant. La partie continue. »
({concession} : « une partie simple », « un gammon ») ;
- `abandon.accepte` : « L'ordinateur accepte votre abandon : vous perdez
{concession}, {points}. ».
- **Glossaire** : nouvelle fiche `abandon.md` (anglais : resign). En bref :
« Abandonner, c'est concéder la partie avant la fin, en proposant de
perdre une partie simple, un gammon ou un backgammon. L'adversaire
accepte ou refuse ; s'il refuse, la partie continue. »
- **Hors périmètre** : l'ordinateur qui propose lui-même d'abandonner
(gnubg sait le faire). C'est à décider plus tard, avec Pierre.
## Bilan du match (`#/matchs/<id>/bilan`)
Demande de Pierre (2026-11-12) : une analyse du match entier, surtout pour
le videau. Maquettes : rangée « Analyse du match entier » du canevas
(« Bilan du match », page entière, et « Vos parties : un match = une
ligne »). L'exemple suit le match de Pierre : mené 0–4, gagné 5–4.
Le bilan d'une partie (1e) reste tel quel. Le bilan du match a la même
forme et les mêmes composants, de haut en bas :
1. **L'en-tête** : en `caption`, « Fin du match · 5 points · <date> » ; en
`display`, « Vous gagnez le match » ou « L'ordinateur gagne le match » ;
puis une phrase factuelle avec le score final et le plus grand retard
remonté, s'il y en a un (« 5 à 4, en cinq parties, après avoir été mené 0
à 4. »). Pour un match en cours : « Match en cours · 1–2 » et « Reprendre
le match » en action principale.
2. **La courbe des chances de gagner le match**, la pièce maîtresse :
- une seule ligne continue, de 0 à 100 %, de la première décision à la
dernière : la fin d'une partie est le début de la suivante ;
- les parties sont séparées par un trait vertical `border` ; au-dessus de
chaque partie, le score **au début** de celle-ci (« 0–2 ») ; en
dessous, son nom court (« P2 ») ;
- la partie Crawford a un fond `surface-sunken` ;
- la ligne des 50 % est en pointillés, avec son libellé ;
- trois marques, sans autre couleur sur la courbe : le moment décisif
(anneau `last`, or), la décision à revoir (point `verdict-blunder`) et
la fin (point `verdict-good` si le match est gagné, `text` sinon) ;
- une légende de trois éléments sous la courbe ;
- **lecture au doigt** : glisser sur la courbe affiche une bulle (partie,
coup, score, chances en %), avec un trait vertical qui suit le doigt et
une vibration légère à chaque changement de partie ; lâcher garde la
bulle ; toucher la bulle ouvre la décision dans l'analyse. Au clavier :
← → d'une décision à l'autre, Entrée pour ouvrir.
C'est la seule cible de la courbe : tout son cadre (au moins 150 px de
haut) sert de zone tactile.
- le libellé accessible résume la courbe en une phrase (départ, plus bas,
fin, moment décisif).
3. **« Partie par partie »** : une liste, une ligne de 52 px par partie (son
numéro, son résultat court comme dans la progression, qui a marqué
combien, et le score **après** la partie en chiffres gras à droite) ;
chaque ligne ouvre le bilan de la partie. Le score se lit toujours
« vous – l'ordinateur », et une phrase `caption` le rappelle sous la
liste.
4. **« Votre jeu sur le match »** : trois tuiles, le taux d'erreur de tout
le match, celui des pions et celui du videau, en millièmes comme ailleurs.
En dessous, les jauges « Votre jeu » et « Les dés » du bilan de partie,
calculées sur tout le match.
5. **« Le videau »**, le cœur de la demande :
- une phrase : « 8 décisions de videau ; pour 2 d'entre elles, j'aurais
fait autrement. » ;
- une ligne par décision qui diffère de celle du moteur, regroupée par
sorte, dans l'ordre : mauvais double, double manqué, prise fautive,
refus fautif. Sur la ligne : la sorte, « Partie 2, coup 6, à 0–2 » (le
score compte beaucoup au videau), et la perte de chances de gagner le
match en %, colorée comme le verdict ;
- les sortes sans écart tiennent en une phrase (« Prises et refus :
aucune différence avec mes choix. ») ;
- chaque ligne ouvre la décision dans l'analyse.
6. **« Le moment décisif »** : la décision ou le jet qui a fait le plus
varier les chances de gagner le match. Une phrase factuelle (« Avec 5-5,
vos chances de gagner le match passent de 24 % à 46 %. C'est le plus
grand écart du match. ») et le lien « Voir ce coup ». Pas de jugement :
ce peut être un coup de dés.
7. **« La décision à revoir »** : la plus grande perte de chances de gagner
le match, pions ou videau, présentée comme « le coup à revoir » du bilan
de partie (miniature, verdict, phrase du maître, « Revoir » et
« Demander à une IA »). Le prompt est le gabarit « bilan de mon match ».
8. **« Exporter le match (SGF) »**, un lien avec une phrase : « pour l'ouvrir
dans GNU Backgammon ».
**Barre d'actions** : « Revoir le match » (secondaire, ouvre l'analyse à
la première décision de la partie 1) et « Nouveau match » (principale).
**Unités** : en match, les pertes s'expriment en **chances de gagner le
match** (%), à une décimale, et jamais en équité. Le taux d'erreur reste en
millièmes, comme dans la progression.
### Accès
- **Fin d'un match** : la fin de partie de la dernière partie propose « Bilan
du match » en action principale, à la place de « Nouvelle partie » ; le
bilan de cette partie garde un lien « Voir le bilan du match ».
- **Bilan d'une partie de match** : sous l'en-tête, une ligne `caption`
« Partie 2 du match en 5 points · <lien>Bilan du match</lien> ».
- **Analyse d'une partie de match** : en tête, « Partie 2 sur 5 · 0–2 »,
avec « ‹ » et « › » (44 px) pour passer à la partie précédente ou
suivante. La courbe de l'analyse reste celle de la partie.
- **Listes (« Vos parties », progression)** : un match tient en **une
ligne** :
- le titre « Match en 5 points · gagné 5–4 » (« perdu », « en cours,
1–2 ») ;
- un trait par partie, `verdict-good` si vous l'avez gagnée,
`border-strong` sinon, et un trait en pointillés pour la partie à
venir d'un match en cours ;
- en `caption`, la date, le nombre de parties et le niveau ;
- à droite, le taux d'erreur du match ; pour un match en cours,
« Reprendre » en brique ;
- la ligne ouvre le bilan du match (le match en cours, s'il n'est pas
fini). Les parties libres gardent leur ligne.
- **Courbe de la progression** : inchangée, un point par partie, y compris
les parties de match.
### Paysage
Deux colonnes : à gauche, l'en-tête, la courbe et « Partie par partie » ; à
droite, le jeu, le videau, le moment décisif et la décision à revoir. La
barre d'actions reste en bas de la colonne de droite.
+2 -2
View File
@@ -5,7 +5,7 @@ Ce système s'applique à la PWA (`web/`). Les noms des couleurs du plateau sont
## Contenus et ton
- **Vouvoiement**, phrases courtes, verbes d'action. Le joueur est « vous », l'adversaire est « l'ordinateur » (ou le nom d'un joueur réel).
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
- **Les mots du jeu**, toujours les mêmes, ceux du glossaire (`docs/glossaire/`) : flèche, pion, barre, videau, compte de points, sortir. Une flèche (le triangle) s’appelle toujours « flèche », jamais « case » ; un « point » est une flèche tenue par au moins deux pions du même joueur. Un terme technique s'explique la première fois qu'il apparaît, avec un lien vers sa fiche.
- **Les boutons disent ce qu'ils font** : « Lancer les dés », « Annuler », « Doubler », « Prendre », « Passer », « Nouvelle partie ». Pas de « OK », pas de « Valider ». Les textes de l'app vivent dans `app/src/ui/texts.ts`.
- **Le maître encourage, il ne juge pas.** « Bien joué : ce coup protège votre point 8. » plutôt que « Coup correct (−0,012) ». Les chiffres d'équité ne viennent que sur demande.
- **Les erreurs disent quoi faire** : « Ce pion ne peut pas aller là : la flèche est tenue par deux pions adverses. »
@@ -18,7 +18,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
- Fond de page `background`, cartes et panneaux `surface`, zones en retrait `surface-sunken`.
- Texte `text` ; texte secondaire `muted`, jamais pour une consigne.
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la case où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
- `accent` est rare et a toujours un sens : le bouton principal de l'écran (un seul), un lien, le pion sélectionné (`highlight`), la flèche où il peut aller (`target`), le focus clavier (`focus`). Ce n'est jamais une couleur de décoration ni une couleur d'erreur.
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
+1 -1
View File
@@ -49,7 +49,7 @@ Chaque action a son raccourci, affiché sur le bouton en petite touche (`kbd`) q
| --- | --- |
| `Espace` | Lancer les dés ; jouer le coup quand il est complet ; passer quand aucun coup n'est possible |
| `←` `→` | Choisir un pion jouable (le focus se voit : anneau `focus`) |
| `↑` `↓` | Choisir sa case d'arrivée |
| `↑` `↓` | Choisir sa flèche d'arrivée |
| `Entrée` | Poser le pion |
| `Z` | Annuler le demi-coup (aussi `Ctrl+Z`) |
| `D` | Doubler |
+3
View File
@@ -47,3 +47,6 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
- [ ] « Nouvelle partie » depuis le menu : effet immédiat, bandeau « Reprendre l'ancienne » 10 s, et la reprise restaure la partie exacte.
- [ ] Vibrations aux quatre moments, coupées par l'interrupteur du menu (test unitaire sur `haptic()` ; ressenti à la main).
- [ ] Écran maintenu allumé pendant une partie, interrupteur dans le menu.
- [ ] Glisser-déposer : le pion suit le doigt, agrandi et au-dessus de lui, un fantôme en pointillés à son départ ; lâché hors d'une case permise, il revient à sa place sans vibration ni message (`tests/browser/plateau.mjs`, captures `plateau/glissement-*`).
- [ ] Double toucher : le coup évident seulement (un seul coup commence par ce pion, ou un seul coup possible) ; sinon rien de plus qu'un toucher.
- [ ] Clavier : `←` `→` choisissent un pion, `↑` `↓` sa case (anneau `focus`), `Entrée` le pose ; les raccourcis s'affichent en `kbd` sur les boutons dès qu'un clavier est détecté (capture `plateau/clavier-*`).
+3 -2
View File
@@ -296,9 +296,10 @@ milieu, quand leur nombre est impair, ne compte dans aucune).
## progression.hausse
Le taux moyen remonte : la phrase reste encourageante, sans reproche.
Le taux moyen remonte : la phrase reste encourageante, sans reproche, sans
minimiser ni dramatiser.
- Sur vos {parties} dernières parties, votre taux d'erreur remonte un peu, de {debut} à {fin}. Les leçons sont là pour reprendre un point.
- Sur vos {parties} dernières parties, votre taux d'erreur remonte, de {debut} à {fin}. Les leçons sont là pour reprendre un point.
## progression.stable
-32
View File
@@ -1,32 +0,0 @@
# Plateau soigné : gestes et finitions (1i)
- **Date** : 2026-11-03
- **Étape** : 1i (`docs/etape-1.md`)
- **Statut** : prévu
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Terminer le plateau soigné du cadrage, une fois la révision UX en place :
- les gestes naturels (principe d'expérience 2) ;
- les écarts restants avec `docs/identite/charte/board.js`.
## Consignes
1. **Glisser-déposer.** On peut glisser un pion en plus du toucher pion puis destination. Le pion suit le doigt, et le lâcher hors d'une case permise le ramène à sa place. Les cibles restent celles du moteur.
2. **Double toucher.** Un double toucher sur un pion joue le seul coup évident (cadrage). Un « coup évident » est défini ainsi : un seul coup légal complet commence par ce pion, ou le moteur ne propose qu'un coup. Sinon, rien ne se passe.
3. **Écarts restants avec board.js.** Les relever par comparaison visuelle (filet des pions, numéros sur le cadre, videau…) et les corriger.
4. **Clavier.** Faire ce que la révision UX a laissé :
- le choix des pions aux flèches ;
- l'affichage des raccourcis en `kbd` quand un clavier est détecté (interaction.md).
5. **Tests.**
- Scénarios Gherkin si un nouveau comportement est visible.
- `make lint`, `make test`, `make test-browser` (glisser-déposer simulé, double toucher), avec captures pour la recette design.
## Critères de réussite
Un coup se joue entièrement au glisser-déposer. Le double toucher joue le coup évident. Les trois commandes passent.
## Résultat
À remplir à la clôture.
+42
View File
@@ -0,0 +1,42 @@
# Analyse d'un match entier
- **Date** : 2026-11-12
- **Étape** : 1 (demande de Pierre)
- **Statut** : prévu (après le 2e lot de retours des joueurs, et une fois les maquettes de la session « identité » disponibles)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Demande de Pierre du 2026-11-12 : « proposer une analyse complète du match, notamment pour l'utilisation du videau (pas que l'analyse d'une manche) ». Il cite son propre exemple : mené 4-0 après deux parties, il a gagné le match en 5 points trois parties plus tard.
Aujourd'hui, chaque partie d'un match a son bilan et son analyse (1e), mais rien ne montre le match dans son ensemble.
## Consignes
Respecter les ADR 0007, 0008, 0011 et 0013. Suivre les règles et maquettes de la session « identité ».
1. **Bilan du match.** Une adresse (par exemple `#/matchs/<id>/bilan`) donne :
- le résultat et le déroulé du score, partie par partie ;
- le taux d'erreur du joueur sur tout le match, à la manière de gnubg (statistiques du match) ;
- les erreurs de videau regroupées : doubles manqués, mauvais doubles, prises et refus fautifs ;
- le moment décisif ;
- « le coup à revoir » du match.
2. **Courbe du match.** Elle suit les chances de gagner le match (MWC, tables d'équité de match du moteur) à travers toutes les parties, et non les chances de gagner la partie. Toucher un point ouvre la décision.
3. **Données.**
- Réutiliser les analyses déjà rangées dans les journaux de chaque partie (`review`).
- Relier les parties d'un même match. Aujourd'hui, une partie = un journal (choix de 1c). Il faut un identifiant de match commun, avec un format durable et une migration testée. Les matchs déjà joués sont rattachés si c'est possible, sinon on le dit honnêtement.
- Les équités de match viennent du moteur. Le domaine ne fait aucun calcul de table d'équité.
4. **Accès.** Depuis le bilan de la dernière partie d'un match, « Voir l'analyse du match ». Depuis « Mes parties » et la progression, le match apparaît comme une entrée.
5. **Prompt.** Un gabarit « bilan de mon match », avec un bouton « Demander à une IA » (1f).
6. **Export SGF.** Exporter le match entier : MI et toutes les parties.
7. **Tests.** Scénario Gherkin d'un match en 5 points avec dés imposés, où le joueur remonte après avoir été mené. Tests unitaires de l'agrégation, référence MWC comparée à gnubg de bureau.
Tests : `make lint`, `make test`, `make test-browser`, captures pour la recette design.
## Critères de réussite
Sur un match joué jusqu'au bout, le bilan du match reprend les erreurs de videau de toutes les parties, et la courbe des chances de gagner le match est continue d'une partie à l'autre. Les trois commandes passent.
## Résultat
À remplir à la clôture.
@@ -0,0 +1,53 @@
# Retours des joueurs, 2e lot : Crawford sur la barre, abandon
- **Date** : 2026-11-12
- **Étape** : 1 (retours des joueurs relayés par Pierre)
- **Statut** : prévu (après 1h et 1i, et les maquettes de la session « identité »)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Deux retours de joueurs relayés par Pierre le 2026-11-12 :
- « il serait bien que le Crawford soit en évidence sur la barre plutôt » ;
- « possibilité de pouvoir abandonner (un petit drapeau ?) si on sait qu'on va perdre, et pouvoir proposer simple, gammon, backgammon sans que l'ordinateur ne se fasse arnaquer (il refuse un simple si on sait qu'il y aura gammon par exemple) ».
## Consignes
Respecter les ADR 0005, 0007, 0008, 0011 et 0013. Suivre les règles et les maquettes de la session « identité », qui seront ajoutées dans `docs/identite/charte/`.
1. **Crawford sur la barre.** L'indication de la partie Crawford est placée sur la barre du plateau, en portrait et en paysage, à l'endroit fixé par la charte.
2. **Abandon, côté moteur.**
- La décision d'accepter ou de refuser un abandon (simple, gammon ou backgammon) reprend le code de GNU Backgammon de bureau qui répond à un abandon proposé par l'humain : évaluation de la position, équité de l'abandon (`getResignation`, `getResignEquity` ou leurs équivalents dans gnubg 1.08.003), comparaison à l'équité de la partie continuée. Les sources sont citées fichier et ligne.
- Ces fichiers ne sont pas dans la copie : porter le code dans `engine.c`, comme pour la chance en 1a, sans modifier `engine/gnubg/`.
- Exposer la décision par `engine.h`, `engine-cli` et l'adaptateur.
- Ajouter des positions de référence comparées à gnubg de bureau (Docker), avec au moins :
- un simple refusé parce que le gammon est probable ;
- un simple accepté ;
- un gammon accepté ;
- un cas de match.
3. **Abandon, côté domaine.**
- Un événement d'abandon, proposé puis accepté ou refusé, rétrocompatible : migration testée (ADR 0007).
- Fin de partie avec les points selon la valeur de l'abandon et le videau.
- Prise en compte dans le match (score, Crawford), le bilan, la progression et l'export SGF (convention de gnubg pour l'abandon).
4. **Abandon, côté interface.**
- Le drapeau, la feuille de choix et la réponse de l'ordinateur, selon la charte.
- Messages dans le ton du maître, ajoutés à `docs/maitre/phrases.md` ou à une source sœur.
5. **Aide et glossaire.**
- Fiche « Abandon » (resign).
- Scénarios Gherkin avec dés imposés : abandon accepté, abandon refusé, abandon en match.
Tests : `make lint`, `make test`, `make test-browser` (suite complète), avec captures pour la recette design.
## Critères de réussite
- Sur une position de référence où le gammon est probable, un abandon simple est refusé, comme dans gnubg de bureau.
- Un abandon accepté termine la partie avec les bons points.
- Les trois commandes passent.
## Résultat
À remplir à la clôture.
+1 -1
View File
@@ -23,7 +23,7 @@ d'après le cadrage, **à relire** par Pierre.
| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse |
| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie |
| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire |
| [exercices.md](exercices.md) | Créer des exercices | Écran de progression (étape 1h) |
| [exercices.md](exercices.md) | Demander des exercices à une IA | Écran de progression (étape 1h) |
Chaque prompt contient :
+1 -1
View File
@@ -1,6 +1,6 @@
# Créer des exercices
Le bouton « Créer des exercices » de l'écran de progression (étape 1h,
Le bouton « Demander des exercices à une IA » de l'écran de progression (étape 1h,
`#/progression/ia`). Il rassemble les erreurs de vos dernières parties
analysées (les 20 plus récentes au plus), et ce qui y revient. Texte sous licence CC BY-SA 4.0 (voir
[README.md](README.md)).
+18
View File
@@ -40,6 +40,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors seul votre 3 est marqué comme joué
Et chacun de vos pions jouables n'a plus qu'une case d'arrivée
@position:BQAACAIAAAAAAA
Scénario: Puis-je faire glisser un pion, ou jouer au clavier ?
Vous pouvez aussi faire glisser un pion jusqu'à sa case d'arrivée : il
suit votre doigt, juste au-dessus pour rester visible, et un pion en
pointillés marque sa place de départ. Lâché ailleurs que sur une case
permise, il revient à sa place. Touchez deux fois de suite un pion pour
jouer le coup évident : quand un seul coup commence par ce pion, ou que
vous n'avez qu'un coup possible. Sinon, rien de plus ne se passe. Votre
coup attend ensuite votre validation, comme d'habitude. Au clavier, les
flèches gauche et droite choisissent un pion, les flèches haut et bas sa
case d'arrivée, et Entrée le pose.
Étant donné que vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1
Quand vous demandez le coup évident de votre pion de la flèche 6
Alors aucun pion n'a bougé
Quand vous demandez le coup évident de votre pion de la flèche 1
Alors vos deux pions sont sortis
Scénario: Je me suis trompé de pion
Tant que vous n'avez pas validé votre coup, « Annuler » reprend le
dernier pion déplacé ; touchez-le encore pour reprendre le précédent.
+2 -2
View File
@@ -5,7 +5,7 @@ Fonctionnalité: Suivre ma progression
votre taux d'erreur partie après partie : plus il est bas, mieux vous avez
joué. Chaque partie terminée y figure avec son résultat, sa date, le niveau
de l'ordinateur et son taux d'erreur, celui de son bilan ; touchez-la pour
rouvrir son bilan, ou son analyse. Une phrase du maître dit, en chiffres,
rouvrir son bilan, et de là son analyse. Une phrase du maître dit, en chiffres,
comment évolue votre taux d'erreur.
Scénario: Comment voir ma progression ?
@@ -44,7 +44,7 @@ Fonctionnalité: Suivre ma progression
Et les deux premières parties n'ont pas été analysées de nouveau
Scénario: Comment créer des exercices sur mes erreurs ?
« Créer des exercices » prépare une question pour une IA : vos erreurs
« Demander des exercices à une IA » prépare une question : vos erreurs
les plus coûteuses sur vos dernières parties, avec leur coût selon le
maître, ce qui y revient (pions frappés, points faits par le coup du
maître et pas par le vôtre), et la position de la plus coûteuse. Votre
+15 -4
View File
@@ -23,10 +23,13 @@
# progression gardée), captures dans build/browser-shots/lecons/. La
# progression (tests/browser/progression.mjs : cas vide, trois parties, courbe
# égale aux taux des bilans, ligne de l'accueil, « Créer des exercices »),
# captures dans build/browser-shots/progression/.
# captures dans build/browser-shots/progression/. Les gestes du plateau soigné
# (tests/browser/plateau.mjs : glisser-déposer, pion lâché hors cible, double
# toucher, clavier, filet des pions), captures dans build/browser-shots/plateau/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts, nouvelle-partie, retours-joueurs, lecons, progression) restreignent le test.
# prompts, nouvelle-partie, retours-joueurs, lecons, progression, plateau)
# 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
@@ -39,7 +42,7 @@ cd "$(dirname "$0")/.."
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
browsers="${BROWSERS:-chromium firefox}"
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs lecons progression plateau}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -78,7 +81,8 @@ rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs tests/browser/progression.mjs "$tools/"
tests/browser/retours-joueurs.mjs tests/browser/lecons.mjs tests/browser/progression.mjs tests/browser/plateau.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
@@ -155,5 +159,12 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node progression.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" plateau "* ]]; then
# Gestes du plateau : glisser-déposer, lâcher hors cible, double
# toucher, clavier ; captures dans build/browser-shots/plateau/.
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 plateau.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+2 -2
View File
@@ -329,8 +329,8 @@ for (;;) {
await page.click("#summary-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")} »`);
check((await page.textContent("#again > span")) === "Rejouer",
`après le match, barre d'actions : « ${await page.textContent("#again > span")} »`);
log(`match ${matches} fini : ${games} parties, ${crawfordGames} Crawford au total`);
await page.click("#again");
await until("match rejoué", () => location.hash === "#/" &&
+328
View File
@@ -0,0 +1,328 @@
// plateau.mjs -- les gestes du plateau soigné (étape 1i ; charte,
// ecrans-etape-1.md, 1i ; interaction.md), joués dans un vrai navigateur sans
// écran (Playwright), sur le site servi par l'image de production :
//
// - un coup joué entièrement au glisser-déposer (pion fantôme au départ, pion
// glissé agrandi, cases d'arrivée du moteur) ;
// - un pion lâché hors d'une case permise revient à sa place, sans rien
// jouer ;
// - le double toucher : rien quand plusieurs coups commencent par le pion,
// le coup évident sinon ;
// - un coup joué au clavier (flèches, Entrée, Espace), raccourcis en kbd
// affichés une fois le clavier détecté ;
// - le filet intérieur des pions ;
// - aucune erreur, aucune violation de la politique de sécurité.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node plateau.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Les dés viennent de Math.random, remplacé ici
// par une file de valeurs choisies (window.__dice), puis par un générateur à
// graine. Captures de la recette design (Chromium) dans
// <dossier des captures>/plateau/, 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 plateau.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/plateau`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Positions de gnubg, vues du joueur : le départ ; un pion sur la 6 et un
// sur la 1 (avec 6-1, seul le pion de la 1 a un coup évident).
const START = "4HPwATDgc/ABMA";
const SIX_AND_ONE = "BQAACAIAAAAAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
const context = await browser.newContext({
viewport: PORTRAIT,
deviceScaleFactor: 1,
locale: "fr-FR",
});
await context.addInitScript(() => {
window.__dice = [];
let state = 7;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", JSON.stringify({ tutorialSeen: 1 }));
}
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
/** 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).catch(() => false)) {
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 nouvelle partie à partir d'une position, joueur au trait, puis les
* dés imposés lancés. */
async function startFrom(position, dice) {
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,
);
await page.evaluate((d) => window.__dice.push(...d), dice);
await page.click("#roll");
await idle();
}
/** L'état du plateau : pions par endroit (pion du dessus marqué), pion
* choisi, cases d'arrivée, glissement en cours, dés joués. */
function boardState() {
return page.evaluate(() => {
const board = document.getElementById("board");
const at = (place) => board.querySelector(`[data-checker="${place}"]`) !== null;
return {
at: Object.fromEntries(
["p1", "p5", "p6", "p7", "p8", "p13", "p24", "off-player"].map((p) => [p, at(p)]),
),
selected: board.querySelector(".checker.selected")?.closest("[data-checker]")
?.getAttribute("data-checker") ?? null,
landing: [...board.querySelectorAll(".hit.can-land")].map((e) =>
e.getAttribute("data-place"),
),
dragging: board.querySelector("#drag-piece") !== null,
ghost: board.querySelector(".drag-origin")?.getAttribute("data-checker") ?? null,
used: board.querySelectorAll(".die-used").length,
focus: board.querySelector(".key-focus") !== null,
rings: board.querySelectorAll(".checker-ring").length,
checkers: board.querySelectorAll("circle.checker").length,
confirm: !document.getElementById("confirm").hidden,
};
});
}
/** Le centre, à l'écran, de la zone à toucher d'un endroit. */
async function center(place) {
const box = await page.locator(`#board .hit[data-place="${place}"]`).boundingBox();
check(box !== null, `zone ${place} introuvable`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Le centre, à l'écran, du pion du dessus d'un endroit. */
async function checkerCenter(place) {
const box = await page.locator(`#board [data-checker="${place}"] circle.checker`).boundingBox();
check(box !== null, `pion ${place} introuvable`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/** Prend le pion de from et le fait glisser au-dessus de la zone de to,
* sans le lâcher ; renvoie l'état du plateau pendant le glissement. */
async function dragOver(from, to) {
const start = await checkerCenter(from);
const end = await center(to);
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 4, start.y + 12, { steps: 3 });
await page.mouse.move(end.x, end.y, { steps: 8 });
await sleep(100);
return boardState();
}
/** Lâche le pion glissé, puis attend que le plateau soit de nouveau libre
* et sans glissement (le pion rentré ou posé). */
async function release() {
await page.mouse.up();
await until("glissement fini", () => document.getElementById("drag-piece") === null);
await idle();
}
/** Une capture de la recette, à la taille du moment (Chromium seulement). */
async function capture(name) {
if (!full) {
return;
}
const size = page.viewportSize();
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
// ------------------------------------------------- Le filet des pions
await startFrom(START, [3, 1]);
let state = await boardState();
check(state.checkers >= 28 && state.rings === state.checkers,
`filet : ${state.rings} filets pour ${state.checkers} pions`);
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 capture("plateau-filet");
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
log(`filet intérieur sur chaque pion (${state.rings})`);
// ------------------------------------- Un coup entier au glisser-déposer
state = await dragOver("p8", "p5");
check(state.dragging && state.ghost === "p8" && state.selected === "p8",
`glissement de 8 : ${JSON.stringify(state)}`);
check(state.landing.includes("p5") && state.landing.includes("p7"),
`glissement de 8 : cases d'arrivée ${state.landing}`);
await capture("glissement");
await release();
state = await boardState();
check(state.used === 1 && state.at.p5 && !state.dragging && state.ghost === null,
`8/5 lâché sur la 5 : ${JSON.stringify(state)}`);
await dragOver("p6", "p5");
await release();
state = await boardState();
check(state.used === 2 && state.confirm, `6/5 lâché sur la 5 : ${JSON.stringify(state)}`);
await page.click("#confirm");
await idle();
log("un coup entier joué au glisser-déposer (8/5 6/5)");
// Le glissement en paysage, pour la recette.
if (full) {
await startFrom(START, [3, 1]);
await until("bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
await page.setViewportSize(LANDSCAPE);
await sleep(300);
state = await dragOver("p13", "p10");
check(state.dragging && state.ghost === "p13", `paysage : ${JSON.stringify(state)}`);
await capture("glissement");
await release();
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
// ------------------------------------------- Lâché hors d'une case permise
await startFrom(START, [3, 1]);
const before = await boardState();
// La 12 est tenue par l'ordinateur : 13/12 n'est pas permis.
state = await dragOver("p13", "p12");
check(state.dragging && !state.landing.includes("p12"), `13 vers 12 : ${JSON.stringify(state)}`);
await release();
state = await boardState();
check(state.used === 0 && state.at.p13 && state.selected === null && !state.dragging &&
state.checkers === before.checkers,
`lâché sur la 12 : le pion n'est pas revenu ${JSON.stringify(state)}`);
log("lâché hors d'une case permise : le pion revient à sa place, rien n'est joué");
// ----------------------------------------------------------- Double toucher
await startFrom(SIX_AND_ONE, [6, 1]);
// Depuis la 6 : 6/off 1/off ou 6/5/off, deux coups : rien.
await page.dblclick('#board .hit[data-place="p6"]');
await sleep(500);
await idle();
state = await boardState();
check(state.used === 0 && state.at.p6 && state.at.p1, `double toucher sur la 6 : ${JSON.stringify(state)}`);
// Depuis la 1 : 1/off puis 6/off, le seul coup.
await page.dblclick('#board .hit[data-place="p1"]');
await until("coup évident joué", () => !document.getElementById("confirm").hidden);
await idle();
state = await boardState();
check(state.used === 2 && !state.at.p6 && !state.at.p1 && state.at["off-player"],
`double toucher sur la 1 : ${JSON.stringify(state)}`);
log("double toucher : rien sur la 6 (deux coups), le coup évident sur la 1");
// ------------------------------------------------------------------ Clavier
await startFrom(START, [3, 1]);
await page.locator("body").focus().catch(() => {});
// Les pions jouables dans le sens de la course : 24, 13, 8, 6.
for (let i = 0; i < 3; i++) {
await page.keyboard.press("ArrowRight");
}
state = await boardState();
check(state.selected === "p8" && state.focus, `flèches : pion ${state.selected}`);
// Les cases d'arrivée de la 8 : la 7, puis la 5.
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await idle();
state = await boardState();
check(state.used === 1 && state.at.p5, `Entrée : 8/5 non joué ${JSON.stringify(state)}`);
// En arrière depuis la fin : 5, puis 6 ; sa seule case, la 5.
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
state = await boardState();
check(state.selected === "p6" && state.focus, `flèche gauche : pion ${state.selected}`);
await page.keyboard.press("Enter");
await idle();
state = await boardState();
check(state.used === 2 && state.confirm, `Entrée : 6/5 non joué ${JSON.stringify(state)}`);
const keys = await page.evaluate(() => {
const kbd = document.querySelector("#confirm kbd.shortcut");
return {
keyboard: document.documentElement.classList.contains("keyboard"),
shown: kbd !== null && kbd.getBoundingClientRect().width > 0,
text: kbd?.textContent ?? null,
};
});
check(keys.keyboard && keys.shown && keys.text === "Espace", `raccourci : ${JSON.stringify(keys)}`);
await capture("clavier");
await page.keyboard.press(" ");
await until("coup validé", () => document.getElementById("confirm").hidden);
await idle();
log("un coup joué au clavier (flèches, Entrée, Espace), raccourcis affichés");
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(`plateau soigné : tout est conforme (${captures} captures)`);
+13 -4
View File
@@ -353,7 +353,8 @@ async function checkStable(when) {
}
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
* bouton tient sur une ligne et fait 48 px de haut (charte), touche de son
* raccourci comprise. Un bouton plus
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
async function wrappedButtons(target) {
@@ -368,7 +369,9 @@ async function wrappedButtons(target) {
const tops = new Set();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
if (node.textContent.trim() === "") {
// La touche du raccourci (kbd, quand un clavier est détecté)
// est un badge en coin, pas une ligne du libellé.
if (node.textContent.trim() === "" || node.parentElement?.closest("kbd.shortcut")) {
continue;
}
const range = document.createRange();
@@ -1193,6 +1196,7 @@ let resumed = false;
let landscapeTurn = false;
let rotatedChecked = false;
let vibrationsChecked = false;
let lastTouch = 0;
let tracesSeen = 0;
let replays = 0;
let skips = 0;
@@ -1420,7 +1424,7 @@ for (let actions = 0; ; actions++) {
const confirm = document.getElementById("confirm");
const cancel = document.getElementById("cancel");
return {
text: confirm.textContent,
text: confirm.firstChild.textContent,
primary: !confirm.classList.contains("secondary"),
right: confirm.getBoundingClientRect().x > cancel.getBoundingClientRect().x,
inBar: confirm.closest("#actions .slot.primary") !== null,
@@ -1496,9 +1500,14 @@ for (let actions = 0; ; actions++) {
check(!(await page.isVisible("#menu")), "Échap ne ferme pas le menu");
}
const vibrationsBefore = (await vibrations()).length;
// Deux touchers rapprochés sur la même pile seraient un double
// toucher (coup évident, tests/browser/plateau.mjs) : ici, un
// toucher à la fois, un demi-coup à la fois.
await sleep(Math.max(0, lastTouch + 500 - Date.now()));
// Un pion à destination unique avance dès qu'on le touche ; sinon,
// ses cases d'arrivée s'allument. Le premier toucher est mesuré :
// l'écran répond dans le gestionnaire du toucher.
lastTouch = Date.now();
const ms = await page.evaluate(() => {
const hit = document.querySelector(".hit.can-move");
const t0 = performance.now();
@@ -1673,7 +1682,7 @@ await shot("4-fin-de-partie");
// reste dans la barre d'actions.
await page.click("#summary-close");
await page.waitForSelector("#summary-veil", { state: "hidden" });
check(!(await page.isVisible("#panel")) && (await page.textContent("#again")) === "Rejouer" &&
check(!(await page.isVisible("#panel")) && (await page.textContent("#again > span")) === "Rejouer" &&
(await page.isVisible("#again")),
"bilan fermé : panneau toujours là, ou pas de « Rejouer »");
+103 -54
View File
@@ -7,14 +7,17 @@
//
// Usage : node progression.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. L'écran sans partie terminée, puis trois
// fins de course courtes jouées par l'interface, depuis un lien de
// position, avec des dés imposés (comme bilan.mjs) : le joueur a 6-1 et
// deux pions, sur 6 et 1 ; il joue 6/5/off (grosse erreur, l'ordinateur
// double et le joueur passe), puis 6/off 1/off (gagne), puis de nouveau
// 6/5/off. Vérifie que la ligne de l'accueil mène à #/progression, que la
// courbe a trois points égaux, dans l'ordre, aux taux d'erreur des trois
// bilans, la phrase du maître, la liste reliée aux bilans et aux analyses,
// et « Créer des exercices » (feuille du prompt). Rien d'interactif sous
// courses courtes jouées jusqu'au bout par l'interface, depuis un lien de
// position (celle des tests du domaine), dés d'une graine fixe : le joueur
// joue toujours le premier pion jouable et la première case d'arrivée, et
// prend les doubles, ce qui donne plusieurs décisions par partie et des
// taux d'erreur réalistes. Vérifie que la ligne de l'accueil (44 px au
// moins) mène à #/progression, que la carte de la partie ne montre plus la
// partie finie, que la courbe a trois points égaux, dans l'ordre, aux taux
// d'erreur des trois bilans, le dernier point en vert seulement si le taux
// baisse, la phrase du maître, la liste (un lien par partie, vers son
// bilan) et « Demander des exercices à une IA » (feuille du prompt). Rien
// d'interactif sous
// une barre d'action, aucune erreur ni violation de la politique de
// sécurité. Captures (Chromium) dans <dossier des captures>/progression/,
// en 412×915 et 915×412.
@@ -35,9 +38,8 @@ mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SIZES = [PORTRAIT, LANDSCAPE];
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2 (positions de gnubg, chacun vu de lui-même).
const BEAROFF = "BQAACAIAAAAAAA";
// Une course courte (Position ID de gnubg, app/tests/progression.test.ts).
const RACE = "trsXAADs7mwAAA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
@@ -95,7 +97,6 @@ async function until(what, fn, arg, ms = 120_000) {
throw new Error(`attente trop longue : ${what}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette dans les deux tailles (Chromium seulement),
* après la règle commune ; settle replace l'écran après le changement de
@@ -141,26 +142,49 @@ async function startFrom(position) {
);
}
const place = (point) => (point === 0 ? "off-player" : `p${point}`);
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup. */
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="${place(from)}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
/** Joue la partie jusqu'au bout : lancer, le premier pion jouable et sa
* première case d'arrivée, valider ; prendre les doubles ; passer quand
* aucun coup n'est possible. */
async function playOut() {
for (let step = 0; step < 2000; step++) {
const s = await page.evaluate(() => {
const shown = (id) => {
const el = document.getElementById(id);
return el !== null && !el.hidden && el.closest("[hidden]") === null && !el.disabled;
};
return {
over: !document.getElementById("panel-veil").hidden || /\/bilan$/.test(location.hash),
busy: document.body.classList.contains("busy"),
take: shown("take"),
confirm: shown("confirm"),
skip: shown("skip"),
roll: shown("roll"),
land: document.querySelector("#board .hit.can-land") !== null,
move: document.querySelector("#board .hit.can-move") !== null,
};
});
if (s.over) {
return;
}
if (s.busy) {
await sleep(100);
} else if (s.take) {
await page.click("#take");
} else if (s.confirm) {
await page.click("#confirm");
} else if (s.skip) {
await page.click("#skip");
} else if (s.land) {
await page.locator("#board .hit.can-land").first().click();
} else if (s.move) {
await page.locator("#board .hit.can-move").first().click();
} else if (s.roll) {
await page.click("#roll");
} else {
await sleep(100);
}
}
check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`);
await page.click("#confirm");
throw new Error("la partie ne finit pas");
}
/** Le bilan de la partie qui vient de finir : son identifiant et son taux
@@ -178,17 +202,10 @@ async function summaryRate() {
}));
}
/** Une partie : le joueur joue 6/5/off (puis passe le double) ou 6/off
* 1/off. */
async function oneGame(mistake) {
await startFrom(BEAROFF);
if (mistake) {
await play([6, 1], [[6, 5], [5, 0]]);
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
} else {
await play([6, 1], [[6, 0], [1, 0]]);
}
/** Une course jouée jusqu'au bout, et le taux de son bilan. */
async function oneGame() {
await startFrom(RACE);
await playOut();
return summaryRate();
}
@@ -228,14 +245,13 @@ log("progression sans partie terminée : phrase et « Nouvelle partie »");
// ----------------------------------------------------- Trois parties jouées
const games = [];
for (const mistake of [true, false, true]) {
games.push(await oneGame(mistake));
for (let i = 0; i < 3; i++) {
games.push(await oneGame());
}
check(
games.every((g) => g.id && g.rate !== undefined && !Number.isNaN(Number(g.rate))),
`bilans : ${JSON.stringify(games)}`,
);
check(Number(games[0].rate) > 0 && Number(games[1].rate) === 0, `taux des bilans : ${JSON.stringify(games)}`);
log(`trois parties jouées, taux des bilans : ${games.map((g) => g.rate).join(", ")}`);
// La ligne de l'accueil mène à la progression.
@@ -251,9 +267,23 @@ await until("accueil", () => {
});
const line = await page.evaluate(() => {
const a = document.getElementById("home-progress");
return { tag: a.tagName, href: a.getAttribute("href"), text: a.textContent };
return {
tag: a.tagName,
href: a.getAttribute("href"),
text: a.textContent,
height: a.getBoundingClientRect().height,
// La carte de la partie, sans partie en cours : ni miniature ni
// compte de la partie finie (recette 1h).
mini: !document.querySelector("#home-game .home-mini").hidden,
pips: !document.getElementById("home-pips").hidden,
resume: !document.getElementById("home-resume").hidden,
};
});
check(line.tag === "A" && line.href === "#/progression", `ligne de l'accueil : ${JSON.stringify(line)}`);
check(
line.tag === "A" && line.href === "#/progression" && line.height >= 44,
`ligne de l'accueil : ${JSON.stringify(line)}`,
);
check(!line.mini && !line.pips && !line.resume, `carte de la partie finie : ${JSON.stringify(line)}`);
await capture("3-accueil-ligne", async () => {
await page.locator("#home-progress").scrollIntoViewIfNeeded();
});
@@ -269,22 +299,37 @@ const shown = await page.evaluate(() => ({
id: c.getAttribute("data-id"),
rate: c.getAttribute("data-rate"),
last: c.classList.contains("last"),
better: c.classList.contains("better"),
r: Number(c.getAttribute("r")),
})),
values: [...document.querySelectorAll("#progression-curve .progression-value")].length,
axisLabels: [...document.querySelectorAll("#progression-curve .curve-label")].length,
items: [...document.querySelectorAll("#progression-list .progression-item")].map((li) => ({
id: li.dataset.id,
summary: li.querySelector(".progression-game").getAttribute("href"),
analysis: li.querySelector(".progression-analysis").getAttribute("href"),
links: li.querySelectorAll("a, button").length,
result: li.querySelector(".progression-text strong").textContent,
chevron: li.querySelector(".progression-game > svg.icon") !== null,
rate: li.querySelector(".progression-rate")?.dataset.rate,
})),
glossary: document.getElementById("progression-glossary")?.getAttribute("href"),
exercises: document.getElementById("progression-exercises")?.getAttribute("href"),
exercisesText: document.getElementById("progression-exercises")?.textContent,
back: document.getElementById("progression-back") !== null,
title: document.getElementById("progression-title")?.tagName,
}));
check(shown.dots.length === 3, `courbe : ${shown.dots.length} points`);
for (const [i, game] of games.entries()) {
const dot = shown.dots[i];
check(dot.id === game.id && dot.rate === game.rate, `point ${i + 1} : ${JSON.stringify(dot)} contre ${JSON.stringify(game)}`);
}
check(shown.dots[2].last && !shown.dots[0].last, "le dernier point n'est pas mis en avant");
check(shown.dots[2].last && !shown.dots[0].last && shown.dots[2].r > shown.dots[0].r,
"le dernier point n'est pas plus gros");
// Vert seulement si le taux baisse (progression.baisse).
const down = shown.summary.includes("vous faites moins d'erreurs");
check(shown.dots[2].better === down && !shown.dots[0].better, `vert du dernier point : ${shown.summary}`);
check(shown.values === 2 && shown.axisLabels === 0, `valeurs de la courbe : ${shown.values}, axe : ${shown.axisLabels}`);
check(!shown.back && shown.title === "H1", `en-tête : ${JSON.stringify({ back: shown.back, title: shown.title })}`);
check(shown.summary.startsWith("Sur vos 3 dernières parties"), `phrase : ${shown.summary}`);
check(!shown.summary.includes("!"), `phrase : ${shown.summary}`);
check(shown.items.length === 3 && shown.items[0].id === games[2].id, `liste : ${JSON.stringify(shown.items)}`);
@@ -292,15 +337,19 @@ for (const item of shown.items) {
const game = games.find((g) => g.id === item.id);
check(
item.summary === `#/parties/${item.id}/bilan` &&
item.analysis === `#/parties/${item.id}/analyse` &&
item.links === 1 &&
item.chevron &&
item.rate === game.rate,
`ligne : ${JSON.stringify(item)}`,
);
}
check(shown.glossary === "#/glossaire/taux-d-erreur", `lien du glossaire : ${shown.glossary}`);
check(shown.exercises === "#/progression/ia", `exercices : ${shown.exercises}`);
check(
shown.exercises === "#/progression/ia" && shown.exercisesText === "Demander des exercices à une IA",
`exercices : ${shown.exercises} « ${shown.exercisesText} »`,
);
await capture("2-trois-parties");
log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} »`);
log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} » ; résultats : ${shown.items.map((i) => i.result).join(" | ")}`);
// Une ligne ouvre le bilan ; le fermer ramène à la progression.
await page.click(`#progression-list .progression-item[data-id="${games[0].id}"] .progression-game`);
@@ -315,7 +364,7 @@ await page.click("#summary-close");
await progressionReady();
log("une ligne ouvre le bilan, la croix ramène à la progression");
// « Créer des exercices » : la feuille du prompt.
// « Demander des exercices à une IA » : la feuille du prompt.
await page.click("#progression-exercises");
await until(
"prompt des exercices",
@@ -326,11 +375,11 @@ await until(
const prompt = await page.textContent("#prompt-text");
check(prompt.includes("Voici mes 3 dernières parties"), `prompt : ${prompt}`);
check(prompt.includes("Ce qui revient dans ces erreurs"), `prompt : ${prompt}`);
check(prompt.includes("Je débute au backgammon."), `prompt : ${prompt}`);
check(/Je débute au backgammon\.|Je suis un joueur de niveau /.test(prompt), `prompt : ${prompt}`);
check(!/[{}]/.test(prompt), `repère non rempli : ${prompt}`);
await page.keyboard.press("Escape");
await until("retour à la progression", () => location.hash === "#/progression");
log("« Créer des exercices » : prompt avec les erreurs et leurs thèmes");
log("« Demander des exercices à une IA » : prompt avec les erreurs et leurs thèmes");
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {
+104 -25
View File
@@ -412,10 +412,42 @@ button:disabled {
}
.icon-button.labelled {
position: relative;
width: 60px;
height: 52px;
}
/* Raccourci clavier d'un bouton, en petite touche (interaction.md) :
* seulement quand un clavier est détecté (classe keyboard, app.ts). */
.shortcut {
display: none;
}
.keyboard .shortcut {
display: inline-block;
min-width: 1.6em;
padding: 0 4px;
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 18px;
text-align: center;
border: 1px solid currentColor;
border-radius: 4px;
opacity: 0.8;
}
/* Barre haute : la touche en coin, sans changer la taille du bouton. */
.keyboard .icon-button.labelled .shortcut {
position: absolute;
top: 0;
right: 2px;
min-width: 0;
padding: 0 3px;
line-height: 15px;
background: var(--background);
}
.icon-button .label {
font-size: 13px;
line-height: 15px;
@@ -966,6 +998,13 @@ a.notation {
font-variant-numeric: tabular-nums lining-nums;
}
/* Le plateau de la partie : le doigt y fait glisser les pions, il ne fait
* ni défiler ni zoomer la page (glisser-déposer, double toucher ; charte,
* 1i). Les autres plateaux (accueil, tutoriel, position) laissent défiler. */
#board {
touch-action: none;
}
.board-frame { fill: var(--frame); }
.board-bed { fill: var(--bed); }
.board-bar { fill: var(--bar); }
@@ -996,8 +1035,8 @@ a.notation {
stroke: var(--checker-computer-edge);
}
/* Filet intérieur des pions (bundle.css), prêt pour le jour où le plateau
* le dessine. */
/* Filet intérieur des pions (bundle.css), l'écho du logo : un cercle aux
* trois cinquièmes du rayon, dessiné avec chaque pion (board-view.ts). */
.checker-ring {
fill: none;
stroke-width: 1.5;
@@ -1016,6 +1055,34 @@ a.notation {
stroke-width: 6;
}
/* Glisser-déposer (charte, 1i) : le pion parti laisse un fantôme en
* pointillés ; le pion glissé, agrandi, passe devant tout. */
.drag-origin .checker {
fill: none;
stroke: var(--highlight);
stroke-width: 2.5;
stroke-dasharray: 5 4;
}
.drag-origin .checker-ring,
.drag-origin .checker-count {
display: none;
}
.drag-piece { pointer-events: none; }
.checker.dragged {
stroke: var(--highlight);
stroke-width: 4;
}
/* Clavier : le pion ou la case d'arrivée choisis aux flèches. */
.key-focus {
fill: none;
stroke: var(--focus);
stroke-width: 3;
}
/* Tailles des chiffres du plateau : attribut font-size, calculé par
* geometry.ts (13 px au moins à l'écran). */
.checker-count {
@@ -3503,9 +3570,17 @@ html.landscape .summary-panel > .summary-end {
.curve-band { fill: transparent; cursor: pointer; }
.curve-band.selected { fill: var(--accent); fill-opacity: 0.08; }
/* La progression (#/progression, 1h) : la phrase du maître, la courbe du
* taux d'erreur (le dernier point en verdict-good), puis les parties. La
* page reprend l'en-tête et les cartes de l'analyse. */
/* La progression (#/progression, 1h) : le titre, la phrase du maître, la
* courbe du taux d'erreur, puis les parties (charte, ecrans-etape-1.md,
* « 1h »). Les cartes reprennent celles de l'analyse. */
.progression-title {
margin: var(--space-3) 0 var(--space-2);
font-size: 32px;
line-height: 36px;
font-weight: 800;
letter-spacing: -0.01em;
}
.progression-summary {
margin: 0 0 var(--space-4);
font-size: 17px;
@@ -3523,10 +3598,13 @@ html.landscape .summary-panel > .summary-end {
font-family: var(--font-sans);
}
/* Le dernier point, plus gros, à l'encre du texte ; en verdict-good
* seulement si le taux baisse (le vert dit « mieux », jamais « dernier »). */
.progression-dot { fill: var(--text); }
.progression-dot.last { fill: var(--verdict-good); }
.progression-value { fill: var(--muted); font-size: 12px; }
.progression-value.last { fill: var(--verdict-good); font-weight: 700; }
.progression-dot.better { fill: var(--verdict-good); }
.progression-value { fill: var(--muted); font-size: 13px; }
.progression-value.last { fill: var(--text); font-weight: 700; }
.progression-value.better { fill: var(--verdict-good); }
.progression .link-button {
display: inline-flex;
@@ -3539,6 +3617,13 @@ html.landscape .summary-panel > .summary-end {
margin: 0 0 var(--space-4);
}
/* Sans partie terminée : « Nouvelle partie », pleine largeur en portrait. */
@media (orientation: portrait) {
.progression-empty .button {
flex: 1;
}
}
.progression-list {
display: flex;
flex-direction: column;
@@ -3549,27 +3634,30 @@ html.landscape .summary-panel > .summary-end {
}
.progression-item {
display: flex;
gap: var(--space-2);
align-items: center;
padding: var(--space-2) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
/* Toute la ligne ouvre le bilan de la partie. */
/* Une ligne, un seul lien vers le bilan : résultat et date à gauche, taux
* en gras, puis le chevron. */
.progression-game {
display: flex;
flex: 1;
gap: var(--space-3);
align-items: center;
min-width: 0;
min-height: 44px;
min-height: 56px;
padding: var(--space-2) var(--space-3);
color: inherit;
text-decoration: none;
}
.progression-game > .icon {
flex: none;
width: 20px;
height: 20px;
color: var(--muted);
}
.progression-text {
display: flex;
flex: 1;
@@ -3577,11 +3665,6 @@ html.landscape .summary-panel > .summary-end {
min-width: 0;
}
.progression-text .muted {
font-size: 15px;
line-height: 20px;
}
.progression-rate {
display: flex;
flex: none;
@@ -3601,10 +3684,6 @@ html.landscape .summary-panel > .summary-end {
color: var(--muted);
}
.progression-analysis {
flex: none;
}
/* Le visualiseur : « ‹ Coup 7 sur 24 · 6-1 › », le plateau d'avant le
* coup, la phrase du maître, votre coup contre celui du maître. */
.viewer-nav {