Compare commits

...
Author SHA1 Message Date
pierre ed6d0a7140 Merge pull request 'Charte des leçons : écarts de la recette 1g' (#62) from charte-lecons-quitter into main 2026-11-12 12:40:48 +00:00
Pierre MARTINandClaude Opus 5.5 217c40a245 Charte des leçons : « Quitter », leçon terminée, actions en bas, « Revoir » sur la ligne du titre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:34:18 +01:00
Pierre MARTINandClaude Opus 5.5 cfb2e485c3 Fusion de la MR !60 : niveau d'évaluation du prompt, tests instables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:33:00 +01:00
Pierre MARTINandClaude Opus 5.5 ac0e924e4c Test navigateur : fermer le bilan rouvert avant l'aide ; plan des corrections clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:14:13 +01:00
Pierre MARTIN ceed97e359 Fusion de main dans corrections-plis-firefox 2026-11-12 09:09:54 +01:00
Pierre MARTINandClaude Opus 5.5 109655537f Fusion de la MR !58 : retours des premiers joueurs, pions blancs et noirs, numéros sur le cadre, flèches du tutoriel, jans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:05:49 +01:00
Pierre MARTINandClaude Opus 5.5 a238da77ec Contour des pions blancs plus sombre en thème clair ; recette des pions du joueur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:05:45 +01:00
Pierre MARTINandClaude Opus 5.5 9f0f20896b Fusion de main (contour des pions blancs, règle de recette des pions du joueur) dans retours-joueurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:42:20 +01:00
pierre 32f5270215 Fusion de la MR !59 : contour des pions blancs 2026-11-12 02:41:51 +00:00
Pierre MARTINandClaude Opus 5.5 3fff880db2 Charte : contour des pions blancs plus sombre en thème clair (3:1 sur le chêne)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:41:49 +01:00
Pierre MARTINandClaude Opus 5.5 282a3d688b Test navigateur : zones du plateau relevées d'un coup, sans boîte nulle
touchTarget mesurait chaque zone par boundingBox ; une zone remplacée
entre-temps par un nouveau dessin du plateau n'avait plus de boîte (null,
vu sous Firefox). Les zones sont relevées dans la page en une lecture, et
le test attend qu'elles soient toutes là.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:38:35 +01:00
Pierre MARTINandClaude Opus 5.5 57eeb19630 Plan des retours des premiers joueurs clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:34:40 +01:00
Pierre MARTINandClaude Opus 5.5 5a13c40bbb Recette des retours : capture après le bandeau, capture si le bouton reste caché
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:54:27 +01:00
Pierre MARTINandClaude Opus 5.5 8c2043aca5 Recette : contraste du contour des pions, retours des joueurs dans le navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 966822084a Tutoriel : deux flèches circulaires ; les jans remplacent le camp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 09a2ddf566 Numéros des flèches sur le cadre en portrait aussi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 cfdbc1cedc Pions blancs et noirs, avec contour : valeurs de tokens.json dans app.css
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:50:10 +01:00
Pierre MARTINandClaude Opus 5.5 cb42052c80 Test navigateur : attendre l'enregistrement de la nouvelle partie
Sous Firefox, sur disque lent, l'enregistrement de la nouvelle partie
attend dans la file derrière l'analyse de la partie finie ; le test lisait
IndexedDB trop tôt. Il attend désormais que la partie en cours change.
Test unitaire : rien n'est perdu avec un stockage lent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 99294900a0 Prompt d'un coup : le classement de l'analyse, au niveau calculé
Le classement du conseil vient de l'analyse du coup joué (conseils en
version 2) : mêmes équités que la feuille du maître et l'analyse. Le
titre dit le niveau du meilleur coup ; une ligne plus basse le dit aussi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 1d21ac15d2 Moteur : classement de l'analyse d'un coup, comme gnubg de bureau
engine_analyse_move rend les dix premiers coups de la liste de l'analyse,
chacun au niveau où gnubg l'a évalué, et refait d'abord l'analyse de la
chance comme AnalyzeMove : son cache change les coups restés à 0 pli.
Les références d'analyse gagnent le niveau de chaque coup du classement,
comparé par compare.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:47:38 +01:00
Pierre MARTINandClaude Opus 5.5 c42316d9d8 Plan des corrections : plis du prompt d'un coup, test Firefox instable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:31:25 +01:00
Pierre MARTINandClaude Opus 5.5 0e17334f45 Plan : retours des premiers joueurs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:12:22 +01:00
Pierre MARTINandClaude Opus 5.5 1f69635c49 Fusion de la MR !57 : nouvelle partie directe après un bilan, bouton Commencer, fiche Notation des coups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:02:48 +01:00
Pierre MARTINandClaude Opus 5.5 fb161789bf Liens de notation : pointillé discret, zone d'appui agrandie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 e20f215030 Fusion de main (1f, prompts à copier) dans nouvelle-partie-directe
Conflits résolus en gardant les deux apports : la feuille du prompt et la
correction de reviewFinishedGame dans app.ts ; prompts et nouvelle-partie
dans tests/browser.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:41:42 +01:00
Pierre MARTINandClaude Opus 5.5 abff843519 Plan de la nouvelle partie directe clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:39:47 +01:00
Pierre MARTINandClaude Opus 5.5 05bd06c946 Fusion de la MR !56 : demander à une IA, prompts à copier (1f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:28:40 +01:00
Pierre MARTINandClaude Opus 5.5 4050517904 Glossaire : fiche « Notation des coups », touchable depuis la partie
La fiche explique 13/7, 24/22*, 6/off, bar/22 et 8/4(2), dans la notation
de gnubg. Le type MoveNotation la relie au code. Le coup de la ligne
« L'ordinateur a joué » et celui de la feuille du maître mènent à la fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 05d2479bd3 Écran « Nouvelle partie » : « Commencer la partie » ou « Commencer le match »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
Pierre MARTINandClaude Opus 5.5 5da3baecde Nouvelle partie : plus de retour au bilan de l'ancienne partie
Le passage différé au bilan (reviewFinishedGame) ne vérifiait pas que la
partie finie était encore à l'écran : « Commencer » redessinait d'abord
l'ancienne partie finie (adresse #/), puis lançait la nouvelle, et la
microtâche ouvrait le bilan de l'ancienne par-dessus. Le bilan ne s'ouvre
plus que pour la partie finie encore affichée, et « Commencer » lance la
partie avant de changer d'adresse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:15:09 +01:00
pierre 65ee563794 Fusion de la MR !55 : pions blancs et noirs 2026-11-11 21:14:56 +00:00
Pierre MARTINandClaude Opus 5.5 145c19ac77 Identité : pions blancs et noirs avec contour (ADR 0010 amendé, choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:54 +01:00
Pierre MARTINandClaude Opus 5.5 0bb41524bf Test navigateur : nouvelle partie après une partie finie et son bilan
Reproduit le défaut signalé : après une partie finie, l'application
rouverte, « Commencer » de l'écran « Nouvelle partie » ramenait au bilan
de l'ancienne partie. Vérifie l'accueil, le menu et « Changer les réglages ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:14:46 +01:00
pierre 6a1ee6f65a Fusion de la MR !54 : retours des premiers joueurs 2026-11-11 21:00:44 +00:00
Pierre MARTINandClaude Opus 5.5 22e415bb81 Charte : retours des premiers joueurs (pions sombres lisibles, numéros sur le cadre, jans, flèches circulaires)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:00:42 +01:00
Pierre MARTIN 850d860658 Merge remote-tracking branch 'origin/main' into prompts 2026-11-11 21:54:32 +01:00
Pierre MARTINandClaude Opus 5.5 d5f08fe393 Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton
Recette de 1f (F1, F2). Depuis la feuille du maître ou le bilan, la page
du prompt prend la place de leur contenu, à la même hauteur, avec
« ‹ Explication » ou « ‹ Bilan » pour revenir ; depuis l'analyse et le
glossaire, elle monte dans sa propre feuille. La croix et « Fermer »
ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à
« ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix,
un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et
amendement de l'ADR 0013 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:32 +01:00
Pierre MARTINandClaude Opus 5.5 62ecff245f Plan : nouvelle partie directe, libellé « Commencer », notation des coups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:54:18 +01:00
Pierre MARTINandClaude Opus 5.5 21baddcea3 Fusion de la MR !53 : corrections, saisie de la sortie, largeur du tutoriel, notation du coup de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:20 +01:00
Pierre MARTINandClaude Opus 5.5 7a6c0ac2ac Aide : texte de « Sortir ses pions dans l'ordre de son choix » choisi par Pierre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:53:14 +01:00
Pierre MARTINandClaude Opus 5.5 f2df1b2e7f Prompts : le niveau du joueur seulement à partir de 20 décisions, son taux d'erreur dans le seul bilan
Recette de 1f (F3). En dessous de 20 décisions analysées, le prompt dit
« Je débute au backgammon. ». Test du seuil ; outil prompt-sample pour
relire des prompts remplis par le moteur. Plan de 1f clos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:52:23 +01:00
Pierre MARTINandClaude Opus 5.5 f509f8746e Test navigateur des prompts : captures sans bandeau résiduel, repli par sélection vérifié
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:04:08 +01:00
Pierre MARTINandClaude Opus 5.5 2a1fe1b04c « Demander à une IA » : bouton, feuille du prompt à son adresse, copie
Le bouton, secondaire, dans la feuille du maître, sur le bilan, dans
l'analyse et sur la fiche ouverte du glossaire. Il ouvre la feuille du
prompt (…/ia, amendement de l'ADR 0013), où le texte se relit avant la
copie : navigator.clipboard, repli par sélection du texte, bandeau
« Prompt copié » (2 s). Gabarits copiés par make site (amendement de
l'ADR 0008), scénario « Demander à une IA », 1f marquée faite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:56:56 +01:00
Pierre MARTINandClaude Opus 5.5 4f0611c24c Prompts : gabarits dans docs/prompts/ et remplissage par le domaine
Cinq gabarits sous CC BY-SA 4.0 (coup, videau, bilan, concept,
exercices), remplis par des fonctions pures de domain/prompts/ avec la
position lue par le moteur (identifiants, lien public, diagramme) et ses
chiffres, écrits comme à l'écran (domain/analysis/numbers.ts, partagé avec
la feuille du maître, le bilan et l'analyse). Testés sur de vraies
analyses : aucun repère vide ni inconnu, mêmes chiffres que l'écran.
Fiche du glossaire « Prompt ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:40:07 +01:00
Pierre MARTINandClaude Opus 5.5 90f835869a Plan des corrections clos : résultat dans la description de la MR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:24:22 +01:00
Pierre MARTINandClaude Opus 5.5 ce36d738fb Saisie : plus de demi-coup demandé une fois tous les dés joués
Après un double complet, la saisie restée ouverte demandait au moteur un
cinquième demi-coup, que la ligne de commande refuse (erreur dans la page,
relevée par make test-browser). Test : un moteur qui refuse plus de
demi-coups que de dés.

Coup de l'ordinateur : la recette du navigateur vérifie la ligne
« L'ordinateur a joué … » au premier coup montré.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:05:14 +01:00
Pierre MARTINandClaude Opus 5.5 fbf317909a Scénario de sortie : mise en forme Biome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:44:36 +01:00
Pierre MARTINandClaude Opus 5.5 b2263c7e3d Tutoriel : même largeur à chaque étape ; règle ajoutée à la recette
Les pages sont des éléments de la colonne flexible #screen ; leurs marges
automatiques annulaient l'étirement, et une page au contenu court se
réduisait à la largeur de ce contenu : l'étape 6 « à vous » (consigne
courte, pas d'explication) avait un plateau de 300 px au lieu de 396 en
412 × 915. Les pages prennent toute la largeur, bornée par max-width.
La recette mesure le plateau et le texte à chaque écran du tutoriel, dans
les trois tailles, et refuse un plateau de largeur différente ou une page
plus large que l'écran (mesures dans tutoriel-largeurs.txt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:42:41 +01:00
Pierre MARTINandClaude Opus 5.5 4e74bab3ac Écran de jeu : le coup de l'ordinateur dans la notation du moteur
« L'ordinateur a joué 6-4 : 6/off » au lieu de « 6/2 2/off » : la
notation vient du coup légal de gnubg qui mène à la même position
(Game.moveNotation), comme dans l'analyse et le bilan. Testé : sur une
partie analysée, chaque coup s'écrit pareil des deux côtés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:37:46 +01:00
Pierre MARTINandClaude Opus 5.5 ab8dc512ba Sortie : un coup déjà complet peut encore se prolonger (6/off puis 1/off avec 6-1)
La partie fermait la saisie dès que les demi-coups formaient un coup
légal. En sortie, 6/off seul mène à la même position que 6/5/off : le
1/off qui suivait était refusé. La saisie reste ouverte tant que le
moteur accepte un demi-coup de plus ; le tutoriel suit la même règle.
Tests : tous les ordres de saisie des fins de partie d'un à quatre pions,
sur les 21 jets, scénario Gherkin et tests du moteur pour les deux ordres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:36:15 +01:00
Pierre MARTINandClaude Opus 5.5 e9bb9ae88d Plan des corrections : sortie, largeur du tutoriel, notation de l'ordinateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:12:29 +01:00
Pierre MARTINandClaude Opus 5.5 f3f977bf66 Fusion de la MR !52 : bilan de fin de partie et écran d'analyse (1e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:03:43 +01:00
112 changed files with 6029 additions and 433 deletions
+4 -3
View File
@@ -127,8 +127,9 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire, des scénarios, du tutoriel et des phrases du
# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître
# - les copies du glossaire, des scénarios, du tutoriel, des phrases du
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
# tutoriel, le conseil du maître et « Demander à une IA »
# (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
@@ -142,7 +143,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre docs/prompts $(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
+3 -4
View File
@@ -102,10 +102,9 @@ export async function analyseDecision(
if (analysis === null) {
return null;
}
const ranking =
options.ranking === false
? []
: await engine.rankMoves(before.position, cube, before.roll, ply);
// Le classement est celui de l'analyse même : mêmes coups, mêmes
// niveaux et mêmes équités que le coup joué et le meilleur coup.
const ranking = options.ranking === false ? [] : analysis.ranking;
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
+77
View File
@@ -0,0 +1,77 @@
// Les chiffres du moteur tels qu'on les écrit : à l'écran (feuille du
// maître, bilan, analyse) et dans les prompts pour une IA (étape 1f). Une
// seule écriture, pour que le prompt donne exactement les chiffres que
// l'application affiche.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { AdvisedMove, MoveAdvice } from "../game/advice.js";
import type { Ply } from "../game/engine.js";
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
/** Des chances de 0 à 1, en pourcentage arrondi, avec une espace
* insécable : « 41 % ». */
export function formatPercent(chance: number): string {
return `${Math.round(chance * 100)} %`;
}
/** Une ligne du classement des coups montré par « Voir les chiffres du
* moteur ». */
export interface RankingRow {
readonly move: AdvisedMove;
/** Rang dans le classement du moteur, à partir de 1. */
readonly rank: number;
/** Écart d'équité avec le premier coup du classement : 0 ou négatif. */
readonly loss: number;
/** C'est le coup joué. */
readonly played: boolean;
}
/**
* Le classement d'un conseil tel que l'affiche la feuille du maître : les
* coups du classement de l'analyse, chacun avec son écart par rapport au
* premier ; puis le coup joué, s'il n'y figure pas. Le coup joué a toujours
* l'écart de l'analyse (equityLoss), celui que dit le maître.
*/
export function rankingRows(advice: MoveAdvice): RankingRow[] {
const first = advice.ranking[0]?.equity ?? advice.best.equity;
const rows = advice.ranking.map((move, i) => {
const played = move.notation === advice.played.notation;
return {
move,
rank: i + 1,
loss: played ? advice.equityLoss : move.equity - first,
played,
};
});
if (!rows.some((row) => row.played)) {
rows.push({
move: advice.played,
rank: advice.rank,
loss: advice.equityLoss,
played: true,
});
}
return rows;
}
/**
* Le niveau du classement d'un conseil : celui auquel le moteur a évalué le
* meilleur coup. C'est le niveau demandé (advice.ply), sauf quand gnubg
* s'arrête avant, parce qu'un seul coup a passé ses filtres : tout le
* classement reste alors au niveau inférieur.
*/
export function rankingPly(advice: MoveAdvice): Ply {
return advice.best.ply;
}
/** « 1 pli », « 2 plis » : gnubg compte les plis à partir de 0. */
export function pliesText(ply: Ply): string {
return `${ply} ${ply > 1 ? "plis" : "pli"}`;
}
+8 -6
View File
@@ -10,13 +10,15 @@
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/** Version du format d'un conseil enregistré. Un conseil d'une autre
* version est ignoré à la reprise (il peut être recalculé). */
export const ADVICE_VERSION = 1;
* version est ignoré à la reprise (il peut être recalculé). Version 2 : le
* classement des coups vient de l'analyse du coup joué, et non plus d'un
* second classement comme la commande « hint » de gnubg. */
export const ADVICE_VERSION = 2;
/**
* Le conseil du maître sur une décision du joueur : un coup ou une décision
@@ -29,7 +31,7 @@ export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
@@ -66,8 +68,8 @@ export interface MoveAdvice {
readonly best: AdvisedMove;
readonly playedFacts: MoveFacts;
readonly bestFacts: MoveFacts;
/** Les meilleurs coups du classement du moteur, du meilleur au moins
* bon (cinq au plus). */
/** Les meilleurs coups du classement de l'analyse, du meilleur au
* moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */
readonly ranking: readonly AdvisedMove[];
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
+16 -12
View File
@@ -80,17 +80,6 @@ export interface Engine {
ply: Ply,
): Promise<MoveAnalysis | null>;
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
* ply). */
rankMoves(
position: PositionId,
cube: EngineCube,
roll: Roll,
ply: Ply,
): Promise<EvaluatedMove[]>;
/** L'analyse d'une décision de videau, position vue du joueur au trait
* (celui qui double ou non ; take et pass sont la réponse de son
* adversaire). */
@@ -222,6 +211,12 @@ export interface MoveAnalysis {
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
/** Le classement de l'analyse, du meilleur au moins bon (dix coups au
* plus) : la liste de l'analyse de partie de gnubg, chaque coup au
* niveau où gnubg l'a évalué (ply). Les coups écartés par ses filtres
* gardent l'évaluation d'un niveau inférieur ; le meilleur coup et le
* coup joué y ont les évaluations de best et played. */
readonly ranking: readonly EvaluatedMove[];
}
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
@@ -281,6 +276,15 @@ export interface EngineMatchAfter {
readonly crawford: boolean;
}
/**
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
* fait que l'afficher.
*
* Glossaire : docs/glossaire/notation-des-coups.md
*/
export type MoveNotation = string;
/**
* Un coup légal complet.
*
@@ -288,7 +292,7 @@ export interface EngineMatchAfter {
*/
export interface Move {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly notation: MoveNotation;
/** Ses demi-coups, dans un ordre jouable. */
readonly halfMoves: readonly HalfMove[];
/** La position après le coup, vue du joueur qui vient de jouer. */
+61 -10
View File
@@ -12,7 +12,15 @@
import type { Advice } from "./advice.js";
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
import type {
BoardSide,
Engine,
EngineCube,
Level,
MoveNotation,
Ply,
ResultKind,
} from "./engine.js";
import {
appendEvent,
currentLine,
@@ -27,7 +35,14 @@ import {
withAnalysesOf,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import {
BAR,
formatHalfMoves,
type HalfMove,
OFF,
type PositionId,
START_POSITION,
} from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
@@ -186,8 +201,11 @@ export class Game {
}
/** Les demi-coups saisis forment un coup complet (le moteur le dit) : il
* n'attend plus que d'être validé (confirmMove) ou défait
* (undoLastHalfMove). */
* peut être validé (confirmMove) ou défait (undoLastHalfMove). La saisie
* n'est pas close pour autant : en sortie, un coup complet peut encore
* se prolonger en un autre coup légal (avec 6-1 et des pions sur 6 et 1,
* 6/off seul mène à la même position que 6/5/off, et 1/off reste
* possible) ; nextHalfMoves dit lesquels, selon le moteur. */
get moveComplete(): boolean {
return this.#pending.at(-1)?.complete === true;
}
@@ -273,6 +291,29 @@ export class Game {
return { side, halfMoves, boards, hits };
}
/**
* Le coup joué à l'événement eventId (un MovePlayed), dans la notation
* du moteur : celle du coup légal de gnubg qui mène à la même position
* (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan.
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
*/
async moveNotation(eventId: string): Promise<MoveNotation> {
const line = currentLine({ ...this.#journal, head: eventId });
const event = line.at(-1);
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
return "";
}
const before = replay(line.slice(0, -1));
const roll = rollToPlay(before);
const [check, legal] = await Promise.all([
this.#engine.checkHalfMoves(before.position, roll, event.halfMoves),
this.#engine.legalMoves(before.position, roll),
]);
const move = legal.find((m) => m.position === check.position);
return move?.notation ?? formatHalfMoves(event.halfMoves);
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
@@ -296,11 +337,14 @@ export class Game {
* déjà saisis (liste vide hors de son tour de jeu). Chaque candidat
* déplace un de ses pions de la valeur d'un dé (vers la sortie si le dé
* dépasse la case 1) ; seuls restent ceux que le moteur déclare menant à
* un coup légal. Aucune règle n'est jugée ici.
* un coup légal. Aucune règle n'est jugée ici : même après un coup
* complet, c'est le moteur qui dit si un dé reste à jouer. Seule
* exception : une fois tous les dés utilisés (diceLeft), rien n'est
* demandé, le moteur n'acceptant pas plus de demi-coups que de dés.
*/
async nextHalfMoves(): Promise<HalfMove[]> {
const state = this.state;
if (state.turn !== "player" || state.phase !== "moving" || this.moveComplete) {
if (state.turn !== "player" || state.phase !== "moving" || !this.#diceLeft(state)) {
return [];
}
const roll = rollToPlay(state);
@@ -370,13 +414,13 @@ export class Game {
/**
* Le joueur déplace un pion (un dé). Renvoie false si ce demi-coup ne
* mène à aucun coup légal (ou si le coup est déjà complet) : il est
* refusé et rien ne change. Le coup n'est pas joué pour autant : une fois
* complet (moveComplete), il attend d'être validé (confirmMove).
* mène à aucun coup légal : il est refusé et rien ne change. Le coup
* n'est pas joué pour autant : une fois complet (moveComplete), il attend
* d'être validé (confirmMove), ou prolongé si le moteur le permet.
*/
async playHalfMove(halfMove: HalfMove): Promise<boolean> {
const state = this.#expect("player", "moving");
if (this.moveComplete) {
if (!this.#diceLeft(state)) {
return false;
}
const check = await this.#engine.checkHalfMoves(state.position, rollToPlay(state), [
@@ -393,6 +437,13 @@ export class Game {
return true;
}
/** Il reste un dé à jouer : moins de demi-coups saisis que de dés
* (quatre pour un double). */
#diceLeft(state: GameState): boolean {
const roll = rollToPlay(state);
return this.#pending.length < (roll[0] === roll[1] ? 4 : 2);
}
/** Défait le dernier demi-coup saisi du coup en cours (rien s'il n'y en a
* pas) : il n'a jamais été dans le journal. */
undoLastHalfMove(): void {
+7 -3
View File
@@ -209,10 +209,14 @@ export class MovePractice {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
if (
this.#game.moveComplete &&
this.#expected.some((m) => m.position === this.#game.displayedPosition)
) {
return "right";
}
// Un coup complet peut encore se prolonger (en sortie) : il n'est
// refusé que s'il ne peut plus mener à un coup attendu.
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
+195
View File
@@ -0,0 +1,195 @@
// La position que décrit un prompt, étape 1f : ses identifiants de GNU
// Backgammon, son plateau décodé par le moteur, le videau et le match ; et
// son diagramme texte.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Rien n'est décodé ici : le moteur lit la position (board) et écrit le
// Match ID (encodeMatch). Le diagramme ne fait que recopier le plateau
// qu'il a décodé.
import type { Cube } from "../game/cube.js";
import type { Engine, Evaluation, MatchId } from "../game/engine.js";
import { currentLine, type GameJournal } from "../game/events.js";
import { engineCube, type SideBoards } from "../game/game.js";
import type { Match } from "../game/match.js";
import type { PositionId } from "../game/position.js";
import type { Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
import { type GameState, replay } from "../game/state.js";
/** L'adresse publique de l'application (ADR 0013 : les liens de position
* s'ouvrent sur elle). */
export const PUBLIC_SITE = "https://ustagammon.sans.pub/";
/** Une position telle que la décrit un prompt. */
export interface PromptPosition {
/** Position ID de gnubg, vue du camp au trait. */
readonly positionId: PositionId;
/** Match ID écrit par le moteur, du point de vue du camp au trait, sans
* dés. */
readonly matchId: MatchId;
readonly onRoll: Side;
/** Le plateau décodé par le moteur, chaque camp dans sa numérotation. */
readonly sides: SideBoards;
readonly cube: Cube;
/** null en partie libre. */
readonly match: Match | null;
/** Le jet à jouer, s'il est déjà tiré. */
readonly roll: Roll | null;
}
/** La position d'avant l'événement eventId du journal (la décision qu'on
* commente), lue par le moteur. */
export async function positionBefore(
engine: Engine,
journal: GameJournal,
eventId: string,
): Promise<PromptPosition> {
const line = currentLine({ ...journal, head: eventId });
return positionOf(engine, replay(line.slice(0, -1)));
}
/** La position actuelle d'une partie, ou la position de départ sans
* partie, lue par le moteur. */
export async function positionNow(
engine: Engine,
journal: GameJournal | null,
): Promise<PromptPosition> {
return positionOf(engine, replay(journal === null ? [] : currentLine(journal)));
}
async function positionOf(engine: Engine, state: GameState): Promise<PromptPosition> {
// Pendant le jet d'ouverture, la position de départ, vue du joueur.
const onRoll = state.turn ?? "player";
const cube = engineCube(state, onRoll);
const [board, matchId] = await Promise.all([
engine.board(state.position, cube),
engine.encodeMatch(cube),
]);
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
return {
positionId: state.position,
matchId,
onRoll,
sides,
cube: state.cube,
match: state.match,
roll: state.phase === "moving" ? state.roll : null,
};
}
/** L'évaluation par le moteur d'une position, pour le camp au trait avant
* de lancer les dés. */
export function evaluatePosition(
engine: Engine,
position: PromptPosition,
ply: number,
): Promise<Evaluation> {
const cube = engineCube({ cube: position.cube, match: position.match }, position.onRoll);
return engine.evaluate(position.positionId, cube, ply);
}
/** L'adresse publique de la position (#/position/<Position ID>/<Match ID>,
* ADR 0013, identifiants encodés avec encodeURIComponent). */
export function positionAddress(position: PromptPosition): string {
const id = encodeURIComponent(position.positionId);
return `${PUBLIC_SITE}#/position/${id}/${encodeURIComponent(position.matchId)}`;
}
/** Nombre de lignes de pions dessinées par moitié du plateau ; au-delà, la
* dernière ligne porte le nombre de pions. */
const ROWS = 5;
/**
* Le diagramme texte d'une position, vu du joueur : ses pions X, ceux de
* l'ordinateur O, dans la numérotation du joueur (sa case 1 est la plus
* proche de sa sortie, en bas à droite). Sous le dessin, la liste des pions
* case par case, la barre, les pions sortis et les comptes de points, tels
* que le moteur les donne.
*/
export function boardDiagram(sides: SideBoards): string {
// La case p du joueur est la case 25 - p de l'ordinateur.
const at = (point: number): { mark: string; count: number } => {
const mine = sides.player.points[point - 1] ?? 0;
const theirs = sides.computer.points[24 - point] ?? 0;
if (mine > 0) {
return { mark: "X", count: mine };
}
return theirs > 0 ? { mark: "O", count: theirs } : { mark: ".", count: 0 };
};
const cell = (text: string) => text.padStart(3, " ");
const half = (points: readonly number[]) =>
`${points.slice(0, 6).map(String).map(cell).join("")} |${points.slice(6).map(String).map(cell).join("")}`;
const row = (points: readonly number[], level: number) => {
const cells = points.map((p) => {
const { mark, count } = at(p);
if (count < level) {
return level === 1 ? cell(".") : cell("");
}
return cell(level === ROWS && count > ROWS ? String(count) : mark);
});
return `${cells.slice(0, 6).join("")} |${cells.slice(6).join("")}`.trimEnd();
};
const top = [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];
const bottom = [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
const lines = [half(top)];
for (let level = 1; level <= ROWS; level++) {
lines.push(row(top, level));
}
lines.push("");
for (let level = ROWS; level >= 1; level--) {
lines.push(row(bottom, level));
}
lines.push(half(bottom));
const list = (mark: "X" | "O") => {
const parts: string[] = [];
for (let point = 24; point >= 1; point--) {
const here = at(point);
if (here.mark === mark) {
parts.push(`${point} (${here.count})`);
}
}
return parts.length === 0 ? "aucun" : parts.join(", ");
};
return [
"```",
...lines,
"```",
"X : mes pions ; O : ceux de l'ordinateur. Les cases sont numérotées de mon point de vue : je joue de la case 24 vers la case 1, mon jan intérieur est en bas à droite (cases 1 à 6).",
`Mes pions (X), case (nombre) : ${list("X")}.`,
`Pions de l'ordinateur (O), dans ma numérotation : ${list("O")}.`,
`Sur la barre : moi ${sides.player.bar}, l'ordinateur ${sides.computer.bar}. Sortis : moi ${sides.player.off}, l'ordinateur ${sides.computer.off}.`,
`Compte de points : moi ${sides.player.pipCount}, l'ordinateur ${sides.computer.pipCount}.`,
].join("\n");
}
/** Le camp, à la première personne du joueur : « moi », « l'ordinateur ». */
export function sideName(side: Side): string {
return side === "player" ? "moi" : "l'ordinateur";
}
/** Le score : « partie libre » ou celui du match. */
export function scoreText(position: Pick<PromptPosition, "match">): string {
const match = position.match;
if (match === null) {
return "partie libre, sans match (règle Jacoby)";
}
const crawford = match.crawford ? ", partie Crawford" : "";
return `match en ${match.length} points, moi ${match.score.player}, l'ordinateur ${match.score.computer}${crawford}`;
}
/** Le videau : sa valeur et qui le possède. */
export function cubeText(position: Pick<PromptPosition, "cube" | "match">): string {
if (position.match?.crawford === true) {
return "aucun (partie Crawford : personne ne peut doubler)";
}
const { value, owner } = position.cube;
const where = owner === null ? "au centre" : owner === "player" ? "à moi" : "à l'ordinateur";
return `${value}, ${where}`;
}
+489
View File
@@ -0,0 +1,489 @@
// Les prompts pour une IA (étape 1f ; cadrage, « IA générative en V1 ») :
// chaque gabarit de docs/prompts/ rempli avec la position, décodée par le
// moteur, et les chiffres du moteur, écrits comme l'application les
// affiche. Le moteur décide, l'IA explique : le prompt lui demande de ne
// pas contredire ces chiffres.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les fonctions qui remplissent (decisionPrompt, summaryPrompt,
// conceptPrompt, exercisesPrompt) sont pures : elles reçoivent les
// analyses du moteur et la position déjà lue. Les fonctions asynchrones
// (summaryPosition, conceptExample, exercisesPosition) ne font que demander
// au moteur la position à décrire.
import {
type ErrorRate,
gameTurns,
levelOfErrorRate,
type ReviewedDecision,
summarizeAnalysis,
} from "../analysis/game-analysis.js";
import {
formatEquity,
formatPercent,
pliesText,
rankingPly,
rankingRows,
} from "../analysis/numbers.js";
import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
verdictOf,
} from "../game/advice.js";
import type { CubeAction, Engine, Evaluation, Ply } from "../game/engine.js";
import { currentLine, type GameJournal } from "../game/events.js";
import { type ComputerLevel, computerLevelOf } from "../game/level.js";
import { replay } from "../game/state.js";
import {
boardDiagram,
cubeText,
evaluatePosition,
type PromptPosition,
positionAddress,
positionBefore,
positionNow,
scoreText,
sideName,
} from "./position.js";
import { fillPrompt, type Prompt, type PromptTemplates } from "./templates.js";
/** La langue des réponses demandées à l'IA. */
export const PROMPT_LANGUAGE = "français";
/** Les noms des niveaux, les mêmes que l'écran « Nouvelle partie » et le
* bilan (vérifié par app/tests/prompts.test.ts). */
export const LEVEL_NAMES: Readonly<Record<ComputerLevel, string>> = {
beginner: "Débutant",
amateur: "Amateur",
intermediate: "Intermédiaire",
advanced: "Avancé",
expert: "Expert",
champion: "Champion",
};
/** Les décisions de videau, comme le bilan les nomme. */
export const CUBE_ACTION_WORDS: Readonly<Record<CubeAction, string>> = {
double: "doubler",
noDouble: "lancer sans doubler",
take: "prendre",
pass: "passer",
};
/** Les lignes des équités de videau, comme « Voir les chiffres du
* moteur ». */
export const CUBE_ROW_WORDS = {
noDouble: "Pas de double",
doubleTake: "Double, prend",
doublePass: "Double, passe",
} as const;
/** Les erreurs citées au plus dans un prompt : bilan, exercices. */
const MAX_ERRORS = 8;
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
/** « analyse à 2 plis ». */
export function plyText(ply: Ply): string {
return `analyse à ${pliesText(ply)}`;
}
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
* dit dans un prompt : en dessous, l'erreur par décision dit peu de chose. */
export const LEVEL_MIN_DECISIONS = 20;
/** Ce que le joueur dit de lui-même dans un prompt : son niveau, d'après
* son erreur par décision mesurée par GNU Backgammon, s'il est mesuré sur
* au moins LEVEL_MIN_DECISIONS décisions ; sinon, qu'il débute (pas encore
* d'historique des parties : étape 1h). Jamais le taux lui-même, qui n'est
* que dans le bilan. */
export function playerSentence(rate: Pick<ErrorRate, "decisions" | "level"> | null): string {
if (rate === null || rate.decisions < LEVEL_MIN_DECISIONS) {
return "Je débute au backgammon.";
}
return `Je suis un joueur de niveau ${LEVEL_NAMES[rate.level]}.`;
}
/** playerSentence, sur les décisions d'une partie. */
function playerOf(journal: GameJournal | null): string {
return playerSentence(journal === null ? null : summarizeAnalysis(journal).player.errorRate);
}
/** Les repères de la position, communs à tous les gabarits. */
function positionValues(position: PromptPosition): Record<string, string> {
return {
position_id: position.positionId,
match_id: position.matchId,
au_trait: sideName(position.onRoll),
lien: positionAddress(position),
diagramme: boardDiagram(position.sides),
score: scoreText(position),
videau: cubeText(position),
langue: PROMPT_LANGUAGE,
};
}
// ------------------------------------------------------ Une décision
/** Ce que demande le prompt d'une décision du joueur. */
export interface DecisionPromptData {
/** La partie : le niveau du joueur se lit sur ses décisions. */
readonly journal: GameJournal;
/** Le conseil du moteur sur la décision. */
readonly advice: Advice;
/** La position d'avant la décision (positionBefore). */
readonly position: PromptPosition;
}
/** Le prompt « Pourquoi ce coup est meilleur » ou « Expliquer cette
* décision de videau ». */
export function decisionPrompt(
templates: PromptTemplates,
phrases: MasterPhrases,
data: DecisionPromptData,
): Prompt {
const common = {
...positionValues(data.position),
joueur: playerOf(data.journal),
niveau_eval: plyText(data.advice.ply),
appreciation: masterWords(phrases, data.advice).word,
};
// Le niveau d'un classement de coups : celui de son meilleur coup, que
// le moteur a pu évaluer moins profondément que demandé.
return data.advice.kind === "move"
? fillPrompt(templates.move, {
...common,
niveau_eval: plyText(rankingPly(data.advice)),
...moveValues(data.advice),
})
: fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) });
}
function moveValues(advice: MoveAdvice): Record<string, string> {
const count = plural(advice.moveCount, "coup possible", "coups possibles");
const rank =
advice.moveCount <= 1
? "le seul coup possible"
: advice.rank === 1
? `le meilleur des ${count}`
: `le ${advice.rank}e choix sur ${count}`;
// Les trois meilleurs coups, et le vôtre s'il n'en est pas : les lignes
// de « Voir les chiffres du moteur », avec les mêmes chiffres.
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
const ranking = rows.map((row) => {
// Un coup écarté par les filtres du moteur garde l'évaluation d'un
// niveau inférieur à celui du classement : la ligne le dit.
const ply =
row.move.ply === rankingPly(advice) ? "" : `, évalué à ${pliesText(row.move.ply)}`;
const mine = row.played ? " (mon coup)" : "";
return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`;
});
return {
des: `${advice.roll[0]}-${advice.roll[1]}`,
coup_joue: advice.played.notation,
rang: rank,
classement: ranking.join("\n"),
};
}
function cubeValues(advice: CubeAdvice): Record<string, string> {
const answering = advice.action === "take" || advice.action === "pass";
// Les lignes de votre décision : doubler mène à l'une des deux lignes
// « Double », selon la réponse de l'ordinateur (comme la feuille).
const mine = {
noDouble: ["noDouble"],
double: ["doubleTake", "doublePass"],
take: ["doubleTake"],
pass: ["doublePass"],
}[advice.action];
const rows = (["noDouble", "doubleTake", "doublePass"] as const).map((key) => {
const note = mine.length === 1 && mine.includes(key) ? " (ma décision)" : "";
return `- ${CUBE_ROW_WORDS[key]}${note} : ${formatEquity(advice.equities[key])}`;
});
return {
decision: answering
? "prendre ou passer le double de l'ordinateur"
: "doubler ou lancer les dés sans doubler",
choix: CUBE_ACTION_WORDS[advice.action],
choix_moteur: CUBE_ACTION_WORDS[masterCubeAction(advice)],
qui_double: answering ? "l'ordinateur" : "moi",
equites: rows.join("\n"),
ecart: formatEquity(advice.equityLoss),
};
}
// ------------------------------------------------------------ Le bilan
/** Une imprécision ou une erreur du joueur, dans son tour. */
interface Mistake {
readonly turn: number;
readonly advice: Advice;
/** Le jet du tour, « 6-1 » (vide pour un tour sans jet). */
readonly roll: string;
}
/** Les imprécisions et erreurs du joueur sur la ligne courante, dans
* l'ordre de la partie. */
function mistakesOf(journal: GameJournal): Mistake[] {
const advice = journal.advice ?? {};
const mistakes: Mistake[] = [];
for (const turn of gameTurns(journal)) {
for (const event of turn.events) {
const a = advice[event.id];
if (a !== undefined && verdictOf(a.skill) !== "good") {
const roll = turn.roll === null ? "" : `${turn.roll[0]}-${turn.roll[1]}`;
mistakes.push({ turn: turn.number, advice: a, roll });
}
}
}
return mistakes;
}
/** Une ligne de la liste des erreurs : ce qui a été joué, ce que le moteur
* préférait, l'écart et le mot du maître. */
function mistakeLine(phrases: MasterPhrases, mistake: Mistake, where: string): string {
const a = mistake.advice;
const word = masterWords(phrases, a).word;
const cost = `écart ${formatEquity(a.equityLoss)} (${word})`;
if (a.kind === "move") {
return `- ${where}, ${mistake.roll} : j'ai joué ${a.played.notation}, GNU Backgammon préférait ${a.best.notation} ; ${cost}.`;
}
return `- ${where} : j'ai choisi de ${CUBE_ACTION_WORDS[a.action]}, GNU Backgammon aurait choisi de ${CUBE_ACTION_WORDS[masterCubeAction(a)]} ; ${cost}.`;
}
/** Le niveau de l'ordinateur d'une partie, par son nom. */
function computerLevelName(journal: GameJournal): string {
const level = replay(currentLine(journal)).level;
const name = computerLevelOf(level);
return name === null ? `GNU Backgammon, réglage ${level}` : LEVEL_NAMES[name];
}
/** Le résultat d'une partie finie, et le match après elle. */
function resultText(journal: GameJournal): string {
const line = currentLine(journal);
const state = replay(line);
const result = state.result;
if (result === null) {
return "partie pas encore finie";
}
const who = result.winner === "player" ? "j'ai gagné" : "l'ordinateur a gagné";
const kind = {
single: "une partie simple",
gammon: "un gammon",
backgammon: "un backgammon",
}[result.kind];
const dropped = line.some((e) => e.type === "CubeDropped")
? result.winner === "player"
? ", l'ordinateur ayant refusé mon double"
: ", après mon refus de son double"
: "";
const cube = state.cube.value > 1 ? `, videau à ${state.cube.value}` : "";
const text = `${who} ${kind}, ${plural(result.points, "point", "points")}${cube}${dropped}`;
const after = state.matchAfter;
if (state.match === null || after === null) {
return text;
}
const over = after.over ? ", match terminé" : "";
return `${text} ; match en ${state.match.length} points, score après la partie : moi ${after.score.player}, l'ordinateur ${after.score.computer}${over}`;
}
/** La position que montre le prompt du bilan : celle d'avant le coup à
* revoir, ou la position finale si le joueur n'a fait aucune erreur. */
export async function summaryPosition(
engine: Engine,
journal: GameJournal,
): Promise<PromptPosition> {
const toReview = summarizeAnalysis(journal).toReview;
return toReview === null
? positionNow(engine, journal)
: positionBefore(engine, journal, toReview.advice.eventId);
}
/** Ce que demande le prompt du bilan. */
export interface SummaryPromptData {
/** La partie finie, analysée (readyForSummary). */
readonly journal: GameJournal;
/** summaryPosition. */
readonly position: PromptPosition;
}
/** Le prompt « Bilan de ma partie ». */
export function summaryPrompt(
templates: PromptTemplates,
phrases: MasterPhrases,
data: SummaryPromptData,
): Prompt {
const summary = summarizeAnalysis(data.journal);
const { player, computer, counts } = summary;
const errors = counts.bad + counts.veryBad;
const veryBad =
counts.veryBad > 0
? ` (dont ${plural(counts.veryBad, "grosse erreur", "grosses erreurs")})`
: "";
const mistakes = mistakesOf(data.journal);
const listed = [...mistakes]
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss)
.slice(0, MAX_ERRORS)
.sort((a, b) => a.turn - b.turn)
.map((m) => mistakeLine(phrases, m, `Coup ${m.turn}`));
if (mistakes.length > listed.length) {
listed.push(`- et ${mistakes.length - listed.length} de moins coûteuses.`);
}
const toReview = summary.toReview;
return fillPrompt(templates.summary, {
...positionValues(data.position),
joueur: playerOf(data.journal),
niveau_eval: plyText(summaryPly(data.journal, toReview)),
ordinateur: computerLevelName(data.journal),
resultat: resultText(data.journal),
decomptes: `${plural(counts.good, "bon coup", "bons coups")}, ${plural(counts.doubtful, "imprécision", "imprécisions")}, ${plural(errors, "erreur", "erreurs")}${veryBad}`,
taux: formatEquity(-player.errorRate.perDecision),
decisions: plural(player.errorRate.decisions, "décision", "décisions"),
niveau_jeu: LEVEL_NAMES[player.errorRate.level],
taux_ordinateur: `${formatEquity(-computer.errorRate.perDecision)} sur ${plural(computer.errorRate.decisions, "décision", "décisions")}, soit le niveau ${LEVEL_NAMES[computer.errorRate.level]}`,
chance: formatEquity(player.luck.perRoll),
chance_mots: diceWords(phrases, player.luck.rating),
chance_ordinateur: formatEquity(computer.luck.perRoll),
erreurs:
listed.length === 0 ? "- aucune : j'ai joué comme GNU Backgammon." : listed.join("\n"),
position_titre:
toReview === null
? "Je n'ai fait ni imprécision ni erreur. Voici la position finale"
: `Le coup à revoir, ma plus grosse erreur (coup ${toReview.turn}) : la position avant ma décision`,
});
}
/** Le niveau des analyses du bilan : celui du coup à revoir, ou de
* l'analyse de partie (2 plis). */
function summaryPly(journal: GameJournal, toReview: ReviewedDecision | null): Ply {
return toReview?.advice.ply ?? journal.review?.ply ?? 2;
}
// ------------------------------------------------------- Un concept
/** La position d'exemple d'un concept : la partie en cours, ou la
* position de départ s'il n'y en a pas (ou si elle est finie), avec
* l'évaluation du moteur. */
export interface ConceptExample {
readonly position: PromptPosition;
readonly evaluation: Evaluation;
readonly ply: Ply;
readonly fromGame: boolean;
}
/** Lit la position d'exemple d'un concept et l'évalue, au niveau ply. */
export async function conceptExample(
engine: Engine,
journal: GameJournal | null,
ply: Ply,
): Promise<ConceptExample> {
const playing =
journal !== null && !["opening", "over"].includes(replay(currentLine(journal)).phase);
const position = await positionNow(engine, playing ? journal : null);
const evaluation = await evaluatePosition(engine, position, ply);
return { position, evaluation, ply, fromGame: playing };
}
/** Ce que demande le prompt d'un concept. */
export interface ConceptPromptData {
/** Le terme, son nom anglais (peut être vide) et sa phrase « En bref ». */
readonly term: string;
readonly english: string;
readonly brief: string;
/** La partie en cours, pour le niveau du joueur (null : aucune). */
readonly journal: GameJournal | null;
readonly example: ConceptExample;
}
/** Le prompt « Expliquer ce concept ». */
export function conceptPrompt(templates: PromptTemplates, data: ConceptPromptData): Prompt {
const { example } = data;
const english =
data.english !== "" && data.english.toLowerCase() !== data.term.toLowerCase()
? ` (en anglais : ${data.english})`
: "";
return fillPrompt(templates.concept, {
...positionValues(example.position),
joueur: playerOf(data.journal),
niveau_eval: plyText(example.ply),
concept: `« ${data.term} »${english}`,
definition: data.brief,
position_titre: example.fromGame
? "une position de ma partie en cours"
: "la position de départ",
evaluation: `chances de gagner ${formatPercent(example.evaluation.win)}, équité ${formatEquity(example.evaluation.equity)}`,
});
}
// ------------------------------------------------------ Des exercices
/** Une erreur dans une des parties. */
interface GameMistake extends Mistake {
/** Rang de la partie, à partir de 1 (la plus ancienne). */
readonly game: number;
readonly journal: GameJournal;
}
/** Les imprécisions et erreurs de plusieurs parties, de la plus coûteuse à
* la moins coûteuse. */
function costliest(journals: readonly GameJournal[]): GameMistake[] {
return journals
.flatMap((journal, i) => mistakesOf(journal).map((m) => ({ ...m, game: i + 1, journal })))
.sort((a, b) => a.advice.equityLoss - b.advice.equityLoss);
}
/** La position de la plus coûteuse des erreurs de ces parties (null s'il
* n'y en a aucune : pas d'exercice à proposer). */
export async function exercisesPosition(
engine: Engine,
journals: readonly GameJournal[],
): Promise<PromptPosition | null> {
const worst = costliest(journals)[0];
return worst === undefined ? null : positionBefore(engine, worst.journal, worst.advice.eventId);
}
/** Ce que demande le prompt des exercices. */
export interface ExercisesPromptData {
/** Les parties analysées, de la plus ancienne à la plus récente. */
readonly journals: readonly GameJournal[];
/** exercisesPosition (non null). */
readonly position: PromptPosition;
}
/** Le prompt « Créer des exercices » (progression, étape 1h). Erreur si
* les parties n'ont aucune imprécision ni erreur. */
export function exercisesPrompt(
templates: PromptTemplates,
phrases: MasterPhrases,
data: ExercisesPromptData,
): Prompt {
const mistakes = costliest(data.journals);
const worst = mistakes[0];
if (worst === undefined) {
throw new Error("exercices : aucune erreur dans ces parties");
}
// Le niveau, sur toutes les décisions de ces parties.
let total = 0;
let decisions = 0;
for (const journal of data.journals) {
const rate = summarizeAnalysis(journal).player.errorRate;
total += rate.total;
decisions += rate.decisions;
}
const level = levelOfErrorRate(decisions > 0 ? total / decisions : 0);
const listed = mistakes
.slice(0, MAX_ERRORS)
.map((m) => mistakeLine(phrases, m, `Partie ${m.game}, coup ${m.turn}`));
return fillPrompt(templates.exercises, {
...positionValues(data.position),
joueur: playerSentence({ decisions, level }),
niveau_eval: plyText(worst.advice.ply),
parties: String(data.journals.length),
erreurs: listed.join("\n"),
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
});
}
+73
View File
@@ -0,0 +1,73 @@
// Les gabarits des prompts pour une IA (étape 1f) : lecture de leur source
// (docs/prompts/, ADR 0008 : une seule source) et remplissage des repères.
// Fonctions pures, testées sous Node sur de vraies analyses du moteur
// (app/tests/prompts.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/**
* Un prompt : le texte que l'application prépare pour une IA, rempli avec
* la position et les chiffres du moteur, et que le joueur copie.
*
* Glossaire : docs/glossaire/prompt.md
*/
export type Prompt = string;
/** Les cinq gabarits du cadrage (« IA générative en V1 »). */
export type PromptKind = "move" | "cube" | "summary" | "concept" | "exercises";
export const PROMPT_KINDS: readonly PromptKind[] = [
"move",
"cube",
"summary",
"concept",
"exercises",
];
/** Le fichier de chaque gabarit, dans docs/prompts/. */
export const PROMPT_FILES: Readonly<Record<PromptKind, string>> = {
move: "coup.md",
cube: "videau.md",
summary: "bilan.md",
concept: "concept.md",
exercises: "exercices.md",
};
/** Les gabarits lus, par sorte. */
export type PromptTemplates = Readonly<Record<PromptKind, string>>;
/** Le gabarit d'un fichier de docs/prompts/ : le bloc de texte qui suit le
* titre « ## Gabarit ». Erreur s'il manque. */
export function parsePromptTemplate(markdown: string): string {
const lines = markdown.split("\n");
const heading = lines.findIndex((line) => line.trim() === "## Gabarit");
const start = lines.findIndex((line, i) => i > heading && line.trim() === "```text");
const end = lines.findIndex((line, i) => i > start && line.trim() === "```");
if (heading < 0 || start < 0 || end < 0) {
throw new Error("gabarit de prompt : bloc « ## Gabarit » absent");
}
return lines.slice(start + 1, end).join("\n");
}
/** Les repères d'un gabarit, dans l'ordre, sans doublon. */
export function placeholders(template: string): string[] {
return [...new Set([...template.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1] as string))];
}
/**
* Remplit les repères {nom} d'un gabarit. Erreur sur un repère inconnu ou
* une valeur vide : un prompt n'a jamais de trou.
*/
export function fillPrompt(template: string, values: Readonly<Record<string, string>>): Prompt {
return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => {
const value = values[name];
if (value === undefined) {
throw new Error(`gabarit de prompt : repère {${name}} inconnu`);
}
if (value.trim() === "") {
throw new Error(`gabarit de prompt : repère {${name}} vide`);
}
return value;
});
}
+2 -8
View File
@@ -182,6 +182,7 @@ export function createWasmEngine(run: RunCommand): Engine {
best: CliEvaluatedMove | null;
equity_loss: number;
skill: CliSkill;
ranking: CliEvaluatedMove[];
};
if (answer.rank === null || answer.played === null || answer.best === null) {
return null;
@@ -193,17 +194,10 @@ export function createWasmEngine(run: RunCommand): Engine {
best: toEvaluatedMove(answer.best),
equityLoss: answer.equity_loss,
skill: toSkill(answer.skill),
ranking: answer.ranking.map(toEvaluatedMove),
};
},
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as {
moves: CliEvaluatedMove[];
};
return answer.moves.map(toEvaluatedMove);
},
async analyseCube(
position: PositionId,
cube: EngineCube,
+10 -1
View File
@@ -5,7 +5,7 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { analyseDecision } from "./domain/analysis/analyse.js";
import { ANALYSIS_PLY, analyseDecision } from "./domain/analysis/analyse.js";
import { runAnalysis } from "./domain/analysis/game-analysis.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
@@ -17,6 +17,8 @@ import {
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { positionBefore } from "./domain/prompts/position.js";
import { conceptExample, summaryPosition } from "./domain/prompts/prompts.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
@@ -164,6 +166,13 @@ startApp({
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
// manquent, une à une, sur le même moteur à part.
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
// Les prompts pour une IA (étape 1f) : la position à décrire, lue par
// le moteur, et l'évaluation de la position d'exemple d'un concept.
prompts: {
before: (journal, eventId) => positionBefore(engine, journal, eventId),
summary: (journal) => summaryPosition(engine, journal),
concept: (journal) => conceptExample(engine, journal, ANALYSIS_PLY),
},
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
+5 -7
View File
@@ -26,8 +26,8 @@ export const ADVICE_TEXTS = {
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (ply: number) =>
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
moveNote: (plies: string) =>
`Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
@@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: st
pass: { file: "passer", term: "Passer" },
};
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
/** Un nombre à la française, signé, à trois décimales : « −0,123 » ; la
* même écriture que les prompts (domain/analysis/numbers.ts). */
export { formatEquity } from "../domain/analysis/numbers.js";
+26 -20
View File
@@ -7,6 +7,7 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { pliesText, rankingPly, rankingRows } from "../domain/analysis/numbers.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
@@ -17,8 +18,10 @@ import {
} from "../domain/game/advice.js";
import type { Game } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { showWithNotation } from "./notation.js";
import { routeHash } from "./router.js";
/** Préfixe des identifiants du plateau de la feuille. */
@@ -46,6 +49,8 @@ export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
/** La feuille, où la page du prompt prend la place du contenu (1f). */
readonly sheet: HTMLElement;
}
type Shown = "mine" | "best";
@@ -90,7 +95,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
clearSheet(sheet);
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
@@ -148,12 +153,21 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton();
closeButton(advice.eventId);
close.focus();
}
function closeButton(): void {
/** « Demander à une IA » (étape 1f), s'il y a un conseil, et
* « Fermer ». */
function closeButton(eventId: string | null = null): void {
const actions = element(sheet, "div", { className: "explain-actions" });
if (eventId !== null) {
askAiLink(
actions,
"explain-ask-ai",
routeHash({ screen: "explanation", event: eventId, ai: true }),
);
}
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
@@ -190,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const svg = sheetBoard(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
svg.setAttribute("aria-label", text);
showWithNotation(legend, text, advice.played.notation);
void player?.play(mine);
return;
}
@@ -220,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
showWithNotation(legend, text, notation);
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
void player?.play(shown === "mine" ? mine : best);
@@ -276,18 +291,8 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
if (advice.kind === "move") {
// Les écarts du classement se lisent par rapport à son premier
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
// l'analyse du coup joué.
const first = advice.ranking[0]?.equity ?? advice.best.equity;
const listed = advice.ranking.map((move, i) => ({
move,
rank: i + 1,
loss: move.equity - first,
}));
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
}
for (const { move, rank, loss } of listed) {
const mine = move.notation === advice.played.notation;
// l'analyse du coup joué (les mêmes lignes que le prompt).
for (const { move, rank, loss, played: mine } of rankingRows(advice)) {
row(
[
String(rank),
@@ -299,7 +304,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(advice.ply),
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
@@ -331,12 +336,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
return {
show,
sheet,
hide() {
opened++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
clearSheet(sheet);
}
},
};
+6 -1
View File
@@ -31,6 +31,7 @@ import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { formatHalfMoves } from "../domain/game/position.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
@@ -212,9 +213,11 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
text: REVIEW_TEXTS.replayFromHere,
});
replayButton.type = "button";
// L'emplacement de « Demander à une IA » (étape 1f).
// « Demander à une IA » (étape 1f) : le prompt de la décision du joueur
// dans ce coup, s'il y en a une.
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
const askAi = askAiLink(slot, "analysis-ask-ai", "#/");
const replayHint = element(viewerText, "p", {
className: "viewer-legend",
text: REVIEW_TEXTS.replayHint,
@@ -412,6 +415,8 @@ export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
theirs?.eventId ?? "",
current.phrases !== null,
].join("|");
askAi.hidden = main === null;
askAi.href = routeHash({ screen: "analysis", game: current.id, turn: selected, ai: true });
replayButton.hidden = replayPoint(current.journal, turn) === null;
replayHint.hidden = replayButton.hidden;
replayButton.onclick = () => {
+214 -19
View File
@@ -21,7 +21,13 @@
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
// changement. Les actions de jeu restent des boutons.
import { missingAnalyses, readyForSummary } from "../domain/analysis/game-analysis.js";
import {
gameTurns,
mainAdvice,
missingAnalyses,
readyForSummary,
summarizeAnalysis,
} from "../domain/analysis/game-analysis.js";
import {
type MasterPhrases,
masterLine,
@@ -30,9 +36,10 @@ import {
} from "../domain/analysis/phrases.js";
import type { Advice } from "../domain/game/advice.js";
import type { Cube } from "../domain/game/cube.js";
import type { MoveNotation } from "../domain/game/engine.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
import type { HalfMove } from "../domain/game/position.js";
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { otherSide, type Side } from "../domain/game/side.js";
@@ -41,6 +48,8 @@ import { ADVICE_TEXTS } from "./advice-texts.js";
import { type Analyse, createAdviceTracker } from "./advice-tracker.js";
import { createAdviceView, VERDICT_ICONS } from "./advice-view.js";
import { createAnalysisView } from "./analysis-view.js";
import { createPromptView, type PromptResult } from "./ask-ai.js";
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import {
boardModel,
moveTargets,
@@ -76,8 +85,17 @@ import { installPlatform, startInstaller } from "./install.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { replacingLink, startNavigation } from "./navigation.js";
import { createNewGameView } from "./new-game-view.js";
import { showWithNotation } from "./notation.js";
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
import { isHelpScreen, type Route, routeHash } from "./router.js";
import {
type AiRoute,
isAiRoute,
isHelpScreen,
type Route,
routeHash,
withoutAi,
} from "./router.js";
import {
chosenGame,
isFirstLaunch,
@@ -140,6 +158,8 @@ export interface AppOptions {
/** L'analyse d'une partie pour son bilan (étape 1e), une analyse à la
* fois, sur le même moteur à part. */
readonly analyseGame: RunAnalysis;
/** Les positions que décrivent les prompts pour une IA (étape 1f). */
readonly prompts: PromptsAccess;
}
/** La source des phrases du maître, copiée dans le site (make site). */
@@ -227,6 +247,7 @@ export function startApp(options: AppOptions): void {
adviceLine: byId<HTMLAnchorElement>("advice-line"),
explainVeil: byId("explain-veil"),
summaryVeil: byId("summary-veil"),
promptVeil: byId("prompt-veil"),
analysis: byId("analysis"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
@@ -424,6 +445,10 @@ export function startApp(options: AppOptions): void {
let stuck = false;
let busy = true;
let info: string = TEXTS.loading;
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
// « Notation des coups ».
let computerLine: ComputerTurnLine | null = null;
let engineFailed = false;
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
// la prochaine action du joueur ; null : rien à montrer.
@@ -627,7 +652,11 @@ export function startApp(options: AppOptions): void {
} else if (computerCouldNotMove(game)) {
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
} else {
ui.info.textContent = info;
showWithNotation(
ui.info,
info,
computerLine?.text === info ? computerLine.notation : null,
);
}
ui.prompt.textContent = panelShown
? ""
@@ -1018,7 +1047,8 @@ export function startApp(options: AppOptions): void {
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
await refresh();
await game.playComputerTurn();
info = describeComputerTurn(game);
computerLine = await describeComputerTurn(game);
info = computerLine.text;
save();
played = true;
}
@@ -1297,6 +1327,112 @@ export function startApp(options: AppOptions): void {
back: (link) => leavingLink(link, (route) => route.screen === "analysis"),
});
// ------------------------------------------- Demander à une IA (1f)
// La feuille du prompt, à l'adresse …/ia : à la place de la feuille du
// maître ou du bilan, ou dans sa propre feuille au-dessus de l'analyse et
// du glossaire. Une seule feuille à la fois.
const promptSource = createPromptSource({
access: options.prompts,
phrases: () => phrasesLoading,
glossaryEntry: loadGlossaryEntry,
});
const promptView = createPromptView({
veil: ui.promptVeil,
back: () => closePrompt(),
close: () => closePromptSheet(),
});
// L'adresse de la feuille ouverte (null : fermée).
let promptShown: string | null = null;
/** « ‹ Explication », Échap ou le retour du téléphone : retour à l'écran
* d'où on l'a ouverte. */
function closePrompt(): void {
const route = navigation.route;
promptView.hide();
promptShown = null;
navigation.leave(isAiRoute, isAiRoute(route) ? routeHash(withoutAi(route)) : "#/");
}
/** La croix et « Fermer » : la feuille se ferme. Celle du maître ou le
* bilan, dont elle avait pris la place, se ferme avec elle. */
function closePromptSheet(): void {
const route = navigation.route;
if (route.screen === "explanation" || route.screen === "summary") {
promptView.hide();
promptShown = null;
leaveSheet();
return;
}
closePrompt();
}
/** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */
function showPrompt(route: AiRoute): void {
promptShown = routeHash(route);
const host =
route.screen === "explanation"
? adviceView.sheet
: route.screen === "summary"
? summaryView.panel
: null;
promptView.show(promptOf(route), {
host,
back: ASK_AI_TEXTS.back[route.screen],
backHref: routeHash(withoutAi(route)),
});
}
/** Le journal de la partie id, pour son bilan ou son analyse : celui de
* la partie à l'écran, ou celui du stockage. */
async function journalOf(id: string): Promise<GameJournal | null> {
const live = liveGame(id);
if (live !== null) {
return live.journal;
}
return reviewed?.id === id ? reviewed.journal : options.games.journal(id);
}
/** Le prompt de l'écran sous la feuille (route sans « /ia »). */
async function promptOf(route: AiRoute): Promise<PromptResult> {
await options.engineReady;
switch (route.screen) {
case "explanation": {
const advice = game?.advice[route.event];
return game === null || advice === undefined
? { message: ASK_AI_TEXTS.noAdvice }
: promptSource.decision(game.journal, advice);
}
case "summary": {
const journal = await journalOf(route.game);
if (journal === null) {
return { message: ASK_AI_TEXTS.missingGame };
}
return readyForSummary(journal)
? promptSource.summary(journal)
: { message: ASK_AI_TEXTS.notReady };
}
case "analysis": {
const journal = await journalOf(route.game);
if (journal === null) {
return { message: ASK_AI_TEXTS.missingGame };
}
// Le coup que montre l'analyse : celui de l'adresse, ou le
// coup à revoir (analysis-view.ts).
const turns = gameTurns(journal);
const fallback = summarizeAnalysis(journal).toReview?.turn ?? 1;
const n = Math.min(Math.max(1, route.turn ?? fallback), Math.max(1, turns.length));
const turn = turns[n - 1];
const advice = turn === undefined ? null : mainAdvice(journal, turn);
return advice === null
? { message: ASK_AI_TEXTS.noAdvice }
: promptSource.decision(journal, advice);
}
case "glossary":
return promptSource.concept(route.term ?? "", game?.journal ?? null);
}
}
/** La partie à l'écran, si c'est la partie id. */
function liveGame(id: string): Game | null {
return game !== null && options.games.shownId() === id ? game : null;
@@ -1363,6 +1499,10 @@ export function startApp(options: AppOptions): void {
if ((route.screen !== "summary" && route.screen !== "analysis") || route.game !== id) {
return;
}
// La feuille du prompt attendait l'analyse : elle se met à jour.
if (isAiRoute(route) && promptView.isOpen && !promptView.ready) {
showPrompt(route);
}
const live = liveGame(id);
const shown =
missing || reviewed?.id !== id
@@ -1400,25 +1540,37 @@ export function startApp(options: AppOptions): void {
* La partie à l'écran vient de finir : son analyse part en
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
* qu'il est prêt (charte 1e), si le panneau est toujours là.
*
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
* signalé le 2026-11-11).
*/
function reviewFinishedGame(): void {
const id = options.games.shownId();
if (game === null || id === null || busy || game.state.phase !== "over") {
const finished = game;
if (finished === null || id === null || busy || finished.state.phase !== "over") {
return;
}
// L'analyse d'une autre partie, regardée, passe d'abord.
const running = reviewRunner.running;
if (running === null || running === id) {
review(id, game.journal);
review(id, finished.journal);
}
if (
navigation.route.screen === "game" &&
!panelDismissed &&
phrases !== null &&
readyForSummary(game.journal)
readyForSummary(finished.journal)
) {
queueMicrotask(() => {
if (navigation.route.screen === "game" && !panelDismissed) {
if (
game === finished &&
options.games.shownId() === id &&
navigation.route.screen === "game" &&
!panelDismissed
) {
navigation.go(routeHash({ screen: "summary", game: id }));
}
});
@@ -1711,6 +1863,8 @@ export function startApp(options: AppOptions): void {
let menuReturnFocus: HTMLElement | null = null;
// L'écran sous la feuille d'installation : le dernier écran affiché.
let underSheet: Route = { screen: "game" };
// L'événement de la feuille d'explication ouverte.
let explained: string | null = null;
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
function applyRoute(route: Route): void {
@@ -1790,8 +1944,14 @@ export function startApp(options: AppOptions): void {
if (route.screen === "explanation") {
closeTransient();
ui.menuVeil.hidden = true;
void adviceView.show(route.event);
// Ouvrir ou fermer la feuille du prompt ne redessine pas
// l'explication dessous.
if (ui.explainVeil.hidden || explained !== route.event) {
explained = route.event;
void adviceView.show(route.event);
}
} else {
explained = null;
adviceView.hide();
}
// Le bilan et l'analyse d'une partie : son journal, lu dans le
@@ -1845,6 +2005,18 @@ export function startApp(options: AppOptions): void {
shownPosition = null;
}
// La feuille du prompt pour une IA, au-dessus de son écran.
if (isAiRoute(route)) {
closeTransient();
ui.menuVeil.hidden = true;
if (!promptView.isOpen || promptShown !== routeHash(route)) {
showPrompt(route);
}
} else if (promptView.isOpen) {
promptView.hide();
promptShown = null;
}
// Une adresse inconnue : la partie, avec un message, à l'adresse de
// la partie.
if (route.screen === "unknown") {
@@ -1866,8 +2038,10 @@ export function startApp(options: AppOptions): void {
welcomed: () => saveSettings(welcomed()),
});
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
// celle de la partie (le retour du téléphone ne le rouvre pas).
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
// remplace son adresse par celle de la partie (le retour du téléphone
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
// jamais avec l'ancienne partie, finie, et son panneau de fin.
const newGameView = createNewGameView({
section: ui.newGameSection,
settings: () => settings,
@@ -1876,8 +2050,8 @@ export function startApp(options: AppOptions): void {
if (busy || game === null) {
return;
}
navigation.replace("#/");
newGame(chosenGame(settings));
navigation.replace("#/");
},
});
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
@@ -2282,6 +2456,15 @@ export function startApp(options: AppOptions): void {
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
return;
}
// La feuille du prompt ouverte : Échap la ferme, et rien d'autre ne
// répond (les flèches font défiler le texte).
if (promptView.isOpen) {
if (event.key === "Escape") {
event.preventDefault();
closePrompt();
}
return;
}
const onButton =
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
// L'écran d'analyse : les flèches passent d'un coup à l'autre.
@@ -2377,6 +2560,9 @@ export function startApp(options: AppOptions): void {
if (route.screen === "explanation") {
void adviceView.show(route.event);
}
if (isAiRoute(route)) {
showPrompt(route);
}
if (route.screen === "summary" || route.screen === "analysis") {
void openReview(route.game);
}
@@ -2500,21 +2686,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
return game.lastMoveSteps();
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). */
function describeComputerTurn(game: Game): string {
/** La ligne d'information après le tour de l'ordinateur, et le coup
* qu'elle cite (null s'il n'a pas joué de coup). */
interface ComputerTurnLine {
readonly text: string;
readonly notation: MoveNotation | null;
}
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
const last = game.events.at(-1);
if (last?.type === "CubeOffered") {
return "";
return { text: "", notation: null };
}
const played = game.events.findLast((e) => e.type === "MovePlayed");
if (played?.type !== "MovePlayed") {
return "";
return { text: "", notation: null };
}
const dice = rollBefore(game);
if (played.halfMoves.length === 0) {
return TEXTS.computerCannotMove(dice);
return { text: TEXTS.computerCannotMove(dice), notation: null };
}
return TEXTS.computerMoved(dice, formatHalfMoves(played.halfMoves));
const notation = await game.moveNotation(played.id);
return { text: TEXTS.computerMoved(dice, notation), notation };
}
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
+30
View File
@@ -0,0 +1,30 @@
// Les textes de l'interface de « Demander à une IA » (étape 1f). Les
// prompts eux-mêmes sont dans docs/prompts/ (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export const ASK_AI_TEXTS = {
button: "Demander à une IA",
title: "Demander à une IA",
lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.",
textLabel: "Le prompt, à relire avant de le copier",
copy: "Copier le prompt",
copied: "Copié",
notCopied:
"Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.",
preparing: "Préparation du prompt…",
close: "Fermer",
/** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */
back: {
explanation: "Explication",
summary: "Bilan",
analysis: "Analyse",
glossary: "Glossaire",
},
failed: "Le prompt n'a pas pu être préparé.",
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
missingGame: "Cette partie n'est plus sur ce téléphone.",
missingTerm: "Ce terme n'est pas dans le glossaire.",
} as const;
+310
View File
@@ -0,0 +1,310 @@
// « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») :
// le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt
// (…/ia, ADR 0013), où le texte se relit avant d'être copié.
//
// Une seule feuille à la fois (recette de 1f) : ouverte depuis la feuille du
// maître ou le bilan, la page du prompt prend la place de leur contenu, à la
// même hauteur, avec « ‹ Explication » (ou « ‹ Bilan ») pour y revenir ;
// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte
// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut,
// le texte est sélectionné dans la feuille et copié par le navigateur, ou
// laissé sélectionné pour être copié à la main. Le bouton la confirme
// (« ✓ Copié », 2 s).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// L'application n'appelle aucune IA et n'envoie rien : elle copie un texte.
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import { icon } from "./icons.js";
/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */
export const COPIED_MS = 2000;
/** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */
export type PromptResult = { readonly text: string } | { readonly message: string };
/** Classe de la page du prompt, posée dans une autre feuille. */
const PAGE_CLASS = "prompt-page";
/** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */
export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.id = id;
link.className = "button secondary ask-ai";
link.href = href;
link.append(icon("copy"), ASK_AI_TEXTS.button);
parent.append(link);
return link;
}
/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */
export function clearSheet(sheet: HTMLElement): void {
for (const child of [...sheet.children]) {
if (!child.classList.contains(PAGE_CLASS)) {
child.remove();
}
}
}
/** D'où l'on vient : la feuille dont la page prend la place (null : sa
* propre feuille) et le nom de l'écran, pour « ‹ Explication ». */
export interface PromptPlace {
readonly host: HTMLElement | null;
readonly back: string;
/** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */
readonly backHref: string;
}
export interface PromptViewOptions {
/** Le voile de sa propre feuille (index.html) ; elle est construite ici. */
readonly veil: HTMLElement;
/** « ‹ Explication » : revenir à l'écran d'où l'on vient. */
readonly back: () => void;
/** La croix et « Fermer » : fermer la feuille. */
readonly close: () => void;
}
export interface PromptView {
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
hide(): void;
readonly isOpen: boolean;
/** Le prompt est affiché (pas un message). */
readonly ready: boolean;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createPromptView(options: PromptViewOptions): PromptView {
const { veil } = options;
// Sa propre feuille, pour l'analyse et le glossaire.
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient.
const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS });
page.setAttribute("role", "dialog");
page.setAttribute("aria-modal", "true");
page.setAttribute("aria-labelledby", "prompt-title");
const head = element(page, "div", { className: "sheet-head prompt-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const back = element(head, "a", { id: "prompt-back", className: "prompt-back" });
back.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
options.back();
});
const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" });
closeIcon.type = "button";
closeIcon.append(icon("close"));
closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close);
closeIcon.onclick = () => options.close();
element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" });
text.tabIndex = 0;
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
const status = element(page, "p", { id: "prompt-status", className: "prompt-status" });
status.setAttribute("role", "status");
const actions = element(page, "div", { className: "prompt-actions" });
const done = element(actions, "button", {
id: "prompt-done",
className: "secondary",
text: ASK_AI_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
const copy = element(actions, "button", { id: "prompt-copy" });
copy.type = "button";
// Le libellé du bouton est annoncé quand il passe à « Copié ».
const copyLabel = document.createElement("span");
copyLabel.setAttribute("aria-live", "polite");
// Chaque ouverture a son numéro : un prompt qui arrive après une autre
// ouverture, ou après la fermeture, n'est pas montré.
let opened = 0;
let ready = false;
let open = false;
// La feuille d'où l'on vient, dont la page a pris la place.
let host: HTMLElement | null = null;
let copiedTimeout: ReturnType<typeof setTimeout> | null = null;
/** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */
function showCopied(copied: boolean): void {
if (copiedTimeout !== null) {
clearTimeout(copiedTimeout);
copiedTimeout = null;
}
copy.classList.toggle("copied", copied);
copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy;
copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel);
if (copied) {
copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS);
}
}
showCopied(false);
/** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */
function place(next: HTMLElement | null): void {
if (host !== next) {
host?.classList.remove("prompting");
host = next;
}
if (next === null) {
if (page.parentElement !== sheet) {
sheet.append(page);
}
veil.hidden = false;
return;
}
veil.hidden = true;
// La page couvre la feuille telle qu'elle s'affiche, défilée en haut.
next.scrollTop = 0;
next.classList.add("prompting");
if (page.parentElement !== next) {
next.append(page);
}
}
/** Le texte de la feuille, sélectionné : le repli quand le
* presse-papiers n'est pas accessible. */
function selectText(): void {
const selection = window.getSelection();
if (selection === null) {
return;
}
const range = document.createRange();
range.selectNodeContents(text);
selection.removeAllRanges();
selection.addRange(range);
}
async function copyText(): Promise<boolean> {
try {
await navigator.clipboard.writeText(text.textContent ?? "");
return true;
} catch {
// Hors contexte sûr (http sur le réseau local), ou refusé :
// repli ci-dessous.
}
selectText();
try {
return document.execCommand("copy");
} catch {
return false;
}
}
copy.onclick = async () => {
if (!ready) {
return;
}
const id = opened;
const copied = await copyText();
if (id !== opened) {
return;
}
if (copied) {
status.textContent = "";
showCopied(true);
} else {
selectText();
showCopied(false);
status.textContent = ASK_AI_TEXTS.notCopied;
}
};
return {
show(prompt, where) {
const id = ++opened;
const opening = !open || host !== where.host;
open = true;
back.textContent = `‹ ${where.back}`;
back.href = where.backHref;
place(where.host);
if (opening) {
ready = false;
text.textContent = "";
text.hidden = true;
copy.disabled = true;
showCopied(false);
status.textContent = ASK_AI_TEXTS.preparing;
delete page.dataset.ready;
back.focus();
}
prompt.then(
(result) => {
if (id !== opened) {
return;
}
if ("text" in result) {
ready = true;
text.textContent = result.text;
text.hidden = false;
copy.disabled = false;
status.textContent = "";
page.dataset.ready = "true";
} else {
ready = false;
text.hidden = true;
copy.disabled = true;
status.textContent = result.message;
delete page.dataset.ready;
}
},
() => {
if (id === opened) {
ready = false;
copy.disabled = true;
status.textContent = ASK_AI_TEXTS.failed;
}
},
);
},
hide() {
opened++;
ready = false;
if (!open) {
return;
}
open = false;
showCopied(false);
host?.classList.remove("prompting");
host = null;
veil.hidden = true;
sheet.append(page);
text.textContent = "";
delete page.dataset.ready;
},
get isOpen() {
return open;
},
get ready() {
return ready;
},
};
}
+37
View File
@@ -156,6 +156,43 @@ export function pointSlot(number: number): { top: boolean; column: number } {
return { top: true, column: number - 13 };
}
/**
* Le jan intérieur d'un camp : ses six dernières flèches avant la sortie,
* numérotées du point de vue du joueur (1 à 6 pour lui, 19 à 24 pour
* l'ordinateur), dans le sens de marche du camp. Pur dessin : le moteur
* reste seul juge des règles qui en dépendent (sortie, rentrée).
* Glossaire : docs/glossaire/jan-interieur.md
*/
export interface HomeBoard {
readonly side: Side;
readonly points: readonly number[];
}
/**
* Le jan extérieur d'un camp : les six flèches qui précèdent son jan
* intérieur (7 à 12 pour le joueur, 13 à 18 pour l'ordinateur), dans le sens
* de marche du camp.
* Glossaire : docs/glossaire/jan-exterieur.md
*/
export interface OuterBoard {
readonly side: Side;
readonly points: readonly number[];
}
/** Six flèches d'un camp, de sa flèche first à first - 5 dans sa propre
* numérotation, renumérotées pour le joueur. */
function sixPoints(side: Side, first: number): number[] {
return Array.from({ length: 6 }, (_, i) => (side === "player" ? first - i : 25 - (first - i)));
}
export function homeBoard(side: Side): HomeBoard {
return { side, points: sixPoints(side, 6) };
}
export function outerBoard(side: Side): OuterBoard {
return { side, points: sixPoints(side, 12) };
}
/** Le plateau de la position de départ, pour le dessiner avant que le
* moteur soit chargé (première ouverture) : chaque camp a 2 pions sur sa case
* 24, 5 sur la 13, 3 sur la 8 et 5 sur la 6 ; 167 points chacun. Une donnée,
+4 -10
View File
@@ -303,23 +303,17 @@ function drawPoint(
"data-point": number,
points: `${left},${base} ${left + g.pointWidth},${base} ${center},${tip}`,
});
// Numéros : sur le cadre en paysage, dans la bande centrale en
// portrait. Leur taille (12 px au moins à l'écran) dépend de l'échelle
// Numéros : sur le cadre, bandes haute et basse, en portrait comme en
// paysage. Leur taille (12 px au moins à l'écran) dépend de l'échelle
// du dessin : c'est une mesure du dessin, comme x et y.
const size = g.numberSize;
let y: number;
if (g.layout === "landscape") {
y = top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36;
} else {
y = top ? middle - g.die / 2 - 4 : middle + g.die / 2 + 4 + size * 0.72;
}
add(
svg,
"text",
{
class: g.layout === "landscape" ? "point-number on-frame" : "point-number",
class: "point-number",
x: center,
y,
y: top ? g.edge / 2 + size * 0.36 : g.height - g.edge / 2 + size * 0.36,
"font-size": size,
},
String(number),
+10 -5
View File
@@ -25,7 +25,7 @@ export interface Geometry {
readonly height: number;
/** Épaisseur du cadre sur les côtés. */
readonly frame: number;
/** Hauteur du cadre en haut et en bas : la bande des numéros en paysage. */
/** Hauteur du cadre en haut et en bas : la bande des numéros des flèches. */
readonly edge: number;
/** Colonne de commandes à gauche (0 en portrait). */
readonly left: number;
@@ -95,14 +95,19 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
arrowWidth: 2.5 / pxPerUnit,
};
if (layout === "portrait") {
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
// Les mesures du plateau d'origine : flèches de 44. Les bandes haute
// et basse du cadre portent les numéros des flèches, comme en
// paysage (12 px au moins à l'écran) : pions de 19 au plus. La bande ne
// dépasse pas 36 unités : sur une miniature (moins de 275 px de
// large), les numéros rapetissent plutôt que d'écraser les flèches.
const frame = 8;
const edge = Math.min(36, Math.max(24, numberSize + 8));
const tray = 48;
const bar = 40;
const width = 640;
const pointWidth = (width - 3 * frame - tray - bar) / 12;
const band = 84;
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - frame - band / 2) / 10);
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2) / 10);
const x0 = frame;
const barLeft = x0 + 6 * pointWidth;
return {
@@ -110,7 +115,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
width,
height,
frame,
edge: frame,
edge,
left: 0,
tray,
bar,
@@ -123,7 +128,7 @@ export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number)
rightHalf: barLeft + bar,
trayLeft: barLeft + bar + 6 * pointWidth + frame,
target,
numberSize,
numberSize: Math.min(numberSize, edge - 8),
bandFont: atLeast(13.5, pxPerUnit, 17),
chipFont: atLeast(13.5, pxPerUnit, 17),
...sizes,
+16 -3
View File
@@ -13,6 +13,7 @@
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import { askAiLink } from "./ask-ai.js";
import {
type GlossaryEntry,
type HelpFeature,
@@ -32,6 +33,7 @@ export const FEATURES = [
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/revoir-ma-partie.feature",
"docs/scenarios/demander-a-une-ia.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
@@ -124,7 +126,10 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
const terms = new Map<
string,
{ dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; askAi: HTMLAnchorElement }
>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
@@ -213,7 +218,14 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
// « Demander à une IA » (étape 1f), sur la fiche ouverte.
const askAi = askAiLink(
dd,
`glossary-ask-ai-${entry.name}`,
routeHash({ screen: "glossary", term: entry.name, ai: true }),
);
askAi.hidden = true;
terms.set(entry.name, { dt, card, entry, askAi });
}
search.addEventListener("input", () => {
let shown = 0;
@@ -307,7 +319,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, { dt, card }] of terms) {
for (const [name, { dt, card, askAi }] of terms) {
askAi.hidden = name !== term;
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
+3 -2
View File
@@ -94,14 +94,15 @@ export const HOME_TEXTS = {
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
pathLabel:
"Le sens du jeu : la flèche claire pour vos pions, la sombre pour ceux de l'ordinateur",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
: "Partie finie : il reste un pion de l'ordinateur dans votre jan intérieur, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
+1 -1
View File
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
advice: "Conseil du maître",
adviceDetail: "Une ligne après chacun de vos coups",
startGame: "Commencer la partie",
startMatch: (n: number) => `Commencer le match en ${n} points`,
startMatch: "Commencer le match",
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
+5 -4
View File
@@ -26,8 +26,9 @@ export interface Navigation {
replace(hash: string): void;
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
* nouvel onglet), remplace l'adresse par celle de la partie. */
leave(stay: (route: Route) => boolean): void;
* nouvel onglet), remplace l'adresse par fallback (celle de la partie,
* par défaut). */
leave(stay: (route: Route) => boolean, fallback?: string): void;
}
interface TrailState {
@@ -123,13 +124,13 @@ export function startNavigation(onChange: (route: Route) => void): Navigation {
},
go,
replace,
leave(stay) {
leave(stay, fallback = "#/") {
if (leaving !== null) {
return; // déjà en route
}
const steps = stepsBack(trail, stay);
if (steps === null) {
replace("#/");
replace(fallback);
return;
}
// Au cas où le navigateur ne reviendrait pas (historique
+1 -3
View File
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch
? MATCH_TEXTS.startMatch(s.matchLength)
: MATCH_TEXTS.startGame;
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
+34
View File
@@ -0,0 +1,34 @@
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
// veulent dire l'étoile, « off », « bar » et « (2) ».
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveNotation } from "../domain/game/engine.js";
import { routeHash } from "./router.js";
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
export const NOTATION_TERM = "notation-des-coups";
/**
* Remplace le contenu de parent par text, où la première occurrence de
* notation devient un lien vers la fiche « Notation des coups ». Sans
* notation dans le texte, le texte seul.
*/
export function showWithNotation(
parent: HTMLElement,
text: string,
notation: MoveNotation | null,
): void {
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
if (notation === null || at < 0) {
parent.textContent = text;
return;
}
const link = document.createElement("a");
link.className = "notation";
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
link.textContent = notation;
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
}
+132
View File
@@ -0,0 +1,132 @@
// Les prompts pour une IA tels que les demande l'interface (étape 1f) :
// les gabarits, lus dans la copie de docs/prompts/ faite par make site
// (ADR 0008), les phrases du maître, et les positions que le domaine lit
// avec le moteur (PromptsAccess, branché par main.ts). Le remplissage
// lui-même est dans le domaine (domain/prompts/prompts.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MasterPhrases } from "../domain/analysis/phrases.js";
import type { Advice } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { PromptPosition } from "../domain/prompts/position.js";
import {
type ConceptExample,
conceptPrompt,
decisionPrompt,
summaryPrompt,
} from "../domain/prompts/prompts.js";
import {
PROMPT_FILES,
PROMPT_KINDS,
type PromptTemplates,
parsePromptTemplate,
} from "../domain/prompts/templates.js";
import type { PromptResult } from "./ask-ai.js";
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import type { GlossaryEntry } from "./help-text.js";
/** La copie des gabarits dans le site (make site). */
const PROMPTS_DIR = "docs/prompts/";
/** Ce que les prompts demandent au domaine et au moteur : la position à
* décrire. */
export interface PromptsAccess {
/** La position d'avant une décision de la partie. */
readonly before: (journal: GameJournal, eventId: string) => Promise<PromptPosition>;
/** La position que montre le prompt du bilan (coup à revoir, ou
* position finale). */
readonly summary: (journal: GameJournal) => Promise<PromptPosition>;
/** La position d'exemple d'un concept et son évaluation. */
readonly concept: (journal: GameJournal | null) => Promise<ConceptExample>;
}
export interface PromptSourceOptions {
readonly access: PromptsAccess;
readonly phrases: () => Promise<MasterPhrases>;
/** Une fiche du glossaire, par son nom de fichier. */
readonly glossaryEntry: (file: string) => Promise<GlossaryEntry | null>;
}
export interface PromptSource {
/** Le prompt d'une décision du joueur (coup ou videau). */
decision(journal: GameJournal, advice: Advice): Promise<PromptResult>;
/** Le prompt du bilan d'une partie finie et analysée. */
summary(journal: GameJournal): Promise<PromptResult>;
/** Le prompt d'un terme du glossaire (nom de sa fiche, sans .md). */
concept(term: string, journal: GameJournal | null): Promise<PromptResult>;
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} : ${response.status}`);
}
return response.text();
}
export function createPromptSource(options: PromptSourceOptions): PromptSource {
let loading: Promise<PromptTemplates> | null = null;
/** Les gabarits, lus une fois (une nouvelle tentative après un
* échec). */
function templates(): Promise<PromptTemplates> {
loading ??= Promise.all(
PROMPT_KINDS.map(async (kind) =>
parsePromptTemplate(await fetchText(PROMPTS_DIR + PROMPT_FILES[kind])),
),
).then(
([move, cube, summary, concept, exercises]) => ({
move: move ?? "",
cube: cube ?? "",
summary: summary ?? "",
concept: concept ?? "",
exercises: exercises ?? "",
}),
(error: unknown) => {
loading = null;
throw error;
},
);
return loading;
}
return {
async decision(journal, advice) {
const [all, phrases, position] = await Promise.all([
templates(),
options.phrases(),
options.access.before(journal, advice.eventId),
]);
return { text: decisionPrompt(all, phrases, { journal, advice, position }) };
},
async summary(journal) {
const [all, phrases, position] = await Promise.all([
templates(),
options.phrases(),
options.access.summary(journal),
]);
return { text: summaryPrompt(all, phrases, { journal, position }) };
},
async concept(term, journal) {
const [all, entry, example] = await Promise.all([
templates(),
options.glossaryEntry(`${term}.md`).catch(() => null),
options.access.concept(journal),
]);
if (entry === null) {
return { message: ASK_AI_TEXTS.missingTerm };
}
return {
text: conceptPrompt(all, {
term: entry.term,
english: entry.english,
brief: entry.brief,
journal,
example,
}),
};
},
};
}
+6 -10
View File
@@ -10,16 +10,12 @@ import type { Roll } from "../domain/game/roll.js";
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
/** « 41 % ». */
export function percent(chance: number): string {
return `${Math.round(chance * 100)} %`;
}
/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */
export function signed(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
/** « 41 % » et « +0,021 » : la même écriture que les prompts
* (domain/analysis/numbers.ts). */
export {
formatEquity as signed,
formatPercent as percent,
} from "../domain/analysis/numbers.js";
export const REVIEW_TEXTS = {
// Bilan
+58 -8
View File
@@ -14,8 +14,9 @@ export type Route =
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md> ; son
* prompt pour une IA : #/glossaire/<fiche>/ia. */
| { readonly screen: "glossary"; readonly term: string | null; readonly ai?: boolean }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
@@ -27,19 +28,27 @@ export type Route =
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e). */
| { readonly screen: "summary"; readonly game: string }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e) ;
* son prompt pour une IA : #/parties/<identifiant>/bilan/ia (1f). */
| { readonly screen: "summary"; readonly game: string; readonly ai?: boolean }
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
* #/parties/<identifiant>/analyse. */
| { readonly screen: "analysis"; readonly game: string; readonly turn: number | null }
* #/parties/<identifiant>/analyse ; le prompt du coup pour une IA :
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
| {
readonly screen: "analysis";
readonly game: string;
readonly turn: number | null;
readonly ai?: boolean;
}
/** #/menu. */
| { readonly screen: "menu" }
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
| { readonly screen: "newGame" }
/** #/explication/<événement> : la feuille d'explication du maître sur
* une décision de la partie en cours (étape 1d). */
| { readonly screen: "explanation"; readonly event: string }
* une décision de la partie en cours (étape 1d) ; son prompt pour une
* IA : #/explication/<événement>/ia (1f). */
| { readonly screen: "explanation"; readonly event: string; readonly ai?: boolean }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
@@ -71,6 +80,11 @@ export function parseRoute(hash: string): Route {
if (rest.at(-1) === "") {
rest.pop();
}
// La feuille du prompt pour une IA (étape 1f) : « /ia » au bout de
// l'adresse de l'écran d'où on la demande.
if (rest.length > 1 && rest.at(-1) === "ia") {
return aiRoute(parseRoute(`#/${[head, ...rest.slice(0, -1)].join("/")}`)) ?? unknown;
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
@@ -113,6 +127,37 @@ export function parseRoute(hash: string): Route {
}
}
/** Les écrans qui ont une feuille de prompt pour une IA. */
export type AiRoute = Extract<
Route,
{ screen: "explanation" | "summary" | "analysis" | "glossary" }
>;
/** L'écran, avec sa feuille de prompt ouverte (null : écran sans prompt). */
function aiRoute(route: Route): AiRoute | null {
switch (route.screen) {
case "explanation":
case "summary":
case "analysis":
return route.ai === true ? null : { ...route, ai: true };
case "glossary":
return route.term === null || route.ai === true ? null : { ...route, ai: true };
default:
return null;
}
}
/** La feuille du prompt pour une IA est-elle ouverte ? */
export function isAiRoute(route: Route): route is AiRoute {
return "ai" in route && route.ai === true;
}
/** L'écran sous la feuille du prompt. */
export function withoutAi(route: AiRoute): AiRoute {
const { ai: _, ...below } = route;
return below;
}
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
function gameReviewRoute(rest: readonly string[]): Route | null {
const [game = "", part, turn, ...more] = rest;
@@ -179,6 +224,11 @@ function positionRoute(text: string): Route | null {
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
const hash = screenHash(route);
return isAiRoute(route) ? `${hash}/ia` : hash;
}
function screenHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
+12 -3
View File
@@ -28,6 +28,7 @@ import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
@@ -64,6 +65,8 @@ export interface SummaryView {
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
/** Le panneau, où la page du prompt prend la place du bilan (1f). */
readonly panel: HTMLElement;
}
function element<K extends keyof HTMLElementTagNameMap>(
@@ -141,7 +144,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
return;
}
drawn = key;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
@@ -299,9 +302,14 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
text: REVIEW_TEXTS.reviewMove,
});
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
// L'emplacement de « Demander à une IA » (étape 1f).
// « Demander à une IA » (étape 1f) : le prompt du bilan.
const slot = element(links, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
askAiLink(
slot,
"summary-ask-ai",
routeHash({ screen: "summary", game: data.id, ai: true }),
);
}
function actions(data: SummaryData): void {
@@ -350,7 +358,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
hide() {
if (!veil.hidden) {
veil.hidden = true;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
drawn = "";
}
@@ -358,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
get isOpen() {
return !veil.hidden;
},
panel,
};
}
+1 -1
View File
@@ -188,7 +188,7 @@ export const TEXTS = {
) =>
[
winner === null
? "Vous avez la main : vos pions ivoire, en bas."
? "Vous avez la main : vos pions blancs, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
+1 -1
View File
@@ -24,7 +24,7 @@ export interface TutorialScreen {
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu dessiné en pointillés. */
/** Explication : le sens du jeu, deux flèches circulaires. */
readonly path: boolean;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
+62 -45
View File
@@ -18,11 +18,14 @@ import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
homeBoard,
moveTargets,
moveTrace,
onlyHalfMove,
outerBoard,
type Place,
placeOf as placeOfChecker,
pointSlot,
} from "./board-model.js";
import {
type BoardDecor,
@@ -149,57 +152,71 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
/** Le sens du jeu : deux flèches circulaires, une claire pour les pions du
* joueur (du haut à droite vers la gauche, puis en bas jusqu'à son jan
* intérieur), une sombre pour ceux de l'ordinateur, en sens inverse et à
* l'intérieur de la première. Chacune a un liseré de la couleur opposée et
* finit par une pointe ; jamais en brique (la brique veut dire « à faire »).
* Dessinées sous les pions, qu'elles ne cachent jamais. pxPerUnit : pixels
* à l'écran par unité du dessin (trait de 5 px sur un liseré de 9 px,
* quelle que soit la taille du plateau). */
function drawDirections(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const px = (n: number) => n / pxPerUnit;
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
// Chaque flèche part du jan intérieur adverse (sa flèche 24), longe les
// deux jans extérieurs, tourne au bout du sien et finit dans son jan
// intérieur. Celle de l'ordinateur passe à l'intérieur de la claire.
const column = (point: number) => pointSlot(point).column;
const loops = [
{
side: "player",
from: g.height * 0.29,
to: g.height * 0.71,
left: center(column(outerBoard("player").points[0] ?? 12)) - g.pointWidth * 0.1,
},
{
side: "computer",
from: g.height * 0.62,
to: g.height * 0.38,
left: center(column(outerBoard("computer").points[0] ?? 13) + 1) + g.pointWidth * 0.5,
},
] as const;
const head = px(18);
const half = px(10);
for (const { side, from, to, left } of loops) {
const other = side === "player" ? "computer" : "player";
const start = center(column(homeBoard(other).points[5] ?? 1));
const end = center(column(homeBoard(side).points[4] ?? 2)) + g.pointWidth * 0.3;
const turn = Math.min(Math.abs(to - from) / 2, g.pointWidth * 0.9);
const down = to > from ? 1 : -1;
const d =
`M ${start} ${from} L ${left + turn} ${from} Q ${left} ${from} ${left} ${from + down * turn} ` +
`L ${left} ${to - down * turn} Q ${left} ${to} ${left + turn} ${to} L ${end - head / 2} ${to}`;
for (const [part, stroke] of [
["edge", px(9)],
["line", px(5)],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", `direction-${part} direction-${side}`);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
group.append(path);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
const tip = document.createElementNS(SVG_NS, "path");
tip.setAttribute("class", `direction-head direction-${side}`);
tip.setAttribute(
"d",
`M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`,
);
tip.setAttribute("stroke-width", String(px(2)));
group.append(tip);
}
// Sous les pions ; les zones à toucher restent devant tout.
svg.insertBefore(group, svg.querySelector(":scope > g.pieces"));
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
@@ -359,7 +376,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView {
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
+230
View File
@@ -0,0 +1,230 @@
// Saisie d'un coup de sortie demi-coup par demi-coup, sur le vrai moteur
// WebAssembly : tout ordre de saisie que le moteur accepte doit être accepté
// par la partie, même quand un début de coup forme déjà un coup complet.
//
// Défaut corrigé (2026-11-10) : avec 6-1 et des pions sur 6 et 1, 6/off
// mène à la même position que le coup légal « 6/5/off » ; la partie le
// tenait pour complet et refusait ensuite 1/off, alors que 1/off puis 6/off
// passait.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Engine } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, type HalfMove } from "../src/domain/game/position.js";
import { fixedDice, type Roll } from "../src/domain/game/roll.js";
import { loadNodeEngine } from "./support/node-engine.js";
// Le joueur, au trait, a un pion sur sa case 6 et un sur sa case 1 ;
// l'ordinateur un sur sa case 1 et un sur sa case 2. Le cas signalé par
// Pierre (#/position/BQAACAIAAAAAAA, « Jouer à partir d'ici »).
const SIX_AND_ONE = "BQAACAIAAAAAAA";
/**
* Position ID (format de gnubg) d'une fin de partie : les pions du joueur au
* trait sur ses cases 1 à 6 (player[i] : nombre de pions sur la case i + 1),
* l'ordinateur avec un pion sur sa case 1 et un sur sa case 2 ; les autres
* pions sont sortis. Outil de test seulement : chaque position produite est
* relue par le moteur, qui doit y retrouver ces pions.
*/
function bearOffPosition(player: readonly number[]): string {
const bits: number[] = [];
const side = (counts: readonly number[]) => {
for (let point = 0; point < 25; point++) {
for (let k = 0; k < (counts[point] ?? 0); k++) {
bits.push(1);
}
bits.push(0);
}
};
side([1, 1]); // l'adversaire d'abord, comme dans gnubg
side(player);
const bytes = new Uint8Array(10);
bits.forEach((bit, i) => {
if (bit === 1) {
bytes[i >> 3] = (bytes[i >> 3] ?? 0) | (1 << (i & 7));
}
});
return Buffer.from(bytes).toString("base64").slice(0, 14);
}
/** Une partie où le joueur, au trait dans position, vient de lancer roll. */
async function rolled(engine: Engine, position: string, roll: Roll): Promise<Game> {
const game = Game.start(engine, {
dice: fixedDice(roll),
setup: { position, firstTurn: "player" },
});
await game.roll();
return game;
}
/** Toutes les permutations distinctes des demi-coups. */
function orders(halfMoves: readonly HalfMove[]): HalfMove[][] {
if (halfMoves.length <= 1) {
return [[...halfMoves]];
}
const seen = new Set<string>();
const result: HalfMove[][] = [];
halfMoves.forEach((h, i) => {
const rest = [...halfMoves.slice(0, i), ...halfMoves.slice(i + 1)];
for (const tail of orders(rest)) {
const order = [h, ...tail];
const key = formatHalfMoves(order);
if (!seen.has(key)) {
seen.add(key);
result.push(order);
}
}
});
return result;
}
test("le cas signalé est bien celui que décrit le test", async () => {
const engine = await loadNodeEngine();
assert.equal(bearOffPosition([1, 0, 0, 0, 0, 1]), SIX_AND_ONE);
const board = await engine.board(SIX_AND_ONE, { value: 1, owner: "centered" });
assert.deepEqual(board.onRoll.points.slice(0, 6), [1, 0, 0, 0, 0, 1]);
});
test("6-1 en sortie : 6/off puis 1/off, comme 1/off puis 6/off", async () => {
const engine = await loadNodeEngine();
const allOff = (await engine.legalMoves(SIX_AND_ONE, [6, 1])).find(
(m) => m.notation === "6/off 1/off",
);
assert.ok(allOff, "le moteur propose 6/off 1/off");
// 6/off d'abord : la position est déjà celle d'un coup légal (6/5/off),
// mais le 1 reste jouable et le moteur l'accepte.
const six = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await six.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.deepEqual(await six.nextHalfMoves(), [{ from: 1, to: 0 }]);
assert.equal(await six.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(six.moveComplete, true);
assert.equal(six.displayedPosition, allOff.position);
assert.deepEqual(
six.diceToPlay.map((d) => d.used),
[true, true],
);
await six.confirmMove();
assert.equal(six.state.phase, "over");
// 1/off d'abord : pas encore complet, puis 6/off.
const one = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await one.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(one.moveComplete, false);
assert.equal(await one.playHalfMove({ from: 6, to: 0 }), true);
assert.equal(one.displayedPosition, allOff.position);
// Et le coup 6/off seul (6/5/off) se valide toujours tel quel.
const alone = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await alone.playHalfMove({ from: 6, to: 0 }), true);
await alone.confirmMove();
assert.equal(alone.state.turn, "computer");
});
test("tous les dés joués, le moteur n'est plus interrogé", async () => {
// Le moteur de la page refuse plus de quatre demi-coups (erreur de la
// ligne de commande) : la partie ne doit pas en demander un cinquième
// après un double, ni un troisième après un jet simple.
const real = await loadNodeEngine();
const engine: Engine = {
...real,
checkHalfMoves: (position, roll, halfMoves) => {
const dice = roll[0] === roll[1] ? 4 : 2;
if (halfMoves.length > dice) {
throw new Error(`${halfMoves.length} demi-coups pour ${dice} dés`);
}
return real.checkHalfMoves(position, roll, halfMoves);
},
};
const double = await rolled(engine, bearOffPosition([0, 0, 2, 2, 0, 0]), [2, 2]);
for (const h of [
{ from: 4, to: 2 },
{ from: 4, to: 2 },
{ from: 3, to: 1 },
{ from: 3, to: 1 },
]) {
assert.equal(await double.playHalfMove(h), true);
}
assert.equal(double.moveComplete, true);
assert.deepEqual(await double.nextHalfMoves(), []);
assert.equal(await double.playHalfMove({ from: 2, to: 0 }), false);
const simple = await rolled(engine, SIX_AND_ONE, [6, 1]);
assert.equal(await simple.playHalfMove({ from: 1, to: 0 }), true);
assert.equal(await simple.playHalfMove({ from: 6, to: 0 }), true);
assert.deepEqual(await simple.nextHalfMoves(), []);
});
test("en sortie, tout ordre accepté par le moteur l'est par la partie", async () => {
const engine = await loadNodeEngine();
// Toutes les fins de partie d'un à trois pions dans le jan intérieur,
// et quelques-unes à quatre (doubles), avec les 21 jets.
const positions: number[][] = [];
for (let a = 0; a < 6; a++) {
positions.push([a]);
for (let b = a; b < 6; b++) {
positions.push([a, b]);
for (let c = b; c < 6; c++) {
positions.push([a, b, c]);
}
}
}
positions.push([0, 0, 0, 0], [1, 2, 3, 4], [5, 5, 4, 4], [0, 2, 4, 5], [3, 3, 3, 3]);
const rolls: Roll[] = [];
for (let a = 1; a <= 6; a++) {
for (let b = 1; b <= a; b++) {
rolls.push([a, b]);
}
}
const failures: string[] = [];
let checked = 0;
for (const pips of positions) {
const counts = [0, 0, 0, 0, 0, 0];
for (const p of pips) {
counts[p] = (counts[p] ?? 0) + 1;
}
const position = bearOffPosition(counts);
for (const roll of rolls) {
const moves = await engine.legalMoves(position, roll);
const game = await rolled(engine, position, roll);
for (const move of moves) {
for (const order of orders(move.halfMoves)) {
// Le moteur doit accepter chaque début de cet ordre…
let accepted = true;
for (let i = 1; i <= order.length && accepted; i++) {
accepted = (await engine.checkHalfMoves(position, roll, order.slice(0, i)))
.valid;
}
if (!accepted) {
continue;
}
// … et alors la partie aussi, jusqu'au coup complet.
game.cancelHalfMoves();
checked++;
const label = `${position} ${roll.join("-")} ${formatHalfMoves(order)}`;
for (const h of order) {
const offered = (await game.nextHalfMoves()).some(
(n) => n.from === h.from && n.to === h.to,
);
if (!offered || !(await game.playHalfMove(h))) {
failures.push(`${label} : ${formatHalfMoves([h])} refusé`);
break;
}
}
if (game.pendingHalfMoves.length === order.length) {
if (!game.moveComplete || game.displayedPosition !== move.position) {
failures.push(`${label} : coup incomplet ou position différente`);
}
}
}
}
}
}
assert.ok(checked > 1000, `${checked} ordres vérifiés`);
assert.deepEqual(failures, []);
});
+29
View File
@@ -35,6 +35,35 @@ test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meil
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
);
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
// Le classement de l'analyse, comme gnubg de bureau
// (tests/reference/analysis/move-opening-31-error.json) : le meilleur
// coup et le coup joué, une erreur, réévalués à 2 plis ; les suivants
// restés à 1 pli, puis à 0, écartés par les filtres.
assert.deepEqual(analysis.ranking[0], analysis.best);
assert.deepEqual(analysis.ranking[1], analysis.played);
assert.deepEqual(
analysis.ranking.map((move) => move.ply),
[2, 2, 1, 1, 1, 0, 0, 0, 0, 0],
);
});
test("ouverture 3-1 jouée 8/5 6/5 : un seul coup passe le filtre, tout reste à 1 pli", async () => {
const engine = await loadNodeEngine();
const analysis = await engine.analyseMove(
START_POSITION,
centered,
[3, 1],
"4HPwATCwZ/ABMA",
2,
);
assert.ok(analysis !== null);
assert.equal(analysis.rank, 1);
assert.deepEqual(analysis.ranking[0], analysis.best);
assert.deepEqual(analysis.best, analysis.played);
assert.deepEqual(
analysis.ranking.slice(0, 5).map((move) => move.ply),
[1, 1, 1, 1, 1],
);
});
test("sans coup possible, il n'y a rien à analyser", async () => {
+27 -1
View File
@@ -30,7 +30,7 @@ import {
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
@@ -315,4 +315,30 @@ test("une partie complète analysée par le moteur, comme une partie d'avant 1d"
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
// Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a
// joué … », moveNotation) et dans l'analyse : la notation du moteur.
let compared = 0;
let rewritten = 0;
for (const move of moves) {
if (move.type !== "MovePlayed" || move.halfMoves.length === 0) {
continue;
}
const advice =
move.side === "computer"
? analysed.review?.computer[move.id]
: analysed.advice?.[move.id];
if (advice?.kind !== "move") {
continue;
}
const notation = await resumed.moveNotation(move.id);
assert.equal(notation, advice.played.notation, `coup ${move.id}`);
compared++;
if (notation !== formatHalfMoves(move.halfMoves)) {
rewritten++;
}
}
assert.ok(compared > 5, `${compared} coups comparés`);
// La course finit en sortie : au moins un coup s'écrit autrement que
// ses demi-coups (« 6/off » pour « 6/2 2/off »…).
assert.ok(rewritten > 0, "aucun coup réécrit par le moteur");
});
-1
View File
@@ -73,7 +73,6 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
};
},
analyseMove: async () => null,
rankMoves: async () => [],
analyseCube: async () => ({
analysed: false,
equities: null,
+29 -5
View File
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { test } from "node:test";
import { createSettingsStore } from "../src/storage/settings-store.js";
import { OPENING_BOARDS, pipLead } from "../src/ui/board-model.js";
import { homeBoard, OPENING_BOARDS, outerBoard, pipLead } from "../src/ui/board-model.js";
import { BOARD_HEIGHT, boardGeometry, MAX_STACK, stackY } from "../src/ui/geometry.js";
import { createHaptics, HAPTIC_PATTERNS } from "../src/ui/haptics.js";
import { ICON_PATHS } from "../src/ui/icons.js";
@@ -37,15 +37,32 @@ const LANDSCAPES: [number, number][] = [
[800, 308],
];
test("géométrie en portrait : les mesures du plateau d'origine", () => {
test("géométrie en portrait : les mesures du plateau d'origine, numéros sur le cadre", () => {
const g = boardGeometry("portrait", 640 / 520, 396 / 640);
assert.equal(g.width, 640);
assert.equal(g.pointWidth, 44);
assert.equal(g.radius, 20);
assert.equal(g.step, 42);
// Pions d'un peu moins de 19 unités (20 avant les numéros sur le cadre).
assert.ok(g.radius >= 18.5 && g.radius <= 19, `pions de ${g.radius}`);
assert.equal(g.step, 2 * g.radius + 2);
assert.equal(g.trayLeft + g.tray + g.frame, 640);
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
// Numéros d'au moins 12 px à l'écran, à 412 px de large, logés dans les
// bandes haute et basse du cadre (retours des premiers joueurs).
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
assert.ok(g.numberSize + 8 <= g.edge, `bande de ${g.edge} pour des numéros de ${g.numberSize}`);
});
test("géométrie en portrait : numéros d'au moins 12 px sur le cadre dès 275 px de large", () => {
for (const width of [275, 320, 358, 380, 396, 600]) {
const px = width / 640;
const g = boardGeometry("portrait", 640 / 520, px);
assert.ok(g.numberSize * px >= 12, `${width} px : numéros de ${g.numberSize * px} px`);
assert.ok(g.numberSize < g.edge, `${width} px : numéros hors du cadre`);
assert.ok(g.edge <= 36, `${width} px : bande de ${g.edge}`);
}
// Une miniature : la bande reste de 36 unités, les numéros y tiennent.
const thumbnail = boardGeometry("portrait", 640 / 520, 200 / 640);
assert.equal(thumbnail.edge, 36);
assert.ok(thumbnail.numberSize < thumbnail.edge);
});
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
@@ -93,6 +110,13 @@ test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comp
}
});
test("les jans de chaque camp, numérotés pour le joueur, dans le sens de marche", () => {
assert.deepEqual(homeBoard("player").points, [6, 5, 4, 3, 2, 1]);
assert.deepEqual(outerBoard("player").points, [12, 11, 10, 9, 8, 7]);
assert.deepEqual(homeBoard("computer").points, [19, 20, 21, 22, 23, 24]);
assert.deepEqual(outerBoard("computer").points, [13, 14, 15, 16, 17, 18]);
});
// -------------------------------------------------------------- Vibrations
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
+519
View File
@@ -0,0 +1,519 @@
// Les prompts pour une IA (étape 1f) : gabarits lus dans leur source
// (docs/prompts/), remplis avec de vraies analyses du moteur. Aucun repère
// vide ni inconnu, et les chiffres sont ceux que l'application affiche.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { formatEquity, rankingRows } from "../src/domain/analysis/numbers.js";
import { diceWords, masterWords } from "../src/domain/analysis/phrases.js";
import type { Advice } from "../src/domain/game/advice.js";
import type { Engine } from "../src/domain/game/engine.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { COMPUTER_LEVELS } from "../src/domain/game/level.js";
import { START_POSITION } from "../src/domain/game/position.js";
import {
boardDiagram,
PUBLIC_SITE,
positionAddress,
positionBefore,
positionNow,
} from "../src/domain/prompts/position.js";
import {
CUBE_ACTION_WORDS,
CUBE_ROW_WORDS,
conceptExample,
conceptPrompt,
decisionPrompt,
exercisesPosition,
exercisesPrompt,
LEVEL_MIN_DECISIONS,
LEVEL_NAMES,
playerSentence,
summaryPosition,
summaryPrompt,
} from "../src/domain/prompts/prompts.js";
import {
fillPrompt,
PROMPT_FILES,
PROMPT_KINDS,
type PromptTemplates,
parsePromptTemplate,
placeholders,
} from "../src/domain/prompts/templates.js";
import { ADVICE_TEXTS } from "../src/ui/advice-texts.js";
import { MATCH_TEXTS } from "../src/ui/match-texts.js";
import { REVIEW_TEXTS, signed } from "../src/ui/review-texts.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import {
adviceOn,
loadPhrases,
playerAnswers,
playerDoubles,
playerRollsWithoutDoubling,
playOpening,
} from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
const root = new URL("../../../", import.meta.url);
const phrases = loadPhrases();
/** Les gabarits, lus dans leur source. */
function loadTemplates(): PromptTemplates {
const read = (file: string) =>
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
return {
move: read(PROMPT_FILES.move),
cube: read(PROMPT_FILES.cube),
summary: read(PROMPT_FILES.summary),
concept: read(PROMPT_FILES.concept),
exercises: read(PROMPT_FILES.exercises),
};
}
const templates = loadTemplates();
/** Ce que chaque prompt doit contenir (cadrage, « IA générative en V1 »). */
function assertComplete(prompt: string, position: { positionId: string; matchId: string }): void {
for (const hole of ["{", "}", "inconnu", "undefined", "NaN", "null", " :"]) {
assert.ok(!prompt.includes(hole), `« ${hole} » dans le prompt :\n${prompt}`);
}
assert.ok(prompt.startsWith("Tu es un coach de backgammon"), "rôle absent");
assert.match(
prompt,
/^Tu es un coach de backgammon, bienveillant et précis\. (Je débute au backgammon\.|Je suis un joueur de niveau [A-ZÉ]\S+\.)\n/,
);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), "Position ID absent");
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), "Match ID absent");
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), "lien absent");
assert.ok(prompt.includes("X : mes pions"), "diagramme absent");
assert.match(prompt, /GNU Backgammon \(analyse à \d plis?\)/);
assert.ok(prompt.includes("ne les contredis pas"), "consigne absente");
assert.ok(prompt.includes("Réponds en français"), "langue absente");
}
test("les cinq gabarits se lisent dans leur source, avec les repères du cadrage", () => {
assert.equal(PROMPT_KINDS.length, 5);
for (const kind of PROMPT_KINDS) {
const names = placeholders(templates[kind]);
for (const name of [
"joueur",
"position_id",
"match_id",
"au_trait",
"lien",
"diagramme",
"score",
"videau",
"niveau_eval",
"langue",
]) {
assert.ok(names.includes(name), `{${name}} absent du gabarit ${kind}`);
}
assert.ok(templates[kind].includes("ne les contredis pas"), `consigne absente : ${kind}`);
}
assert.throws(() => parsePromptTemplate("# Sans gabarit\n"), /Gabarit/);
});
test("un repère inconnu ou vide est une erreur", () => {
assert.equal(fillPrompt("a {b} c", { b: "x" }), "a x c");
assert.throws(() => fillPrompt("a {b}", {}), /inconnu/);
assert.throws(() => fillPrompt("a {b}", { b: " " }), /vide/);
});
test("les mots du prompt sont ceux de l'écran", () => {
for (const level of COMPUTER_LEVELS) {
assert.equal(LEVEL_NAMES[level], MATCH_TEXTS.levels[level].name);
}
assert.deepEqual(CUBE_ACTION_WORDS, REVIEW_TEXTS.cubeActions);
assert.deepEqual(CUBE_ROW_WORDS, ADVICE_TEXTS.cubeRows);
});
test("le niveau du joueur ne se dit qu'à partir de 20 décisions, sans son taux", () => {
assert.equal(LEVEL_MIN_DECISIONS, 20);
assert.equal(playerSentence(null), "Je débute au backgammon.");
for (const level of COMPUTER_LEVELS) {
assert.equal(playerSentence({ decisions: 19, level }), "Je débute au backgammon.");
assert.equal(
playerSentence({ decisions: 20, level }),
`Je suis un joueur de niveau ${MATCH_TEXTS.levels[level].name}.`,
);
}
assert.equal(
playerSentence({ decisions: 120, level: "advanced" }),
"Je suis un joueur de niveau Avancé.",
);
});
test("le diagramme recopie le plateau décodé par le moteur", async () => {
const engine = await loadNodeEngine();
const start = await positionNow(engine, null);
assert.equal(start.positionId, START_POSITION);
const diagram = boardDiagram(start.sides);
assert.ok(diagram.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
assert.ok(
diagram.includes(
"Pions de l'ordinateur (O), dans ma numérotation : 19 (5), 17 (3), 12 (5), 1 (2).",
),
);
assert.ok(diagram.includes("Compte de points : moi 167, l'ordinateur 167."));
// La rangée du haut : 13 (X, 5 pions) à gauche, 24 (X, 2 pions) à
// droite ; 19 (O) juste après la barre.
const lines = diagram.split("\n");
assert.equal(lines[1], " 13 14 15 16 17 18 | 19 20 21 22 23 24");
assert.equal(lines[2], " X . . . O . | O . . . . X");
assert.equal(lines[3], " X O | O X");
assert.equal(lines[0], "```");
assert.equal(lines[lines.indexOf("```", 1) - 1], " 12 11 10 9 8 7 | 6 5 4 3 2 1");
});
test("le lien de la position est l'adresse publique de l'écran « position »", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "24/23 13/10");
const position = await positionBefore(engine, played.game.journal, played.eventId);
const address = positionAddress(position);
const hash = routeHash({
screen: "position",
position: position.positionId,
match: position.matchId,
});
assert.equal(address, `${PUBLIC_SITE}${hash}`);
assert.deepEqual(parseRoute(new URL(address).hash), {
screen: "position",
position: position.positionId,
match: position.matchId,
});
});
/** Les lignes du classement telles que la feuille du maître les affiche :
* rang, coup, écart. */
function shownRanking(advice: Advice): string[] {
if (advice.kind !== "move") {
return [];
}
return rankingRows(advice).map((row) => `${row.move.notation}|${formatEquity(row.loss)}`);
}
test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, mêmes chiffres", async () => {
const engine = await loadNodeEngine();
for (const [dice, move] of [
[[3, 1], "24/23 13/10"],
[[3, 1], "8/5 6/5"],
[[2, 1], "13/11 6/5"],
[[6, 5], "24/18 13/8"],
] as const) {
const played = await playOpening(engine, dice, move);
const advice = await adviceOn(engine, played);
assert.equal(advice.kind, "move");
if (advice.kind !== "move") {
continue;
}
// Le conseil, rattaché au journal comme dans l'application.
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
assert.equal(position.positionId, advice.position);
const prompt = decisionPrompt(templates, phrases, {
journal: played.game.journal,
advice,
position,
});
assertComplete(prompt, position);
assert.ok(prompt.includes(`Dés : ${dice[0]}-${dice[1]}.`), prompt);
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
assert.ok(prompt.includes(`« ${masterWords(phrases, advice).word} »`), prompt);
// Les trois premiers coups du moteur, et le vôtre, avec les écarts
// de « Voir les chiffres du moteur ».
const shown = shownRanking(advice);
for (const row of rankingRows(advice).filter((r) => r.rank <= 3 || r.played)) {
const line = `${row.rank}. ${row.move.notation}${row.played ? " (mon coup)" : ""} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}`;
assert.ok(prompt.includes(line), `ligne absente : ${line}\n${prompt}`);
assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`));
}
assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt);
assert.match(prompt, /\(analyse à [12] plis?\)/);
}
});
/** Une équité écrite (« −0,212 ») proche d'une équité de gnubg de bureau :
* à l'arrondi près, plus la tolérance des positions de référence (0,002 ;
* tests/reference/README.md), le moteur WebAssembly ne calculant pas au
* bit près comme gnubg de bureau (ADR 0005). */
function assertNear(text: string, value: number, message: string): void {
const written = Number(text.replace("−", "-").replace("+", "").replace(",", "."));
assert.ok(Math.abs(written - value) <= 0.0005 + 0.002, `${text} ≠ ${value}\n${message}`);
}
/** Le niveau du titre du classement, et chaque ligne du classement : coup,
* équité, écart, niveau propre (null : celui du titre). */
function rankingOfPrompt(prompt: string) {
const title = /^Classement de GNU Backgammon \(analyse à (\d) plis?\)/m.exec(prompt);
assert.ok(title, prompt);
const lines = [
...prompt.matchAll(
/^(\d+)\. (.+?)( \(mon coup\))? : équité (\S+), écart (\S+?)(?:, évalué à (\d) plis?)?$/gm,
),
].map((m) => ({
rank: Number(m[1]),
notation: m[2],
mine: m[3] !== undefined,
equity: m[4],
loss: m[5],
ply: m[6] === undefined ? null : Number(m[6]),
}));
return { ply: Number(title[1]), lines };
}
test("prompt d'un coup : le niveau du titre est celui des lignes, les équités celles de l'analyse", async () => {
const engine = await loadNodeEngine();
// gnubg de bureau, analyse de partie à 2 plis (tests/reference/analysis/).
const reference = (id: string) =>
JSON.parse(readFileSync(new URL(`tests/reference/analysis/${id}.json`, root), "utf8"))
.plies["2"];
for (const [dice, move, id] of [
// Erreur : gnubg réévalue à 2 plis le meilleur coup et le coup joué ;
// les autres restent au niveau où ses filtres les ont laissés.
[[3, 1], "24/23 13/10", "move-opening-31-error"],
// Meilleur coup : un seul coup passe le filtre de 1 pli, gnubg
// s'arrête là, tout le classement est à 1 pli.
[[3, 1], "8/5 6/5", "move-opening-31-best"],
[[6, 5], "13/8 13/7", "move-opening-65-error"],
] as const) {
const played = await playOpening(engine, dice, move);
const advice = await adviceOn(engine, played);
assert.ok(advice.kind === "move");
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
const prompt = decisionPrompt(templates, phrases, {
journal: played.game.journal,
advice,
position,
});
const { ply, lines } = rankingOfPrompt(prompt);
const expected = reference(id);
// Le titre dit le niveau du meilleur coup, celui que gnubg de bureau
// donne ; une ligne sans niveau propre est à ce niveau, une ligne
// avec un niveau propre, à un niveau inférieur.
assert.equal(ply, expected.ranking[0].ply, prompt);
assert.equal(ply, advice.best.ply);
assert.ok(lines.length >= 3, prompt);
for (const line of lines) {
const reference = expected.ranking.find(
(m: { move: string }) => m.move === line.notation,
);
assert.ok(reference, `${line.notation} absent du classement de gnubg`);
assert.equal(line.ply ?? ply, reference.ply, `${line.notation}\n${prompt}`);
assert.ok(line.ply === null || line.ply < ply, prompt);
assertNear(line.equity, reference.equity, `${line.notation}\n${prompt}`);
}
// Les équités et l'écart du meilleur coup et du vôtre sont ceux de
// l'analyse, que disent la feuille du maître et l'écran d'analyse.
const best = lines[0];
const mine = lines.find((line) => line.mine);
assert.ok(best && mine, prompt);
assert.equal(best.notation, advice.best.notation);
assert.equal(best.equity, formatEquity(advice.best.equity));
assert.equal(best.loss, formatEquity(0));
assert.equal(mine.notation, advice.played.notation);
assert.equal(mine.rank, advice.rank);
assert.equal(mine.equity, formatEquity(advice.played.equity));
assert.equal(mine.loss, formatEquity(advice.equityLoss));
assertNear(mine.loss, expected.equity_loss, prompt);
assert.ok(shownRanking(advice).includes(`${mine.notation}|${mine.loss}`));
}
});
test("prompt d'une décision de videau : les équités de la feuille du maître", async () => {
const engine = await loadNodeEngine();
const cases = [
{ play: playerDoubles, who: "moi", question: "doubler ou lancer" },
{ play: playerRollsWithoutDoubling, who: "moi", question: "doubler ou lancer" },
{ play: (e: Engine) => playerAnswers(e, true), who: "l'ordinateur", question: "prendre" },
{ play: (e: Engine) => playerAnswers(e, false), who: "l'ordinateur", question: "prendre" },
];
for (const { play, who, question } of cases) {
const played = await play(engine);
const advice = await adviceOn(engine, played);
assert.equal(advice.kind, "cube");
if (advice.kind !== "cube") {
continue;
}
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
assert.equal(position.positionId, advice.position);
const prompt = decisionPrompt(templates, phrases, {
journal: played.game.journal,
advice,
position,
});
assertComplete(prompt, position);
assert.ok(prompt.includes(`Il fallait choisir : ${question}`), prompt);
assert.ok(prompt.includes(`(${who}), pour un videau de 1`), prompt);
assert.ok(prompt.includes(`J'ai choisi de ${CUBE_ACTION_WORDS[advice.action]},`), prompt);
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
const value = formatEquity(advice.equities[key]);
assert.match(
prompt,
new RegExp(
`- ${ADVICE_TEXTS.cubeRows[key]}( \\(ma décision\\))? : ${value.replace("+", "\\+")}\\n`,
),
);
}
assert.ok(prompt.includes(`Écart de ma décision : ${formatEquity(advice.equityLoss)}.`));
}
});
/** Toutes les analyses qui manquent, une à une, comme l'application en
* arrière-plan. */
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
/** Une course courte jouée jusqu'au bout et analysée (le joueur prend le
* choice-ième coup légal). */
async function analysedRace(engine: Engine, seed: number, choice: number): Promise<GameJournal> {
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "intermediate",
dice: seededDice(seed),
});
await playToTheEnd(game, engine, choice);
return analyseAll(engine, game.journal);
}
test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreurs", async () => {
const engine = await loadNodeEngine();
const journal = await analysedRace(engine, 3, 3);
const summary = summarizeAnalysis(journal);
assert.ok(summary.toReview !== null, "la partie devrait avoir une erreur");
const position = await summaryPosition(engine, journal);
const prompt = summaryPrompt(templates, phrases, { journal, position });
assertComplete(prompt, position);
const { player, computer, counts } = summary;
// Les chiffres de « Voir les chiffres du moteur » de l'analyse, et les
// trois cases du bilan.
assert.ok(
prompt.includes(
`Mon erreur par décision : ${signed(-player.errorRate.perDecision)} sur ${player.errorRate.decisions} décision`,
),
);
assert.ok(
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name} ;`),
);
assert.ok(
prompt.includes(`celle de l'ordinateur : ${signed(-computer.errorRate.perDecision)} sur`),
);
assert.ok(
prompt.includes(
`La chance de mes dés par tour : ${signed(player.luck.perRoll)} (${diceWords(phrases, player.luck.rating)})`,
),
);
assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(computer.luck.perRoll)}.`));
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
assert.ok(prompt.includes(`, ${counts.bad + counts.veryBad} erreur`), prompt);
assert.ok(prompt.includes("Résultat : "), prompt);
assert.ok(prompt.includes("contre l'ordinateur (niveau Intermédiaire)"), prompt);
// Le coup à revoir : sa position, et sa ligne avec son écart.
const worst = summary.toReview.advice;
assert.equal(position.positionId, worst.position);
assert.ok(
prompt.includes(`Le coup à revoir, ma plus grosse erreur (coup ${summary.toReview.turn})`),
);
assert.ok(
prompt.includes(
`écart ${formatEquity(worst.equityLoss)} (${masterWords(phrases, worst).word})`,
),
prompt,
);
});
test("prompt du bilan sans erreur : la position finale", async () => {
const engine = await loadNodeEngine();
// Le joueur au trait : un pion sur 6, un sur 1 ; il joue 6/off 1/off et
// gagne (scénario « Revoir ma partie »).
const game = Game.start(engine, {
setup: { position: "BQAACAIAAAAAAA", firstTurn: "player" },
dice: () => 6,
});
await game.roll();
const moves = await engine.legalMoves(game.state.position, game.state.roll ?? [6, 6]);
for (const halfMove of moves[0]?.halfMoves ?? []) {
await game.playHalfMove(halfMove);
}
await game.confirmMove();
assert.equal(game.state.phase, "over");
const journal = await analyseAll(engine, game.journal);
const position = await summaryPosition(engine, journal);
const prompt = summaryPrompt(templates, phrases, { journal, position });
assertComplete(prompt, position);
assert.ok(prompt.includes("- aucune : j'ai joué comme GNU Backgammon."), prompt);
assert.ok(prompt.includes("Voici la position finale"), prompt);
assert.ok(prompt.includes("j'ai gagné une partie simple, 1 point"), prompt);
});
test("prompt d'un concept : la position de départ, ou celle de la partie en cours", async () => {
const engine = await loadNodeEngine();
const alone = await conceptExample(engine, null, 2);
const prompt = conceptPrompt(templates, {
term: "Videau",
english: "Cube",
brief: "le dé des enchères.",
journal: null,
example: alone,
});
assertComplete(prompt, alone.position);
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)."), prompt);
assert.ok(prompt.includes("voici la position de départ"), prompt);
assert.ok(prompt.includes("précis. Je débute au backgammon.\n"), prompt);
assert.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/);
const played = await playOpening(engine, [3, 1], "24/23 13/10");
played.game.recordAdvice(await adviceOn(engine, played));
const during = await conceptExample(engine, played.game.journal, 2);
assert.ok(during.fromGame);
const second = conceptPrompt(templates, {
term: "Blot",
english: "Blot",
brief: "un pion seul sur une case.",
journal: played.game.journal,
example: during,
});
assertComplete(second, during.position);
assert.ok(second.includes("le concept « Blot »."), second);
assert.ok(second.includes("une position de ma partie en cours"), second);
// Une seule décision analysée : pas de niveau, pas de taux d'erreur.
assert.ok(second.includes("Je débute au backgammon."), second);
assert.ok(!second.includes("erreur par décision"), second);
});
test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => {
const engine = await loadNodeEngine();
const journals = [await analysedRace(engine, 3, 3), await analysedRace(engine, 7, 2)];
const position = await exercisesPosition(engine, journals);
assert.ok(position !== null);
const prompt = exercisesPrompt(templates, phrases, { journals, position });
assertComplete(prompt, position);
assert.ok(prompt.includes("Voici mes 2 dernières parties"), prompt);
assert.match(prompt, /- Partie [12], coup \d+/);
assert.ok(prompt.includes("La plus coûteuse (partie"), prompt);
assert.ok(prompt.includes("cinq exercices"), prompt);
assert.equal(await exercisesPosition(engine, []), null);
});
+29
View File
@@ -8,12 +8,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
isAiRoute,
isHelpScreen,
parseRoute,
type Route,
routeHash,
slug,
stepsBack,
withoutAi,
} from "../src/ui/router.js";
const START = "4HPwATDgc/ABMA";
@@ -41,6 +43,18 @@ const TABLE: [string, Route][] = [
["#/menu", { screen: "menu" }],
["#/nouvelle-partie", { screen: "newGame" }],
["#/explication/12", { screen: "explanation", event: "12" }],
// La feuille du prompt pour une IA (amendement du 2026-11-10, 1f).
["#/explication/12/ia", { screen: "explanation", event: "12", ai: true }],
["#/glossaire/videau/ia", { screen: "glossary", term: "videau", ai: true }],
["#/parties/mh2k3x-a1b2c3/bilan/ia", { screen: "summary", game: "mh2k3x-a1b2c3", ai: true }],
[
"#/parties/mh2k3x-a1b2c3/analyse/7/ia",
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7, ai: true },
],
[
"#/parties/mh2k3x-a1b2c3/analyse/ia",
{ screen: "analysis", game: "mh2k3x-a1b2c3", turn: null, ai: true },
],
[
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
{ screen: "position", position: START, match: MATCH },
@@ -55,6 +69,17 @@ test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversemen
}
});
test("la feuille du prompt pour une IA se pose sur son écran, et s'en retire", () => {
const route = parseRoute("#/parties/ab/analyse/3/ia");
assert.ok(isAiRoute(route));
if (isAiRoute(route)) {
assert.equal(routeHash(withoutAi(route)), "#/parties/ab/analyse/3");
}
assert.ok(!isAiRoute(parseRoute("#/parties/ab/analyse/3")));
// Un terme nommé « ia » reste un terme.
assert.deepEqual(parseRoute("#/glossaire/ia"), { screen: "glossary", term: "ia" });
});
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
assert.deepEqual(parseRoute(""), { screen: "game" });
assert.deepEqual(parseRoute("#"), { screen: "game" });
@@ -84,6 +109,10 @@ test("une adresse inconnue est reconnue comme telle", () => {
"#/parties/ab/analyse/x",
"#/parties/ab/analyse/3/plus",
"#/parties/AB/bilan",
"#/aide/blot/ia",
"#/menu/ia",
"#/parties/ab/ia",
"#/explication/12/ia/ia",
]) {
assert.equal(parseRoute(hash).screen, "unknown", hash);
}
+230
View File
@@ -0,0 +1,230 @@
// Étapes du scénario docs/scenarios/demander-a-une-ia.feature (ADR 0008) :
// les décisions se jouent par le domaine sur le vrai moteur, leur analyse
// par le domaine, et les prompts sont remplis par le domaine
// (domain/prompts/) avec les gabarits de docs/prompts/, comme dans
// l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { formatEquity, rankingRows } from "../../src/domain/analysis/numbers.js";
import { diceWords } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import {
type PromptPosition,
PUBLIC_SITE,
positionBefore,
} from "../../src/domain/prompts/position.js";
import {
conceptExample,
conceptPrompt,
decisionPrompt,
summaryPosition,
summaryPrompt,
} from "../../src/domain/prompts/prompts.js";
import {
PROMPT_FILES,
type PromptTemplates,
parsePromptTemplate,
} from "../../src/domain/prompts/templates.js";
import { parseGlossaryEntry } from "../../src/ui/help-text.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { loadPhrases, playOpening } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(60_000);
const root = new URL("../../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
const phrases = loadPhrases();
const templates: PromptTemplates = {
move: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.move}`)),
cube: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.cube}`)),
summary: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.summary}`)),
concept: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.concept}`)),
exercises: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.exercises}`)),
};
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2. */
const BEAROFF = "BQAACAIAAAAAAA";
let engine: Engine;
let game: Game | null;
let journal: GameJournal;
let advice: Advice;
let position: PromptPosition;
let prompt: string;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Given(/^vous avez joué (.+) avec un (\d)-(\d) d'ouverture$/, async (move, a, b) => {
const played = await playOpening(engine, [Number(a), Number(b)], move);
const analysed = await analyseDecision(engine, played.game.journal, played.eventId);
assert.ok(analysed !== null);
// Le conseil rattaché au journal, comme dans l'application.
played.game.recordAdvice(analysed);
game = played.game;
journal = played.game.journal;
advice = analysed;
});
When("vous demandez à une IA d'expliquer ce coup", async () => {
position = await positionBefore(engine, journal, advice.eventId);
prompt = decisionPrompt(templates, phrases, { journal, advice, position });
});
Then("la question donne la position, avec son Position ID, son Match ID et son lien", () => {
assert.equal(position.positionId, advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), prompt);
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), prompt);
assert.ok(prompt.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
});
Then(
"elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication",
() => {
assert.equal(advice.kind, "move");
if (advice.kind !== "move") {
return;
}
// Les lignes de « Voir les chiffres du moteur » : coup et écart.
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
assert.ok(rows.length >= 3);
for (const row of rows) {
assert.ok(
prompt.includes(`${row.rank}. ${row.move.notation}`) &&
prompt.includes(`écart ${formatEquity(row.loss)}`),
`${row.move.notation} absent :\n${prompt}`,
);
}
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
},
);
Then("elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français", () => {
assert.ok(prompt.includes("ne les recalcule pas et ne les contredis pas"), prompt);
assert.ok(prompt.includes("Réponds en français"), prompt);
assert.ok(!/[{}]/.test(prompt.replace(/```/g, "")), prompt);
});
/** Toutes les analyses qui manquent, une à une, comme l'application en
* arrière-plan. */
async function analyseAll(start: GameJournal): Promise<GameJournal> {
let current = start;
for (let i = 0; i < 100; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
Given(
/^une partie finie où vous avez joué (.+) avec (\d)-(\d), puis passé le double de l'ordinateur$/,
async (move: string, a: string, b: string) => {
const played = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice([Number(a), Number(b)]),
});
await played.roll();
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await played.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await played.confirmMove();
await played.playComputerTurn();
assert.equal(played.state.phase, "doubled", "l'ordinateur ne double pas");
await played.drop();
assert.equal(played.state.phase, "over");
journal = await analyseAll(played.journal);
},
);
When("vous demandez à une IA le bilan de votre partie", async () => {
position = await summaryPosition(engine, journal);
prompt = summaryPrompt(templates, phrases, { journal, position });
});
Then("la question donne vos décisions, votre niveau et la chance des dés, comme le bilan", () => {
const summary = summarizeAnalysis(journal);
const { counts, player } = summary;
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
assert.ok(prompt.includes(`${counts.bad + counts.veryBad} erreur`), prompt);
assert.ok(
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name}`),
prompt,
);
assert.ok(prompt.includes(`(${diceWords(phrases, player.luck.rating)})`), prompt);
assert.ok(prompt.includes("Résultat : l'ordinateur a gagné"), prompt);
});
Then(/^elle cite votre coup (.+), le coup du maître (.+) et leur écart$/, (mine, best) => {
const toReview = summarizeAnalysis(journal).toReview;
assert.ok(toReview !== null);
assert.ok(
prompt.includes(
`j'ai joué ${mine}, GNU Backgammon préférait ${best} ; écart ${formatEquity(toReview.advice.equityLoss)} (Grosse erreur).`,
),
prompt,
);
});
Then("elle donne la position du coup à revoir", () => {
const toReview = summarizeAnalysis(journal).toReview;
assert.equal(position.positionId, toReview?.advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), prompt);
});
Given("aucune partie n'est en cours", () => {
game = null;
});
When(/^vous demandez à une IA d'expliquer le mot « (.+) »$/, async (term: string) => {
const entry = parseGlossaryEntry(read(`docs/glossaire/${term.toLowerCase()}.md`));
assert.ok(entry !== null);
const example = await conceptExample(engine, game?.journal ?? null, 2);
position = example.position;
prompt = conceptPrompt(templates, {
term: entry.term,
english: entry.english,
brief: entry.brief,
journal: game?.journal ?? null,
example,
});
});
Then("la question reprend la définition de sa fiche", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.ok(entry !== null && prompt.includes(entry.brief), prompt);
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)"), prompt);
});
Then(
"elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon",
() => {
assert.ok(prompt.includes("voici la position de départ"), prompt);
assert.ok(prompt.includes("Position ID : 4HPwATDgc/ABMA"), prompt);
assert.match(prompt, /chances de gagner \d+ %/);
},
);
+30
View File
@@ -476,6 +476,36 @@ Given("un joueur a sorti tous ses pions", async () => {
assert.equal(await playAndConfirm("6/off 5/off"), true);
});
// ------------------------------------------------- Sortir dans tout ordre
// Le joueur, au trait : un pion sur sa case 6, un sur sa case 1 ;
// l'ordinateur un pion sur sa case 1 et un sur sa case 2.
const SIX_AND_ONE = "BQAACAIAAAAAAA";
Given("vous avez un pion sur la flèche 6 et un sur la flèche 1, avec le jet 6-1", async () => {
game = Game.start(engine, {
setup: { position: SIX_AND_ONE, firstTurn: "player" },
dice: fixedDice([6, 1]),
});
await game.roll();
});
Then(/^vous pouvez valider ou jouer encore (.+)$/, async (text: string) => {
assert.equal(accepted, true);
// 6/off seul mène à la même position que 6/5/off, un coup légal.
assert.equal(game.moveComplete, true);
assert.deepEqual(await game.nextHalfMoves(), parseHalfMoves(text));
});
Then("vos deux pions sont sortis", async () => {
assert.equal(accepted, true);
assert.equal(game.moveComplete, true);
const board = await game.displayedBoard();
assert.equal(board.player.off, 15);
await game.confirmMove();
assert.equal(game.state.phase, "over");
});
Then("le résultat indique le gagnant et les points gagnés", () => {
// Gammon (l'ordinateur n'a sorti aucun pion), mais 1 point seulement :
// règle Jacoby, le videau n'a pas bougé.
+51
View File
@@ -222,6 +222,57 @@ test("les identifiants de partie sont distincts", () => {
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
});
// Régression du test navigateur « mes parties » sous Firefox (disque lent) :
// la nouvelle partie n'est pas perdue quand son enregistrement attend
// derrière ceux, lents, de l'analyse de la partie finie ; seule une lecture
// directe du stockage, hors de la file, peut la précéder.
test("stockage lent : la nouvelle partie est enregistrée après l'analyse de l'ancienne", async () => {
const storage = memoryStorage();
const raw = createKeyValueBackend(storage);
const pause = () => new Promise((resolve) => setTimeout(resolve, 30));
const slow = {
...raw,
write: async (...args: Parameters<typeof raw.write>) => {
await pause();
await raw.write(...args);
},
setCurrentId: async (id: string) => {
await pause();
await raw.setCurrentId(id);
},
};
const games = createGameLibrary(slow, { now: clock() });
const engine = {} as Engine;
const session = createGameSession(games, {
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
});
const finished = session.newGame();
const finishedId = session.shownId as string;
session.save(finished.journal);
assert.equal((await games.current())?.summary.id, finishedId);
// L'analyse de la partie finie s'enregistre en arrière-plan, puis
// « Rejouer » : nouvelle partie, enregistrée sans attendre.
for (let i = 0; i < 5; i++) {
void games.saveAnalyses(finishedId, finished.journal, { level: "2ply" });
}
const next = session.newGame();
session.save(next.journal);
const nextId = session.shownId as string;
assert.notEqual(nextId, finishedId);
// Lue directement, tout de suite : encore l'ancienne partie.
assert.equal(await raw.currentId(), finishedId);
// Lue par la bibliothèque, après les écritures en attente : la nouvelle,
// avec son journal ; l'ancienne reste là.
const current = await games.current();
assert.equal(current?.summary.id, nextId);
assert.deepEqual(current?.journal, next.journal);
assert.equal(await raw.currentId(), nextId);
assert.deepEqual((await games.load(finishedId))?.journal, finished.journal);
});
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
const games = library();
// Game.start et Game.resume n'interrogent pas le moteur.
+82
View File
@@ -0,0 +1,82 @@
// Affiche des prompts remplis sur de vraies analyses du moteur, pour les
// relire (étape 1f) : node build/web/tests/tools/prompt-sample.js
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { readFileSync } from "node:fs";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { positionBefore } from "../../src/domain/prompts/position.js";
import {
decisionPrompt,
summaryPosition,
summaryPrompt,
} from "../../src/domain/prompts/prompts.js";
import {
PROMPT_FILES,
type PromptTemplates,
parsePromptTemplate,
} from "../../src/domain/prompts/templates.js";
import { adviceOn, loadPhrases, playOpening } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
const root = new URL("../../../../", import.meta.url);
const read = (file: string) =>
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
const templates: PromptTemplates = {
move: read(PROMPT_FILES.move),
cube: read(PROMPT_FILES.cube),
summary: read(PROMPT_FILES.summary),
concept: read(PROMPT_FILES.concept),
exercises: read(PROMPT_FILES.exercises),
};
const phrases = loadPhrases();
const engine: Engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "24/23 13/10");
const advice = await adviceOn(engine, played);
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
console.log(decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position }));
async function analyseAll(journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (;;) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
}
for (const [seed, choice] of [
[7, 1],
[7, 2],
[11, 2],
[3, 3],
]) {
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "intermediate",
dice: seededDice(seed ?? 0),
});
await playToTheEnd(game, engine, choice);
const journal = await analyseAll(game.journal);
const summary = summarizeAnalysis(journal);
console.log(
`\n=== graine ${seed}, choix ${choice} : coup à revoir ${summary.toReview?.turn ?? "aucun"}`,
);
if (seed === 3) {
const where = await summaryPosition(engine, journal);
console.log(summaryPrompt(templates, phrases, { journal, position: where }));
}
}
+17 -2
View File
@@ -101,8 +101,8 @@ test("chaque position du tutoriel est valide pour le moteur", async () => {
const s = board.sides[side];
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
};
assert.equal(count("player"), 15, `${name} : pions ivoire`);
assert.equal(count("computer"), 15, `${name} : pions noyer`);
assert.equal(count("player"), 15, `${name} : pions blancs`);
assert.equal(count("computer"), 15, `${name} : pions noirs`);
}
});
@@ -174,6 +174,21 @@ test("le joueur peut revenir en arrière après un coup refusé", async () => {
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("en sortie, un coup déjà complet peut encore mener au coup attendu", async () => {
// Pions sur 6 et 1, jet 6-1 : 6/off seul est déjà un coup complet (même
// position que 6/5/off), mais le coup attendu sort les deux pions.
const engine = await loadNodeEngine();
const first = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await first.play({ from: 6, to: 0 }), "continue");
assert.equal(await first.play({ from: 1, to: 0 }), "right");
const other = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await other.play({ from: 1, to: 0 }), "continue");
assert.equal(await other.play({ from: 6, to: 0 }), "right");
// 6/5 puis 5/off : complet, mais ce n'est pas le coup attendu.
const wrong = await MovePractice.start(engine, "BQAACAIAAAAAAA", [6, 1], ["6/off 1/off"]);
assert.equal(await wrong.play({ from: 6, to: 5 }), "wrong");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
+3 -3
View File
@@ -209,14 +209,14 @@ test("aide contextuelle : chaque question ouverte depuis la partie existe dans l
});
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
const entry = parseGlossaryEntry(read("docs/glossaire/jan-interieur.md"));
assert.ok(entry !== null);
assert.equal(entry.english, "Home board");
assert.deepEqual(
entry.seeAlso.map((s) => s.name),
["sortie", "barre", "point"],
["jan-exterieur", "sortie", "barre", "point"],
);
assert.ok(matchesSearch(entry, "camp"));
assert.ok(matchesSearch(entry, "jan interieur"));
assert.ok(matchesSearch(entry, "HOME"));
assert.ok(matchesSearch(entry, "fleches sortir"));
assert.ok(!matchesSearch(entry, "videau"));
@@ -184,3 +184,21 @@ a plus besoin (`app/src/ui/review-runner.ts`).
La bibliothèque gagne `saveAnalyses`, qui enregistre le journal analysé
d'une partie sans en faire la partie en cours ni changer la date de sa
dernière action (l'ordre de « Mes parties »).
### 2026-11-11 — Version 2 des conseils : le classement de l'analyse
Le classement des coups d'un conseil (`ranking`) venait d'un second appel au
moteur, comme la commande « hint » de gnubg, alors que le coup joué et le
meilleur coup venaient de l'analyse de partie. Les deux listes diffèrent :
quand le coup joué est une erreur, l'analyse réévalue le meilleur coup et le
coup joué au niveau demandé, ce que « hint » ne fait pas quand un seul coup
passe ses filtres (ouverture 3-1 jouée 24/23 13/10 : 8/5 6/5 à +0,200 à
2 plis dans l'analyse, +0,204 à 1 pli dans « hint »). Le classement vient
désormais de l'analyse même (`engine_analyse_move`, champ `ranking`), comme
le panneau d'analyse de gnubg de bureau.
- `ADVICE_VERSION` passe à 2 (`app/src/domain/game/advice.ts`). Selon la
règle de l'amendement du 2026-11-10, un conseil de la version 1 est oublié
à la reprise et recalculé en arrière-plan ; les analyses de l'ordinateur
(`review.computer`), du même format, aussi.
- Ni les événements (version 2) ni le schéma IndexedDB ne changent.
@@ -108,3 +108,13 @@ avec Gherkin et l'exécution de bout en bout par l'interface.
- **Perspective** : si les scénarios peuvent un jour être joués dans une
instance réelle de l'application, la même exécution produira les captures
d'écran de l'aide, toujours à jour avec la version installée.
### 2026-11-10 — Les prompts pour une IA, même principe
Avec « Demander à une IA » (étape 1f), les gabarits des prompts suivent la
même règle que le glossaire, les scénarios, le tutoriel et les phrases du
maître : une seule source, `docs/prompts/` (un fichier par gabarit, sous
CC BY-SA 4.0), copiée dans le site par `make site` et lue par l'application
à l'exécution ; jamais recopiée dans le code. `make test` remplit chaque
gabarit avec de vraies analyses du moteur (`app/tests/prompts.test.ts`) et
le scénario `docs/scenarios/demander-a-une-ia.feature` en fait l'aide.
+19
View File
@@ -60,3 +60,22 @@ couleur, en monochrome et en taille réelle d'icône (48 et 24 px).
en dessous de 32 px.
- Les icônes Android (premier plan, arrière-plan, monochrome) sont fournies en
SVG ; leur conversion en `VectorDrawable` se fera avec l'application Android.
## Amendements
### 2026-11-11 — Pions blancs et noirs, avec un contour
Retour des premiers joueurs, relayé par Pierre : les pions sombres se voyaient
mal sur la barre, et les joueurs préféraient le blanc et le noir à l'ivoire et
au noyer. Après comparaison sur le canevas (rangée « Retours des premiers
joueurs »), Pierre retient le blanc et le noir, avec un contour.
- Pions du joueur blancs (`#FAFAF7`), contour et filet gris foncé
(`#55554F` en thème clair, pour 3:1 sur les flèches en chêne ; `#6E6E69` en
sombre) ; pions de l'ordinateur noirs (`#1C1C1C`), contour et filet gris
clair (`#B5B5B0`), dans les deux thèmes. Dés et videau suivent les pions.
- Le contour assure la lisibilité partout : pions noirs sur la barre (6,8:1
en thème clair, 8,5:1 en sombre), pions blancs sur le fond clair (3,8:1).
- Le logo ne change pas : ses pions restent ivoire et noyer, qui sont la
matière de la marque, distincte des pièces du jeu.
- Valeurs dans `docs/identite/charte/tokens.json`.
+33
View File
@@ -159,3 +159,36 @@ non, lancer, jouer), à partir de 1, sur sa ligne courante.
- Comme la feuille d'explication, ces adresses n'ont de sens que sur
l'appareil qui a joué la partie. Une partie absente donne un message,
jamais une page cassée.
### 2026-11-10 — Adresses de la feuille du prompt pour une IA
Ajoutées avec « Demander à une IA » (étape 1f), dans le même schéma. La
feuille montre le prompt pour qu'on le relise avant de le copier : c'est un
écran à part entière, elle a donc son adresse, celle de l'écran d'où on la
demande suivie de `/ia` :
| Écran | Adresse |
| --- | --- |
| Prompt d'une décision, depuis la feuille d'explication du maître | `#/explication/<événement>/ia` |
| Prompt du bilan d'une partie | `#/parties/<id>/bilan/ia` |
| Prompt de la décision d'un coup, depuis l'analyse | `#/parties/<id>/analyse/<coup>/ia` (ou `…/analyse/ia`, sur le coup à revoir) |
| Prompt d'un terme du glossaire | `#/glossaire/<fiche sans .md>/ia` |
- Une seule feuille à la fois : depuis la feuille du maître ou le bilan,
la page du prompt prend la place de leur contenu, à la même hauteur ;
depuis l'analyse ou le glossaire, elle monte dans sa propre feuille.
« ‹ Explication » (« ‹ Bilan », « ‹ Analyse », « ‹ Glossaire »), Échap et
le retour du téléphone reviennent à l'écran d'où on l'a demandée ; la
croix et « Fermer » ferment la feuille, et avec elle la feuille du maître
ou le bilan dont elle avait pris la place. Ouverte par son adresse, sans
historique, le retour remplace l'adresse par celle de l'écran d'avant.
- Rechargée, l'adresse rouvre la même feuille : le prompt est recalculé à
partir du journal enregistré. Comme ses écrans, elle n'a de sens que sur
l'appareil qui a joué la partie (sauf pour le glossaire). Une décision
sans conseil, une partie absente ou pas encore analysée donne un message,
jamais une page cassée.
- La confirmation de la copie (« Copié » sur le bouton, 2 s) n'a pas
d'adresse.
- Le prompt contient, lui, le lien public de la position
(`https://ustagammon.sans.pub/#/position/<Position ID>/<Match ID>`),
qui se partage.
+1 -1
View File
@@ -22,7 +22,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse (fait) | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 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 | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
| 1h. Progression | Historique des parties et courbe du taux d'erreur | 1b, 1e |
| 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 |
+4 -1
View File
@@ -40,7 +40,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Barre](barre.md) | Bar |
| [Blot](blot.md) | Blot |
| [Branche](branche.md) | Branch |
| [Camp](camp.md) | Home board |
| [Chance](chance.md) | Luck |
| [Compte de points](compte-de-points.md) | Pip count |
| [Conseil du maître](conseil-du-maitre.md) | Advice |
@@ -54,11 +53,14 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Gammon](gammon.md) | Gammon |
| [Identifiant de match](identifiant-de-match.md) | Match ID |
| [Identifiant de position](identifiant-de-position.md) | Position ID |
| [Jan extérieur](jan-exterieur.md) | Outer board |
| [Jan intérieur](jan-interieur.md) | Home board |
| [Jet](jet.md) | Roll |
| [Lien de position](lien-de-position.md) | Position link |
| [Match](match.md) | Match |
| [Moteur](moteur.md) | Engine |
| [Niveau de jeu](niveau-de-jeu.md) | Level |
| [Notation des coups](notation-des-coups.md) | Move notation |
| [Partie](partie.md) | Game |
| [Partie enregistrée](partie-enregistree.md) | Saved game |
| [Passer](passer.md) | Drop |
@@ -67,6 +69,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Pli](pli.md) | Ply |
| [Point](point.md) | Point |
| [Prendre](prendre.md) | Take |
| [Prompt](prompt.md) | Prompt |
| [Règle de Crawford](regle-de-crawford.md) | Crawford rule |
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
| [SGF](sgf.md) | SGF |
-12
View File
@@ -1,12 +0,0 @@
---
term: Camp
english: Home board
---
# Camp
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Jan extérieur
english: Outer board
code: OuterBoard
---
# Jan extérieur
**En bref** : les six flèches qui précèdent le jan intérieur d'un joueur : ses pions y passent avant d'y entrer.
Votre jan extérieur est en bas à gauche (vos flèches 7 à 12) ; celui de
l'ordinateur, en haut à gauche (vos flèches 13 à 18). Vos pions partent du jan
intérieur de l'ordinateur, traversent les deux jans extérieurs, puis entrent
dans votre jan intérieur.
Voir aussi : [Jan intérieur](jan-interieur.md), [Point](point.md)
+16
View File
@@ -0,0 +1,16 @@
---
term: Jan intérieur
english: Home board
code: HomeBoard
---
# Jan intérieur
**En bref** : les six dernières flèches d'un joueur avant la sortie : il y rassemble ses quinze pions pour les sortir, et les pions adverses frappés y rentrent.
Le plateau se partage en quatre jans de six flèches. Votre jan intérieur est
en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur, en haut à droite
(vos flèches 19 à 24) : c'est là que rentrent vos pions frappés. Un pion
adverse resté dans votre jan intérieur à la fin de la partie en fait un
backgammon.
Voir aussi : [Jan extérieur](jan-exterieur.md), [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+27
View File
@@ -0,0 +1,27 @@
---
term: Notation des coups
english: Move notation
code: MoveNotation
---
# Notation des coups
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
plus proche de sa sortie) à 24 :
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
dés compte en général deux déplacements, séparés par une espace :
`13/7 8/7`.
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
seul de l'adversaire, qui part sur la barre.
- `6/off` : le pion de la case 6 sort du plateau.
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
6 à 2.
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
de sa case de départ à sa case d'arrivée.
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
+16
View File
@@ -0,0 +1,16 @@
---
term: Prompt
english: Prompt
code: Prompt
---
# Prompt
**En bref** : la question toute prête que l'application copie pour une IA (« Demander à une IA ») : la position, les chiffres de GNU Backgammon, et ce qu'on demande ; il suffit de la coller dans l'IA de votre choix.
L'application n'appelle aucune IA et n'envoie rien : elle prépare le texte,
vous le relisez, vous le copiez. Le moteur décide, l'IA explique : le prompt
lui demande de ne pas contredire les évaluations de GNU Backgammon, qui
restent celles que l'application affiche. Il contient aussi le lien de la
position, pour la retrouver dans l'application.
Voir aussi : [Lien de position](lien-de-position.md), [Moteur](moteur.md), [Conseil du maître](conseil-du-maitre.md)
+34 -15
View File
@@ -76,9 +76,11 @@ interrompre.
demande au moteur si le coup du joueur fait partie des coups permis attendus.
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
veut dire « à faire »).
affiché ; « Revoir » le rejoue. Sur l'écran du sens du jeu, **deux flèches
circulaires** : une claire pour vos pions, une sombre pour ceux de
l'ordinateur, chacune dans le sens de son joueur et finissant par une pointe,
dessinées sous les pions, avec un liseré de la couleur opposée pour rester
lisibles sur le plateau ; jamais en brique (la brique veut dire « à faire »).
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
courte, « Continuer » devient l'action principale. Un geste faux : le pion
revient, et une phrase dit pourquoi, sans le mot « erreur ».
@@ -87,14 +89,14 @@ interrompre.
### Texte, première version
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
1. **Le but du jeu.** « Vous avez quinze pions blancs, l'ordinateur quinze
pions noirs. Chacun fait faire le tour du plateau à ses pions, puis les
sort. Le premier qui a sorti tous les siens gagne la partie. »
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
l'ordinateur font le même chemin, en sens inverse. »
2. **Dans quel sens avancer.** « Vos pions suivent la flèche claire : ils
partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à
votre jan intérieur, les flèches 1 à 6. C'est là que vous les rassemblez
avant de les sortir. Les pions de l'ordinateur suivent la flèche sombre, en
sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24. »
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
@@ -106,16 +108,16 @@ interrompre.
point 7 avec 6-1.
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
jan intérieur de l'ordinateur, en haut à droite. Chaque dé indique une flèche d'entrée :
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
passe. »
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
6. **Sortir ses pions.** « Quand vos quinze pions sont dans votre jan intérieur
(flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
@@ -164,7 +166,7 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Un terme du jeu dans un texte (blot, barre, jan intérieur…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
@@ -174,3 +176,20 @@ ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
## Vocabulaire : les jans
Le plateau se partage en quatre jans de six flèches. Les nommer simplifie les
explications (retour des premiers joueurs, 2026-11-11) :
| Flèches | Nom |
| --- | --- |
| 1 à 6 | votre jan intérieur |
| 7 à 12 | votre jan extérieur |
| 13 à 18 | le jan extérieur de l'ordinateur |
| 19 à 24 | le jan intérieur de l'ordinateur |
Le tutoriel, l'aide, le maître et les leçons emploient ces mots, avec un lien
vers leur fiche du glossaire à la première apparition. Fiches à créer :
`jan-interieur.md` (anglais : home board) et `jan-exterieur.md` (anglais :
outer board). « Camp » n'est plus employé dans les textes.
+29 -7
View File
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
@@ -206,9 +206,21 @@ Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
puis le texte du prompt reste lisible dans une zone `surface-sunken`
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
glossaire. Le bouton ouvre la feuille du prompt : le texte y est lisible
dans une zone `surface-sunken`, pour qui veut le relire avant de le copier
(composant `AskAI`).
Recette de 1f :
- **Une seule feuille à la fois.** Depuis la feuille du maître, la feuille
du prompt en remplace le contenu, à la même hauteur, avec « ‹ Explication »
en haut à gauche pour revenir ; de même depuis le bilan (« ‹ Bilan »).
Depuis l'analyse et le glossaire, elle monte seule (« ‹ Analyse »,
« ‹ Glossaire »). Le retour du téléphone ramène à l'écran précédent.
- **La copie se confirme sur le bouton**, sans bandeau : « Copier le
prompt » devient « ✓ Copié » (coche, `verdict-good`) pendant 2 s, puis
revient. Si le navigateur refuse la copie, le texte est sélectionné et
une phrase invite à le copier avec le menu du téléphone.
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
@@ -224,8 +236,18 @@ l'ordre, chacune avec une pastille ronde à son numéro :
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
moteur).
« Quitter », « Retour », « Continuer », écrans « à vous » contrôlés par le
moteur), aux adresses `#/lecons/<n>/<écran>`, `…/a-vous` et `…/fin`.
- Une leçon est **terminée** quand tous ses exercices sont réussis.
- « Retour » et « Continuer » restent **en bas de l'écran**, sous le pouce,
comme dans le tutoriel, quelle que soit la longueur du texte.
- « Revoir » (rejouer le coup illustré) se place sur la ligne du titre, à
droite, en portrait comme en paysage : le texte garde toute la largeur.
- En paysage, la colonne de droite garde la ligne `caption` « Leçon 1 · 1 sur
4 · À vous » au-dessus du titre.
- Les titres et les textes n'emploient que les mots du glossaire : « point »
et « flèche », jamais « case ».
## 1h. Progression (`#/progression`)
+1 -1
View File
@@ -22,7 +22,7 @@ La palette, c'est le bois, les pions et le café, avec **un seul accent : le rou
- `accent-soft` : fond léger pour signaler « c'est à vous », avec du texte `text` dessus.
- Les appréciations de coups ont leurs propres couleurs, distinctes de la brique : `verdict-good` (vert), `verdict-doubtful` (ocre), `verdict-bad` (lie-de-vin). **Toujours avec le mot et l'icône**, jamais la couleur seule.
- `brand-laiton` (via `last-move`) marque le dernier coup joué, comme les charnières en laiton d'un plateau de tavla. Rien d'autre.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions ivoire (`checker-player`, l'apprenti), l'ordinateur les pions noyer (`checker-computer`, le maître), comme dans le logo.
- Le plateau : cadre `frame` en noyer, fond `bed`, flèches impaires en `point-a` (noyer clair) et paires en `point-b` (chêne), barre `bar`, sorties `tray`. Le joueur a les pions blancs (`checker-player`), l'ordinateur les pions noirs (`checker-computer`), chacun avec un contour et un filet (`checker-player-edge`, `checker-computer-edge`) qui le détachent de tous les fonds ; les pions du logo restent ivoire et noyer (ADR 0010, amendement du 2026-11-11).
- **À éviter** : le vert feutre de casino, le noir et or, les dégradés, les néons.
Les deux thèmes, clair et sombre, sont complets. Tous les textes atteignent 4,5:1 sur leurs fonds dans les deux thèmes ; les contours et marques porteuses de sens, 3:1.
+3
View File
@@ -8,6 +8,9 @@ Référence : `interaction.md`, maquettes « Révision UX » du canevas.
## Toutes tailles
- [ ] Flèches impaires en `point-a`, paires en `point-b` ; 12 et 13 de couleurs différentes.
- [ ] Numéros des flèches à l'extérieur du plateau, sur le cadre (bande haute et bande basse), en portrait comme en paysage ; jamais dans le fond du plateau entre les deux rangées. (Vérifié par `tests/browser/retours-joueurs.mjs`.)
- [ ] Pions du joueur lisibles partout : en thème clair, contour `checker-player-edge` à 3:1 au moins sur les flèches en chêne et sur le fond. Règle automatique : `checkerContrastProblems` de `tests/browser/recette.mjs`.
- [ ] Pions de l'ordinateur lisibles partout : contour et filet `checker-computer-edge` (gris clair) visibles sur la barre, sur les flèches sombres et en thème sombre. Règle automatique : là où sa couleur ne se détache pas de son fond (moins de 3:1), son contour s'en détache à 3:1 au moins (`checkerContrasts` de `tests/browser/recette.mjs`).
- [ ] Une seule action principale (brique) visible à la fois.
- [ ] Aucun `window.confirm` ni `alert` dans le code de l'app (`grep -rn "confirm(\|alert(" app/src` vide).
- [ ] Aucune cible tactile sous 44 × 44 px (boutons, pictogrammes, dés, videau).
+32 -32
View File
@@ -216,96 +216,96 @@
{
"name": "checker-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Pions du joueur : ivoire, l'apprenti."
"usage": "Pions du joueur : blancs."
},
{
"name": "checker-player-edge",
"value": {
"light": "#8C7A62",
"dark": "#8E7C64"
"light": "#55554F",
"dark": "#6E6E69"
},
"usage": "Contour et filet des pions du joueur."
"usage": "Contour des pions blancs. Thème clair : #55554F, pour 3:1 au moins sur les flèches en chêne (3,5:1) et le fond (5,5:1). Thème sombre : le pion blanc se détache déjà par sa couleur (plus de 5:1)."
},
{
"name": "checker-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Pions de l'ordinateur : noyer sombre, le maître."
"usage": "Pions de l'ordinateur : noirs."
},
{
"name": "checker-computer-edge",
"value": {
"light": "#120A05",
"dark": "#A8957D"
"light": "#B5B5B0",
"dark": "#B5B5B0"
},
"usage": "Contour et filet des pions de l'ordinateur ; clair en thème sombre pour rester visible."
"usage": "Contour et filet des pions noirs : gris clair dans les deux thèmes, pour qu'ils se détachent de la barre, des flèches sombres et du fond sombre."
},
{
"name": "count-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Nombre écrit sur une pile de pions du joueur."
"usage": "Nombre écrit sur une pile de pions blancs."
},
{
"name": "count-computer",
"value": {
"light": "#F3EAD8",
"dark": "#F3EAD8"
"light": "#FAFAF7",
"dark": "#FAFAF7"
},
"usage": "Nombre écrit sur une pile de pions de l'ordinateur."
"usage": "Nombre écrit sur une pile de pions noirs."
},
{
"name": "die-player",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Dés du joueur."
"usage": "Dés du joueur, blancs comme ses pions."
},
{
"name": "pip-player",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Points des dés du joueur."
},
{
"name": "die-computer",
"value": {
"light": "#2E1C12",
"dark": "#140D09"
"light": "#1C1C1C",
"dark": "#141414"
},
"usage": "Dés de l'ordinateur."
"usage": "Dés de l'ordinateur, noirs comme ses pions."
},
{
"name": "pip-computer",
"value": {
"light": "#F3EAD8",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Points des dés de l'ordinateur."
},
{
"name": "cube",
"value": {
"light": "#F7F0E2",
"dark": "#EDE3CF"
"light": "#FAFAF7",
"dark": "#F2F2EF"
},
"usage": "Face du videau."
},
{
"name": "cube-text",
"value": {
"light": "#2B1D15",
"dark": "#2B1D15"
"light": "#1C1C1C",
"dark": "#1C1C1C"
},
"usage": "Valeur du videau (2, 4, 8…)."
},
-42
View File
@@ -1,42 +0,0 @@
# Prompts à copier (1f)
- **Date** : 2026-11-03
- **Étape** : 1f (`docs/etape-1.md`)
- **Statut** : prévu (après 1e)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
## Consignes
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
- « pourquoi ce coup est meilleur » ;
- « expliquer cette décision de videau » ;
- « bilan de ma partie » ;
- « expliquer ce concept » ;
- « créer des exercices », à garder pour la progression.
Chaque gabarit contient :
- le rôle et le niveau du joueur ;
- le Position ID et le Match ID ;
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
- les chiffres du moteur, avec le niveau d'évaluation ;
- la consigne de ne pas contredire ces chiffres ;
- la langue.
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
4. **Tests.**
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
## Critères de réussite
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
## Résultat
À remplir à la clôture.
+58
View File
@@ -0,0 +1,58 @@
# Les prompts à copier pour une IA
Les textes que l'application copie dans le presse-papiers quand on touche
« Demander à une IA » (étape 1f ; `docs/cadrage.md`, « IA générative en
V1 »). L'application n'appelle aucune IA : elle prépare une question
complète, que le joueur colle dans l'IA de son choix. Règle d'or : **le
moteur décide, l'IA explique**.
Une seule source (ADR 0008) : `make site` copie ce dossier dans le site
publié (`docs/prompts/`), l'application lit les gabarits à l'exécution
(`app/src/domain/prompts/`), et `make test` remplit chacun d'eux avec de
vraies analyses du moteur (`app/tests/prompts.test.ts`) : aucun repère
laissé vide, et les chiffres du prompt sont ceux que l'application affiche.
Textes sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code
d'après le cadrage, **à relire** par Pierre.
## Les gabarits
| Fichier | Bouton | Où |
| --- | --- | --- |
| [coup.md](coup.md) | Pourquoi ce coup est meilleur | Feuille du maître, bilan, analyse |
| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse |
| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie |
| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire |
| [exercices.md](exercices.md) | Créer des exercices | Progression (étape 1h, pas encore de bouton) |
Chaque prompt contient :
- le rôle de l'IA (un coach) et, quand il est mesuré, le niveau du joueur ;
- la position : Position ID et Match ID de GNU Backgammon, le lien qui
l'ouvre dans l'application (`https://ustagammon.sans.pub/#/position/…`,
ADR 0013) et un diagramme texte dessiné d'après le plateau décodé par le
moteur ;
- les chiffres du moteur, avec le niveau d'évaluation (en plis) ;
- la consigne de ne pas recalculer ni contredire ces chiffres ;
- la langue de la réponse.
## Format
Dans chaque fichier, le gabarit est le bloc de texte qui suit le titre
`## Gabarit`. Le reste du fichier n'est pas lu. Les mots entre accolades sont
remplis par l'application ; un repère inconnu ou une valeur vide est une
erreur, vérifiée par les tests.
Repères communs à plusieurs gabarits :
| Repère | Ce qu'il devient |
| --- | --- |
| `{joueur}` | « Je suis un joueur de niveau Avancé. », le niveau que donne GNU Backgammon d'après l'erreur par décision, seulement à partir de 20 décisions analysées ; en dessous, « Je débute au backgammon. ». Le taux d'erreur lui-même n'apparaît que dans le bilan. |
| `{position_id}`, `{match_id}` | les identifiants de GNU Backgammon de la position |
| `{au_trait}` | « moi » ou « l'ordinateur » : celui dont les identifiants décrivent le point de vue |
| `{lien}` | l'adresse de la position dans l'application publiée |
| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation |
| `{score}` | « partie libre » ou le score du match |
| `{videau}` | sa valeur et qui le possède |
| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » ; pour un classement de coups, celui auquel le moteur a évalué le meilleur coup (voir [coup.md](coup.md)) |
| `{langue}` | la langue de la réponse : « français » |
+44
View File
@@ -0,0 +1,44 @@
# Bilan de ma partie
Bouton « Demander à une IA » de la carte « Le coup à revoir » du bilan de
fin de partie. Texte sous licence CC BY-SA 4.0 (voir
[README.md](README.md)).
Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{ordinateur}` | le niveau de l'ordinateur, « Intermédiaire » |
| `{resultat}` | le résultat de la partie, et le score du match s'il y en a un |
| `{decomptes}` | vos décisions par appréciation, comme les trois cases du bilan |
| `{taux}`, `{decisions}`, `{niveau_jeu}` | votre erreur par décision, le nombre de décisions comptées et le niveau correspondant, comme « Voir les chiffres du moteur » de l'analyse |
| `{taux_ordinateur}` | la même mesure pour l'ordinateur |
| `{chance}`, `{chance_mots}`, `{chance_ordinateur}` | la chance par tour de vos dés, en chiffres et en mots, et celle de l'ordinateur |
| `{erreurs}` | vos imprécisions et erreurs, coup par coup, avec leur coût |
| `{position_titre}` | ce que montre la position : le coup à revoir, ou la position finale s'il n'y en a pas |
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Je viens de finir une partie contre l'ordinateur (niveau {ordinateur}), analysée par GNU Backgammon ({niveau_eval}).
Résultat : {resultat}.
Mes décisions : {decomptes}.
Mon erreur par décision : {taux} sur {decisions}, soit le niveau {niveau_jeu} ; celle de l'ordinateur : {taux_ordinateur}.
La chance de mes dés par tour : {chance} ({chance_mots}) ; celle de l'ordinateur : {chance_ordinateur}.
Mes imprécisions et erreurs, avec leur coût en équité :
{erreurs}
{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
{diagramme}
Score : {score}. Videau : {videau}.
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
À partir de ce bilan, identifie trois thèmes que je devrais travailler en priorité, avec pour chacun un conseil concret.
Réponds en {langue}, simplement, en moins de 300 mots.
```
+36
View File
@@ -0,0 +1,36 @@
# Expliquer ce concept
Bouton « Demander à une IA » sur la fiche d'un terme du glossaire (« Les
mots du jeu »). La position d'exemple est celle de la partie en cours, ou la
position de départ s'il n'y en a pas. Texte sous licence CC BY-SA 4.0 (voir
[README.md](README.md)).
Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{concept}` | le terme, et son nom anglais s'il en a un |
| `{definition}` | la phrase « En bref » de sa fiche |
| `{position_titre}` | « une position de ma partie en cours » ou « la position de départ » |
| `{evaluation}` | les chances de gagner et l'équité du joueur au trait, avant de lancer les dés |
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Explique-moi le concept {concept}.
Mon application le définit ainsi : {definition}
Pour l'illustrer, voici {position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
{diagramme}
Score : {score}. Videau : {videau}.
Évaluation de GNU Backgammon ({niveau_eval}), pour le joueur qui a la main ({au_trait}), avant de lancer les dés : {evaluation}.
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
Explique ce concept simplement, avec une analogie de la vie courante, puis dis s'il joue un rôle dans cette position et comment.
Réponds en {langue}, en moins de 250 mots.
```
+44
View File
@@ -0,0 +1,44 @@
# Pourquoi ce coup est meilleur
Bouton « Demander à une IA » sur un coup du joueur : feuille d'explication
du maître, carte « Le coup à revoir » du bilan, visualiseur de l'analyse.
Texte sous licence CC BY-SA 4.0 (voir [README.md](README.md)).
Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{des}` | le jet joué, « 3-1 » |
| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon |
| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » |
| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » |
| `{classement}` | le classement de l'analyse de votre coup : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » |
Le niveau du titre (`{niveau_eval}`) est celui auquel GNU Backgammon a
évalué le meilleur coup. C'est d'ordinaire le niveau de l'analyse (2 plis),
mais le moteur s'arrête plus tôt quand un seul coup passe ses filtres : à
l'ouverture 3-1 jouée 8/5 6/5, tout le classement est à 1 pli. Les coups que
ses filtres ont écartés gardent l'évaluation d'un niveau inférieur, et leur
ligne le dit (« évalué à 1 pli »). Les équités et l'écart de votre coup sont
ceux de l'analyse, que disent la feuille du maître et l'écran d'analyse.
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici la position avant mon coup (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
{diagramme}
Score : {score}. Videau : {videau}. Dés : {des}.
J'ai joué {coup_joue} : {rang}, jugé « {appreciation} ».
Classement de GNU Backgammon ({niveau_eval}), équités du point de vue du joueur qui joue :
{classement}
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
Explique-moi pourquoi le meilleur coup est supérieur au mien : quels concepts sont en jeu, et ce que je dois retenir pour la prochaine fois.
Réponds en {langue}, simplement, en moins de 200 mots.
```
+36
View File
@@ -0,0 +1,36 @@
# Créer des exercices
Prévu pour l'écran de progression (étape 1h) : le gabarit est écrit et
testé, sans bouton pour l'instant. Il rassemble les erreurs de vos
dernières parties analysées. Texte sous licence CC BY-SA 4.0 (voir
[README.md](README.md)).
Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{parties}` | le nombre de parties analysées |
| `{erreurs}` | vos erreurs les plus coûteuses sur ces parties, avec leur coût |
| `{position_titre}` | la plus coûteuse d'entre elles |
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici mes {parties} dernières parties contre l'ordinateur, analysées par GNU Backgammon ({niveau_eval}).
Mes erreurs les plus coûteuses, avec leur coût en équité :
{erreurs}
{position_titre} (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
{diagramme}
Score : {score}. Videau : {videau}.
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
Repère les thèmes qui reviennent dans ces erreurs, puis propose-moi cinq exercices ciblés : pour chacun, le thème, une position type décrite simplement, et ce que je dois y chercher.
Réponds en {langue}, en moins de 350 mots.
```
+40
View File
@@ -0,0 +1,40 @@
# Expliquer cette décision de videau
Bouton « Demander à une IA » sur une décision de videau du joueur (doubler,
lancer sans doubler, prendre, passer) : feuille d'explication du maître,
carte « Le coup à revoir » du bilan, visualiseur de l'analyse. Texte sous
licence CC BY-SA 4.0 (voir [README.md](README.md)).
Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{decision}` | la question posée : « doubler ou lancer les dés sans doubler » ou « prendre ou passer le double de l'ordinateur » |
| `{choix}` | votre décision, « passer » |
| `{choix_moteur}` | la décision que préfère GNU Backgammon |
| `{appreciation}` | le mot du maître : « Bonne décision », « Imprécision », « Erreur » ou « Grosse erreur » |
| `{qui_double}` | « moi » ou « l'ordinateur » : celui qui double ou non |
| `{equites}` | les équités sans double, double et prise, double et refus, comme dans « Voir les chiffres du moteur » |
| `{ecart}` | l'équité perdue par votre décision |
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici la position au moment d'une décision de videau (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
{diagramme}
Score : {score}. Videau : {videau}.
Il fallait choisir : {decision}. J'ai choisi de {choix}, jugé « {appreciation} » ; GNU Backgammon aurait choisi de {choix_moteur}.
Équités calculées par GNU Backgammon ({niveau_eval}), du point de vue de celui qui double ou non ({qui_double}), pour un videau de 1 :
{equites}
Écart de ma décision : {ecart}.
Ces évaluations font foi : ne les recalcule pas et ne les contredis pas.
Explique-moi cette décision : pourquoi doubler ou non, prendre ou passer, quels repères de la position comptent, et ce que je dois retenir.
Réponds en {langue}, simplement, en moins de 200 mots.
```
+1
View File
@@ -59,6 +59,7 @@ Règles d'écriture :
| [jouer-un-match.feature](jouer-un-match.feature) | Choisir sa partie (libre ou match, niveau) et jouer un match | Étape 1c |
| [comprendre-mes-coups.feature](comprendre-mes-coups.feature) | Comprendre ses coups avec le conseil du maître | Étape 1d |
| [revoir-ma-partie.feature](revoir-ma-partie.feature) | Revoir sa partie : bilan, coup à revoir, analyse, rejouer d'ici | Étape 1e |
| [demander-a-une-ia.feature](demander-a-une-ia.feature) | Demander à une IA d'expliquer un coup, une partie ou un mot | Étape 1f |
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu), la mettre à jour | Étape 0e |
+45
View File
@@ -0,0 +1,45 @@
# language: fr
Fonctionnalité: Demander à une IA d'expliquer un coup, une partie ou un mot
Le maître dit quel coup il aurait joué ; pour comprendre pourquoi, vous
pouvez demander à une IA de votre choix. Touchez « Demander à une IA »
dans l'explication du maître, sur le bilan, dans l'analyse ou sur la fiche
d'un mot du jeu : l'application prépare une question complète, avec la
position, son lien et les chiffres de GNU Backgammon. Relisez-la, copiez-la,
puis collez-la dans l'IA. L'application n'appelle aucune IA et n'envoie
rien : le moteur décide, l'IA explique, et la question lui demande de ne
pas contredire les chiffres du moteur.
Scénario: Comment demander à une IA d'expliquer mon erreur ?
Touchez « Demander à une IA » dans l'explication du maître : la question
donne la position avant votre coup, dessinée en texte, ses identifiants
de GNU Backgammon et son lien, puis les trois meilleurs coups du maître et
le vôtre, avec les mêmes chiffres que l'explication du maître.
Étant donné que vous avez joué 24/23 13/10 avec un 3-1 d'ouverture
Quand vous demandez à une IA d'expliquer ce coup
Alors la question donne la position, avec son Position ID, son Match ID et son lien
Et elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication
Et elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français
Scénario: Comment demander à une IA le bilan de ma partie ?
Sur le bilan, « Demander à une IA » prépare une question sur toute la
partie : le résultat, vos décisions par appréciation, votre niveau, la
chance des dés, vos erreurs et leur coût, et la position du coup à
revoir. L'IA vous propose trois thèmes à travailler.
Étant donné une partie finie où vous avez joué 6/5 5/off avec 6-1, puis passé le double de l'ordinateur
Quand vous demandez à une IA le bilan de votre partie
Alors la question donne vos décisions, votre niveau et la chance des dés, comme le bilan
Et elle cite votre coup 6/off, le coup du maître 6/off 1/off et leur écart
Et elle donne la position du coup à revoir
Scénario: Puis-je demander à une IA d'expliquer un mot du jeu ?
Oui : sur la fiche d'un mot du glossaire, la question reprend sa
définition et l'illustre par la position de votre partie en cours, ou par
la position de départ, avec l'évaluation de GNU Backgammon.
Étant donné qu'aucune partie n'est en cours
Quand vous demandez à une IA d'expliquer le mot « Videau »
Alors la question reprend la définition de sa fiche
Et elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon
+3 -1
View File
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
coups. Vos choix sont gardés pour la prochaine fois.
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
plateau, même juste après le bilan d'une partie finie.
Scénario: Comment choisir le niveau de l'ordinateur ?
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
+18
View File
@@ -142,6 +142,24 @@ Fonctionnalité: Jouer une partie contre l'ordinateur
Alors la partie s'arrête
Et vous perdez la partie à sa valeur d'avant le double
@position:BQAACAIAAAAAAA
Scénario: Sortir ses pions dans l'ordre de son choix
Une fois tous vos pions dans votre jan intérieur (flèches 1 à 6), vous les
sortez dans l'ordre que vous voulez. Avec 6-1, un pion sur la flèche 6
et un sur la flèche 1 : sortez l'un puis l'autre, ou l'inverse. Si vous
ne sortez que le pion de la 6, « Jouer ce coup » apparaît déjà, car ce
pion a pu utiliser vos deux dés (6/5, puis 5/off). Touchez l'autre pion
pour le sortir aussi.
É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 jouez 6/off
Alors vous pouvez valider ou jouer encore 1/off
Quand vous jouez 1/off
Alors vos deux pions sont sortis
É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 jouez 1/off 6/off
Alors vos deux pions sont sortis
Scénario: Comment se termine une partie ?
La partie s'arrête dès qu'un joueur a sorti tous ses pions. Le panneau de
fin dit qui gagne, et combien de points : la valeur du videau, doublée
+2 -1
View File
@@ -51,7 +51,8 @@ Le texte de la fin du tutoriel.
position (par défaut, vous) ;
- `jet` et `coup` : sur un écran d'explication, le coup illustré, qui se
joue tout seul (notation de gnubg) ;
- `chemin: oui` : le sens du jeu, dessiné en pointillés sur le plateau ;
- `chemin: oui` : le sens du jeu, deux flèches circulaires sur le plateau
(claire pour vos pions, sombre pour ceux de l'ordinateur) ;
- `attendu` (une ligne par coup accepté) : sur un écran « à vous », les
coups justes, en notation de gnubg, tels que le moteur les écrit ;
- `réponse` : `prendre` ou `passer`, pour un écran « à vous » où
+11 -9
View File
@@ -1,13 +1,15 @@
# Les règles du backgammon
Tutoriel d'Ustagammon, en huit étapes. Texte sous licence CC BY-SA 4.0,
validé par Pierre le 9 novembre 2026. Format : voir README.md.
validé par Pierre le 9 novembre 2026 ; pions blancs et noirs, flèches
circulaires et jans repris de la charte le 11 novembre 2026
(docs/identite/charte/accueil-tutoriel-aide.md). Format : voir README.md.
## 1. Le but du jeu
- position: 4HPwATDgc/ABMA
Vous avez quinze [pions](pion.md) ivoire, l'ordinateur quinze pions noyer. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Vous avez quinze [pions](pion.md) blancs, l'ordinateur quinze pions noirs. Chacun fait faire le tour du [plateau](plateau.md) à ses pions, puis les [sort](sortie.md).
Le premier qui a sorti tous les siens gagne la [partie](partie.md).
@@ -16,9 +18,9 @@ Le premier qui a sorti tous les siens gagne la [partie](partie.md).
- position: 4HPwATDgc/ABMA
- chemin: oui
Vos pions ivoire font le tour du plateau dans le sens des pointillés : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'aux **six dernières flèches**, en bas à droite. C'est là que vous les rassemblerez avant de les sortir.
Vos pions suivent la **flèche claire** : ils partent d'en haut à droite, passent à gauche, puis reviennent en bas jusqu'à votre [jan intérieur](jan-interieur.md), les flèches 1 à 6. C'est là que vous les rassemblez avant de les sortir.
Les pions noyer de l'ordinateur font le même chemin, en sens inverse.
Les pions de l'ordinateur suivent la **flèche sombre**, en sens inverse, jusqu'à son jan intérieur, les flèches 19 à 24.
## 3. Les dés et les doubles
@@ -60,10 +62,10 @@ Une flèche où l'adversaire a deux pions ou plus est fermée : vous ne pouvez p
- attendu: 24/23 13/7*
- attendu: 24/18 8/7*
- attendu: 8/7* 8/2
- juste: Touché : le pion noyer part sur la barre, il devra refaire tout le chemin.
- juste: Touché : le pion noir part sur la barre, il devra refaire tout le chemin.
- refus: Ce coup ne frappe pas le pion seul de la flèche 7. Un pion qui s'y pose le frappe : celui de la flèche 13 avec le 6, ou celui de la flèche 8 avec le 1.
Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
Vous avez 6 et 1. Le pion noir de la flèche 7 est seul : frappez-le.
## 5. Rentrer depuis la barre
@@ -71,7 +73,7 @@ Vous avez 6 et 1. Le pion noyer de la flèche 7 est seul : frappez-le.
- jet: 4-3
- coup: bar/22 13/9
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le [camp](camp.md) adverse, en haut à droite.
Votre pion frappé attend sur la barre, au milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le jan intérieur de l'ordinateur, en haut à droite.
Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour passe.
@@ -81,7 +83,7 @@ Chaque dé indique une flèche d'entrée : avec 3 et 4, la 22 ou la 21. Si les d
- jet: 5-2
- coup: 5/off 2/off
Quand vos quinze pions sont dans les six dernières flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Quand vos quinze pions sont dans votre jan intérieur (flèches 1 à 6), vous pouvez commencer à les sortir : un 6 sort un pion de la flèche 6, un 1 un pion de la flèche 1.
Touchez le pion, puis la sortie à droite.
@@ -101,7 +103,7 @@ Vous avez 6 et 3 : sortez deux pions.
- jet: 2-1
- coup: 2/off 1/off
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre camp, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
Une partie gagnée vaut 1 point. Si l'adversaire n'a sorti aucun pion, c'est un [gammon](gammon.md) : 2 points ; s'il lui reste en plus un pion sur la barre ou dans votre jan intérieur, c'est un [backgammon](backgammon.md) : 3 points. En partie libre, gammon et backgammon ne comptent que si le videau a été doublé : c'est la [règle de Jacoby](regle-de-jacoby.md).
## 8. Le videau
+10 -2
View File
@@ -43,7 +43,8 @@
* analyse du coup joué (POSITION_JOUÉE :
* position après le coup, vue du joueur
* qui l'a joué, comme position_id dans
* moves et legal)
* moves et legal), avec le classement
* de l'analyse (dix coups au plus)
* analysecube POSITION_ID MATCH_ID DÉCISION
* analyse d'une décision de videau :
* no_double ou double (joueur au trait),
@@ -684,7 +685,7 @@ run_analyse_move_command(const options *opt)
const char *played_id = opt->args[3];
engine_move_analysis a;
engine_status status;
int die1, die2;
int die1, die2, i;
if (opt->arg_count != 4)
return usage_error("arguments attendus : POSITION_ID MATCH_ID DÉS POSITION_JOUÉE");
@@ -705,6 +706,13 @@ run_analyse_move_command(const options *opt)
printf(", \"best\": ");
print_move(&a.best, 1);
}
printf(", \"ranking\": [");
for (i = 0; i < a.ranking_count; i++) {
if (i > 0)
printf(", ");
print_move(&a.ranking[i], 1);
}
printf("]");
printf(", \"equity_loss\": " NUMBER ", \"skill\": \"%s\"}\n", a.equity_loss,
skill_names[a.skill]);
return EXIT_SUCCESS;
+31 -9
View File
@@ -512,6 +512,8 @@ engine_level_best_moves(const char *position_id, const char *match_id, int die1,
/* ------------------------------------------------- Analyse d'un coup joué */
static float luck_analysis(const char *position_id, position *pos, int die1, int die2);
engine_status
engine_analyse_move(const char *position_id, const char *match_id, int die1, int die2,
const char *played_position_id, int ply, engine_move_analysis *analysis)
@@ -546,6 +548,17 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
* premier.
*/
memset(analysis, 0, sizeof *analysis);
/*
* gnubg analyse la chance du jet juste avant le coup (AnalyzeMove,
* l. 679-682), à 0 pli sans élagage, et garde ces évaluations dans son
* cache, qui ne distingue pas une évaluation à 0 pli avec ou sans
* réseaux d'élagage : le tri à 0 pli de FindnSaveBestMoves les reprend
* ensuite, ce qui change l'équité des coups restés à 0 pli, et parfois
* ceux que les filtres retiennent. Faire de même, sans garder le
* résultat, donne le classement de gnubg de bureau.
*/
if (luck_analysis(position_id, &pos, die1, die2) == ERR_VAL)
return ENGINE_ERROR_INTERNAL;
if (FindnSaveBestMoves(&list, die1, die2, (ConstTanBoard) pos.board, &played_key, TRUE,
DOUBTFUL_THRESHOLD, &pos.cube, &context,
aaamfMoveFilterSettings[0]) < 0)
@@ -565,6 +578,10 @@ engine_analyse_move(const char *position_id, const char *match_id, int die1, int
status = ENGINE_OK;
break;
}
/* Le classement : le début de la même liste, dans l'ordre de gnubg. */
for (i = 0; i < list.cMoves && i < ENGINE_ANALYSIS_RANKING; i++)
fill_move(&analysis->ranking[i], &list.amMoves[i], &pos, 1);
analysis->ranking_count = (int) i;
g_free(list.amMoves);
return status;
}
@@ -1005,13 +1022,25 @@ luck_first(ConstTanBoard board, int i0, int j0, cubeinfo *cube)
return equities[i0][j0] - mean / 30.0f;
}
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
* faible ; LuckFirst à la position de départ hors doubles. */
static float
luck_analysis(const char *position_id, position *pos, int die1, int die2)
{
int high = (die1 > die2 ? die1 : die2) - 1;
int low = (die1 > die2 ? die2 : die1) - 1;
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
return luck_first((ConstTanBoard) pos->board, high, low, &pos->cube);
return luck_normal((ConstTanBoard) pos->board, high, low, &pos->cube);
}
engine_status
engine_luck_of(const char *position_id, const char *match_id, int die1, int die2,
engine_luck *luck)
{
position pos;
engine_status status;
int high, low;
float value;
status = decode_position(position_id, match_id, &pos);
@@ -1021,14 +1050,7 @@ engine_luck_of(const char *position_id, const char *match_id, int die1, int die2
return status;
start_evaluation();
/* LuckAnalysis (analysis.c l. 252-268) : dés rangés du plus fort au plus
* faible ; LuckFirst à la position de départ hors doubles. */
high = (die1 > die2 ? die1 : die2) - 1;
low = (die1 > die2 ? die2 : die1) - 1;
if (strcmp(position_id, START_POSITION_ID) == 0 && high != low)
value = luck_first((ConstTanBoard) pos.board, high, low, &pos.cube);
else
value = luck_normal((ConstTanBoard) pos.board, high, low, &pos.cube);
value = luck_analysis(position_id, &pos, die1, die2);
if (value == ERR_VAL)
return ENGINE_ERROR_INTERNAL;
luck->luck = value;
+21 -1
View File
@@ -207,6 +207,10 @@ typedef struct {
* (double_take <= double_pass) */
} engine_cube;
/* Coups gardés dans le classement d'une analyse : les dix premiers, comme
* les positions de référence (tests/reference/README.md). */
#define ENGINE_ANALYSIS_RANKING 10
/* Analyse d'un coup joué, comme l'analyse de partie de gnubg. */
typedef struct {
int move_count; /* nombre de coups légaux (0 : aucun coup
@@ -218,6 +222,14 @@ typedef struct {
float equity_loss; /* équité du coup joué moins celle du meilleur :
* 0 ou négative */
engine_skill skill; /* appréciation de gnubg pour cette perte */
/* Le classement de l'analyse, du meilleur au moins bon : les premiers
* coups de la liste que gnubg garde pour l'analyse de partie (ce que
* montre son panneau d'analyse), chacun au niveau où gnubg l'a
* évalué. Le meilleur coup et le coup joué y ont les mêmes évaluations
* que best et played ; le coup joué n'y figure que s'il est parmi les
* premiers. */
engine_move ranking[ENGINE_ANALYSIS_RANKING];
int ranking_count;
} engine_move_analysis;
/* Analyse d'une décision de videau, comme l'analyse de partie de gnubg. */
@@ -452,7 +464,15 @@ engine_luck_rating engine_luck_rating_of(float luck);
* Analyse le coup joué par le joueur au trait avec le jet die1, die2, au
* niveau ply, comme l'analyse de partie de gnubg (AnalyzeMove dans
* analysis.c : FindnSaveBestMoves avec le coup joué, seuil « douteux »,
* filtre « Normal », sans RefreshMoveList). played_position_id est la
* filtre « Normal », sans RefreshMoveList), classement compris.
*
* Niveaux : gnubg n'évalue au niveau ply que les coups retenus par ses
* filtres ; les autres gardent l'évaluation d'un niveau inférieur (champ
* ply de chaque coup, même convention que gnubg de bureau : 0 = statique).
* Si un seul coup passe le filtre du niveau ply - 1, gnubg s'arrête là, et
* tout le classement reste à ce niveau, sauf quand le coup joué est une
* erreur (écart au meilleur supérieur au seuil « douteux ») : le meilleur
* coup et le coup joué sont alors réévalués au niveau ply. played_position_id est la
* position après le coup, vue du joueur qui vient de le jouer (champ
* position_id d'engine_move). ENGINE_ERROR_MOVE si ce n'est pas un coup
* légal ; si aucun coup n'est possible, move_count vaut 0.
+36 -5
View File
@@ -11,10 +11,17 @@
# Le conseil du maître (tests/browser/maitre.mjs), captures dans
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
# d'analyse (tests/browser/bilan.mjs), captures dans
# build/browser-shots/bilan/.
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
# partie finie et son bilan, par chaque entrée
# (tests/browser/nouvelle-partie.mjs), captures dans
# build/browser-shots/nouvelle-partie/. Les retours des premiers joueurs
# (tests/browser/retours-joueurs.mjs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel, en thème clair et sombre), captures dans
# build/browser-shots/retours-joueurs/.
#
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan)
# restreignent le test.
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
# prompts, nouvelle-partie, retours-joueurs) 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
@@ -27,7 +34,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}"
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie retours-joueurs}"
unset MAKEFLAGS MFLAGS MAKELEVEL
make -s package >/dev/null
@@ -65,7 +72,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
rm -rf build/browser-shots
mkdir -p build/browser-shots
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
tests/browser/maitre.mjs tests/browser/bilan.mjs "$tools/"
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
tests/browser/retours-joueurs.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
@@ -105,5 +113,28 @@ for browser in $browsers; do
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
node bilan.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" prompts "* ]]; then
# « Demander à une IA » : boutons, feuille du prompt, copie (relue
# dans Chromium) ; captures dans build/browser-shots/prompts/.
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 prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
# « Nouvelle partie » après une partie finie et son bilan : la
# nouvelle partie tout de suite ; captures dans
# build/browser-shots/nouvelle-partie/.
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 nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
if [[ " $scripts " == *" retours-joueurs "* ]]; then
# Retours des premiers joueurs : pions sur la barre, numéros sur le
# cadre, flèches du tutoriel ; captures dans
# build/browser-shots/retours-joueurs/.
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 retours-joueurs.mjs "http://localhost:$port/" "$browser" /shots || status=1
fi
done
exit "$status"
+25 -8
View File
@@ -16,7 +16,7 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs";
const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
@@ -171,6 +171,7 @@ async function checkTutorialView(page, name) {
return b.scrollHeight - b.clientHeight;
})(),
height: innerHeight,
width: innerWidth,
buttons,
};
});
@@ -189,8 +190,13 @@ async function checkTutorialView(page, name) {
}
tutorialViews++;
tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber);
// Largeurs du plateau et du texte, comparées d'une étape à l'autre à la
// fin du tutoriel (tutorialWidthProblems).
const widths = await tutorialWidths(page);
tutorialWidthViews.push({ name, size: `${view.width}x${view.height}`, ...widths });
}
let tutorialViews = 0;
const tutorialWidthViews = [];
let tutorialBodyScroll = { px: 0, where: "" };
let tutorialMinNumber = Infinity;
@@ -268,17 +274,16 @@ async function playTouches(page, touches) {
await tutorialReady(page, hash);
}
if (step === 2) {
check(await page.locator("#tutorial-board .tutorial-path").count() === 1,
"étape 2 : pas de chemin en pointillés");
// Sous les pions et sous les numéros des flèches.
check(await page.locator("#tutorial-board .tutorial-path .direction-line").count() === 2,
"étape 2 : pas les deux flèches du sens du jeu");
// Sous les pions (détail : retours-joueurs.mjs).
const order = await page.evaluate(() => {
const children = [...document.getElementById("tutorial-board").children];
const at = (selector) => children.findIndex((c) => c.matches(selector));
const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c));
return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) };
return { path: at(".tutorial-path"), pieces: at("g.pieces") };
});
check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber,
`étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`);
check(order.path >= 0 && order.path < order.pieces,
`étape 2 : les flèches passent au-dessus des pions ${JSON.stringify(order)}`);
}
await tutorialCapture(page, `tutoriel-${step}`);
screens++;
@@ -331,6 +336,18 @@ async function playTouches(page, touches) {
await until(page, "fin", () => location.hash === "#/tutoriel/fin");
await page.click("#tutorial-next");
await until(page, "première partie", () => location.hash === "#/" && window.__shown("game"));
if (full) {
// Les mesures, écran par écran, à côté des captures.
writeFileSync(`${dir}/tutoriel-largeurs.txt`, tutorialWidthViews.map((v) =>
`${v.size}\t${v.name}\tplateau ${v.board.toFixed(1)}\ttexte ${v.text.toFixed(1)}\tpage ${v.page}`)
.join("\n") + "\n");
const widthProblems = tutorialWidthProblems(tutorialWidthViews);
check(widthProblems.length === 0, `largeurs du tutoriel :\n ${widthProblems.join("\n ")}`);
const sizes = [...new Set(tutorialWidthViews.map((v) => v.size))];
log(`tutoriel : plateau de même largeur à chaque étape (${sizes.map((s) =>
`${s} : ${tutorialWidthViews.find((v) => v.size === s).board.toFixed(1)} px`).join(", ")}), ` +
"aucune page plus large que l'écran");
}
log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` +
(full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` +
`texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : ""));
+9
View File
@@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine")
await page.click("#explain-mine");
const sheetLegend = await page.textContent("#explain-legend");
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
// Le coup y est un lien vers la fiche « Notation des coups ».
const notationLink = await page.evaluate(() => {
const a = document.querySelector("#explain-legend a.notation");
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
});
check(
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
);
await page.click("#explain-done");
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
// Le joueur passe.
+2 -2
View File
@@ -194,7 +194,7 @@ await page.click("#new-game-match");
await page.click("#new-game-length-3");
await page.click("#new-game-level-beginner");
s = await screen();
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match",
`match en 3 points : ${JSON.stringify(s)}`);
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
@@ -254,7 +254,7 @@ await page.click("#menu-new-game");
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden);
s = await screen();
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match",
`choix retrouvés : ${JSON.stringify(s)}`);
await page.click("#new-game-start");
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
+321
View File
@@ -0,0 +1,321 @@
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
// l'image de production.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
// une fin de course courte, partie d'un lien de position avec des dés
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
// (« Commencer la partie », « Commencer le match ») et que le coup de
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
// en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/nouvelle-partie`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
const BEAROFF = "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",
reducedMotion: "reduce",
});
await context.addInitScript(() => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
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}`),
);
});
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 = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture en portrait et en paysage (Chromium seulement), après la
* règle de la recette : rien d'interactif sous une barre d'action. */
async function capture(name) {
if (!full) {
return;
}
for (const size of [PORTRAIT, LANDSCAPE]) {
await page.setViewportSize(size);
await sleep(300);
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
* l'identifiant de la partie. */
async function finishedGameWithSummary() {
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
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(() => window.__dice.push(6, 1));
await page.click("#roll");
await idle();
for (const [from, to] of [
["p6", "p5"],
["p5", "off-player"],
]) {
await page.click(`#board .hit[data-place="${from}"]`);
await idle();
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
await page.click("#confirm");
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await page.click("#drop");
await until(
"bilan de la partie finie",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true",
);
return page.evaluate(() => location.hash.split("/")[2]);
}
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
* du bouton, puis commence. */
async function chooseAndStart(choice, shot) {
await until(
"écran « Nouvelle partie »",
() =>
location.hash === "#/nouvelle-partie" &&
!document.getElementById("new-game").hidden &&
!document.getElementById("new-game-start").disabled,
);
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
if (choice.match) {
await page.click(`#new-game-length-${choice.length}`);
}
await page.click(`#new-game-level-${choice.level}`);
const label = await page.textContent("#new-game-start");
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
if (shot) {
await capture("1-nouvelle-partie");
}
await page.click("#new-game-start");
}
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
* peu plus tard. */
async function checkNewGame(entry, oldId, choice) {
await until(
`${entry} : partie commencée`,
() => location.hash === "#/" && !document.body.classList.contains("busy"),
);
// Le défaut apparaissait juste après : l'adresse passait au bilan de
// l'ancienne partie. On regarde pendant deux secondes.
for (let waited = 0; waited < 2000; waited += 100) {
const s = await page.evaluate(() => ({
hash: location.hash,
summary: !document.getElementById("summary-veil").hidden,
panel: !document.getElementById("panel-veil").hidden,
board: !document.getElementById("game").hidden,
newGame: !document.getElementById("new-game").hidden,
}));
check(
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
);
await sleep(100);
}
await idle();
const shown = await page.evaluate(() => ({
line: document.getElementById("match-line").textContent,
lineShown: !document.getElementById("match-line").hidden,
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
over: document.getElementById("again").hidden === false,
}));
check(!shown.over, `${entry} : la partie à l'écran est finie`);
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
if (choice.match) {
check(
shown.lineShown && shown.line === `Match en ${choice.length} points`,
`${entry} : ligne du match « ${shown.line} »`,
);
} else {
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
}
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
}
// --------------------------- 1. L'application rouverte, puis l'accueil
let oldId = await finishedGameWithSummary();
// L'application rouverte : sans adresse, elle ouvre l'accueil.
await page.goto(base);
await until(
"accueil",
() =>
location.hash === "#/accueil" &&
!document.getElementById("home-new").hidden &&
!document.getElementById("home-new").disabled,
);
await page.click("#home-new");
let choice = { match: true, length: 3, level: "beginner" };
await chooseAndStart(choice, true);
await checkNewGame("accueil", oldId, choice);
await capture("2-partie-demarree");
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
for (let actions = 0; ; actions++) {
check(actions < 200, "l'ordinateur ne joue pas de coup");
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
if (link !== null) {
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
break;
}
const what = await page.evaluate(() => {
const usable = (id) => {
const b = document.getElementById(id);
return !b.hidden && !b.disabled;
};
for (const id of ["take", "skip", "confirm", "roll"]) {
if (usable(id)) {
return `#${id}`;
}
}
if (document.querySelector("#board .hit.can-land")) {
return "#board .hit.can-land";
}
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
});
if (what === null) {
await sleep(100);
continue;
}
await page.locator(what).first().click();
await idle();
}
await page.click("#info a.notation");
await until(
"fiche « Notation des coups »",
() =>
location.hash === "#/glossaire/notation-des-coups" &&
document.getElementById("glossaire-notation-des-coups") !== null,
);
await page.goBack();
await until("retour à la partie", () => location.hash === "#/");
log("ligne de l'ordinateur : la notation mène à sa fiche");
// --------------------------------- 2. L'application rouverte sur le menu
oldId = await finishedGameWithSummary();
await page.goto(`${base}#/menu`);
await until("menu", () => !document.getElementById("menu-veil").hidden);
await page.click("#menu-new-game");
choice = { match: false, level: "amateur" };
await chooseAndStart(choice, false);
await checkNewGame("menu", oldId, choice);
// ------------------------------- 3. « Changer les réglages » du bilan
oldId = await finishedGameWithSummary();
await page.click("#summary-settings");
choice = { match: true, length: 5, level: "intermediate" };
await chooseAndStart(choice, false);
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
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(`nouvelle partie : tout est conforme (${captures} captures)`);
+95 -11
View File
@@ -164,6 +164,45 @@ async function readJournal() {
);
}
/** L'identifiant de la partie en cours enregistrée dans IndexedDB
* (magasin settings, clé « current »), ou null. */
async function savedGameId() {
return page.evaluate(
() =>
new Promise((resolve, reject) => {
const open = indexedDB.open("ustagammon");
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const db = open.result;
const current = db.transaction("settings").objectStore("settings").get("current");
current.onsuccess = () => {
db.close();
resolve(current.result?.value ?? null);
};
};
}),
);
}
/**
* Attend que la partie en cours enregistrée ne soit plus oldId, et renvoie
* le temps d'attente en ms (null au bout de 30 s). L'application enregistre
* sans attendre l'écriture, dans une file (app/src/storage/game-library.ts) :
* sous Firefox, sur un disque lent et chargé, l'enregistrement de la
* nouvelle partie peut attendre derrière ceux de l'analyse de la partie
* finie, et une lecture directe d'IndexedDB trouver encore l'ancienne.
*/
async function waitForSavedGameOtherThan(oldId) {
const start = Date.now();
while (Date.now() - start < 30_000) {
if ((await savedGameId()) !== oldId) {
return Date.now() - start;
}
await sleep(100);
}
return null;
}
/** Le nombre de parties enregistrées dans IndexedDB. */
async function savedGameCount() {
await savedJournal(); // les enregistrements en cours terminés
@@ -587,17 +626,41 @@ async function checkTraces() {
* (les dés en ont une de chaque côté du plateau), chacune un bouton
* accessible d'au moins 44 px de côté. Renvoie le locator de la zone index. */
async function touchTarget(action, label, count = 1, index = 0) {
const zones = page.locator(`[data-action="${action}"]`);
check((await zones.count()) === count,
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
for (const zone of await zones.all()) {
check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") &&
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
const box = await zone.boundingBox();
check(box.width >= 44 && box.height >= 44,
`zone ${action} trop petite : ${Math.round(box.width)} x ${Math.round(box.height)} px`);
// Les zones sont relevées d'un coup dans la page, où le plateau ne peut
// pas se redessiner entre deux lectures : mesurées une à une depuis
// Playwright (boundingBox), une zone remplacée entre-temps par un
// nouveau dessin du plateau n'avait plus de boîte (null, vu sous
// Firefox). On attend l'état observable : le nombre de zones attendu,
// toutes dessinées (5 s au plus).
const read = () =>
page.evaluate(
(name) =>
[...document.querySelectorAll(`[data-action="${name}"]`)].map((zone) => {
const box = zone.getBoundingClientRect();
return {
role: zone.getAttribute("role"),
label: zone.getAttribute("aria-label"),
width: box.width,
height: box.height,
};
}),
action,
);
let zones = await read();
const start = Date.now();
while ((zones.length !== count || zones.some((z) => z.width === 0 && z.height === 0)) &&
Date.now() - start < 5000) {
await sleep(100);
zones = await read();
}
return zones.nth(index);
check(zones.length === count, `zone ${action} : ${zones.length} sur le plateau, ${count} attendues`);
for (const zone of zones) {
check(zone.role === (action === "menu" ? "link" : "button") && zone.label === label,
`zone ${action} sans rôle accessible`);
check(zone.width >= 44 && zone.height >= 44,
`zone ${action} trop petite : ${Math.round(zone.width)} x ${Math.round(zone.height)} px`);
}
return page.locator(`[data-action="${action}"]`).nth(index);
}
/** Le nombre de dés montrés, et de dés estompés (déjà joués). */
@@ -1026,6 +1089,7 @@ async function rematch() {
* immédiat, bandeau 10 s. Renvoie le journal de la partie quittée. */
async function newGameFromMenu() {
const before = await savedJournal();
const beforeId = await savedGameId();
const started = () =>
page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").gamesStarted);
const startedBefore = await started();
@@ -1038,6 +1102,8 @@ async function newGameFromMenu() {
`bandeau : « ${await page.textContent("#banner")} »`);
check(!(await page.isVisible("#menu")), "le menu reste ouvert après « Nouvelle partie »");
await waitForPlayer();
check((await waitForSavedGameOtherThan(beforeId)) !== null,
"« Nouvelle partie » : la partie n'a pas été enregistrée (30 s)");
const after = await savedJournal();
// (Les identifiants d'événements se suivent : une partie neuve peut avoir
// la même tête que la précédente ; on compte les parties commencées.)
@@ -1193,6 +1259,12 @@ for (let actions = 0; ; actions++) {
tracesSeen++;
if (!moments.has("coup-ordinateur")) {
moments.add("coup-ordinateur");
// La ligne du dernier coup (portrait seulement : en paysage, le
// plateau occupe tout l'écran), dans la notation du moteur.
const played = (await page.textContent("#info")) ?? "";
check(/^L'ordinateur a joué \d-\d : \S.*\.$/.test(played),
`coup de l'ordinateur mal écrit : « ${played} »`);
log(`coup de l'ordinateur : « ${played} »`);
await review("4-coup-ordinateur");
}
if (replays < 2) {
@@ -1611,11 +1683,17 @@ step("mes parties");
// recharger la page.
{
const finished = await savedJournal();
const finishedId = await savedGameId();
await rematch();
await waitForPlayer();
// L'état observable, et non un délai : la nouvelle partie devient la
// partie en cours enregistrée.
const waited = await waitForSavedGameOtherThan(finishedId);
check(waited !== null, "la nouvelle partie n'a pas été enregistrée (30 s)");
log(`nouvelle partie enregistrée ${waited} ms après la main au joueur`);
const second = await savedJournal();
check(second.head !== finished.head || second.events.length !== finished.events.length,
"la nouvelle partie n'a pas été enregistrée");
"la nouvelle partie enregistrée a le journal de la partie finie");
await openMenu();
await page.click("#menu-games");
await page.waitForSelector("#games-veil:not([hidden]) .games-item");
@@ -1738,6 +1816,12 @@ if (offline === "roll") {
await page.click("#roll");
await waitForPlayer();
}
// Si la partie reprise est finie, son bilan se rouvre au rechargement et
// couvre l'écran (c'est voulu) : on le ferme avant d'ouvrir l'aide.
if (await page.locator("#summary-veil").isVisible()) {
await page.click("#summary-close");
await page.waitForSelector("#summary-veil", { state: "hidden" });
}
await page.click("#help-button");
await page.waitForSelector(".glossary dt", { state: "attached" });
await page.click("#help-back");
+543
View File
@@ -0,0 +1,543 @@
// prompts.mjs -- « Demander à une IA » (étape 1f) par l'interface, dans un
// vrai navigateur sans écran (Playwright), sur le site servi par l'image de
// production (charte, ecrans-etape-1.md, « 1f. Demander à une IA »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node prompts.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. La fin de course courte de bilan.mjs : le
// joueur a 6-1, deux pions sur 6 et 1, et joue 6/5/off (grosse erreur) ;
// l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une
// IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une
// fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible
// avant la copie, rechargeable ; une seule feuille à la fois (depuis la
// feuille du maître ou le bilan, la page du prompt prend leur place, à la
// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans
// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et
// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié »,
// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à
// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ;
// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// dans <dossier des captures>/prompts/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
import { chromium, firefox } from "playwright";
import { hiddenUnderActionBar, tallButtons } from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node prompts.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/prompts`;
mkdirSync(dir, { recursive: true });
const PORTRAIT = { width: 412, height: 915 };
const LANDSCAPE = { width: 915, height: 412 };
const SIZES = [PORTRAIT, LANDSCAPE];
const BEAROFF = "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",
reducedMotion: "reduce",
});
if (full) {
// Chromium : le test relit le presse-papiers.
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: new URL(base).origin,
});
}
await context.addInitScript(() => {
window.__dice = [];
let state = 5;
Math.random = () => {
const die = window.__dice.shift();
if (die !== undefined) {
return (die - 0.5) / 6;
}
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
});
const page = await context.newPage();
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") {
problems.push(`console : ${message.text()}`);
}
});
// Aucun appel réseau hors du site : l'application n'appelle aucune IA.
const origin = new URL(base).origin;
page.on("request", (request) => {
const url = request.url();
if (!url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:")) {
problems.push(`requête hors du site : ${url}`);
}
});
/** 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 = 120_000) {
for (let waited = 0; waited < ms; waited += 100) {
if (await page.evaluate(fn, arg)) {
return;
}
await sleep(100);
}
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw new Error(`attente trop longue : ${what}`);
}
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
/** Une capture de la recette dans les deux tailles (Chromium seulement),
* après la règle commune : rien d'interactif sous une barre d'action, pas
* de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne
* dure que 2 s sur le bouton). */
async function capture(name, before = null) {
if (!full) {
return;
}
// « Copié » d'une copie précédente est reparti (sauf pour sa propre
// capture, où before le fait réapparaître).
await until("bouton revenu", notCopied, null, 10_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
await before?.();
const hidden = await hiddenUnderActionBar(page);
check(
hidden.length === 0,
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
);
const tall = await tallButtons(page);
check(tall.length === 0, `${name} ${size.width}x${size.height} : boutons étirés : ${tall.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
captures++;
}
await page.setViewportSize(PORTRAIT);
await sleep(300);
}
/** Le bouton de copie n'est pas (ou plus) en « Copié ». */
const notCopied = () => {
const copy = document.getElementById("prompt-copy");
return copy === null || !copy.classList.contains("copied");
};
/** La page du prompt, prête et visible : son texte. */
async function openPrompt(button, hashEnd) {
await page.locator(button).scrollIntoViewIfNeeded();
await page.click(button);
await until(
"feuille du prompt prête",
(end) => {
const prompt = document.getElementById("prompt-page");
return (
location.hash.endsWith(end) &&
prompt?.dataset.ready === "true" &&
prompt.getClientRects().length > 0
);
},
hashEnd,
);
return page.textContent("#prompt-text");
}
/** Une seule feuille visible : la page du prompt, dans la feuille hostId
* (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une
* croix et un titre visibles au plus ; « ‹ back ». */
async function checkOneSheet(hostId, back, what) {
const seen = await page.evaluate((host) => {
const visible = (el) =>
el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible";
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
const prompt = document.getElementById("prompt-page");
return {
parent: prompt.parentElement.id,
ownSheet: !document.getElementById("prompt-veil").hidden,
grips: count(".grip"),
closes: count('[role="dialog"] .icon-button'),
titles: count('[role="dialog"] h2'),
back: document.getElementById("prompt-back").textContent,
hostVisible: host === null || !document.getElementById(host).parentElement.hidden,
};
}, hostId);
check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`);
check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`);
check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`);
check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`);
check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`);
check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`);
check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`);
}
/** La hauteur et le haut d'un élément. */
const boxOf = (selector) =>
page.evaluate((s) => {
const r = document.querySelector(s).getBoundingClientRect();
return { top: Math.round(r.top), height: Math.round(r.height) };
}, selector);
/** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */
function checkPrompt(text, what) {
for (const part of [
"Tu es un coach de backgammon",
"Je débute au backgammon.",
"Position ID : ",
"Match ID : ",
"https://ustagammon.sans.pub/#/position/",
"X : mes pions",
"GNU Backgammon (analyse à 2 plis)",
"ne les contredis pas",
"Réponds en français",
]) {
check(text.includes(part), `${what} : « ${part} » absent`);
}
check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`);
}
/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */
const copiedOnButton = () => {
const copy = document.getElementById("prompt-copy");
return copy.classList.contains("copied") && copy.textContent === "Copié";
};
/** Copie faite (« Copié ») ou repli (le message sous le texte). */
const copyDone = () => {
const copy = document.getElementById("prompt-copy");
return (
copy.classList.contains("copied") ||
document.getElementById("prompt-status").textContent !== ""
);
};
/** Copie le prompt, attend « Copié » sur le bouton puis son retour à
* « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */
async function copyPrompt(what) {
await page.click("#prompt-copy");
await until("copie faite", copyDone, null, 10_000);
const shown = await page.evaluate(() => {
const copy = document.getElementById("prompt-copy");
return {
copied: copy.classList.contains("copied"),
label: copy.textContent,
check: copy.querySelector("svg.icon") !== null,
color: getComputedStyle(copy).color,
good: getComputedStyle(document.documentElement)
.getPropertyValue("--verdict-good")
.trim(),
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
banners: document.querySelectorAll("#prompt-banner").length,
};
});
check(shown.banners === 0, `${what} : un bandeau de copie`);
if (shown.copied) {
check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`);
// La couleur du bouton est verdict-good (#rrggbb dans app.css).
const rgb = shown.good
.replace("#", "")
.match(/../g)
.map((h) => Number.parseInt(h, 16))
.join(", ");
check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`);
// Deux secondes après, le bouton revient.
await until("« Copier le prompt » revenu", notCopied, null, 5_000);
const label = await page.textContent("#prompt-copy");
check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`);
}
if (full) {
check(shown.copied, `${what} : pas de « Copié » sur le bouton`);
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`);
return clipboard;
}
// Firefox : la copie, ou son repli (texte sélectionné, et un message).
check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`);
return shown.text;
}
/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */
async function backFromPrompt(hashBelow) {
await page.click("#prompt-back");
await promptGone(hashBelow);
}
/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */
async function promptGone(hashBelow) {
await until(
"feuille du prompt fermée",
(below) =>
document.getElementById("prompt-veil").hidden &&
document.querySelector(".prompting") === null &&
location.hash === below,
hashBelow,
);
}
// --------------------------------------- Une erreur, et son explication
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
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(() => window.__dice.push(6, 1));
await page.click("#roll");
await idle();
for (const [from, to] of [[6, 5], [5, 0]]) {
await page.click(`#board .hit[data-place="p${from}"]`);
await idle();
const target = to === 0 ? "off-player" : `p${to}`;
const land = page.locator(`#board .hit.can-land[data-place="${target}"]`);
if ((await land.count()) > 0) {
await land.click();
await idle();
}
}
await page.click("#confirm");
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
await until("ligne du maître", () => !document.getElementById("advice-line").hidden);
await page.click("#advice-line");
await until("feuille du maître", () => document.getElementById("explain-ask-ai") !== null);
const explainHash = await page.evaluate(() => location.hash);
// Les écarts de « Voir les chiffres du moteur », pour les comparer au prompt.
await page.click("#explain-numbers summary");
const sheetNumbers = await page.evaluate(() =>
[...document.querySelectorAll("#explain-numbers .explain-table span")].map((s) => s.textContent),
);
await capture("1-bouton-feuille-du-maitre", () =>
page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(),
);
// La feuille du maître, avant : sa hauteur, que la page du prompt garde.
const explainBox = await boxOf("#explain-sheet");
const moveText = await openPrompt("#explain-ask-ai", "/ia");
await checkOneSheet("explain-sheet", "Explication", "feuille du maître");
const promptBox = await boxOf("#prompt-page");
check(
Math.abs(promptBox.top - explainBox.top) <= 1 &&
Math.abs(promptBox.height - explainBox.height) <= 1,
`la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`,
);
check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille");
checkPrompt(moveText, "prompt du coup");
check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué");
check(moveText.includes("1. 6/off 1/off : équité"), "prompt du coup : meilleur coup");
check(moveText.includes("« Grosse erreur »"), "prompt du coup : appréciation");
// Chaque ligne du classement de la feuille (rang, coup, écart) est dans le
// prompt, avec le même écart.
for (let i = 0; i + 2 < sheetNumbers.length; i += 3) {
const [rank, move, loss] = sheetNumbers.slice(i, i + 3);
const notation = move.replace(" (vous)", "");
if (Number(rank) <= 3 || move.includes("(vous)")) {
check(
moveText.includes(`${rank}. ${notation}`) && moveText.includes(`écart ${loss}`),
`prompt du coup : ligne ${rank} ${notation} ${loss} absente`,
);
}
}
await capture("2-feuille-du-prompt");
const copied = await copyPrompt("prompt du coup");
await capture("3-prompt-copie", async () => {
await page.click("#prompt-copy");
await until("« Copié »", copiedOnButton);
});
log(`prompt du coup copié (${copied.length} caractères)`);
// Échap ramène à l'explication, qui n'est pas fermée.
await page.keyboard.press("Escape");
await promptGone(explainHash);
check(
await page.evaluate(
() =>
!document.getElementById("explain-veil").hidden &&
getComputedStyle(document.getElementById("explain-close")).visibility === "visible",
),
"l'explication n'est pas revenue",
);
// Rechargée, l'adresse de la feuille la rouvre.
await page.goto(`${base}${explainHash}/ia`);
await page.reload();
await until(
"feuille rouverte au rechargement",
() => document.getElementById("prompt-page")?.dataset.ready === "true",
);
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée");
// « ‹ Explication » y ramène.
await backFromPrompt(explainHash);
// La croix ferme la feuille : l'explication avec.
await openPrompt("#explain-ask-ai", "/ia");
await page.click("#prompt-close");
await until(
"feuille du maître fermée",
() => document.getElementById("explain-veil").hidden && location.hash === "#/",
);
await promptGone("#/");
// ------------------------------------------------------------- Le bilan
await page.click("#drop");
await until(
"bilan prêt",
() =>
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
document.getElementById("summary")?.dataset.ready === "true" &&
document.getElementById("summary-ask-ai") !== null,
);
await until("mini-plateau du coup à revoir", () =>
document.querySelector("#summary-board .board-frame") !== null,
);
const summaryHash = await page.evaluate(() => location.hash);
const id = summaryHash.split("/")[2];
await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded());
const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia");
await checkOneSheet("summary", "Bilan", "bilan");
checkPrompt(summaryText, "prompt du bilan");
check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat");
check(
summaryText.includes("j'ai joué 6/off, GNU Backgammon préférait 6/off 1/off"),
"prompt du bilan : erreur",
);
check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir");
await capture("5-feuille-du-prompt-bilan");
await copyPrompt("prompt du bilan");
// Le retour du téléphone ramène au bilan.
await page.goBack();
await promptGone(summaryHash);
check(
await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"),
"le bilan n'est plus là après la feuille du prompt",
);
// ----------------------------------------------------------- L'analyse
await page.click("#summary-revisit");
await until(
"analyse sur le coup à revoir",
(game) =>
location.hash === `#/parties/${game}/analyse/1` &&
document.querySelector("#analysis-board .board-frame") !== null &&
!document.getElementById("analysis-ask-ai").hidden,
id,
);
await capture("6-bouton-analyse", () =>
page.locator("#analysis-ask-ai").scrollIntoViewIfNeeded(),
);
const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`);
checkPrompt(analysisText, "prompt de l'analyse");
await checkOneSheet(null, "Analyse", "analyse");
// Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau
// du joueur, lui, compte maintenant toutes ses décisions de la partie).
const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations"));
check(
numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50,
"les chiffres du coup diffèrent entre la feuille du maître et l'analyse",
);
await backFromPrompt(`#/parties/${id}/analyse/1`);
// Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de
// videau.
await page.goto(`${base}#/parties/${id}/analyse/2`);
await until("coup 2", () =>
document.getElementById("analysis-turn")?.textContent.startsWith("Coup 2 sur 2") &&
!document.getElementById("analysis-ask-ai").hidden,
);
const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia");
checkPrompt(cubeText, "prompt du videau");
check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau");
check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision");
// « Fermer » : sans feuille dessous, retour à l'analyse.
await page.click("#prompt-done");
await promptGone(`#/parties/${id}/analyse/2`);
// --------------------------------------------------------- Le glossaire
await page.goto(`${base}#/glossaire/videau`);
await until(
"fiche du videau",
() => {
const link = document.getElementById("glossary-ask-ai-videau");
return link !== null && !link.hidden;
},
);
check(
(await page.locator(".term-card .ask-ai:not([hidden])").count()) === 1,
"un seul bouton « Demander à une IA » dans le glossaire",
);
await capture("7-bouton-glossaire", () =>
page.locator("#glossary-ask-ai-videau").scrollIntoViewIfNeeded(),
);
const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia");
checkPrompt(conceptText, "prompt du concept");
await checkOneSheet(null, "Glossaire", "glossaire");
check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept");
check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation");
await capture("8-feuille-du-prompt-glossaire");
await copyPrompt("prompt du concept");
if (full) {
// Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le
// texte est sélectionné dans la feuille et copié par le navigateur.
await until("bouton revenu", notCopied, null, 5_000);
await page.evaluate(async () => {
await navigator.clipboard.writeText("");
navigator.clipboard.writeText = () => Promise.reject(new Error("refusé"));
});
await page.click("#prompt-copy");
await until("copie de repli", copyDone, null, 10_000);
const fallback = await page.evaluate(async () => ({
selected: window.getSelection().toString(),
text: document.getElementById("prompt-text").textContent,
copied: document.getElementById("prompt-copy").classList.contains("copied"),
clipboard: await navigator.clipboard.readText(),
}));
// Copié depuis une sélection, le texte perd ses espaces insécables
// (« 41 % ») : ce sont des espaces ordinaires dans le presse-papiers.
const plain = (text) => text.replace(/ /g, " ").trim();
check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné");
check(
fallback.copied && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
);
log("repli par sélection du texte : copié");
}
await backFromPrompt("#/glossaire/videau");
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(`demander à une IA : tout est conforme (${captures} captures)`);
+172
View File
@@ -98,3 +98,175 @@ export async function hiddenUnderActionBar(target) {
return hidden;
});
}
/**
* Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne
* de texte, et la largeur de la page (scrollWidth) face à celle de l'écran.
* Une page plus large que l'écran défile de côté ; sur un téléphone, Chrome
* la dézoome pour la montrer en entier, et tout l'écran paraît rétréci.
*/
export async function tutorialWidths(target) {
return target.evaluate(() => {
const width = (selector) => {
const el = document.querySelector(selector);
return el === null ? 0 : el.getBoundingClientRect().width;
};
const page = document.scrollingElement;
// Le plus à droite des descendants du tutoriel, pour nommer le
// coupable d'un débordement.
let widest = { what: "", right: 0 };
for (const el of document.querySelectorAll("#tutorial *")) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > widest.right) {
const name = el.id
? `#${el.id}`
: `${el.tagName.toLowerCase()}${[...el.classList].map((c) => `.${c}`).join("")}`;
widest = { what: name, right: r.right };
}
}
return {
board: width("#tutorial-board"),
text: width("#tutorial .tutorial-body"),
page: Math.max(page.scrollWidth, document.body.scrollWidth),
screen: innerWidth,
widest,
};
});
}
/**
* Règle de la recette : pour une taille d'écran donnée, le plateau du
* tutoriel a la même largeur à toutes les étapes, et aucune page n'est plus
* large que l'écran. views : [{ name, size, board, text, page, screen,
* widest }]. Renvoie la liste des écarts, décrits (vide si tout va bien).
*/
export function tutorialWidthProblems(views) {
const problems = [];
const bySize = new Map();
for (const v of views) {
bySize.set(v.size, [...(bySize.get(v.size) ?? []), v]);
if (v.page > v.screen + 0.5) {
problems.push(
`${v.name} en ${v.size} : page de ${v.page} px pour ${v.screen} px ` +
`(le plus à droite : ${v.widest.what}, ${Math.round(v.widest.right)} px)`,
);
}
}
for (const [size, list] of bySize) {
const widths = list.map((v) => v.board);
const min = Math.min(...widths);
const max = Math.max(...widths);
if (max - min > 0.5) {
const odd = list
.filter((v) => Math.abs(v.board - max) > 0.5)
.map((v) => `${v.name} ${v.board.toFixed(1)}`);
problems.push(
`${size} : plateau de ${min.toFixed(1)} à ${max.toFixed(1)} px (${odd.join(", ")})`,
);
}
}
return problems;
}
/**
* Les contrastes des pions dessinés sur les plateaux visibles de la page
* (svg.board), mesurés sur le fond qui porte chacun : la barre, une flèche,
* le fond du plateau ou la sortie, trouvé au centre du pion (le dernier
* dessiné qui le contient). Pour chaque pion : son camp, ce fond, le
* contraste de son contour (stroke) et celui de sa couleur (fill) avec ce
* fond. Les pions entourés de brique (jouable, sélectionné) sont laissés :
* leur contour est celui du pion jouable, pas celui de son camp.
*/
export async function checkerContrasts(target) {
return target.evaluate(() => {
const rgb = (color) => (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color) => {
const [r, g, b] = rgb(color).map((v) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [dark, light] = [luminance(a), luminance(b)].sort((x, y) => x - y);
return (light + 0.05) / (dark + 0.05);
};
const BACKGROUNDS = [
["board-bar", "barre"],
["board-tray", "sortie"],
["point-a", "flèche impaire"],
["point-b", "flèche paire"],
["board-bed", "fond"],
];
const selector = BACKGROUNDS.map(([c]) => `.${c}`).join(", ");
const found = [];
for (const svg of document.querySelectorAll("svg.board")) {
if (svg.closest("[hidden]") !== null || svg.getClientRects().length === 0) {
continue;
}
const shapes = [...svg.querySelectorAll(selector)].reverse();
for (const checker of svg.querySelectorAll("circle.checker")) {
if (checker.matches(".movable, .selected")) {
continue;
}
const point = svg.createSVGPoint();
point.x = Number(checker.getAttribute("cx"));
point.y = Number(checker.getAttribute("cy"));
const under = shapes.find((s) => s.isPointInFill(point));
if (under === undefined) {
continue;
}
const ground = getComputedStyle(under).fill;
const style = getComputedStyle(checker);
found.push({
board: svg.id || "plateau",
side: checker.matches(".checker-computer") ? "computer" : "player",
on: BACKGROUNDS.find(([c]) => under.classList.contains(c))[1],
edge: ratio(style.stroke, ground),
fill: ratio(style.fill, ground),
});
}
}
return found;
});
}
/**
* Règle de la recette (recette-ux.md, « Pions de l'ordinateur lisibles
* partout ») : là où un pion de l'ordinateur ne se détache pas de son fond
* par sa couleur (moins de 3:1 : sur la barre, les flèches sombres, le fond
* sombre), son contour s'en détache à 3:1 au moins (guide de marque :
* contours porteurs de sens à 3:1). Et (« Pions du joueur lisibles
* partout ») : en thème clair, le contour d'un pion du joueur atteint 3:1 sur
* les flèches en chêne et sur le fond. contrasts : le résultat de
* checkerContrasts ; theme : « light » ou « dark ». Renvoie la liste des
* écarts, décrits (vide si tout va bien).
*/
export function checkerContrastProblems(contrasts, theme) {
const problems = new Set();
for (const c of contrasts) {
if (c.side === "computer" && c.fill < 3 && c.edge < 3) {
problems.add(`pion de l'ordinateur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
}
if (
c.side === "player" &&
theme === "light" &&
(c.on === "flèche paire" || c.on === "fond") &&
c.edge < 3
) {
problems.add(`pion du joueur sur ${c.on} (${c.board}) : contour à ${c.edge.toFixed(2)}:1`);
}
}
return [...problems];
}
/** Le plus faible contraste du contour, par camp et par fond, pour le
* journal du test (« ordinateur sur barre 6.79:1, … »). */
export function checkerContrastSummary(contrasts) {
const lowest = new Map();
for (const c of contrasts) {
const key = `${c.side === "computer" ? "ordinateur" : "joueur"} sur ${c.on}`;
lowest.set(key, Math.min(lowest.get(key) ?? Number.POSITIVE_INFINITY, c.edge));
}
return [...lowest].map(([key, edge]) => `${key} ${edge.toFixed(2)}:1`).join(", ");
}
+263
View File
@@ -0,0 +1,263 @@
// retours-joueurs.mjs -- les règles tirées des retours des premiers joueurs
// (ADR 0010, amendement du 2026-11-11 ; recette-ux.md), dans un vrai
// navigateur sans écran (Playwright), sur le site servi par l'image de
// production, en thème clair et en thème sombre :
//
// - pions de l'ordinateur lisibles sur la barre : un pion de chaque camp sur
// la barre, contraste du contour de chaque pion sur son fond
// (recette.mjs, checkerContrasts) ;
// - numéros des flèches sur le cadre, en portrait comme en paysage, de
// 12 px au moins ;
// - étape 2 du tutoriel : deux flèches circulaires, claire et sombre, sous
// les pions, jamais en brique.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node retours-joueurs.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Captures (Chromium) dans
// <dossier des captures>/retours-joueurs/, en 412×915 et 915×412.
import { mkdirSync, writeFileSync } from "node:fs";
import { chromium, firefox } from "playwright";
import {
checkerContrastProblems,
checkerContrastSummary,
checkerContrasts,
hiddenUnderActionBar,
} from "./recette.mjs";
const [base, browserName, shots] = process.argv.slice(2);
const browserType = { chromium, firefox }[browserName];
if (!base || !browserType || !shots) {
console.error("usage : node retours-joueurs.mjs <adresse> <chromium|firefox> <dossier des captures>");
process.exit(2);
}
const full = browserName === "chromium";
const dir = `${shots}/retours-joueurs`;
mkdirSync(dir, { recursive: true });
const SIZES = [
{ width: 412, height: 915 },
{ width: 915, height: 412 },
];
// Positions de gnubg, chacun vu de lui-même : la position de départ, et la
// même avec un pion de chaque camp de sa flèche 24 sur la barre.
const OPENING = "4HPwATDgc/ABMA";
const BOTH_ON_BAR = "4HPwAVDgc/ABUA";
const problems = [];
const log = (text) => console.log(`[${browserName}] ${text}`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const contrasts = { light: [], dark: [] };
let captures = 0;
function check(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const browser = await browserType.launch();
/** Un contexte neuf dans un thème, tutoriel déjà vu (l'accueil ne propose
* plus le premier lancement). */
async function newPage(theme) {
const context = await browser.newContext({
viewport: SIZES[0],
deviceScaleFactor: 1,
locale: "fr-FR",
reducedMotion: "reduce",
});
await context.addInitScript((saved) => {
window.__csp = [];
document.addEventListener("securitypolicyviolation", (e) =>
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
);
if (localStorage.getItem("ustagammon.settings") === null) {
localStorage.setItem("ustagammon.settings", saved);
}
}, JSON.stringify({ theme, 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()}`);
}
});
return { context, page };
}
/** 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(page, 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}`);
}
/** Une partie à partir d'une position, joueur au trait, dés à lancer. */
async function startFrom(page, position) {
await page.goto(`${base}#/position/${encodeURIComponent(position)}`);
await until(page, "position lue", () => {
const play = document.getElementById("position-play");
return !play.hidden && !play.disabled;
});
await page.click("#position-play", { timeout: 15_000 }).catch(async (error) => {
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
throw error;
});
await until(
page,
"partie commencée",
() =>
location.hash === "#/" &&
!document.body.classList.contains("busy") &&
!document.getElementById("roll").hidden,
);
}
/** Les numéros des flèches du plateau visible : tous sur le cadre (hors du
* fond du plateau) et de 12 px au moins à l'écran. Renvoie les écarts. */
async function numberProblems(page, selector) {
return page.evaluate((selector) => {
const svg = document.querySelector(selector);
const box = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scale = Math.min(box.width / vb.width, box.height / vb.height);
const bed = svg.querySelector(".board-bed").getBBox();
const found = [];
const numbers = [...svg.querySelectorAll(".point-number")];
if (numbers.length !== 24) {
found.push(`${numbers.length} numéros`);
}
for (const text of numbers) {
const b = text.getBBox();
const px = Number(text.getAttribute("font-size")) * scale;
if (b.y + b.height > bed.y + 0.5 && b.y < bed.y + bed.height - 0.5) {
found.push(`flèche ${text.textContent} dans le fond du plateau`);
}
if (px < 12) {
found.push(`flèche ${text.textContent} en ${px.toFixed(1)} px`);
}
}
return found;
}, selector);
}
/** Les règles sur l'écran courant, dans chaque taille, et sa capture
* (Chromium). */
async function checkScreen(page, theme, name, selector, settle = async () => {}) {
// Le bandeau « Reprendre l'ancienne » (10 s) n'est pas le sujet.
await until(page, "bandeau parti", () => document.getElementById("banner").hidden, null, 15_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
await settle();
const where = `${name} ${theme} ${size.width}x${size.height}`;
const numbers = await numberProblems(page, selector);
check(numbers.length === 0, `${where} : numéros : ${numbers.join(", ")}`);
const measured = await checkerContrasts(page);
check(measured.length > 0, `${where} : aucun pion mesuré`);
const low = checkerContrastProblems(measured, theme);
check(low.length === 0, `${where} : ${low.join(", ")}`);
contrasts[theme].push(...measured);
if (full) {
const hidden = await hiddenUnderActionBar(page);
check(hidden.length === 0, `${where} : caché sous la barre d'action : ${hidden.join(", ")}`);
await page.screenshot({ path: `${dir}/${name}-${theme}-${size.width}x${size.height}.png` });
captures++;
}
}
await page.setViewportSize(SIZES[0]);
await sleep(200);
}
for (const theme of ["light", "dark"]) {
const { context, page } = await newPage(theme);
// Un pion de chaque camp sur la barre.
await startFrom(page, BOTH_ON_BAR);
check(
(await page.evaluate(() => document.documentElement.dataset.theme)) === theme,
`thème ${theme} non appliqué`,
);
const onBar = await page.evaluate(() =>
["player", "computer"].map(
(side) => document.querySelectorAll(`#board [data-checker="bar-${side}"]`).length,
),
);
check(onBar[0] === 1 && onBar[1] === 1, `pions sur la barre : ${onBar}`);
await checkScreen(page, theme, "barre", "#board");
const bar = contrasts[theme].filter((c) => c.on === "barre" && c.side === "computer");
check(bar.length > 0, `${theme} : pion de l'ordinateur sur la barre non mesuré`);
// La position de départ : les numéros sur le cadre, en portrait aussi.
await startFrom(page, OPENING);
await checkScreen(page, theme, "numeros-sur-le-cadre", "#board");
// L'étape 2 du tutoriel : deux flèches circulaires sous les pions.
await page.goto(`${base}#/tutoriel/2`);
const ready = () =>
until(page, "tutoriel, étape 2", () => {
const section = document.getElementById("tutorial");
return (
location.hash === "#/tutoriel/2" &&
section.dataset.ready !== undefined &&
document.querySelectorAll("#tutorial-board .tutorial-path").length === 1
);
});
await ready();
const arrows = async () => {
const drawn = await page.evaluate(() => {
const svg = document.getElementById("tutorial-board");
const children = [...svg.children];
const path = svg.querySelector(".tutorial-path");
const colors = (selector) =>
[...path.querySelectorAll(selector)].map((el) => getComputedStyle(el).stroke);
return {
path: children.indexOf(path),
pieces: children.findIndex((c) => c.matches("g.pieces")),
player: colors(".direction-line.direction-player"),
computer: colors(".direction-line.direction-computer"),
heads: path.querySelectorAll(".direction-head").length,
pieceColors: ["player", "computer"].map(
(side) => getComputedStyle(svg.querySelector(`.checker-${side}`)).fill,
),
};
});
check(drawn.path >= 0 && drawn.path < drawn.pieces, `étape 2 : flèches au-dessus des pions ${JSON.stringify(drawn)}`);
check(drawn.player.length === 1 && drawn.computer.length === 1 && drawn.heads === 2,
`étape 2 : flèches ${JSON.stringify(drawn)}`);
// Aux couleurs des pions de chaque camp, donc jamais en brique.
check(drawn.player[0] === drawn.pieceColors[0] && drawn.computer[0] === drawn.pieceColors[1],
`étape 2 : flèches pas aux couleurs des pions ${JSON.stringify(drawn)}`);
};
await checkScreen(page, theme, "tutoriel-2", "#tutorial-board", async () => {
await ready();
await arrows();
});
const csp = await page.evaluate(() => window.__csp);
check(csp.length === 0, `violations de la CSP : ${csp.join(", ")}`);
await context.close();
log(`thème ${theme} : contours ${checkerContrastSummary(contrasts[theme])}`);
}
await browser.close();
if (full) {
// Les mesures, à côté des captures.
writeFileSync(
`${dir}/contrastes.txt`,
["light", "dark"].map((theme) => `${theme}\t${checkerContrastSummary(contrasts[theme])}`).join("\n") + "\n",
);
}
check(problems.length === 0, `problèmes relevés :\n ${problems.join("\n ")}`);
log(
"retours des joueurs : pions des deux camps sur la barre, numéros sur le cadre, " +
`flèches circulaires du tutoriel, thèmes clair et sombre${full ? ` ; ${captures} captures` : ""}`,
);
+9 -1
View File
@@ -256,7 +256,11 @@ Un fichier de résultat reprend le cas, avec `type` (`move`, `cube` ou
gnubg plus 1), `played_equity`, `best_move`, `best_equity`, `equity_loss`
(perte du coup joué, 0 ou négative : statistique « error-skill » de gnubg
changée de signe), `skill` (`none`, `doubtful`, `bad`, `very_bad`) et
`ranking` (les dix premiers coups, et jusqu'au coup joué) ; plus, une fois
`ranking` (les dix premiers coups, et jusqu'au coup joué, chacun avec
`move`, `ply` et `equity` : comme pour `hint`, `ply` est le niveau auquel
gnubg a réellement évalué le coup ; à la différence de `hint`, l'analyse
réévalue au niveau demandé le meilleur coup et le coup joué quand celui-ci
est une erreur, au-delà du seuil « douteux ») ; plus, une fois
pour toutes, `luck` : `value` (chance du jet) et `rating`
(`very_unlucky`, `unlucky`, `none`, `lucky`, `very_lucky`) ;
- **videau** : `analysed` (gnubg analyse-t-il cette décision ? pas à la
@@ -337,6 +341,10 @@ Pour chaque analyse (`engine-cli analysemove`, `analysecube`, `luck`, et
équité que le coup joué, à la tolérance près : remarque), même meilleur coup
sauf égalité, et `played_equity`, `best_equity`, `equity_loss` à la
tolérance près ;
- classement de l'analyse (`ranking`, dix coups au plus) : chaque coup de
gnubg présent (sauf égalité avec le dixième), au même `ply` et à la même
équité à la tolérance près ; son premier coup et le coup joué y sont
exactement `best` et `played` ;
- chance : `value` à la tolérance près ;
- videau : même `analysed`, même décision et équités à la tolérance près,
`equity_loss` à la tolérance près ;
@@ -25,6 +25,7 @@
"ranking": [
{
"move": "6/off(2) 4/off 1/off",
"ply": 0,
"equity": 0.8888838
}
]
@@ -42,6 +43,7 @@
"ranking": [
{
"move": "6/off(2) 4/off 1/off",
"ply": 0,
"equity": 0.8888838
}
]
@@ -25,438 +25,547 @@
"ranking": [
{
"move": "13/10 6/3 4/1*(2)",
"ply": 0,
"equity": 0.9465638
},
{
"move": "8/5 6/3 4/1*(2)",
"ply": 0,
"equity": 0.9422797
},
{
"move": "13/7 13/10 6/3",
"ply": 0,
"equity": 0.94128984
},
{
"move": "13/7 8/5 6/3",
"ply": 0,
"equity": 0.9370698
},
{
"move": "13/10(2) 7/4 6/3",
"ply": 0,
"equity": 0.93615985
},
{
"move": "13/10(2) 8/5 6/3",
"ply": 0,
"equity": 0.9330082
},
{
"move": "13/10 8/5 7/4 6/3",
"ply": 0,
"equity": 0.932737
},
{
"move": "13/10 7/1* 6/3",
"ply": 0,
"equity": 0.9313148
},
{
"move": "8/5 7/1* 6/3",
"ply": 0,
"equity": 0.93129885
},
{
"move": "13/4 6/3",
"ply": 0,
"equity": 0.92980635
},
{
"move": "13/10 8/5(2) 6/3",
"ply": 0,
"equity": 0.9264537
},
{
"move": "13/10(3) 6/3",
"ply": 0,
"equity": 0.9262361
},
{
"move": "13/10(2) 4/1*(2)",
"ply": 0,
"equity": 0.92529553
},
{
"move": "8/5(2) 7/4 6/3",
"ply": 0,
"equity": 0.9252107
},
{
"move": "7/1* 6/3 4/1",
"ply": 0,
"equity": 0.92395884
},
{
"move": "13/10 6/3 5/2(2)",
"ply": 0,
"equity": 0.9204093
},
{
"move": "13/7 4/1*(2)",
"ply": 0,
"equity": 0.9190106
},
{
"move": "13/7(2)",
"ply": 0,
"equity": 0.9184398
},
{
"move": "13/10 8/5 4/1*(2)",
"ply": 0,
"equity": 0.9182539
},
{
"move": "7/4 6/3 5/2(2)",
"ply": 0,
"equity": 0.91752934
},
{
"move": "13/7 13/10(2)",
"ply": 0,
"equity": 0.91483337
},
{
"move": "13/10 8/2 6/3",
"ply": 0,
"equity": 0.912639
},
{
"move": "13/7 13/10 8/5",
"ply": 0,
"equity": 0.91152656
},
{
"move": "13/10(2) 7/1*",
"ply": 0,
"equity": 0.9115156
},
{
"move": "13/1*",
"ply": 0,
"equity": 0.9111869
},
{
"move": "13/10(2) 6/3 4/1*",
"ply": 0,
"equity": 0.91055506
},
{
"move": "8/2 8/5 6/3",
"ply": 0,
"equity": 0.9091699
},
{
"move": "8/2 7/4 6/3",
"ply": 0,
"equity": 0.90897554
},
{
"move": "13/7 8/5(2)",
"ply": 0,
"equity": 0.90817225
},
{
"move": "13/7 6/3 4/1*",
"ply": 0,
"equity": 0.90714073
},
{
"move": "13/10(2) 8/5 7/4",
"ply": 0,
"equity": 0.9070756
},
{
"move": "8/2(2)",
"ply": 0,
"equity": 0.9065211
},
{
"move": "13/10 8/5 7/1*",
"ply": 0,
"equity": 0.90571
},
{
"move": "13/10(3) 7/4",
"ply": 0,
"equity": 0.9040942
},
{
"move": "13/10 8/5 6/3 4/1*",
"ply": 0,
"equity": 0.90343916
},
{
"move": "8/5(2) 4/1*(2)",
"ply": 0,
"equity": 0.90325713
},
{
"move": "5/2(2) 4/1*(2)",
"ply": 0,
"equity": 0.90183336
},
{
"move": "13/10 7/1* 4/1",
"ply": 0,
"equity": 0.9013542
},
{
"move": "13/4 7/4",
"ply": 0,
"equity": 0.9010268
},
{
"move": "13/7 5/2(2)",
"ply": 0,
"equity": 0.90037674
},
{
"move": "8/5(2) 7/1*",
"ply": 0,
"equity": 0.89956194
},
{
"move": "13/10 8/5(2) 7/4",
"ply": 0,
"equity": 0.8988826
},
{
"move": "8/2 6/3 5/2",
"ply": 0,
"equity": 0.8984738
},
{
"move": "13/4 13/10",
"ply": 0,
"equity": 0.89749956
},
{
"move": "6/3(2) 4/1*(2)",
"ply": 0,
"equity": 0.8951012
},
{
"move": "13/10(2) 8/5(2)",
"ply": 0,
"equity": 0.89493525
},
{
"move": "13/7 8/2",
"ply": 0,
"equity": 0.8946096
},
{
"move": "13/7 6/3(2)",
"ply": 0,
"equity": 0.89403474
},
{
"move": "7/1* 5/2(2)",
"ply": 0,
"equity": 0.8924208
},
{
"move": "8/5 7/1* 4/1",
"ply": 0,
"equity": 0.891961
},
{
"move": "13/10(2) 6/3(2)",
"ply": 0,
"equity": 0.89077055
},
{
"move": "13/4 8/5",
"ply": 0,
"equity": 0.8907301
},
{
"move": "8/5(2) 6/3 4/1*",
"ply": 0,
"equity": 0.8900244
},
{
"move": "13/10(2) 5/2(2)",
"ply": 0,
"equity": 0.8890666
},
{
"move": "7/4 6/3(3)",
"ply": 0,
"equity": 0.8890004
},
{
"move": "13/10 7/4 5/2(2)",
"ply": 0,
"equity": 0.8888954
},
{
"move": "8/2 8/5 7/4",
"ply": 0,
"equity": 0.8876569
},
{
"move": "13/10(4)",
"ply": 0,
"equity": 0.88716996
},
{
"move": "7/1* 6/3(2)",
"ply": 0,
"equity": 0.8856077
},
{
"move": "8/2 4/1*(2)",
"ply": 0,
"equity": 0.8853629
},
{
"move": "13/10 6/3(3)",
"ply": 0,
"equity": 0.8853096
},
{
"move": "13/7 6/3 5/2",
"ply": 0,
"equity": 0.8831558
},
{
"move": "13/10 8/2 7/4",
"ply": 0,
"equity": 0.8819176
},
{
"move": "7/1* 4/1(2)",
"ply": 0,
"equity": 0.88113916
},
{
"move": "13/10 7/4 6/3(2)",
"ply": 0,
"equity": 0.88064635
},
{
"move": "13/10(3) 8/5",
"ply": 0,
"equity": 0.88049895
},
{
"move": "6/3 5/2(2) 4/1*",
"ply": 0,
"equity": 0.8790827
},
{
"move": "13/10(2) 8/2",
"ply": 0,
"equity": 0.8782023
},
{
"move": "6/3 5/2 4/1*(2)",
"ply": 0,
"equity": 0.8774586
},
{
"move": "8/5 6/3(3)",
"ply": 0,
"equity": 0.8774268
},
{
"move": "8/2 7/1*",
"ply": 0,
"equity": 0.8772669
},
{
"move": "13/7 13/10 4/1*",
"ply": 0,
"equity": 0.87700987
},
{
"move": "13/10 8/5 6/3(2)",
"ply": 0,
"equity": 0.8757389
},
{
"move": "13/10 8/2 8/5",
"ply": 0,
"equity": 0.87530077
},
{
"move": "13/10(2) 6/3 5/2",
"ply": 0,
"equity": 0.87215745
},
{
"move": "13/7 8/5 4/1*",
"ply": 0,
"equity": 0.87202764
},
{
"move": "8/5 7/4 6/3(2)",
"ply": 0,
"equity": 0.86896765
},
{
"move": "7/1* 6/3 5/2",
"ply": 0,
"equity": 0.8687051
},
{
"move": "13/10 7/4 6/3 5/2",
"ply": 0,
"equity": 0.8679464
},
{
"move": "8/2 6/3 4/1*",
"ply": 0,
"equity": 0.86750233
},
{
"move": "13/10(2) 8/5 4/1*",
"ply": 0,
"equity": 0.86701244
},
{
"move": "8/5(2) 6/3(2)",
"ply": 0,
"equity": 0.86167765
},
{
"move": "13/10(3) 4/1*",
"ply": 0,
"equity": 0.8568015
},
{
"move": "6/3(2) 5/2(2)",
"ply": 0,
"equity": 0.85501754
},
{
"move": "8/2 7/4 5/2",
"ply": 0,
"equity": 0.8537997
},
{
"move": "8/2 5/2(2)",
"ply": 0,
"equity": 0.8524791
},
{
"move": "13/10 8/5(2) 4/1*",
"ply": 0,
"equity": 0.84969926
},
{
"move": "13/10 8/2 5/2",
"ply": 0,
"equity": 0.8466199
},
{
"move": "8/2 6/3(2)",
"ply": 0,
"equity": 0.80814815
},
{
"move": "13/10 5/2(2) 4/1*",
"ply": 0,
"equity": 0.8037353
},
{
"move": "13/10 5/2 4/1*(2)",
"ply": 0,
"equity": 0.77350885
},
{
"move": "13/10 6/3(2) 4/1*",
"ply": 0,
"equity": 0.74067366
},
{
"move": "13/7 13/10 5/2",
"ply": 0,
"equity": 0.72703636
},
{
"move": "13/10(2) 7/4 5/2",
"ply": 0,
"equity": 0.7053956
},
{
"move": "6/3(3) 4/1*",
"ply": 0,
"equity": 0.66575944
},
{
"move": "13/10 7/1* 5/2",
"ply": 0,
"equity": 0.6462204
},
{
"move": "8/5 6/3(2) 4/1*",
"ply": 0,
"equity": 0.6374039
},
{
"move": "8/2 8/5 4/1*",
"ply": 0,
"equity": 0.62353325
},
{
"move": "13/10 8/2 4/1*",
"ply": 0,
"equity": 0.61257124
},
{
"move": "13/10 6/3 5/2 4/1*",
"ply": 0,
"equity": 0.5349428
},
{
"move": "7/1* 5/2 4/1",
"ply": 0,
"equity": 0.45721298
},
{
"move": "8/2 5/2 4/1*",
"ply": 0,
"equity": 0.44362232
},
{
"move": "13/4 5/2",
"ply": 0,
"equity": 0.43828273
},
{
"move": "13/10(3) 5/2",
"ply": 0,
"equity": 0.2798175
},
{
"move": "13/10 6/3(2) 5/2",
"ply": 0,
"equity": 0.24207813
},
{
"move": "6/3(3) 5/2",
"ply": 0,
"equity": 0.23385207
},
{
"move": "7/4 6/3(2) 5/2",
"ply": 0,
"equity": 0.22096781
},
{
"move": "13/7 5/2 4/1*",
"ply": 0,
"equity": 0.18849733
},
{
"move": "13/10(2) 5/2 4/1*",
"ply": 0,
"equity": 0.084023334
}
]
@@ -474,42 +583,52 @@
"ranking": [
{
"move": "13/10 7/1* 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/7 13/10 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 8/5 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/7 8/5 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 7/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10 8/5 7/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10(2) 5/2 4/1*",
"ply": 2,
"equity": -0.022027964
},
{
"move": "13/10 8/5(2) 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/4 6/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/10 6/3 4/1*(2)",
"ply": 2,
"equity": 0.99692726
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "8/5 6/2",
"ply": 0,
"equity": 0.7345603
},
{
"move": "5/2 5/1*",
"ply": 0,
"equity": 0.7229391
},
{
"move": "8/1*",
"ply": 0,
"equity": 0.71705425
},
{
"move": "13/10 6/2",
"ply": 0,
"equity": 0.7142835
},
{
"move": "13/9 8/5",
"ply": 0,
"equity": 0.6963019
},
{
"move": "8/5 8/4",
"ply": 0,
"equity": 0.68610644
},
{
"move": "13/10 8/4",
"ply": 0,
"equity": 0.6855368
},
{
"move": "13/10 13/9",
"ply": 0,
"equity": 0.6818689
},
{
"move": "13/6",
"ply": 0,
"equity": 0.6633271
},
{
"move": "13/9 5/2",
"ply": 0,
"equity": 0.6538005
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "8/5 6/2",
"ply": 2,
"equity": 0.7697691
},
{
"move": "13/10 6/2",
"ply": 2,
"equity": 0.7615671
},
{
"move": "13/9 8/5",
"ply": 2,
"equity": 0.69608325
},
{
"move": "5/2 5/1*",
"ply": 2,
"equity": 0.6911188
},
{
"move": "8/1*",
"ply": 2,
"equity": 0.6836242
},
{
"move": "13/10 13/9",
"ply": 2,
"equity": 0.6807338
},
{
"move": "13/10 8/4",
"ply": 1,
"equity": 0.6733323
},
{
"move": "13/6",
"ply": 1,
"equity": 0.66055346
},
{
"move": "8/5 8/4",
"ply": 1,
"equity": 0.6556259
},
{
"move": "13/9 5/2",
"ply": 1,
"equity": 0.645951
}
]
@@ -25,6 +25,7 @@
"ranking": [
{
"move": "bar/24",
"ply": 0,
"equity": -1.0
}
]
@@ -42,6 +43,7 @@
"ranking": [
{
"move": "bar/24",
"ply": 0,
"equity": -1.0
}
]
+24
View File
@@ -25,58 +25,72 @@
"ranking": [
{
"move": "13/3",
"ply": 0,
"equity": 0.9328213
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.9175232
},
{
"move": "8/2 7/3",
"ply": 0,
"equity": 0.91263336
},
{
"move": "13/7 8/4",
"ply": 0,
"equity": 0.90736973
},
{
"move": "13/9 7/1*",
"ply": 0,
"equity": 0.9053596
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.9017469
},
{
"move": "8/4 7/1*",
"ply": 0,
"equity": 0.89993495
},
{
"move": "13/7 6/2",
"ply": 0,
"equity": 0.8962312
},
{
"move": "7/1* 5/1",
"ply": 0,
"equity": 0.8907822
},
{
"move": "7/1* 6/2",
"ply": 0,
"equity": 0.87481093
},
{
"move": "13/9 8/2",
"ply": 0,
"equity": 0.87251335
},
{
"move": "8/4 8/2",
"ply": 0,
"equity": 0.8716274
},
{
"move": "13/7 5/1*",
"ply": 0,
"equity": 0.87005275
},
{
"move": "8/2 5/1*",
"ply": 0,
"equity": 0.50889754
}
]
@@ -94,42 +108,52 @@
"ranking": [
{
"move": "13/3",
"ply": 2,
"equity": 1.0
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": 0.99894416
},
{
"move": "8/2 7/3",
"ply": 2,
"equity": 0.99641544
},
{
"move": "13/7 8/4",
"ply": 2,
"equity": 0.98718643
},
{
"move": "13/7 6/2",
"ply": 2,
"equity": 0.9808178
},
{
"move": "13/9 7/1*",
"ply": 2,
"equity": 0.9452111
},
{
"move": "8/2 5/1*",
"ply": 2,
"equity": 0.3304975
},
{
"move": "8/4 7/1*",
"ply": 1,
"equity": 0.93114305
},
{
"move": "7/1* 5/1",
"ply": 1,
"equity": 0.91711015
},
{
"move": "8/2 6/2",
"ply": 1,
"equity": 0.9160911
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "13/5 11/3",
"ply": 0,
"equity": 0.16933289
},
{
"move": "13/9(2) 11/3",
"ply": 0,
"equity": 0.16701066
},
{
"move": "13/5 9/5 7/3",
"ply": 0,
"equity": 0.16552198
},
{
"move": "13/5(2)",
"ply": 0,
"equity": 0.16473158
},
{
"move": "13/5 13/9 11/7",
"ply": 0,
"equity": 0.16365689
},
{
"move": "13/5 13/9 7/3",
"ply": 0,
"equity": 0.16245355
},
{
"move": "13/5 11/7 9/5",
"ply": 0,
"equity": 0.16167049
},
{
"move": "11/3 9/5(2)",
"ply": 0,
"equity": 0.16043647
},
{
"move": "13/9 11/3 11/7",
"ply": 0,
"equity": 0.1603657
},
{
"move": "13/5 11/7(2)",
"ply": 0,
"equity": 0.1602792
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "13/5 11/3",
"ply": 2,
"equity": 0.15129912
},
{
"move": "13/5 13/9 11/7",
"ply": 2,
"equity": 0.15059008
},
{
"move": "13/9(2) 11/3",
"ply": 2,
"equity": 0.14950007
},
{
"move": "13/5 11/7 9/5",
"ply": 2,
"equity": 0.14858595
},
{
"move": "11/3 11/7 9/5",
"ply": 2,
"equity": 0.14829463
},
{
"move": "13/9 11/3 11/7",
"ply": 2,
"equity": 0.1463402
},
{
"move": "13/5 9/5 7/3",
"ply": 1,
"equity": 0.13938732
},
{
"move": "13/5 11/7(2)",
"ply": 1,
"equity": 0.13921705
},
{
"move": "13/9(2) 11/7(2)",
"ply": 1,
"equity": 0.13873637
},
{
"move": "13/5 13/9 6/2",
"ply": 1,
"equity": 0.13852078
}
]
@@ -25,18 +25,22 @@
"ranking": [
{
"move": "3/off",
"ply": 0,
"equity": -0.8607614
},
{
"move": "2/off 1/off",
"ply": 0,
"equity": -0.8607614
},
{
"move": "2/1 2/off",
"ply": 0,
"equity": -0.95561147
},
{
"move": "3/1 2/1",
"ply": 0,
"equity": -1.0
}
]
@@ -54,18 +58,22 @@
"ranking": [
{
"move": "3/off",
"ply": 2,
"equity": -0.8607614
},
{
"move": "2/off 1/off",
"ply": 2,
"equity": -0.8607614
},
{
"move": "2/1 2/off",
"ply": 2,
"equity": -0.95561147
},
{
"move": "3/1 2/1",
"ply": 1,
"equity": -1.0
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "18/9",
"ply": 0,
"equity": 0.2544027
},
{
"move": "18/14 13/8",
"ply": 0,
"equity": 0.22496489
},
{
"move": "24/20 18/13",
"ply": 0,
"equity": 0.20747662
},
{
"move": "24/20 13/8",
"ply": 0,
"equity": 0.1158272
},
{
"move": "13/9 13/8",
"ply": 0,
"equity": 0.09201278
},
{
"move": "24/15",
"ply": 0,
"equity": 0.061017293
},
{
"move": "18/13 6/2",
"ply": 0,
"equity": 0.03581623
},
{
"move": "13/4",
"ply": 0,
"equity": -0.028887823
},
{
"move": "13/8 6/2",
"ply": 0,
"equity": -0.033572298
},
{
"move": "18/13 8/4",
"ply": 0,
"equity": -0.09662818
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "18/9",
"ply": 2,
"equity": 0.27687487
},
{
"move": "18/14 13/8",
"ply": 2,
"equity": 0.21801135
},
{
"move": "24/20 18/13",
"ply": 2,
"equity": 0.21000355
},
{
"move": "24/20 13/8",
"ply": 2,
"equity": 0.1144446
},
{
"move": "13/9 13/8",
"ply": 2,
"equity": 0.09412725
},
{
"move": "24/15",
"ply": 2,
"equity": 0.07892294
},
{
"move": "18/13 6/2",
"ply": 1,
"equity": -0.020656409
},
{
"move": "13/4",
"ply": 0,
"equity": -0.028887823
},
{
"move": "13/8 6/2",
"ply": 0,
"equity": -0.033572298
},
{
"move": "18/13 8/4",
"ply": 0,
"equity": -0.09662818
}
]
+20
View File
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "13/5",
"ply": 0,
"equity": 0.7553502
},
{
"move": "18/13 8/5",
"ply": 0,
"equity": 0.5814649
},
{
"move": "24/16*",
"ply": 0,
"equity": 0.4581785
},
{
"move": "18/10",
"ply": 0,
"equity": 0.31719947
},
{
"move": "8/5 8/3",
"ply": 0,
"equity": 0.31371918
},
{
"move": "18/15 13/8",
"ply": 0,
"equity": 0.26037523
},
{
"move": "24/21 18/13",
"ply": 0,
"equity": 0.23547156
},
{
"move": "8/3 6/3",
"ply": 0,
"equity": 0.15432969
},
{
"move": "13/10 13/8",
"ply": 0,
"equity": 0.13837479
},
{
"move": "18/13 5/2",
"ply": 0,
"equity": 0.12815686
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "13/5",
"ply": 2,
"equity": 0.85497326
},
{
"move": "24/16*",
"ply": 2,
"equity": 0.46805125
},
{
"move": "18/13 8/5",
"ply": 1,
"equity": 0.6511238
},
{
"move": "18/10",
"ply": 0,
"equity": 0.31719947
},
{
"move": "8/5 8/3",
"ply": 0,
"equity": 0.31371918
},
{
"move": "18/15 13/8",
"ply": 0,
"equity": 0.26037523
},
{
"move": "24/21 18/13",
"ply": 0,
"equity": 0.23547156
},
{
"move": "8/3 6/3",
"ply": 0,
"equity": 0.15432969
},
{
"move": "13/10 13/8",
"ply": 0,
"equity": 0.13837479
},
{
"move": "18/13 5/2",
"ply": 0,
"equity": 0.12815686
}
]
@@ -25,58 +25,72 @@
"ranking": [
{
"move": "24/14",
"ply": 0,
"equity": 0.15439394
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.10821653
},
{
"move": "24/18 13/9",
"ply": 0,
"equity": 0.10337136
},
{
"move": "24/20 24/18",
"ply": 0,
"equity": 0.072762795
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.0029048775
},
{
"move": "24/20 13/7",
"ply": 0,
"equity": 9.1438415e-05
},
{
"move": "13/3",
"ply": 0,
"equity": -0.012697897
},
{
"move": "24/18 6/2",
"ply": 0,
"equity": -0.075399
},
{
"move": "24/18 8/4",
"ply": 0,
"equity": -0.088734664
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
},
{
"move": "13/9 8/2",
"ply": 0,
"equity": -0.09528468
},
{
"move": "13/7 8/4",
"ply": 0,
"equity": -0.12819159
},
{
"move": "13/7 6/2",
"ply": 0,
"equity": -0.14467186
},
{
"move": "8/4 8/2",
"ply": 0,
"equity": -0.35321844
}
]
@@ -94,42 +108,52 @@
"ranking": [
{
"move": "24/14",
"ply": 2,
"equity": 0.13111727
},
{
"move": "8/2 6/2",
"ply": 2,
"equity": 0.11303549
},
{
"move": "24/18 13/9",
"ply": 2,
"equity": 0.105096616
},
{
"move": "24/20 24/18",
"ply": 2,
"equity": 0.049068402
},
{
"move": "13/3",
"ply": 2,
"equity": -0.0040049786
},
{
"move": "8/4 8/2",
"ply": 2,
"equity": -0.36824828
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": -0.014309813
},
{
"move": "24/20 13/7",
"ply": 1,
"equity": -0.029367238
},
{
"move": "24/18 8/4",
"ply": 1,
"equity": -0.081171095
},
{
"move": "24/18 6/2",
"ply": 1,
"equity": -0.09313507
}
]
@@ -25,42 +25,52 @@
"ranking": [
{
"move": "24/14",
"ply": 0,
"equity": 0.15439394
},
{
"move": "8/2 6/2",
"ply": 0,
"equity": 0.10821653
},
{
"move": "24/18 13/9",
"ply": 0,
"equity": 0.10337136
},
{
"move": "24/20 24/18",
"ply": 0,
"equity": 0.072762795
},
{
"move": "13/9 13/7",
"ply": 0,
"equity": 0.0029048775
},
{
"move": "24/20 13/7",
"ply": 0,
"equity": 9.1438415e-05
},
{
"move": "13/3",
"ply": 0,
"equity": -0.012697897
},
{
"move": "24/18 6/2",
"ply": 0,
"equity": -0.075399
},
{
"move": "24/18 8/4",
"ply": 0,
"equity": -0.088734664
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
}
]
@@ -78,42 +88,52 @@
"ranking": [
{
"move": "24/14",
"ply": 2,
"equity": 0.13111727
},
{
"move": "8/2 6/2",
"ply": 2,
"equity": 0.11303549
},
{
"move": "24/18 13/9",
"ply": 2,
"equity": 0.105096616
},
{
"move": "24/20 24/18",
"ply": 2,
"equity": 0.049068402
},
{
"move": "13/3",
"ply": 2,
"equity": -0.0040049786
},
{
"move": "13/9 13/7",
"ply": 2,
"equity": -0.014309813
},
{
"move": "24/20 13/7",
"ply": 1,
"equity": -0.029367238
},
{
"move": "24/18 8/4",
"ply": 1,
"equity": -0.081171095
},
{
"move": "24/18 6/2",
"ply": 1,
"equity": -0.09313507
},
{
"move": "24/20 8/2",
"ply": 0,
"equity": -0.095189385
}
]

Some files were not shown because too many files have changed in this diff Show More