Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:02:18 +01:00
Pierre MARTIN 2664486541 Merge remote-tracking branch 'origin/main' into progression 2026-11-12 19:09:07 +01:00
pierre 2d787b2c0b Merge pull request 'Charte de la progression : retours de la recette 1h' (#64) from charte-progression into main 2026-11-12 18:08:37 +00:00
Pierre MARTINandClaude Opus 5.5 368d3c9e8f Charte de la progression : point final vert seulement en baisse, lignes de partie en un lien, phrase de hausse sans « un peu »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:04:05 +01:00
Pierre MARTINandClaude Opus 5.5 0d9622eff9 Test de la progression : captures après le départ du bandeau
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:02:10 +01:00
Pierre MARTINandClaude Opus 5.5 d31ab0e30a Fusion de origin/main (MR !61, 1g) dans progression
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:36:07 +01:00
Pierre MARTINandClaude Opus 5.5 ac27625e67 Fusion de la MR !61 : cinq premières leçons, progression et carte de l'accueil (1g)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:23:51 +01:00
Pierre MARTINandClaude Opus 5.5 73f4cfce51 Leçon 4 : une seule phrase pour le pion frappé (recette)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:23:48 +01:00
Pierre MARTINandClaude Opus 5.5 cff8be8b35 Fusion de origin/lecons (recette 1g) dans progression
Conflit sur la ligne de progression de l'accueil : le texte de 1g
(« Aucune leçon terminée sur 5 », majuscule en tête) dans le lien vers
#/progression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:19:08 +01:00
Pierre MARTIN a6c6c8a68c Merge remote-tracking branch 'origin/main' into progression 2026-11-12 13:57:29 +01:00
Pierre MARTINandClaude Opus 5.5 a5157dd6bd Test navigateur de la progression ; 1h faite, plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:57:25 +01:00
Pierre MARTINandClaude Opus 5.5 351482fa9e Recette 1g : textes corrigés, boutons en bas, barre haute en paysage
Leçon 1 §2 (flèche 7 vide, 5 déjà bloqué), leçon 2 « Faire des points »,
flèches 7 à 12, reformulations des leçons 3 et 4. Écran des leçons et du
tutoriel : « Retour » et « Continuer » en bas en portrait, « Revoir » sur
la ligne du titre, ligne « Leçon n · … » gardée en paysage. Barre haute
visible hors de l'écran de jeu en paysage (défaut antérieur à 1g), avec
un contrôle dans la recette automatique. Rappel des règles haut de
44 px ; « Aucune leçon terminée sur 5 · … » sur l'accueil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:55:17 +01:00
Pierre MARTINandClaude Opus 5.5 fdbbff50ea Écran de progression (#/progression) : courbe du taux d'erreur, parties, exercices
La ligne de progression de l'accueil y mène. Les taux viennent des
analyses rangées ; les parties non analysées le sont en arrière-plan.
« Créer des exercices » ouvre le prompt du gabarit exercices, avec les
thèmes des erreurs comptés sur les faits du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:50:02 +01:00
Pierre MARTIN 5d0f7a9616 Merge remote-tracking branch 'origin/main' into lecons 2026-11-12 13:41:31 +01:00
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 0493858c1a Écrans des leçons, carte de l'accueil et adresses #/lecons
Liste des leçons, écrans d'une leçon sur le moteur d'écrans du
tutoriel, carte « prochaine leçon » de l'accueil, ligne du menu.
Tests du contenu sur le vrai moteur, scénario Gherkin, test navigateur.
Amendement de l'ADR 0013 ; 1g faite, plan clos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:18:07 +01:00
Pierre MARTINandClaude Opus 5.5 e9ef965438 Progression des leçons dans le stockage des parties
Nouvelle clé « lessons » du magasin settings d'IndexedDB (et
ustagammon.lessons en repli), format versionné relu par
readLessonProgress. Amendement de l'ADR 0007.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:18:07 +01:00
Pierre MARTINandClaude Opus 5.5 635612a2a8 Cinq premières leçons dans docs/lecons/ (1g)
Les ouvertures, construire ses cases, frapper ou non, la course et le
compte de points, sortir sans se faire frapper : seize exercices au
format du tutoriel, avec le compte de points en plus. Amendement de
l'ADR 0011 : pas de relecture préalable, garde-fous du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:18:07 +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
Pierre MARTINandClaude Opus 5.5 9f72b6212f Bilan : ne se redessine que si ce qu'il montre change ; le test attend son mini-plateau
Les analyses de l'ordinateur et les chances de gagner, rangées en
arrière-plan pour l'écran d'analyse, redessinaient tout le bilan, et son
mini-plateau disparaissait le temps d'être redessiné.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:03:01 +01:00
Pierre MARTINandClaude Opus 5.5 f2417097f9 Bilan : l'état prêt s'efface quand il se ferme ; le test du match attend le bilan visible
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:07:54 +01:00
Pierre MARTINandClaude Opus 5.5 663ef35b51 Recette 1e : titre du bilan sur l'issue, notation du moteur partout, boutons de 48 px en paysage, filtre en accent-soft, longue partie ; tests navigateur adaptés au bilan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:37:44 +01:00
Pierre MARTINandClaude Opus 5.5 ced004edb0 1e faite : scénario ajusté (l'ordinateur double, vous passez), plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:41:35 +01:00
Pierre MARTINandClaude Opus 5.5 4d6f8cfc2e ADR 0007 et 0013 amendés : analyse de partie dans le journal, adresses du bilan et de l'analyse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 2c5ae08f46 Bilan et analyse : scénario, fiches du glossaire, test dans le navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 006fd62704 Bilan de fin de partie et écran d'analyse : courbe des chances, visualiseur, liste filtrable, rejouer d'ici
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:30:17 +01:00
Pierre MARTINandClaude Opus 5.5 a657d7d7a9 Analyse de partie : tours, chance, taux d'erreur à la manière de gnubg, format durable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:56:48 +01:00
Pierre MARTINandClaude Opus 5.5 3e7b161933 Fusion de la MR !51 : conseil du maître, une ligne après chaque coup, l'explication sur demande (1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:32:34 +01:00
pierre ff3871d86f Fusion de la MR !50 : charte 1d, rang du coup 2026-11-10 04:39:36 +00:00
Pierre MARTINandClaude Opus 5.5 02467d32e0 Charte 1d : rang du coup seulement pour un coup proche ou une imprécision ; pastille en paysage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:39:34 +01:00
Pierre MARTINandClaude Opus 5.5 c7a54a8c81 Conseil du maître : rang cité seulement pour un coup proche ou une imprécision au 2e ou 3e choix ; 1d faite, plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:36:11 +01:00
Pierre MARTINandClaude Opus 5.5 4fa54ad3f3 Plan 1d : retouches P1 et P2 consignées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:33:13 +01:00
Pierre MARTIN f609987f4e Merge remote-tracking branch 'origin/main' into conseil-du-maitre 2026-11-10 05:23:49 +01:00
Pierre MARTINandClaude Opus 5.5 9dce965164 Phrases du maître : grosse erreur sans « sur N », rang cité seulement s'il est proche ; « Mon coup frappe un pion. »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:23:48 +01:00
Pierre MARTINandClaude Opus 5.5 3ae1f23938 Plan 1d : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:12:43 +01:00
Pierre MARTIN 9ada35e46d Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre
# Conflicts:
#	app/src/ui/board-view.ts
#	tests/browser.sh
2026-11-10 05:08:23 +01:00
Pierre MARTINandClaude Opus 5.5 526e47e79b Fusion de la MR !49 : match, niveaux et réglages, Crawford, Jacoby, Rejouer (1c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:07:51 +01:00
Pierre MARTINandClaude Opus 5.5 7c88b3ba76 Feuille du maître : écarts lus dans le classement, phrase sans redite du titre, plateau à la hauteur en paysage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:07:07 +01:00
Pierre MARTINandClaude Opus 5.5 74693c4277 Recette design du match : Crawford en paysage, « Nouvelle partie » en deux colonnes, « Rejouer »
- Paysage : pendant la partie Crawford, la consigne de la bande centrale
  commence par « Partie Crawford · » ; la police se resserre si besoin.
- Écran « Nouvelle partie » en paysage : deux colonnes au-dessus de la
  barre d'action (type de partie et longueur à gauche, niveau et conseil
  du maître à droite), tout tient en 915x412 et en 844x320.
- Fin de partie : « Rejouer » relance tout de suite une partie aux mêmes
  réglages (un match de même longueur après un match), « Partie suivante »
  dans un match qui continue, lien « Changer les réglages ».
- Recette automatique : aucun élément interactif visible sous une barre
  d'action collée, vérifié sur chaque écran capturé (tests/browser/recette.mjs).
- Scénario « Comment rejouer avec les mêmes réglages ? ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:03:59 +01:00
Pierre MARTINandClaude Opus 5.5 84086900d1 Scénario du conseil du maître : des questions, comme l'aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:57:48 +01:00
Pierre MARTINandClaude Opus 5.5 cd5f70e548 Conseil du maître : test dans le navigateur, ADR 0007 et 0013 amendés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:49:03 +01:00
Pierre MARTINandClaude Opus 5.5 5d6cbec89b Mise en forme (Biome)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:47:23 +01:00
Pierre MARTINandClaude Opus 5.5 5897925b33 Conseil du maître : ligne du maître, feuille d'explication, second moteur pour l'analyse
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:47:14 +01:00
Pierre MARTINandClaude Opus 5.5 1330ee6df4 Conseil du maître : analyse des décisions, phrases du maître, conseil rattaché au journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:36:57 +01:00
Pierre MARTIN 16fc836cb9 Merge remote-tracking branch 'origin/match-et-reglages' into conseil-du-maitre 2026-11-10 00:31:12 +01:00
Pierre MARTINandClaude Opus 5.5 5eaf6743b6 Fusion de la MR !48 : publication, session CapRover vérifiée et version servie contrôlée
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:20:51 +01:00
Pierre MARTINandClaude Opus 5.5 715176e144 Publication : session CapRover vraiment vérifiée, version servie contrôlée
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:20:44 +01:00
Pierre MARTINandClaude Opus 5.5 67bd207337 Fusion d'origin/main (MR !44) ; 1c faite, plan clos
Conflits : l'aide garde l'approche de main (défilement sur la carte du
terme) ; styles des deux côtés conservés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:14:58 +01:00
Pierre MARTINandClaude Opus 5.5 dd18a0cc21 Fusion de la MR !44 : accueil, tutoriel des règles, aide en questions, installation et mise à jour (0e.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:44 +01:00
Pierre MARTINandClaude Opus 5.5 86c0ebfc36 Recette design : tutoriel en paysage, sens du jeu, bandeau de mise à jour
- Sens du jeu (étape 2) : le tracé passe sous les pions et sous les
  numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran.
- Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie
  du jeu en paysage, colonne gauche du cadre), colonne à droite avec la
  barre de progression, le titre, le texte, la consigne et les boutons ;
  seul le texte peut défiler. Tient en 915x412 et 844x320.
- Recette automatique étendue au tutoriel : numéros d'au moins 12 px,
  page sans défilement et boutons en vue en paysage, captures en 844x320.
- Boutons du videau « Prendre » et « Passer », lien « Quitter ».
- Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre
  d'actions, seulement entre deux tours (betweenTurns, testé).
- Test des adresses : le lien Aide est lu au moment du toucher, la carte
  du terme ouvert est mesurée sous la barre de l'aide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 7c10c688c3 Tutoriel : étape 7 réécrite, « Prendre », « Passer » et « Quitter »
Texte de l'étape 7 remplacé par la version de la recette design (gammon,
backgammon et règle de Jacoby en un paragraphe), légende du plateau
gardée. Le texte du tutoriel est validé par Pierre (9 novembre 2026).
Scénario : les réponses au double sont « Prendre » et « Passer », le lien
du haut est « Quitter ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:10:15 +01:00
Pierre MARTINandClaude Opus 5.5 1e9dfbb74b Aide : le terme ouvert s'arrête sous la barre ; test du match : attendre l'enregistrement avant de recharger
Après la fusion de main, l'aide amenait la carte du terme en haut alors
que la marge de défilement est posée sur son titre (dt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:03:17 +01:00
Pierre MARTINandClaude Opus 5.5 414dfa79f8 Plan 1c : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:36 +01:00
Pierre MARTINandClaude Opus 5.5 d1e234bb11 Fusion de main (MR !43) dans accueil-tutoriel-aide
Conflits résolus en gardant les deux apports : barre de l'aide collée
(scroll-margin sur la carte du terme) et position partagée en paysage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:37:15 +01:00
Pierre MARTINandClaude Opus 5.5 4357f7e3f2 Fusion d'origin/main dans match-et-reglages
Conflits résolus dans web/app.css (styles ajoutés des deux côtés) et
app/src/ui/help.ts (commentaire de main, fiche de la branche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:32:07 +01:00
Pierre MARTINandClaude Opus 5.5 85bdf4bb93 Interface : écran Nouvelle partie, score du match et partie Crawford
Écran #/nouvelle-partie (type de partie, niveau, conseil du maître,
choix gardés), score du match en portrait et en paysage, videau caché
pendant la partie Crawford, « Partie suivante » ; test navigateur d'un
match en 3 points jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:26 +01:00
Pierre MARTINandClaude Opus 5.5 809b5ec832 Fusion de la MR !47 : décisions de Pierre du 2026-11-09, journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:31:16 +01:00
Pierre MARTINandClaude Opus 5.5 1eeb6c95ae Décisions de Pierre du 2026-11-09 ; journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:12:54 +01:00
Pierre MARTINandClaude Opus 5.5 339d699488 Domaine : match, niveau de l'ordinateur, événements en version 2
Partie de match (score et Crawford dits par le moteur), niveau choisi
porté par GameStarted, ordinateur joué comme ComputerTurn de gnubg ;
migration des journaux de la version 1 ; scénarios, glossaire, ADR 0007
et 0013 amendés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:07:21 +01:00
Pierre MARTINandClaude Opus 5.5 65a4638475 Fusion de la MR !43 : une adresse pour chaque écran, et un lien pour chaque position (0e.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:03:28 +01:00
Pierre MARTINandClaude Opus 5.5 2b8fa1a0b0 Recette design des adresses : barre de l'aide collée, boutons sur une ligne, position en paysage
Barre haute de l'aide en position sticky ; question ou terme ouvert par
son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s.
Boutons en white-space: nowrap ; actions de la position empilées
(Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la
hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler),
colonne de 288 px à droite. Test navigateur : aucun bouton sur deux
lignes, barre de l'aide en vue, actions visibles sans défiler, videau
entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:52:50 +01:00
Pierre MARTINandClaude Opus 5.5 c0f827c5ba Moteur : videau de l'ordinateur comme ComputerTurn, match après une partie, Match ID d'un match
Fenêtre de marché et double facultatif à 0 pli (play.c), réponse au
double, règle de Crawford automatique ; commandes double, take et
aftergame d'engine-cli, matchid étendu aux matchs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:35 +01:00
pierre 8e5be8e339 Fusion de la MR !46 : tutoriel, Jacoby 2026-11-09 17:38:55 +00:00
Pierre MARTINandClaude Opus 5.5 e724368f09 Charte : tutoriel, étape 7 avec la règle de Jacoby, lien « Quitter »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:38:53 +01:00
Pierre MARTINandClaude Opus 5.5 2352a5317f Fusion de la MR !45 : « Grosse erreur » plutôt que « gaffe »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:41 +01:00
Pierre MARTINandClaude Opus 5.5 0a88e835d2 Vocabulaire : « Grosse erreur » plutôt que « gaffe » (choix de Pierre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:37:29 +01:00
Pierre MARTINandClaude Opus 5.5 71bff4def1 Accueil, tutoriel, aide : plan clos ; adresses ajoutées à l'ADR 0013
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:49:16 +01:00
Pierre MARTINandClaude Opus 5.5 8fd1ba3f4f Lint : formatage, ShellCheck sur le nettoyage du second site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:36:19 +01:00
Pierre MARTINandClaude Opus 5.5 5fdca812d9 Tests navigateur : accueil, tutoriel, aide, installation, mise à jour ; plan : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:35:00 +01:00
Pierre MARTINandClaude Opus 5.5 441bca1bd7 Accueil, tutoriel, aide en questions, installation et mise à jour de l'application
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:17:49 +01:00
Pierre MARTINandClaude Opus 5.5 3f5b00133d Tutoriel : texte source, lecture, vérification par le moteur, adresses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:55:32 +01:00
Pierre MARTIN f1c589eff1 Merge remote-tracking branch 'origin/adresses-ecrans' into accueil-tutoriel-aide 2026-11-09 14:45:10 +01:00
Pierre MARTINandClaude Opus 5.5 d1db2b33c8 Adresses des écrans : plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:41:06 +01:00
Pierre MARTIN f74b03fa0d Fusion de main dans adresses-ecrans 2026-11-09 14:41:03 +01:00
Pierre MARTINandClaude Opus 5.5 d26033b7bb Plan 0e.4 : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:40:16 +01:00
Pierre MARTINandClaude Opus 5.5 2f1d36413e Test navigateur : le coup joué a 10 s pour arriver dans IndexedDB
L'application enregistre sans attendre ; sur le disque dur de la machine
de développement, l'écriture de Firefox peut suivre de loin. En cas
d'échec, le message dit ce que montre l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
Pierre MARTINandClaude Opus 5.5 9a5a0bb677 Fin de partie : la vibration attend la fin de l'animation du dernier coup
Le dessin lancé au début de l'animation annonçait la fin ; une prise de
l'ordinateur pendant ce dernier coup vibrait ensuite et la remplaçait (vu
dans Firefox par make test-browser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:16:48 +01:00
pierre dd5954a113 Fusion de la MR !42 : écrans 1e à 1i 2026-11-09 13:01:54 +00:00
Pierre MARTINandClaude Opus 5.5 4cdef612fd Charte : écrans de l'étape 1, lots 1e à 1i (bilan, analyse, IA, leçons, progression, gestes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:01:51 +01:00
pierre 2eaa8ef304 Fusion de la MR !41 : écrans 1d 2026-11-09 12:53:29 +00:00
Pierre MARTINandClaude Opus 5.5 71a30cb2b6 Charte : écrans de l'étape 1, lot 1d (ligne et feuille du maître, phrases)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:53:27 +01:00
pierre 76fd72bbb5 Fusion de la MR !40 : écrans 1c 2026-11-09 12:47:21 +00:00
Pierre MARTINandClaude Opus 5.5 0e816564f9 Charte : écrans de l'étape 1, lot 1c (nouvelle partie, score de match)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:47:19 +01:00
Pierre MARTINandClaude Opus 5.5 b40b28d25b Fusion de la MR !39 : plan 0e.5, mise à jour de l'application en un toucher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:46:28 +01:00
Pierre MARTINandClaude Opus 5.5 9a6e82a2db Plan 0e.5 : mise à jour de l'application en un toucher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:45:27 +01:00
Pierre MARTINandClaude Opus 5.5 97a098e98f Navigation : un retour en cours retient l'adresse suivante ; test des adresses
history.go est asynchrone : une adresse demandée pendant un retour attend
son arrivée. Le test dans le navigateur ouvre chaque adresse du tableau de
l'ADR 0013 dans un nouvel onglet, vérifie le retour, le rôle link des liens
et l'aller-retour d'un lien de position, et range ses captures dans
browser-shots/adresses/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:45:17 +01:00
Pierre MARTINandClaude Opus 5.5 f4914b30ab Une adresse pour chaque écran, de vrais liens pour y aller
Routeur pur (fragment vers écran et inversement) et navigation sur
hashchange : aide et ses questions, glossaire et ses termes, à propos,
menu, mes parties et chaque partie, vue d'une position avec « Jouer à
partir d'ici » et « Retour à ma partie », « Copier le lien de la
position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
Pierre MARTINandClaude Opus 5.5 43031d1ec6 Domaine : lire et écrire le lien d'une position (ADR 0013)
Le moteur écrit et décode le Match ID ; readPosition valide un lien,
linkOf donne celui d'une partie, setupOf la partie à jouer depuis la
position. Scénario « Partager une position », fiche « Lien de position ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:21:45 +01:00
Pierre MARTINandClaude Opus 5.5 edcbcea131 Fusion de la MR !38 : plans 1e à 1i
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:03:51 +01:00
Pierre MARTINandClaude Opus 5.5 5c75f29f32 Plans 1e à 1i : bilan et analyse, prompts, leçons, progression, plateau soigné
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:03:46 +01:00
Pierre MARTINandClaude Opus 5.5 3cdd017768 Fusion de la MR !37 : parties enregistrées, branches et export SGF (étape 1b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:17 +01:00
Pierre MARTINandClaude Opus 5.5 19a54193f9 ADR 0007 : section Amendements
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:55:00 +01:00
Pierre MARTINandClaude Opus 5.5 2c4448549d Parties enregistrées : plan clos ; stockage déclaré format durable (ADR 0007)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:51:01 +01:00
Pierre MARTINandClaude Opus 5.5 d228d70387 Mes parties : la feuille se ferme à la reprise ; plan 1b : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:50:33 +01:00
Pierre MARTINandClaude Opus 5.5 e3aaa0b3a3 Stockage ouvert pendant le chargement du moteur ; test navigateur : nombre de parties
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:29:18 +01:00
Pierre MARTINandClaude Opus 5.5 71ee3605d4 Mes parties : suppression en deux touchers, sans boîte du navigateur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:24:24 +01:00
Pierre MARTINandClaude Opus 5.5 196ccd4b9f Fusion de main (révision UX) ; Mes parties dans le menu, reprise sans rechargement
« Reprendre l'ancienne » relit l'ancienne partie dans le stockage par son
identifiant et supprime la nouvelle (GameSession).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:17:53 +01:00
pierre ae8a2d6ad2 Fusion de la MR !36 : liens 0e.5 2026-11-03 19:15:06 +00:00
Pierre MARTINandClaude Opus 5.5 9420bf8bd6 Charte : liens des écrans d'accueil, du tutoriel et de l'aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:15:03 +01:00
Pierre MARTINandClaude Opus 5.5 231dd02ac0 Fusion de la MR !35 : révision UX, portrait stable, compte de points, menu, paysage plateau = écran
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:07:18 +01:00
Pierre MARTINandClaude Opus 5.5 31815134e5 Mes parties : scénarios, glossaire, test navigateur sur IndexedDB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:36:54 +01:00
Pierre MARTINandClaude Opus 5.5 274fbf6252 Parties enregistrées : IndexedDB, branches, export SGF relu par gnubg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:29:01 +01:00
221 changed files with 31939 additions and 1010 deletions
+9 -4
View File
@@ -30,7 +30,8 @@ CADDY_IMAGE := caddy:2.8.4-alpine
# Navigateurs sans écran pour make test-browser (Playwright, Apache-2.0).
PLAYWRIGHT_IMAGE := mcr.microsoft.com/playwright:v1.48.2-noble
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE
export SHELLCHECK_IMAGE GHERKIN_IMAGE BUILD_C_IMAGE WASM_IMAGE CADDY_IMAGE WEB_IMAGE PLAYWRIGHT_IMAGE \
REFERENCE_IMAGE
# Lance une commande dans la chaîne C, sous l'utilisateur courant pour que
# build/ lui appartienne.
@@ -126,8 +127,11 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
# l'application (ADR 0008 : jamais commitées ailleurs) ;
# - les copies du glossaire, des scénarios, du tutoriel, des leçons, des phrases du
# maître et des prompts pour une IA sous docs/, lues par l'aide, le
# tutoriel, les leçons, le conseil du maître et « Demander à une IA »
# (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
site: engine-wasm web ## Assemble le site publié dans build/site/
@@ -139,7 +143,8 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/lecons docs/maitre docs/prompts \
$(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
+189
View File
@@ -0,0 +1,189 @@
// L'analyse des décisions du joueur par le moteur, pour le conseil du
// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité
// perdue, appréciation), le classement des coups, l'analyse de la décision
// de videau, et les plateaux décodés, où se lisent les pions frappés et les
// points faits. L'application lance ces analyses sur un second moteur, en
// arrière-plan : elles ne retardent jamais la partie.
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
import { engineCube, STANDARD_PLY } from "../game/game.js";
import { otherSide, type Side } from "../game/side.js";
import { replay } from "../game/state.js";
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
* défaut, et le niveau « standard » (ADR 0009). */
export const ANALYSIS_PLY: Ply = STANDARD_PLY;
/** Nombre de coups gardés du classement du moteur, pour « Voir les
* chiffres du moteur ». */
export const RANKING_SIZE = 5;
/**
* Les décisions du joueur à analyser parmi des événements : ses coups (pas
* un tour passé faute de coup possible), ses jets (il aurait pu doubler
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
*/
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
return decisionsOf(events, "player");
}
/** Les décisions de side parmi des événements, comme playerDecisions. */
export function decisionsOf(events: readonly GameEvent[], side: Side): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === side && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === side;
default:
return false;
}
});
}
/** Ce qu'on demande à l'analyse d'une décision. */
export interface DecisionOptions {
/** Niveau de l'analyse ; ANALYSIS_PLY par défaut. */
readonly ply?: Ply;
/** Le camp qui décide ; le joueur par défaut. Le conseil est alors vu
* de ce camp (sa position, ses coups). */
readonly side?: Side;
/** Garder le classement des coups (une seconde analyse) ; oui par
* défaut. L'analyse des coups de l'ordinateur s'en passe. */
readonly ranking?: boolean;
}
/**
* Le conseil du moteur sur la décision eventId du joueur (ou du camp
* options.side) ; null s'il n'y a rien à analyser (pas une décision de ce
* camp, aucun coup possible, videau inaccessible ou position de départ pour
* gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
options: DecisionOptions = {},
): Promise<Advice | null> {
const ply = options.ply ?? ANALYSIS_PLY;
const side = options.side ?? "player";
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || decisionsOf([event], side).length === 0) {
return null;
}
const earlier = line.slice(0, -1);
const before = replay(earlier);
if (event.type === "MovePlayed") {
if (before.roll === null) {
return null;
}
const cube = engineCube(before, side);
// Le coup joué est enregistré vu de l'adversaire : le moteur donne
// la même position vue de celui qui l'a joué.
const after = await engine.board(event.position, engineCube(before, otherSide(side)));
const analysis = await engine.analyseMove(
before.position,
cube,
before.roll,
after.swappedPosition,
ply,
);
if (analysis === null) {
return null;
}
// Le classement est celui de l'analyse même : mêmes coups, mêmes
// niveaux et mêmes équités que le coup joué et le meilleur coup.
const ranking = options.ranking === false ? [] : analysis.ranking;
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply,
position: before.position,
roll: before.roll,
opening: !earlier.some((e) => e.type === "MovePlayed"),
moveCount: analysis.moveCount,
rank: analysis.rank,
played: advised(analysis.played),
best: advised(analysis.best),
playedFacts: await facts(analysis.played),
bestFacts: await facts(analysis.best),
ranking: ranking.slice(0, RANKING_SIZE).map(advised),
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
let action: CubeAction;
let decider: Side = side;
if (event.type === "DiceRolled") {
if (before.phase !== "rolling") {
return null;
}
action = "noDouble";
} else if (event.type === "CubeOffered") {
action = "double";
} else {
action = event.type === "CubeTaken" ? "take" : "pass";
decider = otherSide(side);
}
const analysis = await engine.analyseCube(
before.position,
engineCube(before, decider),
action,
ply,
);
if (!analysis.analysed || analysis.equities === null) {
return null;
}
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply,
action,
position: before.position,
equities: analysis.equities,
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
function advised(move: EvaluatedMove): AdvisedMove {
return {
notation: move.notation,
halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })),
equity: move.equity,
ply: move.ply,
};
}
/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur
* (vus du joueur) : pions adverses envoyés sur la barre, cases où le
* joueur a désormais deux pions ou plus. */
function factsOf(
mine: BoardSide,
theirs: BoardSide,
after: { readonly onRoll: BoardSide; readonly opponent: BoardSide },
): MoveFacts {
const madePoints: number[] = [];
for (let i = 0; i < 24; i++) {
if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) {
madePoints.push(i + 1);
}
}
return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints };
}
+579
View File
@@ -0,0 +1,579 @@
// L'analyse d'une partie (étape 1e) : ce qui manque à analyser, le calcul
// de chaque analyse par le moteur, et les chiffres du bilan à la manière de
// GNU Backgammon (taux d'erreur, chance, résultat corrigé de la chance).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qui juge le jeu vient du moteur : l'analyse de chaque décision
// (équité perdue, appréciation), la chance de chaque jet, l'évaluation des
// positions. Ce module ne fait que les ranger et les additionner, comme le
// fait gnubg dans ses statistiques de partie. Sources, dans gnubg 1.08.003
// (commit 14b67984, ADR 0005), fichiers analysis.c et formatgs.c :
// - updateStatcontext (analysis.c, l. 304-596) : les erreurs de chaque
// décision et leur nombre ;
// - getMWCFromError (analysis.c, l. 1432-1472) : le taux d'erreur ;
// - GetRating et arThrsRating (analysis.c, l. 83-101) : le classement ;
// - formatgs.c, l. 348-374 et getLuckRating (analysis.c, l. 1757) : la
// chance par coup et son appréciation ;
// - arLuckAdj (analysis.c, l. 304-330 et 405-414) : le résultat corrigé de
// la chance.
// Les tâches d'analyse sont indépendantes : l'application les lance une à
// une sur le second moteur, en arrière-plan, et peut s'arrêter entre deux.
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { Engine, LuckRating } from "../game/engine.js";
import { LUCK_THRESHOLDS } from "../game/engine.js";
import {
currentLine,
type GameEnded,
type GameEvent,
type GameJournal,
type MovePlayed,
withAdvice,
withReview,
} from "../game/events.js";
import { engineCube } from "../game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel } from "../game/level.js";
import { emptyReview, type GameReview } from "../game/review.js";
import type { Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
import { applyEvent, replay } from "../game/state.js";
import { ANALYSIS_PLY, analyseDecision, decisionsOf } from "./analyse.js";
// ------------------------------------------------------------ Les tours
/**
* Un tour de la partie : ce qu'un camp a fait quand il avait la main
* (doubler ou non, lancer, jouer), avec la réponse de l'adversaire à son
* double. Les tours sont numérotés à partir de 1 ; c'est le « coup » de
* l'écran d'analyse (#/parties/<id>/analyse/<n>).
*/
export interface Turn {
readonly number: number;
/** Le camp qui avait la main. */
readonly side: Side;
/** Le jet joué (null : le tour s'est fini sur un double passé). */
readonly roll: Roll | null;
/** Le coup joué (null : double passé). */
readonly move: MovePlayed | null;
/** Les événements du tour, dans l'ordre (sans la fin de partie). */
readonly events: readonly GameEvent[];
/** La fin de partie, si elle suit ce tour. */
readonly end: GameEnded | null;
}
/** Les tours d'une ligne de jeu (événements du début à la fin). */
export function turnsOf(line: readonly GameEvent[]): Turn[] {
const turns: Turn[] = [];
let bucket: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "GameStarted") {
continue;
}
if (event.type === "GameEnded") {
const last = turns.at(-1);
if (bucket.length === 0 && last !== undefined) {
turns[turns.length - 1] = { ...last, end: event };
} else if (bucket.length > 0) {
const offer = bucket.find((e) => e.type === "CubeOffered");
const side = offer?.type === "CubeOffered" ? offer.side : (before.turn ?? "player");
turns.push(makeTurn(turns.length + 1, side, bucket, null, event));
}
bucket = [];
continue;
}
bucket.push(event);
if (event.type === "MovePlayed") {
turns.push(makeTurn(turns.length + 1, event.side, bucket, event, null));
bucket = [];
}
}
return turns;
}
function makeTurn(
number: number,
side: Side,
events: readonly GameEvent[],
move: MovePlayed | null,
end: GameEnded | null,
): Turn {
const rolled = move === null ? undefined : events.findLast((e) => e.type === "DiceRolled");
return {
number,
side,
roll: rolled?.type === "DiceRolled" ? rolled.roll : null,
move,
events,
end,
};
}
/** Les tours de la ligne courante d'un journal. */
export function gameTurns(journal: GameJournal): Turn[] {
return turnsOf(currentLine(journal));
}
// --------------------------------------------- Ce qui reste à analyser
/** Une analyse à demander au moteur. */
export type AnalysisTask =
/** Une décision (coup ou videau) d'un camp. */
| { readonly kind: "decision"; readonly side: Side; readonly eventId: string }
/** La chance d'un jet. */
| { readonly kind: "luck"; readonly eventId: string }
/** Les chances de gagner du joueur après un coup. */
| { readonly kind: "chance"; readonly eventId: string };
/** L'analyse de partie du journal, au niveau d'analyse ; vide s'il n'y en
* a pas encore (ou à un autre niveau). */
export function reviewOf(journal: GameJournal): GameReview {
const review = journal.review;
return review !== undefined && review.ply === ANALYSIS_PLY ? review : emptyReview(ANALYSIS_PLY);
}
/**
* Ce qui reste à analyser sur la ligne courante, dans l'ordre où le bilan
* en a besoin : les décisions du joueur (celles d'avant l'étape 1d, ou
* dont l'analyse est arrivée trop tard), la chance de chaque jet, puis,
* pour l'écran d'analyse, les décisions de l'ordinateur et les chances de
* gagner après chaque coup.
*/
export function missingAnalyses(journal: GameJournal): AnalysisTask[] {
const line = currentLine(journal);
const review = reviewOf(journal);
const skipped = new Set(review.unanalysed);
const advice = journal.advice ?? {};
const tasks: AnalysisTask[] = [];
for (const event of decisionsOf(line, "player")) {
if (advice[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "player", eventId: event.id });
}
}
for (const event of luckyRolls(line)) {
if (review.luck[event.id] === undefined) {
tasks.push({ kind: "luck", eventId: event.id });
}
}
for (const event of decisionsOf(line, "computer")) {
if (review.computer[event.id] === undefined && !skipped.has(event.id)) {
tasks.push({ kind: "decision", side: "computer", eventId: event.id });
}
}
for (const event of line) {
if (event.type === "MovePlayed" && review.winChances[event.id] === undefined) {
tasks.push({ kind: "chance", eventId: event.id });
}
}
return tasks;
}
/** Ce qu'il faut au bilan : toutes les décisions du joueur et la chance
* de tous les jets. */
export function readyForSummary(journal: GameJournal): boolean {
return missingAnalyses(journal).every(
(task) => task.kind === "chance" || (task.kind === "decision" && task.side === "computer"),
);
}
/** Les jets qui ont une chance : tous ceux d'un camp, et le jet
* d'ouverture qui désigne le premier joueur (pas une égalité, qui se
* relance). */
function luckyRolls(line: readonly GameEvent[]): GameEvent[] {
const rolls: GameEvent[] = [];
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type !== "DiceRolled") {
continue;
}
if (event.side !== null || (before.phase === "opening" && state.phase === "moving")) {
rolls.push(event);
}
}
return rolls;
}
/**
* Fait une analyse avec le moteur et la range dans le journal, qui est
* renvoyé (le journal reçu ne change pas). Une décision que le moteur
* n'analyse pas est notée comme telle, pour ne plus la demander.
*/
export async function runAnalysis(
engine: Engine,
journal: GameJournal,
task: AnalysisTask,
): Promise<GameJournal> {
const review = reviewOf(journal);
switch (task.kind) {
case "decision": {
const advice = await analyseDecision(engine, journal, task.eventId, {
side: task.side,
ranking: task.side === "player",
});
if (advice === null) {
return withReview(journal, {
...review,
unanalysed: [...review.unanalysed, task.eventId],
});
}
return task.side === "player"
? withAdvice(journal, advice)
: withReview(journal, {
...review,
computer: { ...review.computer, [task.eventId]: advice },
});
}
case "luck": {
const line = currentLine({ ...journal, head: task.eventId });
const event = line.at(-1);
if (event?.type !== "DiceRolled") {
throw new Error(`pas un jet : ${task.eventId}`);
}
const before = replay(line.slice(0, -1));
// Le camp qui joue le jet ; au jet d'ouverture, celui qu'il
// désigne. La position de départ est la même pour les deux.
const roller = event.side ?? replay(line).turn ?? "player";
const luck = await engine.luck(before.position, engineCube(before, roller), event.roll);
return withReview(journal, { ...review, luck: { ...review.luck, [event.id]: luck } });
}
case "chance": {
const chance = await chanceAfter(engine, journal, task.eventId);
return withReview(journal, {
...review,
winChances: { ...review.winChances, [task.eventId]: chance },
});
}
}
}
/** Les chances de gagner du joueur après le coup moveId : le résultat si
* la partie finit là, sinon l'évaluation du moteur, vue de celui qui a
* alors la main. */
async function chanceAfter(engine: Engine, journal: GameJournal, moveId: string): Promise<number> {
const line = currentLine(journal);
const index = line.findIndex((e) => e.id === moveId);
const upTo = index >= 0 ? line.slice(0, index + 1) : currentLine({ ...journal, head: moveId });
const next = index >= 0 ? line[index + 1] : undefined;
if (next?.type === "GameEnded") {
return next.winner === "player" ? 1 : 0;
}
const after = replay(upTo);
const onRoll = after.turn ?? "player";
const evaluation = await engine.evaluate(
after.position,
engineCube(after, onRoll),
ANALYSIS_PLY,
);
return onRoll === "player" ? evaluation.win : 1 - evaluation.win;
}
// ------------------------------------------------ Les chiffres du bilan
/**
* Classement de gnubg selon le taux d'erreur par décision (GetRating et
* arThrsRating, analysis.c l. 83-101) : en dessous de 0,005, « Super
* Grandmaster » ; puis 0,008 « Grandmaster », 0,013 « Master », 0,020
* « Advanced », 0,032 « Intermediate », au-delà « Beginner ». Les six
* classements portent les noms des six niveaux de l'ordinateur, dans le
* même ordre (correspondance dans docs/glossaire/taux-d-erreur.md).
*/
export const RATING_THRESHOLDS: readonly number[] = [0.032, 0.02, 0.013, 0.008, 0.005];
/**
* Le taux d'erreur d'un camp, à la manière de gnubg (getMWCFromError,
* analysis.c) : l'équité perdue sur toutes ses décisions, divisée par le
* nombre de coups non forcés et de décisions de videau « serrées ».
*
* Glossaire : docs/glossaire/taux-d-erreur.md
*/
export interface ErrorRate {
/** Équité perdue au total, pour un videau de 1 (positive ou nulle). */
readonly total: number;
/** Nombre de décisions comptées. */
readonly decisions: number;
/** Équité perdue par décision (0 sans décision). */
readonly perDecision: number;
/** Le niveau correspondant, du nom des niveaux de l'ordinateur. */
readonly level: ComputerLevel;
}
/** La chance d'un camp sur la partie, à la manière de gnubg (formatgs.c,
* « Luck total », « Luck rate », « Luck rating »). */
export interface LuckSummary {
/** Somme de la chance de ses jets, pour un videau de 1. */
readonly total: number;
/** Nombre de ses tours avec un jet (anTotalMoves de gnubg). */
readonly rolls: number;
/** Chance par tour (0 sans tour). */
readonly perRoll: number;
/** getLuckRating : la chance par tour, multipliée par 10, comparée aux
* seuils d'un jet (LUCK_THRESHOLDS). */
readonly rating: LuckRating;
/** La chance comptée en points : chaque jet multiplié par la valeur
* du videau à ce moment (arLuck[…][1] de gnubg en partie libre). */
readonly points: number;
}
/** Ce que le bilan dit d'un camp. */
export interface SideSummary {
readonly errorRate: ErrorRate;
readonly luck: LuckSummary;
}
/** Une décision du joueur et son conseil, dans son tour. */
export interface ReviewedDecision {
readonly turn: number;
readonly advice: Advice;
}
/** Le bilan d'une partie (ligne courante). */
export interface GameSummary {
readonly player: SideSummary;
readonly computer: SideSummary;
/** Les décisions du joueur comptées, par appréciation : ses coups, ses
* doubles et ses réponses aux doubles, et ses jets sans doubler
* seulement quand doubler était mieux. */
readonly counts: Readonly<Record<Verdict, number>>;
/** La plus grosse erreur du joueur (null : aucune imprécision ni
* erreur). */
readonly toReview: ReviewedDecision | null;
/** Le résultat de la partie en points pour le joueur (0 si elle n'est
* pas finie), et ce résultat corrigé de la chance des deux camps
* (arLuckAdj de gnubg) : ce qu'il serait sans les dés. */
readonly result: number;
readonly luckAdjustedResult: number;
}
/** Le niveau de gnubg pour un taux d'erreur par décision. */
export function levelOfErrorRate(perDecision: number): ComputerLevel {
let index = 0;
while (index < RATING_THRESHOLDS.length && perDecision < (RATING_THRESHOLDS[index] ?? 0)) {
index++;
}
return COMPUTER_LEVELS[index] ?? "beginner";
}
/** getLuckRating de gnubg : Luck(chance par tour × 10). */
export function luckRatingOf(perRoll: number): LuckRating {
const r = perRoll * 10;
if (r > LUCK_THRESHOLDS.veryLucky) {
return "veryLucky";
}
if (r > LUCK_THRESHOLDS.lucky) {
return "lucky";
}
if (r < -LUCK_THRESHOLDS.veryLucky) {
return "veryUnlucky";
}
if (r < -LUCK_THRESHOLDS.lucky) {
return "unlucky";
}
return "none";
}
/**
* Une décision de videau « serrée » pour gnubg (isCloseCubedecision,
* eval.c l. 5107) : doubler coûte moins de 0,16 par rapport à l'optimum.
* Un jet sans doubler n'est compté dans le taux d'erreur que dans ce cas ;
* un double, une prise ou un refus le sont toujours.
*/
function closeCube(advice: CubeAdvice): boolean {
const e = advice.equities;
return e.optimal - Math.min(e.doubleTake, 1) < 0.16;
}
/** L'erreur d'une décision (équité perdue, positive) et si elle compte
* dans le nombre de décisions (updateStatcontext). */
function errorOf(advice: Advice): { error: number; counted: boolean } {
const error = -Math.min(0, advice.equityLoss);
if (advice.kind === "move") {
// Un coup forcé ne compte pas (anUnforcedMoves).
return { error: advice.moveCount > 1 ? error : 0, counted: advice.moveCount > 1 };
}
return { error, counted: advice.action !== "noDouble" || closeCube(advice) };
}
function errorRateOf(advices: readonly Advice[]): ErrorRate {
let total = 0;
let decisions = 0;
for (const advice of advices) {
const { error, counted } = errorOf(advice);
total += error;
decisions += counted ? 1 : 0;
}
const perDecision = decisions > 0 ? total / decisions : 0;
return { total, decisions, perDecision, level: levelOfErrorRate(perDecision) };
}
/** Le bilan de la ligne courante, avec les analyses déjà faites (les
* manquantes ne comptent pas : voir readyForSummary). */
export function summarizeAnalysis(journal: GameJournal): GameSummary {
const line = currentLine(journal);
const review = reviewOf(journal);
const advice = journal.advice ?? {};
const turns = turnsOf(line);
const turnOf = new Map<string, number>();
for (const turn of turns) {
for (const event of turn.events) {
turnOf.set(event.id, turn.number);
}
}
const decided = (side: Side): Advice[] =>
decisionsOf(line, side)
.map((e) => (side === "player" ? advice[e.id] : review.computer[e.id]))
.filter((a): a is Advice => a !== undefined);
const mine = decided("player");
// La chance de chaque camp, et la valeur du videau à chaque jet.
const luck: Record<Side, { total: number; points: number; rolls: number }> = {
player: { total: 0, points: 0, rolls: 0 },
computer: { total: 0, points: 0, rolls: 0 },
};
let state = replay([]);
for (const event of line) {
const before = state;
state = applyEvent(state, event);
if (event.type === "MovePlayed") {
luck[event.side].rolls++;
}
const value = event.type === "DiceRolled" ? review.luck[event.id] : undefined;
if (value !== undefined && event.type === "DiceRolled") {
const roller = event.side ?? state.turn ?? "player";
luck[roller].total += value.value;
luck[roller].points += value.value * before.cube.value;
}
}
const luckSummary = (side: Side): LuckSummary => {
const { total, points, rolls } = luck[side];
const perRoll = rolls > 0 ? total / rolls : 0;
return { total, rolls, perRoll, rating: luckRatingOf(perRoll), points };
};
const counts: Record<Verdict, number> = { good: 0, doubtful: 0, bad: 0, veryBad: 0 };
let toReview: ReviewedDecision | null = null;
for (const a of mine) {
const verdict = verdictOf(a.skill);
// Un jet sans doubler n'est une décision visible que s'il était
// une faute.
if (!(a.kind === "cube" && a.action === "noDouble" && verdict === "good")) {
counts[verdict]++;
}
if (
verdict !== "good" &&
(toReview === null || a.equityLoss < toReview.advice.equityLoss)
) {
toReview = { turn: turnOf.get(a.eventId) ?? 0, advice: a };
}
}
const ended = line.findLast((e) => e.type === "GameEnded");
const result =
ended?.type === "GameEnded" ? (ended.winner === "player" ? 1 : -1) * ended.points : 0;
const player = luckSummary("player");
const computer = luckSummary("computer");
return {
player: { errorRate: errorRateOf(mine), luck: player },
computer: { errorRate: errorRateOf(decided("computer")), luck: computer },
counts,
toReview,
result,
luckAdjustedResult: result - player.points + computer.points,
};
}
// ------------------------------------------------ Coup par coup
/** Un point de la courbe des chances de gagner : un tour. */
export interface ChancePoint {
readonly turn: number;
readonly side: Side;
/** Chances de gagner du joueur après le tour, de 0 à 1 (null : pas
* encore analysé). */
readonly chance: number | null;
/** L'appréciation de la décision du joueur la plus coûteuse du tour
* (null : aucune décision du joueur analysée). */
readonly verdict: Verdict | null;
}
/**
* La courbe des chances de gagner du joueur, tour après tour : la position
* après chaque coup évaluée par le moteur, et le résultat après le dernier.
*
* Glossaire : docs/glossaire/courbe-des-chances.md
*/
export type WinChanceCurve = readonly ChancePoint[];
/** La décision du joueur à revoir dans un tour : la plus coûteuse, le coup
* à égalité (null : aucune décision du joueur analysée dans ce tour). */
export function mainAdvice(journal: GameJournal, turn: Turn): Advice | null {
const advice = journal.advice ?? {};
let main: Advice | null = null;
for (const event of decisionsOf(turn.events, "player")) {
const a = advice[event.id];
if (a === undefined) {
continue;
}
if (
main === null ||
a.equityLoss < main.equityLoss ||
(a.equityLoss === main.equityLoss && a.kind === "move")
) {
main = a;
}
}
return main;
}
/** Le conseil sur le coup de l'ordinateur d'un tour (null sinon). */
export function computerMoveAdvice(journal: GameJournal, turn: Turn): MoveAdvice | null {
if (turn.side !== "computer" || turn.move === null) {
return null;
}
const advice = reviewOf(journal).computer[turn.move.id];
return advice?.kind === "move" ? advice : null;
}
export function chanceCurve(journal: GameJournal): WinChanceCurve {
const review = reviewOf(journal);
return gameTurns(journal).map((turn) => {
const main = mainAdvice(journal, turn);
let chance: number | null = null;
if (turn.move !== null) {
chance = review.winChances[turn.move.id] ?? null;
}
if (turn.end !== null && (turn.move === null || chance === null)) {
chance = turn.end.winner === "player" ? 1 : 0;
}
return {
turn: turn.number,
side: turn.side,
chance,
verdict: main === null ? null : verdictOf(main.skill),
};
});
}
/**
* « Rejouer d'ici » sur un tour : l'événement où revenir pour rejouer la
* décision à revoir du joueur (juste avant elle : même jet pour un coup,
* même moment pour un videau). null pour un tour sans décision du joueur.
*/
export function replayPoint(journal: GameJournal, turn: Turn): string | null {
const main = mainAdvice(journal, turn);
const decisions = decisionsOf(turn.events, "player");
const event = main === null ? decisions.at(-1) : decisions.find((e) => e.id === main.eventId);
return event?.parent ?? null;
}
+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"}`;
}
+337
View File
@@ -0,0 +1,337 @@
// Les mots et les phrases du maître : lecture de leur source
// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des
// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node
// sur de vraies analyses (app/tests/advice.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type MoveFacts,
masterCubeAction,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { CubeAction, LuckRating } from "../game/engine.js";
import type { GameSummary } from "./game-analysis.js";
import type { ProgressionTrend } from "./progression.js";
/** Les gabarits, par clé de section (« coup.meilleur »…). */
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
/** Longueur maximale de la ligne du maître (mot et phrase), en caractères
* (charte, « Les phrases du maître ») : elle doit rester en dessous. */
export const LINE_LIMIT = 140;
/** Ce que le maître dit d'une décision. */
export interface MasterWords {
readonly verdict: Verdict;
/** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */
readonly word: string;
/** La phrase du maître. */
readonly phrase: string;
/** Le titre de la feuille d'explication. */
readonly title: string;
/** Le contexte de la décision : « votre 3-1 d'ouverture »… */
readonly context: string;
}
/** Lit la source des phrases : les sections « ## clé » à partir de la
* première « ## mot.… », un gabarit par ligne « - … ». */
export function parsePhrases(text: string): MasterPhrases {
const phrases = new Map<string, string[]>();
let key: string | null = null;
let started = false;
for (const raw of text.split("\n")) {
const line = raw.trim();
const heading = /^## (\S+)$/.exec(line);
if (heading !== null) {
const name = heading[1] as string;
started ||= name.startsWith("mot.");
key = started ? name : null;
if (key !== null) {
phrases.set(key, []);
}
} else if (key !== null && line.startsWith("- ")) {
phrases.get(key)?.push(line.slice(2).trim());
}
}
return phrases;
}
/** Les clés que la source doit contenir (vérifié par les tests). */
export const PHRASE_KEYS: readonly string[] = [
"mot.coup.bon",
"mot.videau.bon",
"mot.douteux",
"mot.mauvais",
"mot.tres-mauvais",
"coup.meilleur",
"coup.seul",
"coup.proche",
"coup.douteux",
"coup.mauvais",
"coup.tres-mauvais",
"coup.loin",
"fait.frappe",
"fait.frappe-plusieurs",
"fait.point",
"fait.frappe-et-point",
"fait.maitre-frappe",
"fait.maitre-point",
"fait.joueur-frappe",
...["bon", "proche", "autre"].flatMap((group) =>
["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`),
),
"titre.coup.meme",
"titre.coup.autre",
"titre.videau.meme",
...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`),
"contexte.coup",
"contexte.coup.ouverture",
"contexte.videau.double",
"contexte.videau.pas-de-double",
"contexte.videau.reponse",
"bilan.jeu.gagne",
"bilan.jeu.perdu",
"bilan.des",
"bilan.des.tres-pour",
"bilan.des.pour",
"bilan.des.neutres",
"bilan.des.contre",
"bilan.des.tres-contre",
"bilan.aucun-coup",
"progression.baisse",
"progression.hausse",
"progression.stable",
"progression.attente",
];
const DICE_KEYS: Readonly<Record<LuckRating, string>> = {
veryLucky: "bilan.des.tres-pour",
lucky: "bilan.des.pour",
none: "bilan.des.neutres",
unlucky: "bilan.des.contre",
veryUnlucky: "bilan.des.tres-contre",
};
/** La chance de vos dés sur la partie, en mots : « un peu contre vous »… */
export function diceWords(phrases: MasterPhrases, rating: LuckRating): string {
return pick(phrases, DICE_KEYS[rating], "0");
}
/**
* La lecture du bilan d'une partie finie : ce qui a fait la différence,
* selon le résultat corrigé de la chance (gnubg) ; et vos dés, en mots.
* Partie gagnée : votre jeu, si le résultat corrigé de la chance reste
* positif, sinon les dés ; partie perdue : le jeu de l'ordinateur, si le
* résultat corrigé reste négatif, sinon les dés.
*/
export function summaryReading(phrases: MasterPhrases, summary: GameSummary): string {
const dice = diceWords(phrases, summary.player.luck.rating);
const won = summary.result > 0;
const playDecided = won ? summary.luckAdjustedResult > 0 : summary.luckAdjustedResult < 0;
if (!playDecided) {
return pick(phrases, "bilan.des", "0");
}
return fillIn(pick(phrases, won ? "bilan.jeu.gagne" : "bilan.jeu.perdu", "0"), { dice });
}
/** « Aucun coup à revoir… » : la carte du coup à revoir sans erreur. */
export function nothingToReview(phrases: MasterPhrases): string {
return pick(phrases, "bilan.aucun-coup", "0");
}
const TREND_KEYS: Readonly<Record<ProgressionTrend["kind"], string>> = {
down: "progression.baisse",
up: "progression.hausse",
steady: "progression.stable",
waiting: "progression.attente",
};
/** La phrase de l'écran de progression (étape 1h) : la tendance du taux
* d'erreur, en faits (nombre de parties, moyennes en millièmes). */
export function progressionSentence(phrases: MasterPhrases, trend: ProgressionTrend): string {
const template = pick(phrases, TREND_KEYS[trend.kind], "0");
return trend.kind === "waiting"
? template
: fillIn(template, {
parties: String(trend.games),
debut: String(trend.from),
fin: String(trend.to),
});
}
/** Ce que le maître dit d'un conseil du moteur. */
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
}
/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */
export function masterLine(words: MasterWords): string {
return `${words.word}. ${words.phrase}`;
}
const ACTION_KEYS: Readonly<Record<CubeAction, string>> = {
double: "double",
noDouble: "pas-de-double",
take: "prendre",
pass: "passer",
};
function wordKey(verdict: Verdict, kind: Advice["kind"]): string {
switch (verdict) {
case "good":
return kind === "move" ? "mot.coup.bon" : "mot.videau.bon";
case "doubtful":
return "mot.douteux";
case "bad":
return "mot.mauvais";
case "veryBad":
return "mot.tres-mauvais";
}
}
function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const word = pick(phrases, wordKey(verdict, "move"), advice.eventId);
const values = {
best: advice.best.notation,
played: advice.played.notation,
rank: String(advice.rank),
roll: `${advice.roll[0]}-${advice.roll[1]}`,
};
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra });
let base: string;
let fact: string | null;
if (advice.moveCount <= 1) {
base = fill("coup.seul");
fact = null;
} else if (advice.rank === 1) {
base = fill("coup.meilleur");
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
} else {
// Le rang n'est cité que pour un bon coup non meilleur ou une
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
// erreur, et le classement est dans les chiffres du moteur.
const near = advice.rank <= 3;
const key = {
good: near ? "coup.proche" : "coup.loin",
doubtful: near ? "coup.douteux" : "coup.loin",
bad: "coup.mauvais",
veryBad: "coup.tres-mauvais",
}[verdict];
base = fill(key);
fact = differenceFact(phrases, advice);
}
const withFact = fact === null ? base : `${base} ${fact}`;
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
return {
verdict,
word,
phrase,
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
};
}
/** Ce que fait le coup lui-même : pions frappés, points faits. */
function selfFact(
phrases: MasterPhrases,
id: string,
move: string,
facts: MoveFacts,
): string | null {
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
const made = facts.madePoints.length > 0;
if (facts.hits === 1 && made) {
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
}
if (facts.hits > 1) {
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
}
if (facts.hits === 1) {
return fillIn(pick(phrases, "fait.frappe", id), values);
}
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
}
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
const best = advice.bestFacts;
const played = advice.playedFacts;
if (best.hits > 0 && played.hits === 0) {
return pick(phrases, "fait.maitre-frappe", advice.eventId);
}
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
if (onlyBest.length > 0) {
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
points: pointsText(onlyBest),
});
}
if (played.hits > 0 && best.hits === 0) {
return pick(phrases, "fait.joueur-frappe", advice.eventId);
}
return null;
}
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const master = masterCubeAction(advice);
const same = master === advice.action;
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
const shown = group === "bon" ? advice.action : master;
const context =
advice.action === "take" || advice.action === "pass"
? "contexte.videau.reponse"
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
return {
verdict,
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
title: pick(
phrases,
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
advice.eventId,
),
context: pick(phrases, context, advice.eventId),
};
}
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
function pointsText(points: readonly number[]): string {
const sorted = [...points].sort((a, b) => a - b).map(String);
if (sorted.length <= 1) {
return `votre point ${sorted[0] ?? ""}`;
}
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
}
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
* de l'événement. Erreur si la section manque. */
function pick(phrases: MasterPhrases, key: string, id: string): string {
const choices = phrases.get(key) ?? [];
if (choices.length === 0) {
throw new Error(`phrases du maître : section « ${key} » absente`);
}
const n = Number.parseInt(id, 10);
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
return choices[index] as string;
}
/** Remplace les {repères} ; erreur sur un repère inconnu. */
function fillIn(template: string, values: Readonly<Record<string, string>>): string {
return template.replace(/\{([a-z]+)\}/g, (_, name: string) => {
const value = values[name];
if (value === undefined) {
throw new Error(`phrases du maître : repère {${name}} inconnu`);
}
return value;
});
}
+122
View File
@@ -0,0 +1,122 @@
// La progression du joueur (étape 1h ; charte, ecrans-etape-1.md, « 1h ») :
// le taux d'erreur de chaque partie terminée, tel que le bilan le calcule
// (summarizeAnalysis), sa courbe partie après partie et la tendance que dit
// la phrase du maître. Fonctions pures, testées sous Node
// (app/tests/progression.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Rien n'est recalculé ici : le taux d'une partie vient des analyses
// rangées dans son journal (1e). Une partie dont l'analyse n'est pas finie
// n'a pas encore de taux ; l'application l'analyse en arrière-plan.
import type { Level } from "../game/engine.js";
import type { GameJournal } from "../game/events.js";
import type { GameResult } from "../game/state.js";
import { type ErrorRate, readyForSummary, summarizeAnalysis } from "./game-analysis.js";
/** Les parties prises en compte au plus : les plus récentes. Au-delà, la
* courbe devient illisible sur un téléphone, et il faudrait relire tous
* les journaux à chaque visite. */
export const PROGRESSION_MAX_GAMES = 20;
/** Parties analysées qu'il faut pour tracer la courbe (charte : « Votre
* courbe apparaîtra après trois parties »). */
export const CURVE_MIN_GAMES = 3;
/** Une partie terminée, telle que le stockage la donne. */
export interface FinishedGame {
readonly id: string;
/** Début de la partie, en millisecondes depuis 1970. */
readonly startedAt: number;
/** Le niveau de l'ordinateur. */
readonly level: Level;
readonly result: GameResult | null;
readonly journal: GameJournal;
}
/** Une ligne de la progression : une partie terminée et son taux
* d'erreur (null : analyse pas encore finie). */
export interface ProgressionEntry {
readonly id: string;
readonly startedAt: number;
readonly level: Level;
readonly result: GameResult | null;
readonly errorRate: ErrorRate | null;
}
/** Les lignes de la progression, de la plus ancienne à la plus récente.
* Le taux d'erreur est celui du bilan, sur les analyses déjà rangées. */
export function progressionEntries(games: readonly FinishedGame[]): ProgressionEntry[] {
return [...games]
.sort((a, b) => a.startedAt - b.startedAt)
.map((game) => ({
id: game.id,
startedAt: game.startedAt,
level: game.level,
result: game.result,
errorRate: readyForSummary(game.journal)
? summarizeAnalysis(game.journal).player.errorRate
: null,
}));
}
/** Un point de la courbe : une partie analysée. */
export interface ProgressionPoint {
readonly id: string;
/** Équité perdue par décision (ErrorRate.perDecision). */
readonly perDecision: number;
}
/** Les points de la courbe, de la plus ancienne partie analysée à la plus
* récente. */
export function progressionCurve(entries: readonly ProgressionEntry[]): ProgressionPoint[] {
return entries.flatMap((entry) =>
entry.errorRate === null
? []
: [{ id: entry.id, perDecision: entry.errorRate.perDecision }],
);
}
/**
* Le taux d'erreur tel que l'écran l'écrit : en millièmes d'équité par
* décision, arrondi (0,014 s'écrit 14), comme les « mEMG » de GNU
* Backgammon. Le glossaire le dit (docs/glossaire/taux-d-erreur.md).
*/
export function errorRateMillis(perDecision: number): number {
return Math.round(perDecision * 1000);
}
/** Ce que dit la phrase de la progression. */
export type ProgressionTrend =
/** Moins de CURVE_MIN_GAMES parties analysées. */
| { readonly kind: "waiting" }
/** Le taux baisse, monte ou ne change pas : moyenne de la première
* moitié des parties (from) et de la seconde (to), en millièmes. */
| {
readonly kind: "down" | "up" | "steady";
readonly games: number;
readonly from: number;
readonly to: number;
};
/**
* La tendance de la courbe : la moyenne des taux de la plus ancienne moitié
* des parties, comparée à celle de la plus récente (la partie du milieu,
* quand leur nombre est impair, n'entre dans aucune). Les deux moyennes
* sont arrondies en millièmes avant d'être comparées : la phrase ne dit
* jamais « passé de 9 à 9 ».
*/
export function progressionTrend(points: readonly ProgressionPoint[]): ProgressionTrend {
if (points.length < CURVE_MIN_GAMES) {
return { kind: "waiting" };
}
const half = Math.floor(points.length / 2);
const mean = (part: readonly ProgressionPoint[]) =>
part.reduce((sum, p) => sum + p.perDecision, 0) / part.length;
const from = errorRateMillis(mean(points.slice(0, half)));
const to = errorRateMillis(mean(points.slice(points.length - half)));
const kind = to < from ? "down" : to > from ? "up" : "steady";
return { kind, games: points.length, from, to };
}
+126
View File
@@ -0,0 +1,126 @@
// Le conseil du maître sur une décision du joueur : ce que le moteur en dit,
// rattaché à l'événement de la décision dans le journal (étape 1d).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur (analyse du coup, classement des coups, analyse de la décision de
// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/** Version du format d'un conseil enregistré. Un conseil d'une autre
* version est ignoré à la reprise (il peut être recalculé). Version 2 : le
* classement des coups vient de l'analyse du coup joué, et non plus d'un
* second classement comme la commande « hint » de gnubg. */
export const ADVICE_VERSION = 2;
/**
* Le conseil du maître sur une décision du joueur : un coup ou une décision
* de videau, avec l'appréciation du moteur.
*
* Glossaire : docs/glossaire/conseil-du-maitre.md
*/
export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: MoveNotation;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
readonly equity: number;
readonly ply: Ply;
}
/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après
* un coup : de quoi écrire une phrase sans rien inventer. */
export interface MoveFacts {
/** Pions adverses envoyés sur la barre. */
readonly hits: number;
/** Cases (numérotation du joueur) où il n'avait pas deux pions avant
* le coup et en a deux ou plus après : les points qu'il fait. */
readonly madePoints: readonly number[];
}
export interface MoveAdvice {
readonly version: number;
readonly kind: "move";
/** L'événement MovePlayed du coup. */
readonly eventId: string;
/** Niveau de l'analyse, en plis. */
readonly ply: Ply;
/** La position avant le coup, vue du joueur, et son jet. */
readonly position: PositionId;
readonly roll: Roll;
/** Le premier coup de la partie (après le jet d'ouverture). */
readonly opening: boolean;
/** Nombre de coups légaux et rang du coup joué (à partir de 1). */
readonly moveCount: number;
readonly rank: number;
readonly played: AdvisedMove;
readonly best: AdvisedMove;
readonly playedFacts: MoveFacts;
readonly bestFacts: MoveFacts;
/** Les meilleurs coups du classement de l'analyse, du meilleur au
* moins bon (cinq au plus), chacun au niveau où le moteur l'a évalué. */
readonly ranking: readonly AdvisedMove[];
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
export interface CubeAdvice {
readonly version: number;
readonly kind: "cube";
/** L'événement de la décision : DiceRolled (le joueur lance sans
* doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il
* répond au double de l'ordinateur). */
readonly eventId: string;
readonly ply: Ply;
/** La décision du joueur. */
readonly action: CubeAction;
/** La position, vue de celui qui double ou non (l'ordinateur quand le
* joueur prend ou passe). */
readonly position: PositionId;
/** Équités de celui qui double ou non, pour un videau de 1. */
readonly equities: CubeEquities;
/** Équité perdue par le joueur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
/** L'appréciation dite au joueur : celle du moteur, « rien à signaler »
* devenant « bon ». */
export type Verdict = "good" | "doubtful" | "bad" | "veryBad";
export function verdictOf(skill: Skill): Verdict {
return skill === "none" ? "good" : skill;
}
/** L'autre choix d'une décision de videau. */
export function otherCubeAction(action: CubeAction): CubeAction {
switch (action) {
case "double":
return "noDouble";
case "noDouble":
return "double";
case "take":
return "pass";
case "pass":
return "take";
}
}
/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu
* aucune équité, l'autre sinon (une décision de videau n'a que deux
* choix). */
export function masterCubeAction(advice: CubeAdvice): CubeAction {
return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action;
}
+91
View File
@@ -0,0 +1,91 @@
// Les branches d'une partie : les lignes de jeu de son journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal est un arbre (events.ts) : chaque événement pointe vers son
// parent, et « Rejouer d'ici » accroche un nouvel événement à un ancien. Une
// branche est le chemin du premier événement jusqu'à une feuille de l'arbre.
// Les identifiants des événements sont attribués dans l'ordre d'ajout : de
// deux enfants d'un même événement, le plus ancien a le plus petit indice
// dans le journal, et c'est lui qui continue la ligne d'origine.
import { currentLine, type GameEvent, type GameJournal, goBackTo } from "./events.js";
import { replay } from "./state.js";
/**
* Une branche de la partie : la suite d'événements du début jusqu'à head.
*
* Glossaire : docs/glossaire/branche.md
*/
export interface Branch {
/** Le dernier événement de la branche (une feuille de l'arbre). */
readonly head: string;
/** L'événement à partir duquel elle a été rejouée (« Rejouer d'ici ») ;
* null pour la ligne d'origine de la partie. */
readonly forkedFrom: string | null;
/** Les coups joués avant de rejouer (0 : depuis le début) ; 0 aussi pour
* la ligne d'origine. */
readonly forkMoveCount: number;
/** Les coups joués sur toute la branche, depuis le début. */
readonly moveCount: number;
/** La partie est finie au bout de cette branche. */
readonly over: boolean;
/** L'événement courant de la partie est sur cette branche. */
readonly containsHead: boolean;
}
/**
* Les branches de la partie, dans l'ordre de leur création (celui de leur
* premier événement propre) : la ligne d'origine d'abord. Liste vide pour un
* journal vide.
*/
export function branches(journal: GameJournal): Branch[] {
const order = new Map(journal.events.map((event, index) => [event.id, index]));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
const leaves = journal.events.filter((event) => !children.has(event.id));
return leaves
.map((leaf) => {
const line = currentLine({ events: journal.events, head: leaf.id });
// Le point de départ de la branche : le parent de l'événement le
// plus récent du chemin qui n'est pas le premier enfant de son
// parent (la ligne d'origine n'en a aucun).
let forkIndex = -1;
for (let i = line.length - 1; i > 0; i--) {
const event = line[i] as GameEvent;
const first = children.get(event.parent as string)?.[0];
if (first !== undefined && first.id !== event.id) {
forkIndex = i - 1;
break;
}
}
const moves = (events: readonly GameEvent[]) =>
events.filter((event) => event.type === "MovePlayed").length;
const fork = forkIndex >= 0 ? (line[forkIndex] as GameEvent) : null;
const start = line[forkIndex + 1] as GameEvent;
return {
created: order.get(start.id) ?? 0,
head: leaf.id,
forkedFrom: fork?.id ?? null,
forkMoveCount: fork === null ? 0 : moves(line.slice(0, forkIndex + 1)),
moveCount: moves(line),
over: replay(line).phase === "over",
containsHead: line.some((event) => event.id === journal.head),
};
})
.sort((a, b) => a.created - b.created)
.map(({ created: _, ...branch }) => branch);
}
/** Passe à une autre branche : la partie reprend au bout de celle-ci. */
export function switchToBranch(journal: GameJournal, head: string): GameJournal {
if (!branches(journal).some((branch) => branch.head === head)) {
throw new Error(`branche introuvable : ${head}`);
}
return goBackTo(journal, head);
}
+99 -2
View File
@@ -51,6 +51,24 @@ export interface Engine {
/** La décision de videau de l'ordinateur à ce niveau, bruit compris. */
levelCubeDecision(position: PositionId, cube: EngineCube, level: Level): Promise<CubeDecision>;
/** L'ordinateur au trait, à ce niveau, double-t-il avant de lancer les
* dés ? Comme ComputerTurn de gnubg (play.c) : accès au videau, fenêtre
* de marché, double facultatif seulement à 0 pli. */
levelDouble(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
/** Le joueur au trait double : l'ordinateur, son adversaire, prend-il à
* ce niveau ? Comme ComputerTurn de gnubg (play.c). */
levelTake(position: PositionId, cube: EngineCube, level: Level): Promise<boolean>;
/** Le match après une partie gagnée par winner pour points points :
* score, fin du match et partie Crawford suivante, comme gnubg de
* bureau. */
matchAfterGame(
match: EngineMatch,
winner: "onRoll" | "opponent",
points: number,
): Promise<EngineMatchAfter>;
/** L'analyse du coup joué (played : position après le coup, vue du joueur
* qui l'a joué, comme Move.position), au niveau d'analyse ply ; null si
* aucun coup n'était possible. */
@@ -74,6 +92,41 @@ export interface Engine {
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** L'évaluation d'une position, joueur au trait avant de lancer les
* dés, au niveau ply : ses chances de gain (sans videau) et son équité
* avec videau. */
evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation>;
/** Le Match ID, joueur 0 au trait, avec ce videau et ce match vus de lui
* (partie libre sans match), sans dés. */
encodeMatch(cube: EngineCube): Promise<MatchId>;
/** Un Match ID décodé ; erreur s'il est illisible pour le moteur. */
decodeMatch(matchId: MatchId): Promise<MatchDescription>;
}
/**
* Identifiant de match (Match ID de gnubg, 12 caractères) : score, longueur
* du match, videau, joueur au trait. Le domaine ne le décode jamais
* lui-même : il le passe au moteur.
*
* Glossaire : docs/glossaire/identifiant-de-match.md
*/
export type MatchId = string;
/** Un Match ID décodé par le moteur. Les joueurs sont numérotés 0 et 1,
* comme dans gnubg. Les dés qu'il peut contenir ne sont pas lus. */
export interface MatchDescription {
/** 0 : partie libre. */
readonly matchLength: number;
readonly score: readonly [number, number];
readonly cubeValue: number;
/** -1 : videau au centre ; sinon le joueur qui le possède. */
readonly cubeOwner: -1 | 0 | 1;
readonly playerOnRoll: 0 | 1;
readonly crawford: boolean;
readonly jacoby: boolean;
}
/**
@@ -130,6 +183,16 @@ export interface Luck {
readonly rating: LuckRating;
}
/** Une position évaluée par le moteur, du point de vue du joueur au
* trait. */
export interface Evaluation {
/** Chances de gagner la partie, de 0 à 1, sans tenir compte du videau
* (première probabilité de gnubg). */
readonly win: number;
/** Équité avec videau, normalisée pour un videau de 1. */
readonly equity: number;
}
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
export interface EvaluatedMove extends Move {
readonly equity: number;
@@ -148,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
@@ -182,12 +251,40 @@ export interface CubeEquities {
*/
export type Ply = number;
/** Le videau vu du joueur au trait. */
/** Le videau vu du joueur au trait, et le match s'il y en a un (sans lui :
* partie libre, avec la règle Jacoby). */
export interface EngineCube {
readonly value: number;
readonly owner: "centered" | "onRoll" | "opponent";
readonly match?: EngineMatch;
}
/** Un match vu du joueur au trait : sa longueur, le score avant la partie,
* et si la partie est la partie Crawford. Le moteur le code dans le Match
* ID. */
export interface EngineMatch {
readonly length: number;
readonly score: { readonly onRoll: number; readonly opponent: number };
readonly crawford: boolean;
}
/** Le match après une partie, vu du même joueur que le match d'avant. */
export interface EngineMatchAfter {
readonly score: { readonly onRoll: number; readonly opponent: number };
readonly over: boolean;
/** La partie suivante est la partie Crawford. */
readonly crawford: boolean;
}
/**
* 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.
*
@@ -195,7 +292,7 @@ export interface EngineCube {
*/
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. */
+168 -7
View File
@@ -12,14 +12,131 @@
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
// le moteur : l'état se recalcule par une simple boucle (state.ts).
import { ADVICE_VERSION, type Advice } from "./advice.js";
import type { Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { Level, ResultKind } from "./engine.js";
import type { Match, MatchAfterGame } from "./match.js";
import type { HalfMove, PositionId } from "./position.js";
import { type GameReview, REVIEW_VERSION } from "./review.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
/** Version du format des événements. */
export const EVENT_VERSION = 1;
/**
* Version du format des événements :
* - 1 : jusqu'à l'étape 1b, partie libre au niveau 2 plis ;
* - 2 (étape 1c) : GameStarted porte le niveau de l'ordinateur (level) et,
* pour une partie de match, le match (match) ; GameEnded porte le match
* après la partie. migrateJournal passe un journal de la version 1 à la 2.
*/
export const EVENT_VERSION = 2;
/** Le niveau des parties de la version 1 : 2 plis (STANDARD_PLY, sans
* bruit), le seul qui existait. */
const VERSION_1_LEVEL: Level = "2ply";
/**
* Un journal enregistré, à la version courante du format. Version 1 : le
* niveau, implicite, devient explicite (2 plis) ; rien d'autre ne change,
* une partie de la version 1 étant une partie libre. Un événement d'une
* version inconnue est laissé tel quel (Game.resume le refuse).
*/
export function migrateJournal(saved: GameJournal): GameJournal {
const journal = keepKnownReview(keepKnownAdvice(saved));
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
const events = journal.events.map((event): GameEvent => {
if (event.version !== 1) {
return event;
}
if (event.type === "GameStarted") {
return Object.freeze({
...event,
level: event.level ?? VERSION_1_LEVEL,
version: EVENT_VERSION,
});
}
return Object.freeze({ ...event, version: EVENT_VERSION });
});
return { ...journal, events };
}
/**
* Les conseils du maître d'un journal enregistré : seuls restent ceux de la
* version courante (ADVICE_VERSION), rattachés à un événement du journal.
* Les autres sont oubliés : ce ne sont que des analyses du moteur, qui
* peuvent être refaites. Un journal sans conseils (enregistré avant l'étape
* 1d) reste tel quel.
*/
function keepKnownAdvice(journal: GameJournal): GameJournal {
const advice = journal.advice;
if (advice === undefined) {
return journal;
}
const ids = new Set(journal.events.map((event) => event.id));
const kept = Object.entries(advice).filter(
([id, a]) =>
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id &&
ids.has(id),
);
if (kept.length === Object.keys(advice).length) {
return journal;
}
return { ...journal, advice: Object.fromEntries(kept) };
}
/**
* L'analyse de partie d'un journal enregistré (étape 1e) : oubliée si elle
* est d'une autre version (REVIEW_VERSION) ou mal formée ; sinon, seules
* restent ses entrées rattachées à un événement du journal. Ce n'est
* qu'une analyse du moteur, qui peut être refaite. Un journal sans analyse
* (enregistré avant l'étape 1e) reste tel quel.
*/
function keepKnownReview(journal: GameJournal): GameJournal {
const review = journal.review;
if (review === undefined) {
return journal;
}
const tables = [review.luck, review.winChances, review.computer];
const wellFormed =
typeof review === "object" &&
review !== null &&
review.version === REVIEW_VERSION &&
typeof review.ply === "number" &&
tables.every((table) => typeof table === "object" && table !== null) &&
Array.isArray(review.unanalysed);
if (!wellFormed) {
const { review: _, ...rest } = journal;
return rest;
}
const ids = new Set(journal.events.map((event) => event.id));
const known = <T>(table: Readonly<Record<string, T>>) =>
Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(id)));
const computer = Object.fromEntries(
Object.entries(review.computer).filter(
([id, a]) =>
ids.has(id) &&
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id,
),
);
const kept: GameReview = {
...review,
luck: known(review.luck),
winChances: known(review.winChances),
computer,
unanalysed: review.unanalysed.filter((id) => ids.has(id)),
};
// Le nombre d'entrées de chaque table : rien d'oublié, rien ne change.
const sizes = (r: GameReview) =>
[r.luck, r.winChances, r.computer, r.unanalysed].map((t) => Object.keys(t).length).join();
return sizes(kept) === sizes(review) ? journal : { ...journal, review: kept };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
@@ -31,12 +148,18 @@ interface EventBase {
/** Début de partie : la position, le videau, et qui commence (null : le jet
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
* départ est la même pour les deux. */
* départ est la même pour les deux. level et match viennent avec la version
* 2 du format (étape 1c). */
export interface GameStarted extends EventBase {
readonly type: "GameStarted";
readonly position: PositionId;
readonly cube: Cube;
readonly firstTurn: Side | null;
/** Le niveau de l'ordinateur ; absent : DEFAULT_LEVEL (state.ts). Les
* journaux de la version 1 l'ont après migrateJournal. */
readonly level?: Level;
/** Le match dont c'est une partie ; absent : partie libre. */
readonly match?: Match;
}
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
@@ -80,6 +203,9 @@ export interface GameEnded extends EventBase {
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
/** Dans un match : le match après cette partie, dit par le moteur
* (absent en partie libre). */
readonly match?: MatchAfterGame;
}
/**
@@ -104,10 +230,17 @@ export type NewGameEvent = WithoutBase<GameEvent>;
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
/** Tous les événements d'une partie, branches comprises, et l'événement
* courant (head) : la partie en est là. */
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
* conseil du maître sur les décisions du joueur, par identifiant de
* l'événement de la décision. Ce n'est pas un événement : il arrive après
* coup, quand l'analyse du moteur est prête, et ne change pas la partie.
* review (étape 1e, facultatif) : le reste de l'analyse de la partie (chance
* des jets, chances de gagner, décisions de l'ordinateur), pour le bilan. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
readonly review?: GameReview;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -120,7 +253,35 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
return { events: [...journal.events, added], head: added.id };
return { ...journal, events: [...journal.events, added], head: added.id };
}
/** Rattache un conseil du maître à son événement (il remplace un conseil
* déjà rattaché). Erreur si l'événement n'est pas dans le journal. */
export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
if (!journal.events.some((event) => event.id === advice.eventId)) {
throw new Error(`événement introuvable : ${advice.eventId}`);
}
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
}
/** Remplace l'analyse de partie du journal (elle ne change pas la
* partie). */
export function withReview(journal: GameJournal, review: GameReview): GameJournal {
return { ...journal, review };
}
/** Le journal avec les analyses d'une copie de lui-même, faite en
* arrière-plan : ses conseils s'ajoutent, son analyse de partie remplace
* celle du journal. Les événements ne changent pas. */
export function withAnalysesOf(journal: GameJournal, analysed: GameJournal): GameJournal {
const ids = new Set(journal.events.map((event) => event.id));
const advice = Object.entries(analysed.advice ?? {}).filter(([id]) => ids.has(id));
return {
...journal,
advice: { ...Object.fromEntries(advice), ...journal.advice },
...(analysed.review === undefined ? {} : { review: analysed.review }),
};
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
@@ -144,5 +305,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal {
if (!journal.events.some((event) => event.id === id)) {
throw new Error(`événement introuvable : ${id}`);
}
return { events: journal.events, head: id };
return { ...journal, head: id };
}
+257 -90
View File
@@ -1,4 +1,4 @@
// Une partie libre contre l'ordinateur.
// Une partie contre l'ordinateur : partie libre, ou une partie d'un match.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -6,10 +6,21 @@
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
// des événements au journal (events.ts), d'où l'état est recalculé
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
// l'ordinateur, videau (Crawford compris), fin de partie, points et score du
// match.
import type { Advice } from "./advice.js";
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import type {
BoardSide,
Engine,
EngineCube,
Level,
MoveNotation,
Ply,
ResultKind,
} from "./engine.js";
import {
appendEvent,
currentLine,
@@ -18,16 +29,27 @@ import {
type GameEvent,
type GameJournal,
goBackTo,
migrateJournal,
type NewGameEvent,
withAdvice,
withAnalysesOf,
} from "./events.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { firstGameOf, type Match, nextGameOf } from "./match.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";
/**
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const STANDARD_PLY: Ply = 2;
@@ -61,12 +83,19 @@ export interface MoveSteps {
readonly hits: readonly boolean[];
}
export interface GameOptions {
/** Ce que l'on choisit pour une nouvelle partie. */
export interface NewGame {
/** Une position de départ particulière (sans jet d'ouverture). */
readonly setup?: GameSetup;
/** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */
readonly level?: Level;
/** Le match dont ce sera une partie ; absent : partie libre. */
readonly match?: Match;
}
export interface GameOptions extends NewGame {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
readonly ply?: Ply;
readonly setup?: GameSetup;
}
/**
@@ -77,7 +106,6 @@ export interface GameOptions {
export class Game {
readonly #engine: Engine;
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
@@ -87,11 +115,11 @@ export class Game {
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
this.#dice = options.dice ?? randomDice;
this.#ply = options.ply ?? STANDARD_PLY;
}
/** Commence une partie : à la position de départ avec le jet d'ouverture,
* ou à la position de options.setup. */
* ou à la position de options.setup ; au niveau options.level, et dans
* le match options.match s'il est donné. */
static start(engine: Engine, options: GameOptions = {}): Game {
const game = new Game(engine, options);
const setup = options.setup;
@@ -100,6 +128,8 @@ export class Game {
position: setup?.position ?? START_POSITION,
cube: setup?.cube ?? CENTERED_CUBE,
firstTurn: setup?.firstTurn ?? null,
...(options.level === undefined ? {} : { level: options.level }),
...(options.match === undefined ? {} : { match: options.match }),
});
while (game.state.phase === "opening") {
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
@@ -111,11 +141,13 @@ export class Game {
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
* journal est d'un autre format ou incohérent.
*/
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
const first = journal.events[0];
if (first?.type !== "GameStarted" || journal.head === null) {
static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game {
const first = saved.events[0];
if (first?.type !== "GameStarted" || saved.head === null) {
throw new Error("journal de partie vide ou mal formé");
}
// Un journal d'une version précédente passe à la version courante.
const journal = migrateJournal(saved);
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
throw new Error("journal de partie d'une autre version");
}
@@ -140,14 +172,40 @@ export class Game {
return replay(this.events);
}
/** La partie finie appartient à un match qui continue : ce que sera la
* partie suivante (score et Crawford dits par le moteur à la fin de
* celle-ci) ; null sinon. */
get nextGame(): NewGame | null {
const state = this.state;
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
return null;
}
const match = nextGameOf(state.match, state.matchAfter);
return match === null ? null : { level: state.level, match };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
}
/** 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;
}
@@ -181,7 +239,7 @@ export class Game {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
}
/**
@@ -190,36 +248,88 @@ export class Game {
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
const events = this.events;
const index = events.findLastIndex((e) => e.type === "MovePlayed");
const played = events[index];
if (played?.type !== "MovePlayed") {
return null;
}
// L'état juste avant le coup : la position vue de celui qui joue, et
// son jet.
const before = replay(events.slice(0, index));
const roll = rollToPlay(before);
const played = this.events.findLast((e) => e.type === "MovePlayed");
return played?.type === "MovePlayed"
? this.stepsBefore(played.id, played.side, played.halfMoves)
: null;
}
/**
* Un coup de side joué demi-coup par demi-coup à partir de la position
* d'avant l'événement eventId (le coup réellement joué, ou un autre coup
* du même jet : celui du maître). Les plateaux viennent du moteur ;
* erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient
* que la position d'avant l'événement.
*/
async stepsBefore(
eventId: string,
side: Side,
halfMoves: readonly HalfMove[],
): Promise<MoveSteps> {
const line = currentLine({ ...this.#journal, head: eventId });
// L'état juste avant l'événement : la position vue de celui qui a la
// main, et son jet.
const before = replay(line.slice(0, -1));
const positions = [before.position];
for (let i = 1; i <= played.halfMoves.length; i++) {
for (let i = 1; i <= halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
roll,
played.halfMoves.slice(0, i),
rollToPlay(before),
halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup enregistré refusé par le moteur");
throw new Error("coup refusé par le moteur");
}
positions.push(check.position);
}
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
);
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
const onRoll = before.turn ?? side;
const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before)));
const opponent = otherSide(side);
const hits = halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
return { side, halfMoves, boards, hits };
}
/**
* Le coup joué à l'événement eventId (un MovePlayed), dans la notation
* du moteur : celle du coup légal de gnubg qui mène à la même position
* (« 6/off » et non « 6/5 5/off »), comme dans l'analyse et le bilan.
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
*/
async moveNotation(eventId: string): Promise<MoveNotation> {
const line = currentLine({ ...this.#journal, head: eventId });
const event = line.at(-1);
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
return "";
}
const before = replay(line.slice(0, -1));
const roll = rollToPlay(before);
const [check, legal] = await Promise.all([
this.#engine.checkHalfMoves(before.position, roll, event.halfMoves),
this.#engine.legalMoves(before.position, roll),
]);
const move = legal.find((m) => m.position === check.position);
return move?.notation ?? formatHalfMoves(event.halfMoves);
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
return this.#journal.advice ?? {};
}
/** Rattache le conseil du maître à sa décision dans le journal (il sera
* enregistré avec la partie). Erreur si l'événement n'existe pas. */
recordAdvice(advice: Advice): void {
this.#journal = withAdvice(this.#journal, advice);
}
/** Range dans le journal les analyses faites en arrière-plan sur une
* copie de celui-ci (étape 1e : conseils, analyse de partie). */
recordAnalyses(analysed: GameJournal): void {
this.#journal = withAnalysesOf(this.#journal, analysed);
}
/**
@@ -227,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);
@@ -259,10 +372,11 @@ export class Game {
if (state.turn !== "player" || state.phase !== "rolling") {
return false;
}
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
// moteur le refuse aussi pendant la partie Crawford.
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
engineCube(state, "player"),
0,
);
return decision.available;
@@ -300,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), [
@@ -323,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 {
@@ -348,62 +469,61 @@ export class Game {
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
* selon le moteur. */
* à son niveau, selon le moteur. */
async double(): Promise<void> {
const state = this.#expect("player", "rolling");
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
this.#ply,
);
const cube = engineCube(state, "player");
const decision = await this.#engine.cubeDecision(state.position, cube, 0);
if (!decision.available) {
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
throw new Error("vous ne pouvez pas doubler maintenant");
}
const take = await this.#engine.levelTake(state.position, cube, state.level);
this.#append({ type: "CubeOffered", side: "player" });
this.#answerDouble("computer", decision.take);
await this.#answerDouble("computer", take);
}
/** Le joueur prend le double de l'ordinateur. */
take(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", true);
this.#append({ type: "CubeTaken", side: "player" });
}
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
drop(): void {
async drop(): Promise<void> {
this.#expect("computer", "doubled");
this.#answerDouble("player", false);
await this.#answerDouble("player", false);
}
// --------------------------------------------------------- Ordinateur
/**
* Le tour de l'ordinateur : il double si le moteur le recommande (le
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
* les dés et joue le meilleur coup du moteur.
* Le tour de l'ordinateur, à son niveau : il double si le moteur le dit
* (le tour s'arrête alors, en attendant la réponse du joueur), sinon il
* lance les dés et joue son coup. Le déroulement est celui de
* l'ordinateur de gnubg (ComputerTurn, play.c), repris par le moteur.
*/
async playComputerTurn(): Promise<void> {
let state = this.#expect("computer", "rolling", "moving");
if (state.phase === "rolling") {
const decision = await this.#engine.cubeDecision(
const doubles = await this.#engine.levelDouble(
state.position,
engineCube(state.cube, "computer"),
this.#ply,
engineCube(state, "computer"),
state.level,
);
if (decision.double) {
if (doubles) {
this.#append({ type: "CubeOffered", side: "computer" });
return;
}
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
state = this.state;
}
const best = await this.#engine.bestMove(
const move = await this.#engine.levelMove(
state.position,
engineCube(state.cube, "computer"),
engineCube(state, "computer"),
rollToPlay(state),
this.#ply,
state.level,
);
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
await this.#playMove("computer", move?.halfMoves ?? [], move?.position ?? null);
}
// ------------------------------------------------------------ Retour
@@ -415,6 +535,19 @@ export class Game {
this.#journal = goBackTo(this.#journal, eventId);
}
/** Les branches de la partie (voir branches.ts), la ligne d'origine
* d'abord. */
get branches(): Branch[] {
return branches(this.#journal);
}
/** Passe à une autre branche, désignée par son dernier événement : la
* partie reprend au bout de celle-ci ; les autres restent intactes. */
switchBranch(head: string): void {
this.cancelHalfMoves();
this.#journal = switchToBranch(this.#journal, head);
}
/** Annule le dernier événement : la partie repart de son parent. */
undo(): void {
const last = this.events.at(-1);
@@ -431,8 +564,8 @@ export class Game {
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(state, onRoll));
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
@@ -460,10 +593,7 @@ export class Game {
after: PositionId | null,
): Promise<void> {
const state = this.state;
const board = await this.#engine.board(
after ?? state.position,
engineCube(state.cube, side),
);
const board = await this.#engine.board(after ?? state.position, engineCube(state, side));
this.#append({
type: "MovePlayed",
side,
@@ -471,28 +601,54 @@ export class Game {
position: board.swappedPosition,
});
if (board.result !== null) {
this.#append({
type: "GameEnded",
winner: board.result.winner === "onRoll" ? side : otherSide(side),
kind: board.result.kind,
points: board.result.points,
});
await this.#endGame(
board.result.winner === "onRoll" ? side : otherSide(side),
board.result.kind,
board.result.points,
);
}
}
/** taker prend ou passe le double proposé par l'autre camp. */
#answerDouble(taker: Side, take: boolean): void {
async #answerDouble(taker: Side, take: boolean): Promise<void> {
if (take) {
this.#append({ type: "CubeTaken", side: taker });
return;
}
// Passer : on perd la partie à la valeur du videau d'avant le double.
this.#append({ type: "CubeDropped", side: taker });
await this.#endGame(otherSide(taker), "single", this.state.cube.value);
}
/** Fin de partie ; dans un match, le moteur dit ce que devient le match
* (score, fin du match, partie Crawford suivante). */
async #endGame(winner: Side, kind: ResultKind, points: number): Promise<void> {
const match = this.state.match;
if (match === null) {
this.#append({ type: "GameEnded", winner, kind, points });
return;
}
// Le joueur est « au trait » pour le moteur : les scores restent à
// leur place.
const after = await this.#engine.matchAfterGame(
{
length: match.length,
score: { onRoll: match.score.player, opponent: match.score.computer },
crawford: match.crawford,
},
winner === "player" ? "onRoll" : "opponent",
points,
);
this.#append({
type: "GameEnded",
winner: otherSide(taker),
kind: "single",
points: this.state.cube.value,
winner,
kind,
points,
match: {
score: { player: after.score.onRoll, computer: after.score.opponent },
over: after.over,
crawford: after.crawford,
},
});
}
}
@@ -562,10 +718,21 @@ function occupiedPoints(side: BoardSide): number[] {
return points;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
return { value: cube.value, owner: "centered" };
/** Le videau et le match vus du camp au trait, pour le moteur. */
export function engineCube(state: Pick<GameState, "cube" | "match">, onRoll: Side): EngineCube {
const { cube, match } = state;
const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent";
if (match === null) {
return { value: cube.value, owner };
}
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
const opponent = otherSide(onRoll);
return {
value: cube.value,
owner,
match: {
length: match.length,
score: { onRoll: match.score[onRoll], opponent: match.score[opponent] },
crawford: match.crawford,
},
};
}
+50
View File
@@ -0,0 +1,50 @@
// Les niveaux de l'ordinateur tels que le joueur les choisit.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Six noms (docs/identite/charte/ecrans-etape-1.md, chapitre 1c), chacun
// relié à un des niveaux prédéfinis de GNU Backgammon (Level, engine.ts). La
// correspondance est documentée dans docs/glossaire/niveau-de-jeu.md :
// - Débutant, Amateur, Intermédiaire et Avancé : les quatre niveaux de gnubg
// à 0 pli avec bruit (beginner, casual, intermediate, advanced) ;
// - Expert : 0 pli sans bruit, que gnubg appelait autrefois « expert » ;
// - Champion : 2 plis, l'ancien « world class » de gnubg, le plus fort qui
// tienne dans le temps du téléphone (au plus 348 ms par coup sur le
// Fairphone 4, ADR 0009). 3 et 4 plis ne sont pas proposés.
import type { Level } from "./engine.js";
/** Un niveau proposé au joueur, du plus faible au plus fort. */
export type ComputerLevel =
| "beginner"
| "amateur"
| "intermediate"
| "advanced"
| "expert"
| "champion";
export const COMPUTER_LEVELS: readonly ComputerLevel[] = [
"beginner",
"amateur",
"intermediate",
"advanced",
"expert",
"champion",
];
/** Le niveau de gnubg de chaque niveau proposé. */
export const ENGINE_LEVELS: Readonly<Record<ComputerLevel, Level>> = {
beginner: "beginner",
amateur: "casual",
intermediate: "intermediate",
advanced: "advanced",
expert: "0ply",
champion: "2ply",
};
/** Le niveau proposé qui correspond à un niveau de gnubg (null pour 1, 3 et
* 4 plis, jamais proposés). */
export function computerLevelOf(level: Level): ComputerLevel | null {
return COMPUTER_LEVELS.find((name) => ENGINE_LEVELS[name] === level) ?? null;
}
+55
View File
@@ -0,0 +1,55 @@
// Le match : une suite de parties jusqu'à un nombre de points.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque partie d'un match est une partie à part entière (son journal, son
// enregistrement), qui porte le match tel qu'il est à son début. Les règles
// viennent du moteur : le score après une partie, la fin du match et la
// partie Crawford (Engine.matchAfterGame, qui reprend gnubg de bureau). Ce
// module ne fait que recopier ce que le moteur a dit.
import type { Side } from "./side.js";
/**
* Un match, au début d'une de ses parties : sa longueur, le score avant
* cette partie, et si elle est la partie Crawford (où personne ne peut
* doubler).
*
* Glossaire : docs/glossaire/match.md
*/
export interface Match {
/** Le nombre de points à atteindre pour gagner le match. */
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
readonly crawford: boolean;
}
/** Le match après une partie, tel que le moteur le calcule. */
export interface MatchAfterGame {
readonly score: Readonly<Record<Side, number>>;
/** Le gagnant de la partie a gagné le match. */
readonly over: boolean;
/** La partie suivante est la partie Crawford. */
readonly crawford: boolean;
}
/** Les longueurs de match proposées à l'écran « Nouvelle partie ». */
export const MATCH_LENGTHS = [3, 5, 7, 11] as const;
/** La première partie d'un match en length points : 0 à 0. */
export function firstGameOf(length: number): Match {
if (!Number.isInteger(length) || length < 1) {
throw new Error(`longueur de match invalide : ${length}`);
}
return { length, score: { player: 0, computer: 0 }, crawford: false };
}
/** La partie suivante d'un match, d'après ce que le moteur a dit à la fin
* de la précédente ; null si le match est fini. */
export function nextGameOf(match: Match, after: MatchAfterGame): Match | null {
if (after.over) {
return null;
}
return { length: match.length, score: after.score, crawford: after.crawford };
}
+123
View File
@@ -0,0 +1,123 @@
// Une position du plateau dans un lien (ADR 0013) : le Position ID et le
// Match ID de GNU Backgammon, lus et écrits par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Un lien décrit la position du point de vue du joueur au trait : c'est lui
// qu'on montre en bas (« vous »), et c'est lui que l'on joue avec « Jouer à
// partir d'ici ». Rien n'est décodé ici : le moteur valide et décode les deux
// identifiants (aucune règle de représentation n'est réimplémentée).
import type { Cube } from "./cube.js";
import type { BoardResult, Engine, EngineCube, MatchId } from "./engine.js";
import { engineCube, type GameSetup, type SideBoards } from "./game.js";
import type { PositionId } from "./position.js";
import type { Side } from "./side.js";
import type { GameState } from "./state.js";
/**
* Ce que porte l'adresse d'une position : son Position ID, et son Match ID
* s'il est donné (sans lui : partie libre, videau au centre).
*
* Glossaire : docs/glossaire/lien-de-position.md
*/
export interface PositionLink {
readonly position: PositionId;
readonly matchId: MatchId | null;
}
/** Une position partagée, lue par le moteur. Le camp « player » est celui
* au trait. */
export interface SharedPosition {
readonly link: PositionLink;
readonly sides: SideBoards;
readonly cube: Cube;
/** null en partie libre ; sinon la longueur du match et le score. */
readonly match: {
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
} | null;
/** La partie est déjà finie dans cette position (null sinon). */
readonly result: BoardResult | null;
}
/** Un lien que le moteur ne sait pas lire. */
export class UnreadableLinkError extends Error {
constructor(reason: string) {
super(reason);
this.name = "UnreadableLinkError";
}
}
/**
* Lit une position partagée : le moteur décode le Match ID (s'il est
* donné), puis la position avec son videau. Erreur UnreadableLinkError si
* l'un des deux est illisible.
*/
export async function readPosition(engine: Engine, link: PositionLink): Promise<SharedPosition> {
let cube: EngineCube = { value: 1, owner: "centered" };
let match: SharedPosition["match"] = null;
if (link.matchId !== null) {
const description = await engine.decodeMatch(link.matchId).catch((error: unknown) => {
throw new UnreadableLinkError(`Match ID illisible : ${message(error)}`);
});
const onRoll = description.playerOnRoll;
cube = {
value: description.cubeValue,
owner:
description.cubeOwner === -1
? "centered"
: description.cubeOwner === onRoll
? "onRoll"
: "opponent",
};
if (description.matchLength > 0) {
match = {
length: description.matchLength,
score: {
player: description.score[onRoll],
computer: description.score[onRoll === 0 ? 1 : 0],
},
};
}
}
const board = await engine.board(link.position, cube).catch((error: unknown) => {
throw new UnreadableLinkError(`Position ID illisible : ${message(error)}`);
});
return {
link,
sides: { player: board.onRoll, computer: board.opponent },
cube: {
value: cube.value,
owner:
cube.owner === "centered" ? null : cube.owner === "onRoll" ? "player" : "computer",
},
match,
result: board.result,
};
}
/**
* Le lien de la position d'une partie : la position vue du camp au trait,
* et le Match ID que le moteur écrit pour son videau et son match (score,
* Crawford), sans dés. Pendant le jet d'ouverture, la position de départ, vue
* du joueur.
*/
export async function linkOf(engine: Engine, state: GameState): Promise<PositionLink> {
const onRoll: Side = state.turn ?? "player";
return {
position: state.position,
matchId: await engine.encodeMatch(engineCube(state, onRoll)),
};
}
/** La partie à commencer avec « Jouer à partir d'ici » : le joueur prend la
* place du camp au trait, avec le même videau ; partie libre. */
export function setupOf(shared: SharedPosition): GameSetup {
return { position: shared.link.position, firstTurn: "player", cube: shared.cube };
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+55
View File
@@ -0,0 +1,55 @@
// L'analyse de la partie par le moteur, rangée dans le journal à côté du
// conseil du maître (étape 1e) : la chance de chaque jet, les chances de
// gagner après chaque coup, et les décisions de l'ordinateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur. Le calcul est dans domain/analysis/game-analysis.ts. Comme le
// conseil (advice.ts), ce n'est pas un événement : l'analyse arrive après
// coup et ne change pas la partie. Le format est durable (ADR 0007) : il
// porte sa version (REVIEW_VERSION), et une analyse d'une autre version
// est oubliée à la reprise (elle peut être refaite).
import type { Advice } from "./advice.js";
import type { Luck, Ply } from "./engine.js";
/** Version du format de l'analyse de partie enregistrée. */
export const REVIEW_VERSION = 1;
/**
* L'analyse de la partie, en plus des conseils du maître : de quoi
* calculer le bilan (taux d'erreur, chance) et tracer la courbe des chances
* de gagner. Chaque table est indexée par l'identifiant d'un événement du
* journal ; une table incomplète est complétée en arrière-plan.
*/
export interface GameReview {
readonly version: number;
/** Niveau des analyses et des évaluations, en plis. */
readonly ply: Ply;
/** Pour chaque jet (DiceRolled, ouverture comprise), sa chance pour le
* camp qui le joue, selon le moteur. */
readonly luck: Readonly<Record<string, Luck>>;
/** Pour chaque coup (MovePlayed), les chances de gagner du joueur dans
* la position d'après, de 0 à 1, selon le moteur. */
readonly winChances: Readonly<Record<string, number>>;
/** Les décisions de l'ordinateur, analysées comme celles du joueur (le
* conseil d'un coup de l'ordinateur est vu de lui). */
readonly computer: Readonly<Record<string, Advice>>;
/** Les décisions (des deux camps) que le moteur n'analyse pas : videau
* inaccessible, position de départ. Elles ne sont plus demandées. */
readonly unanalysed: readonly string[];
}
/** Une analyse vide, au niveau ply. */
export function emptyReview(ply: Ply): GameReview {
return {
version: REVIEW_VERSION,
ply,
luck: {},
winChances: {},
computer: {},
unanalysed: [],
};
}
+93
View File
@@ -0,0 +1,93 @@
// Une partie enregistrée, telle que la liste « Mes parties » la présente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le journal reste la source (ADR 0007, point 3) : le résumé n'en est qu'une
// lecture, recalculée à chaque enregistrement, pour afficher la liste sans
// relire tous les journaux.
import { branches } from "./branches.js";
import type { Level } from "./engine.js";
import { currentLine, type GameJournal } from "./events.js";
import type { Side } from "./side.js";
import { DEFAULT_LEVEL, type GameResult, replay } from "./state.js";
/**
* Le résumé d'une partie enregistrée.
*
* Glossaire : docs/glossaire/partie-enregistree.md
*/
export interface SavedGameSummary {
/** Identifiant de la partie, unique sur l'appareil. */
readonly id: string;
/** Début de la partie et dernière action, en millisecondes depuis 1970. */
readonly startedAt: number;
readonly updatedAt: number;
/** Niveau de l'ordinateur, l'adversaire. */
readonly level: Level;
/** En cours ou finie, sur la branche courante. */
readonly status: "playing" | "over";
/** Le résultat sur la branche courante (null si elle n'est pas finie). */
readonly result: GameResult | null;
/** Coups joués sur la branche courante. */
readonly moveCount: number;
/** Nombre de branches (1 : jamais rejouée). */
readonly branchCount: number;
/** La partie d'un match : sa longueur, le score (après la partie si
* elle est finie, avant sinon) et si le match est fini. Absent en
* partie libre, et dans les résumés enregistrés avant l'étape 1c. */
readonly match?: SavedMatch;
}
/** Le match d'une partie enregistrée, pour la liste. */
export interface SavedMatch {
readonly length: number;
readonly score: Readonly<Record<Side, number>>;
readonly crawford: boolean;
readonly over: boolean;
}
/** Ce qu'il faut savoir d'une partie pour l'enregistrer, en plus de son
* journal. */
export interface SavedGameDetails {
readonly level: Level;
}
/** Le niveau de l'ordinateur d'une partie, d'après son journal. */
export function levelOf(journal: GameJournal): Level {
const start = journal.events[0];
return start?.type === "GameStarted" ? (start.level ?? DEFAULT_LEVEL) : DEFAULT_LEVEL;
}
/** Le résumé d'un journal ; startedAt et updatedAt viennent de l'appelant
* (le domaine ne lit pas l'horloge). */
export function summarizeGame(
id: string,
journal: GameJournal,
details: SavedGameDetails,
times: { readonly startedAt: number; readonly updatedAt: number },
): SavedGameSummary {
const line = currentLine(journal);
const state = replay(line);
return {
id,
startedAt: times.startedAt,
updatedAt: times.updatedAt,
level: details.level,
status: state.phase === "over" ? "over" : "playing",
result: state.result,
moveCount: line.filter((event) => event.type === "MovePlayed").length,
branchCount: branches(journal).length,
...(state.match === null
? {}
: {
match: {
length: state.match.length,
score: state.matchAfter?.score ?? state.match.score,
crawford: state.match.crawford,
over: state.matchAfter?.over ?? false,
},
}),
};
}
+233
View File
@@ -0,0 +1,233 @@
// Export d'une partie au format SGF de GNU Backgammon.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le SGF (Smart Game Format, FF[4], GM[6] pour le backgammon) est le format
// de fichier de GNU Backgammon : une suite de nœuds, un par coup ou décision
// de videau, et des variantes entre parenthèses. Le journal étant un arbre,
// l'export en est un simple parcours (ADR 0007, point 3) : chaque branche
// devient une variante. GNU Backgammon ne lit que la première variante de
// chaque embranchement ; la branche courante de la partie vient donc en
// premier, pour que gnubg ouvre la partie telle que le joueur la voit.
//
// Conventions de gnubg (sgf.c, au commit figé par l'ADR 0005) :
// - le joueur est « B » (le joueur 1 de gnubg, l'humain), l'ordinateur « W » ;
// - un coup s'écrit [dés][départ arrivée]… ; une case c (1 à 24, vue de celui
// qui joue) s'écrit 'x' - (c - 1) pour B et 'a' + (c - 1) pour W ; la barre
// s'écrit 'y', la sortie 'z' ; un coup impossible n'a que ses dés ;
// - [double], [take], [drop] pour le videau ;
// - une position de départ autre que l'habituelle se pose par AE, AW, AB et
// PL, le videau par CV et CP.
// Aucune règle n'est jugée ici : les coups et positions viennent du journal,
// et une position de départ particulière est décodée par le moteur.
import type { Engine } from "./engine.js";
import { currentLine, type GameEvent, type GameJournal } from "./events.js";
import { BAR, type HalfMove, OFF, START_POSITION } from "./position.js";
import type { Side } from "./side.js";
import { replay } from "./state.js";
export interface SgfOptions {
/** Nom du joueur (PB). */
readonly playerName: string;
/** Nom de l'ordinateur (PW). */
readonly computerName: string;
/** Date de la partie, AAAA-MM-JJ (DT) ; absente si non fournie. */
readonly date?: string;
}
/** Un nœud SGF et les variantes qui le suivent. */
interface SgfNode {
readonly properties: string;
readonly children: SgfNode[];
}
/**
* La partie au format SGF de GNU Backgammon, branches comprises (en
* variantes, la branche courante d'abord). Le moteur ne sert qu'à décoder
* une position de départ particulière. (Le format : docs/glossaire/sgf.md.)
*/
export async function exportSgf(
engine: Engine,
journal: GameJournal,
options: SgfOptions,
): Promise<string> {
const start = journal.events[0];
if (start?.type !== "GameStarted") {
throw new Error("journal de partie vide ou mal formé");
}
const byId = new Map(journal.events.map((event) => [event.id, event]));
const onHeadLine = new Set(currentLine(journal).map((event) => event.id));
const children = new Map<string, GameEvent[]>();
for (const event of journal.events) {
if (event.parent !== null) {
children.set(event.parent, [...(children.get(event.parent) ?? []), event]);
}
}
// La branche courante d'abord, puis les autres dans l'ordre du journal.
for (const list of children.values()) {
list.sort((a, b) => Number(onHeadLine.has(b.id)) - Number(onHeadLine.has(a.id)));
}
/** Les variantes qui suivent un événement. */
const variations = (id: string): SgfNode[] =>
(children.get(id) ?? []).flatMap((child) => {
const properties = nodeProperties(child, byId, children.has(child.id));
const below = variations(child.id);
return properties === null ? below : [{ properties, children: below }];
});
// La partie telle que la voit gnubg : la ligne courante. Dans un match,
// sa longueur et le score avant la partie (W, l'ordinateur, est le
// joueur 0 de gnubg), et les règles comme gnubg les écrit (sgf.c) :
// Crawford, et CrawfordGame pour la partie Crawford. Le numéro de la
// partie dans le match n'est pas gardé : 0.
const { result, match } = replay(currentLine(journal));
const root = [
"FF[4]GM[6]CA[UTF-8]AP[Ustagammon]",
match === null
? "MI[length:0][game:0][ws:0][bs:0]"
: `MI[length:${match.length}][game:0][ws:${match.score.computer}][bs:${match.score.player}]` +
`RU[Crawford${match.crawford ? ":CrawfordGame" : ""}]`,
`PW[${escapeText(options.computerName)}]PB[${escapeText(options.playerName)}]`,
options.date === undefined ? "" : `DT[${escapeText(options.date)}]`,
result === null ? "" : `RE[${letter(result.winner)}+${result.points}]`,
].join("");
// Les nœuds d'installation, à la suite l'un de l'autre, puis la partie.
const setup = await setupProperties(engine, start);
const tree = setup.reduceRight<SgfNode[]>(
(below, properties) => [{ properties, children: below }],
variations(start.id),
);
return `(;${root}${sequence(tree)})\n`;
}
/** Écrit des nœuds : une suite tant qu'il n'y a qu'une variante, puis
* chaque variante entre parenthèses. */
function sequence(nodes: readonly SgfNode[]): string {
if (nodes.length === 1) {
const [node] = nodes as [SgfNode];
return `\n;${node.properties}${sequence(node.children)}`;
}
return nodes.map((node) => `(\n;${node.properties}${sequence(node.children)})`).join("");
}
/** Les propriétés du nœud d'un événement, ou null s'il n'en fait pas
* (début, dés suivis d'un coup, fin de partie). */
function nodeProperties(
event: GameEvent,
byId: ReadonlyMap<string, GameEvent>,
hasChildren: boolean,
): string | null {
switch (event.type) {
case "MovePlayed": {
const rolled = event.parent === null ? undefined : byId.get(event.parent);
if (rolled?.type !== "DiceRolled") {
throw new Error(`coup sans jet de dés : ${event.id}`);
}
const moves = event.halfMoves.map((h) => halfMove(h, event.side)).join("");
return `${letter(event.side)}[${rolled.roll[0]}${rolled.roll[1]}${moves}]`;
}
case "CubeOffered":
return `${letter(event.side)}[double]`;
case "CubeTaken":
return `${letter(event.side)}[take]`;
case "CubeDropped":
return `${letter(event.side)}[drop]`;
case "DiceRolled": {
// Des dés pas encore joués, au bout d'une branche.
if (hasChildren) {
return null;
}
const [a, b] = event.roll;
const side = event.side ?? (a > b ? "player" : a < b ? "computer" : null);
return side === null ? null : `PL[${letter(side)}]DI[${a}${b}]`;
}
case "GameStarted":
case "GameEnded":
return null;
}
}
/** Les propriétés des nœuds qui posent une position de départ particulière
* (aucun pour la position habituelle, videau au centre, jet d'ouverture à
* faire). */
async function setupProperties(
engine: Engine,
start: Extract<GameEvent, { type: "GameStarted" }>,
): Promise<string[]> {
const usual =
start.position === START_POSITION &&
start.firstTurn === null &&
start.cube.value === 1 &&
start.cube.owner === null;
if (usual) {
return [];
}
const onRoll: Side = start.firstTurn ?? "player";
const owner =
start.cube.owner === null
? "centered"
: start.cube.owner === onRoll
? "onRoll"
: "opponent";
const board = await engine.board(start.position, { value: start.cube.value, owner });
// Chaque camp avec sa propre numérotation : W en 'a' + (c - 1), B en
// 'x' - (c - 1), la barre en 'y' (comme les coups).
const stones = (side: Side, points: readonly number[], bar: number) => {
const out: string[] = [];
points.forEach((count, i) => {
for (let k = 0; k < count; k++) {
out.push(`[${pointLetter(i + 1, side)}]`);
}
});
for (let k = 0; k < bar; k++) {
out.push("[y]");
}
return out.join("");
};
const other: Side = onRoll === "player" ? "computer" : "player";
const sides = { [onRoll]: board.onRoll, [other]: board.opponent } as Record<
Side,
typeof board.onRoll
>;
const white = stones("computer", sides.computer.points, sides.computer.bar);
const black = stones("player", sides.player.points, sides.player.bar);
const nodes = [
`PL[${letter(onRoll)}]AE[a:y]${white === "" ? "" : `AW${white}`}${black === "" ? "" : `AB${black}`}`,
];
if (start.cube.value !== 1) {
nodes.push(`CV[${start.cube.value}]`);
}
if (start.cube.owner !== null) {
nodes.push(`CP[${start.cube.owner === "player" ? "b" : "w"}]`);
}
return nodes;
}
function letter(side: Side): "B" | "W" {
return side === "player" ? "B" : "W";
}
function halfMove(h: HalfMove, side: Side): string {
return pointLetter(h.from, side) + pointLetter(h.to, side);
}
/** Une case vue du camp side : 1 à 24, BAR ou OFF. */
function pointLetter(point: number, side: Side): string {
if (point === BAR) {
return "y";
}
if (point === OFF) {
return "z";
}
const code =
side === "player" ? "x".charCodeAt(0) - (point - 1) : "a".charCodeAt(0) + point - 1;
return String.fromCharCode(code);
}
/** Échappe un texte libre dans une valeur SGF. */
function escapeText(text: string): string {
return text.replace(/[\\\]]/g, (c) => `\\${c}`);
}
+22 -1
View File
@@ -4,8 +4,9 @@
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { Level, ResultKind } from "./engine.js";
import type { GameEvent } from "./events.js";
import type { Match, MatchAfterGame } from "./match.js";
import { type PositionId, START_POSITION } from "./position.js";
import type { Roll } from "./roll.js";
import { otherSide, type Side } from "./side.js";
@@ -36,8 +37,22 @@ export interface GameState {
readonly roll: Roll | null;
readonly cube: Cube;
readonly result: GameResult | null;
/** Le niveau de l'ordinateur. */
readonly level: Level;
/** Le match au début de la partie (null : partie libre). */
readonly match: Match | null;
/** Le match après la partie, une fois finie (null sinon, ou en partie
* libre). */
readonly matchAfter: MatchAfterGame | null;
}
/**
* Le niveau d'une partie qui n'en dit rien : 2 plis, celui de toutes les
* parties d'avant le choix du niveau (étape 1c). Sur le Fairphone 4, le
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const DEFAULT_LEVEL: Level = "2ply";
/** Avant le premier événement. */
const NOT_STARTED: GameState = {
phase: "opening",
@@ -46,6 +61,9 @@ const NOT_STARTED: GameState = {
roll: null,
cube: CENTERED_CUBE,
result: null,
level: DEFAULT_LEVEL,
match: null,
matchAfter: null,
};
/** Rejoue des événements, du premier au dernier. */
@@ -63,6 +81,8 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
cube: event.cube,
turn: event.firstTurn,
phase: event.firstTurn === null ? "opening" : "rolling",
level: event.level ?? DEFAULT_LEVEL,
match: event.match ?? null,
};
case "DiceRolled":
if (event.side !== null) {
@@ -103,6 +123,7 @@ export function applyEvent(state: GameState, event: GameEvent): GameState {
phase: "over",
roll: null,
result: { winner: event.winner, kind: event.kind, points: event.points },
matchAfter: event.match ?? null,
};
}
}
+184
View File
@@ -0,0 +1,184 @@
// La progression dans les leçons (ADR 0011, étape 1g) : où en est chaque
// leçon, et laquelle proposer ensuite. Code pur, testé sous Node
// (app/tests/lessons.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le contenu des leçons vit dans docs/lecons/ (ADR 0008) ; ici, une leçon
// n'est que son numéro et les étapes qui portent un exercice « à vous ».
// La progression est enregistrée telle quelle (ADR 0007, amendement du
// 2026-11-12) : son format est durable, et readLessonProgress relit toute
// version enregistrée.
/** Version du format de la progression enregistrée. */
export const LESSON_PROGRESS_VERSION = 1;
/** reached quand l'écran de fin d'une leçon a été vu. */
export const LESSON_END = 99;
/** Où en est une leçon. */
export interface LessonRecord {
/** La dernière étape vue (0 : aucune ; LESSON_END : la fin). */
readonly last: number;
/** L'étape la plus loin déjà vue : la barre de progression ne mène
* qu'aux étapes vues. */
readonly reached: number;
/** Les étapes dont l'exercice « à vous » a été réussi, dans l'ordre. */
readonly solved: readonly number[];
}
/** La progression dans toutes les leçons, par numéro de leçon. */
export interface LessonProgress {
readonly version: typeof LESSON_PROGRESS_VERSION;
readonly lessons: Readonly<Record<string, LessonRecord>>;
}
export const NO_PROGRESS: LessonProgress = { version: LESSON_PROGRESS_VERSION, lessons: {} };
const NO_RECORD: LessonRecord = { last: 0, reached: 0, solved: [] };
/** Ce qu'une leçon offre à la progression : son numéro et les étapes qui
* ont un exercice « à vous ». */
export interface LessonOutline {
readonly number: number;
readonly exercises: readonly number[];
}
function step(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= LESSON_END
? value
: null;
}
/**
* La progression enregistrée, relue : absente (avant l'étape 1g) ou
* illisible, elle est vide ; une leçon mal formée est oubliée, sans toucher
* aux autres. Une version inconnue (enregistrée par une version future de
* l'application) est lue comme vide plutôt que mal comprise.
*/
export function readLessonProgress(value: unknown): LessonProgress {
if (value === null || typeof value !== "object") {
return NO_PROGRESS;
}
const raw = value as { version?: unknown; lessons?: unknown };
if (raw.version !== LESSON_PROGRESS_VERSION) {
return NO_PROGRESS;
}
if (raw.lessons === null || typeof raw.lessons !== "object") {
return NO_PROGRESS;
}
const lessons: Record<string, LessonRecord> = {};
for (const [key, entry] of Object.entries(raw.lessons as Record<string, unknown>)) {
if (!/^[1-9][0-9]?$/.test(key) || entry === null || typeof entry !== "object") {
continue;
}
const record = entry as { last?: unknown; reached?: unknown; solved?: unknown };
const last = step(record.last);
const reached = step(record.reached);
const solved = Array.isArray(record.solved)
? record.solved.map(step).filter((s): s is number => s !== null && s > 0)
: null;
if (last === null || reached === null || solved === null) {
continue;
}
lessons[key] = { last, reached, solved: [...new Set(solved)].sort((a, b) => a - b) };
}
return { version: LESSON_PROGRESS_VERSION, lessons };
}
/** Où en est la leçon number (rien de fait si elle n'a jamais été ouverte). */
export function lessonRecord(progress: LessonProgress, number: number): LessonRecord {
return progress.lessons[String(number)] ?? NO_RECORD;
}
function withRecord(
progress: LessonProgress,
number: number,
change: (record: LessonRecord) => LessonRecord,
): LessonProgress {
return {
version: LESSON_PROGRESS_VERSION,
lessons: { ...progress.lessons, [String(number)]: change(lessonRecord(progress, number)) },
};
}
/** La progression après l'arrivée sur une étape d'une leçon (LESSON_END :
* sa fin). */
export function withVisit(progress: LessonProgress, number: number, at: number): LessonProgress {
return withRecord(progress, number, (record) => ({
...record,
last: at,
reached: Math.max(record.reached, at),
}));
}
/** La progression après la réussite de l'exercice d'une étape. */
export function withSolved(progress: LessonProgress, number: number, at: number): LessonProgress {
return withRecord(progress, number, (record) =>
record.solved.includes(at)
? record
: { ...record, solved: [...record.solved, at].sort((a, b) => a - b) },
);
}
/** L'état d'une leçon, tel que l'affichent la liste et l'accueil : à
* commencer, en cours (« 2 exercices sur 4 »), terminée (tous ses
* exercices réussis). */
export type LessonState =
| { readonly kind: "toStart"; readonly total: number }
| { readonly kind: "inProgress"; readonly solved: number; readonly total: number }
| { readonly kind: "done"; readonly total: number };
export function lessonState(progress: LessonProgress, outline: LessonOutline): LessonState {
const record = lessonRecord(progress, outline.number);
const total = outline.exercises.length;
const solved = outline.exercises.filter((s) => record.solved.includes(s)).length;
if (solved === total && (total > 0 || record.reached === LESSON_END)) {
return { kind: "done", total };
}
if (solved === 0 && record.reached === 0) {
return { kind: "toStart", total };
}
return { kind: "inProgress", solved, total };
}
/**
* La leçon à proposer ensuite (carte de l'accueil, « Continuer » de la
* liste) : la première en cours, sinon la première à commencer, sinon
* aucune (toutes terminées). L'ordre est conseillé, jamais imposé.
*/
export function nextLesson(
progress: LessonProgress,
outlines: readonly LessonOutline[],
): LessonOutline | null {
const states = outlines.map((outline) => ({ outline, state: lessonState(progress, outline) }));
return (
states.find((s) => s.state.kind === "inProgress")?.outline ??
states.find((s) => s.state.kind === "toStart")?.outline ??
null
);
}
/**
* L'étape où reprendre une leçon : là où on l'a quittée ; après sa fin, le
* premier exercice pas encore réussi ; sinon le début.
*/
export function resumeStep(progress: LessonProgress, outline: LessonOutline): number {
const record = lessonRecord(progress, outline.number);
if (record.last > 0 && record.last < LESSON_END) {
return record.last;
}
if (record.last === LESSON_END) {
return outline.exercises.find((s) => !record.solved.includes(s)) ?? 1;
}
return 1;
}
/** Le nombre de leçons terminées. */
export function finishedLessons(
progress: LessonProgress,
outlines: readonly LessonOutline[],
): number {
return outlines.filter((outline) => lessonState(progress, outline).kind === "done").length;
}
+245
View File
@@ -0,0 +1,245 @@
// Le tutoriel des règles, côté domaine : les positions illustrées et les
// écrans « à vous », vérifiés par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
// source) ; il donne pour chaque écran une position par son identifiant
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
// reconnus.
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
import type { HalfMove, PositionId } from "../game/position.js";
import { fixedDice, type Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
* partie y est finie (vu du camp « player »). */
export interface TutorialBoard {
readonly sides: SideBoards;
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
}
/** Le videau vu du camp au trait. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
return {
value: cube.value,
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
};
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
* l'identifiant est illisible). */
export async function tutorialBoard(
engine: Engine,
position: PositionId,
onRoll: Side = "player",
cube: Cube = CENTERED_CUBE,
): Promise<TutorialBoard> {
const board = await engine.board(position, engineCube(cube, onRoll));
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const result =
board.result === null
? null
: {
winner:
(board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
}
/** Les coups du jet que le moteur déclare légaux et dont la notation est
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
* légal : le texte du tutoriel ne correspond plus au moteur. */
export async function expectedMoves(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<Move[]> {
const legal = await engine.legalMoves(position, roll);
return notations.map((notation) => {
const move = legal.find((m) => m.notation === notation);
if (move === undefined) {
throw new Error(
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
);
}
return move;
});
}
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
* finit la partie. */
export async function illustratedMove(
engine: Engine,
position: PositionId,
roll: Roll,
notation: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
const [move] = await expectedMoves(engine, position, roll, [notation]);
if (move === undefined) {
throw new Error(`coup illustré introuvable : ${notation}`);
}
const positions = [position];
for (let i = 1; i <= move.halfMoves.length; i++) {
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
if (check.position === null) {
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
}
positions.push(check.position);
}
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
const hits = move.halfMoves.map(
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
);
const after = boards.at(-1) ?? boards[0];
if (after === undefined) {
throw new Error("aucun plateau");
}
return {
steps: {
side: "player",
halfMoves: move.halfMoves,
boards: boards.map((b) => b.sides),
hits,
},
after,
};
}
/**
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
if (i < 0) {
return false;
}
left.splice(i, 1);
return true;
});
});
}
/** Ce que devient un écran « à vous » après un demi-coup. */
export type PracticeVerdict = "continue" | "right" | "wrong";
/**
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
* coup est juste s'il mène à la position de l'un des coups attendus.
*/
export class MovePractice {
readonly #game: Game;
readonly #expected: readonly Move[];
readonly roll: Roll;
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
this.#game = game;
this.#expected = expected;
this.roll = roll;
}
/** Prépare l'écran : la position, le jet, et les coups attendus,
* reconnus par le moteur (erreur sinon). */
static async start(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<MovePractice> {
const expected = await expectedMoves(engine, position, roll, notations);
const game = Game.start(engine, {
setup: { position, firstTurn: "player" },
dice: fixedDice(roll),
level: "0ply",
});
await game.roll();
return new MovePractice(game, expected, roll);
}
/** Le plateau affiché, demi-coups saisis compris. */
board(): Promise<SideBoards> {
return this.#game.displayedBoard();
}
/** Les demi-coups permis maintenant (le moteur le dit). */
nextHalfMoves(): Promise<HalfMove[]> {
return this.#game.nextHalfMoves();
}
get pendingHalfMoves(): readonly HalfMove[] {
return this.#game.pendingHalfMoves;
}
/** Les dés du jet, et ceux déjà utilisés. */
get diceToPlay(): { value: number; used: boolean }[] {
return this.#game.diceToPlay;
}
/**
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
* arrière (reset). « right » quand le coup complet est l'un des coups
* attendus.
*/
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (
this.#game.moveComplete &&
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";
}
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
reset(): void {
this.#game.cancelHalfMoves();
}
}
/**
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
* peut pas doubler dans cette position.
*/
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
const decision = await engine.cubeDecision(
position,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
if (!decision.available) {
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
}
return decision.take;
}
+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}`;
}
+526
View File
@@ -0,0 +1,526 @@
// 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 qui revient dans les imprécisions et erreurs de plusieurs parties,
* compté sur les faits que le moteur donne de chaque coup (pions frappés,
* points faits, lus sur les plateaux qu'il décode) : jamais une
* explication stratégique. Une ligne par fait présent au moins une fois.
*/
export function mistakeThemes(mistakes: readonly { readonly advice: Advice }[]): string[] {
const moves = mistakes.flatMap((m) => (m.advice.kind === "move" ? [m.advice] : []));
const cubes = mistakes.length - moves.length;
const times = (n: number) => `${n} fois`;
const masterHits = moves.filter((a) => a.bestFacts.hits > 0 && a.playedFacts.hits === 0);
const masterPoints = moves.filter((a) =>
a.bestFacts.madePoints.some((p) => !a.playedFacts.madePoints.includes(p)),
);
const playerHits = moves.filter((a) => a.playedFacts.hits > 0 && a.bestFacts.hits === 0);
const lines = [
`- ${plural(moves.length, "coup", "coups")} et ${plural(cubes, "décision de videau", "décisions de videau")}`,
];
if (masterHits.length > 0) {
lines.push(
`- ${times(masterHits.length)}, le coup de GNU Backgammon frappait un pion et le mien non`,
);
}
if (masterPoints.length > 0) {
lines.push(
`- ${times(masterPoints.length)}, le coup de GNU Backgammon faisait un point que le mien ne faisait pas`,
);
}
if (playerHits.length > 0) {
lines.push(
`- ${times(playerHits.length)}, mon coup frappait un pion et celui de GNU Backgammon non`,
);
}
return lines;
}
/** 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"),
themes: mistakeThemes(mistakes).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;
});
}
+90 -5
View File
@@ -17,17 +17,26 @@ import type {
CubeDecision,
Engine,
EngineCube,
EngineMatch,
EvaluatedMove,
Evaluation,
HalfMovesCheck,
Level,
Luck,
LuckRating,
MatchDescription,
MatchId,
Move,
MoveAnalysis,
Ply,
Skill,
} from "../domain/game/engine.js";
import { formatHalfMoves, type HalfMove, type PositionId } from "../domain/game/position.js";
import {
formatHalfMoves,
type HalfMove,
type PositionId,
START_POSITION,
} from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
/** Exécute une commande d'engine-cli et renvoie son JSON décodé ; la
@@ -68,13 +77,23 @@ export function createWasmEngine(run: RunCommand): Engine {
const matchIds = new Map<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
// trait, 1 son adversaire, -1 le centre.
// Joueur 0 au trait : le propriétaire 0 est le joueur au trait, 1 son
// adversaire, -1 le centre ; le score 0 est le sien.
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
const key = `${cube.value} ${owner}`;
const args = ["matchid", String(cube.value), String(owner)];
const match = cube.match;
if (match !== undefined) {
args.push(
String(match.length),
String(match.score.onRoll),
String(match.score.opponent),
match.crawford ? "1" : "0",
);
}
const key = args.join(" ");
let id = matchIds.get(key);
if (id === undefined) {
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
const answer = (await run(args)) as {
match_id: string;
};
id = answer.match_id;
@@ -122,6 +141,32 @@ export function createWasmEngine(run: RunCommand): Engine {
return toCubeDecision((await run(args)) as CliCubeDecision);
},
async levelDouble(position: PositionId, cube: EngineCube, level: Level) {
const args = ["--level", level, "double", position, await matchId(cube)];
return ((await run(args)) as { double: boolean }).double;
},
async levelTake(position: PositionId, cube: EngineCube, level: Level) {
const args = ["--level", level, "take", position, await matchId(cube)];
return ((await run(args)) as { take: boolean }).take;
},
async matchAfterGame(match: EngineMatch, winner: "onRoll" | "opponent", points: number) {
// Le joueur au trait est le joueur 0 du Match ID.
const id = await matchId({ value: 1, owner: "centered", match });
const answer = (await run([
"aftergame",
id,
winner === "onRoll" ? "0" : "1",
String(points),
])) as { score: [number, number]; match_over: boolean; crawford: boolean };
return {
score: { onRoll: answer.score[0], opponent: answer.score[1] },
over: answer.match_over,
crawford: answer.crawford,
};
},
async analyseMove(
position: PositionId,
cube: EngineCube,
@@ -137,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;
@@ -148,6 +194,7 @@ export function createWasmEngine(run: RunCommand): Engine {
best: toEvaluatedMove(answer.best),
equityLoss: answer.equity_loss,
skill: toSkill(answer.skill),
ranking: answer.ranking.map(toEvaluatedMove),
};
},
@@ -208,6 +255,44 @@ export function createWasmEngine(run: RunCommand): Engine {
return { value: answer.luck, rating };
},
async evaluate(position: PositionId, cube: EngineCube, ply: Ply): Promise<Evaluation> {
const args = ["--ply", String(ply), "eval", position, await matchId(cube)];
const answer = (await run(args)) as {
probabilities: { win: number };
cubeful_equity: number;
};
return { win: answer.probabilities.win, equity: answer.cubeful_equity };
},
encodeMatch: matchId,
async decodeMatch(id: MatchId): Promise<MatchDescription> {
// engine-cli n'a pas de commande qui décode seule un Match ID :
// l'évaluation à 0 pli (quelques millisecondes) le décode, le
// valide (SetCubeInfo de gnubg) et le renvoie lisible.
const answer = (await run(["--ply", "0", "eval", START_POSITION, id])) as {
match: {
match_length: number;
score: [number, number];
cube_value: number;
cube_owner: number;
player_on_roll: number;
crawford: boolean;
jacoby: boolean;
};
};
const m = answer.match;
return {
matchLength: m.match_length,
score: m.score,
cubeValue: m.cube_value,
cubeOwner: m.cube_owner === 0 || m.cube_owner === 1 ? m.cube_owner : -1,
playerOnRoll: m.player_on_roll === 1 ? 1 : 0,
crawford: m.crawford,
jacoby: m.jacoby,
};
},
async checkHalfMoves(
position: PositionId,
roll: Roll,
+170 -28
View File
@@ -5,50 +5,192 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { ANALYSIS_PLY, analyseDecision } from "./domain/analysis/analyse.js";
import { runAnalysis } from "./domain/analysis/game-analysis.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { levelOf } from "./domain/game/saved-game.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { positionBefore } from "./domain/prompts/position.js";
import { conceptExample, exercisesPosition, summaryPosition } from "./domain/prompts/prompts.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
import { startUpdates } from "./ui/updates.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
const settings = createSettingsStore(window.localStorage);
// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un
// second moteur, dans son propre Web Worker. Le premier ne traite qu'une
// commande à la fois : une analyse y retarderait le coup de l'ordinateur.
// Le second démarre à la première analyse demandée, une fois le premier
// chargé et le service worker installé (au plus 20 s d'attente) : si deux
// téléchargements des données du moteur se croisent, Chromium fait échouer
// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les
// données viennent du cache.
let analysisWorker: Promise<EngineWorker> | null = null;
function startAnalysisWorker(): Promise<EngineWorker> {
analysisWorker ??= worker.ready
.then(() => {
const installed =
"serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve();
return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]);
})
.then(() => {
const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
return started.ready.then(() => started);
});
return analysisWorker;
}
const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args));
// Chaque accès au stockage du navigateur peut lever une exception (stockage
// interdit) : on fait alors sans.
const localStorage = attempt(() => window.localStorage);
const settings = createSettingsStore(localStorage ?? memoryStorage());
// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes
// pendant que le moteur se charge : le plateau est dessiné sans les
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
const opening = (async () => {
const { library, lessons } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage,
});
// Le niveau et le match de chaque partie viennent de l'écran « Nouvelle
// partie » (ou de la partie précédente du match).
const session = createGameSession(library, {
start: (newGame) => Game.start(engine, newGame ?? {}),
resume: (journal) => Game.resume(engine, journal),
});
const saved = await session.restore();
return { library, session, saved, lessons };
})();
let stored: Awaited<typeof opening> | null = null;
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
function storage(): Awaited<typeof opening> {
if (stored === null) {
throw new Error("stockage pas encore ouvert");
}
return stored;
}
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
savedGame: () => {
const journal = store.load();
if (journal === null) {
return null;
}
try {
return Game.resume(engine, journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
engineReady: Promise.all([
worker.ready,
opening.then((opened) => {
stored = opened;
}),
]),
newGame: (newGame) => storage().session.newGame(newGame),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
},
savedGame: () => storage().saved,
resumePrevious: () => storage().session.resumePrevious(),
saveGame: (journal) => storage().session.save(journal),
games: {
list: () => storage().library.list(),
journal: async (id) => (await storage().library.load(id))?.journal ?? null,
shownId: () => storage().session.shownId,
open: (id, branchHead) => storage().session.open(id, branchHead),
view: async (id) => {
const saved = await storage().library.load(id);
if (saved === null) {
return null;
}
try {
return Game.resume(engine, saved.journal);
} catch {
return null;
}
},
saveAnalyses: (id, journal) => {
storage()
.library.saveAnalyses(id, journal, { level: levelOf(journal) })
.catch(() => {});
},
remove: (id) => storage().library.remove(id),
exportSgf: async (id) => {
const game = await storage().library.load(id);
if (game === null) {
throw new Error("partie introuvable");
}
const started = new Date(game.summary.startedAt);
const two = (n: number) => String(n).padStart(2, "0");
const date = `${started.getFullYear()}-${two(started.getMonth() + 1)}-${two(started.getDate())}`;
const text = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
date,
});
const time = `${two(started.getHours())}${two(started.getMinutes())}`;
return { fileName: `ustagammon-${date}-${time}.sgf`, text };
},
},
resumeGame: (journal) => Game.resume(engine, journal),
saveGame: (journal) => store.save(journal),
cachedBoard: () => settings.loadBoard(),
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// La progression dans les leçons (étape 1g), dans le stockage des
// parties : lue une fois ouvert, enregistrée sans être attendue.
lessons: {
load: async () => (await opening).lessons.load(),
save: (progress) => {
void opening.then((opened) => opened.lessons.save(progress));
},
},
// Hors ligne : le service worker met tout le site en cache. Le navigateur
// le refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// network error »), et l'application n'est pas disponible hors ligne.
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
// Le bilan et l'analyse de partie (étape 1e) : les analyses qui
// manquent, une à une, sur le même moteur à part.
analyseGame: (journal, task) => runAnalysis(analysisEngine, journal, task),
// 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),
exercises: (journals) => exercisesPosition(engine, journals),
},
startUpdates: (onAvailable) =>
startUpdates(new URL("../sw.js", import.meta.url), worker.ready, onAvailable),
});
// Hors ligne : le service worker met tout le site en cache. Le navigateur le
// refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps, Chromium
// fait échouer l'installation (« Cache.add() encountered a network error »),
// et l'application n'est pas disponible hors ligne. Cela évite aussi de
// télécharger deux fois ces données à la première visite.
if ("serviceWorker" in navigator) {
const sw = new URL("../sw.js", import.meta.url);
worker.ready.then(() => navigator.serviceWorker.register(sw)).catch(() => {});
/** La valeur, ou undefined si l'obtenir lève une exception. */
function attempt<T>(get: () => T): T | undefined {
try {
return get();
} catch {
return undefined;
}
}
+157
View File
@@ -0,0 +1,157 @@
// Les parties enregistrées sur l'appareil : leur résumé, leur journal
// d'événements, et laquelle est la partie en cours.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Deux stockages derrière la même interface (GameLibrary) :
// - IndexedDB, le stockage normal du navigateur (indexeddb-backend.ts) ;
// - un stockage clé-valeur (localStorage), en repli quand IndexedDB est
// indisponible, par exemple en navigation privée de certains navigateurs.
// Les opérations sont exécutées l'une après l'autre, dans l'ordre des
// appels : un enregistrement lancé sans être attendu est donc toujours fait
// avant la lecture qui suit.
import type { GameJournal } from "../domain/game/events.js";
import {
type SavedGameDetails,
type SavedGameSummary,
summarizeGame,
} from "../domain/game/saved-game.js";
/** Une partie enregistrée : son résumé et son journal. */
export interface SavedGame {
readonly summary: SavedGameSummary;
readonly journal: GameJournal;
}
export interface GameLibrary {
/** Les parties, la dernière jouée d'abord. */
list(): Promise<SavedGameSummary[]>;
/** Une partie, ou null si elle n'existe pas ou est illisible. */
load(id: string): Promise<SavedGame | null>;
/** Enregistre la partie id (nouvelle ou non), qui devient la partie en
* cours. Renvoie son résumé. */
save(id: string, journal: GameJournal, details: SavedGameDetails): Promise<SavedGameSummary>;
/** Enregistre le journal d'une partie analysée en arrière-plan (étape
* 1e) : ni la partie en cours ni la date de la dernière action ne
* changent. Rien si la partie n'existe plus. */
saveAnalyses(id: string, journal: GameJournal, details: SavedGameDetails): Promise<void>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
/** La progression dans les leçons (étape 1g), telle qu'enregistrée
* (undefined : jamais enregistrée) ; relue par readLessonProgress. */
lessonProgress(): Promise<unknown>;
saveLessonProgress(progress: unknown): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
saveAnalyses: (id, journal, details) =>
enqueue(async () => {
const known = await backend.summary(id);
if (known === undefined) {
return;
}
const times = { startedAt: known.startedAt, updatedAt: known.updatedAt };
await backend.write(summarizeGame(id, journal, details, times), journal);
}),
remove: (id) => enqueue(() => backend.remove(id)),
current: () =>
enqueue(async () => {
const id = await backend.currentId();
const chosen = id === undefined ? null : await load(id);
if (chosen !== null) {
return chosen;
}
const latest = (await list())[0];
return latest === undefined ? null : load(latest.id);
}),
setCurrent: (id) =>
enqueue(async () => {
if ((await backend.summary(id)) === undefined) {
throw new Error(`partie introuvable : ${id}`);
}
await backend.setCurrentId(id);
}),
};
}
+127
View File
@@ -0,0 +1,127 @@
// La partie à l'écran et son enregistrement : quel identifiant elle porte,
// la nouvelle partie qu'on peut annuler (« Reprendre l'ancienne »), la
// reprise d'une partie enregistrée ou de l'une de ses branches.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// main.ts la branche sur l'écran de jeu ; les scénarios l'utilisent telle
// quelle, sur un stockage en mémoire.
import { switchToBranch } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, NewGame } from "../domain/game/game.js";
import { levelOf } from "../domain/game/saved-game.js";
import { type GameLibrary, newGameId } from "./game-library.js";
/** Comment créer une partie : commencée, ou reprise d'un journal (erreur si
* le journal est illisible). */
export interface GameFactory {
readonly start: (newGame?: NewGame) => Game;
readonly resume: (journal: GameJournal) => Game;
}
export interface GameSession {
/** L'identifiant de la partie à l'écran (null avant la première). */
readonly shownId: string | null;
/** La partie en cours enregistrée, reprise ; null s'il n'y en a pas ou
* si elle est illisible. */
restore(): Promise<Game | null>;
/** Une nouvelle partie (niveau, match, position de départ éventuels) ;
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
* prochain changement de partie. */
newGame(newGame?: NewGame): Game;
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
* un échec est ignoré : la partie continue). */
save(journal: GameJournal): void;
/** « Reprendre l'ancienne » : la partie quittée par newGame, relue dans
* le stockage ; la nouvelle est supprimée. null s'il n'y en a pas. */
resumePrevious(): Promise<Game | null>;
/** Reprend une partie enregistrée, au bout de la branche branchHead si
* elle est donnée. Erreur si elle n'existe pas ou est illisible. */
open(id: string, branchHead?: string): Promise<Game>;
}
export function createGameSession(library: GameLibrary, games: GameFactory): GameSession {
let shownId: string | null = null;
let previousId: string | null = null;
/** Une partie enregistrée, reprise ; null si absente ou illisible. */
const resume = async (id: string): Promise<Game | null> => {
const saved = await library.load(id);
if (saved === null) {
return null;
}
try {
return games.resume(saved.journal);
} catch {
return null;
}
};
return {
get shownId() {
return shownId;
},
async restore() {
const saved = await library.current().catch(() => null);
if (saved === null) {
return null;
}
try {
const game = games.resume(saved.journal);
shownId = saved.summary.id;
return game;
} catch {
return null; // partie illisible : on en commencera une nouvelle
}
},
newGame(newGame) {
previousId = shownId;
shownId = newGameId();
return games.start(newGame);
},
save(journal) {
if (shownId !== null) {
library.save(shownId, journal, { level: levelOf(journal) }).catch(() => {});
}
},
async resumePrevious() {
const id = previousId;
if (id === null) {
return null;
}
const resumed = await resume(id);
if (resumed === null) {
return null;
}
const abandoned = shownId;
previousId = null;
shownId = id;
await library.setCurrent(id);
if (abandoned !== null) {
await library.remove(abandoned);
}
return resumed;
},
async open(id, branchHead) {
const saved = await library.load(id);
if (saved === null) {
throw new Error("partie introuvable");
}
const journal =
branchHead === undefined
? saved.journal
: switchToBranch(saved.journal, branchHead);
const game = games.resume(journal);
previousId = null;
shownId = id;
if (branchHead === undefined) {
await library.setCurrent(id);
} else {
await library.save(id, journal, { level: levelOf(journal) });
}
return game;
},
};
}
-47
View File
@@ -1,47 +0,0 @@
// L'enregistrement de la partie en cours, pour la reprendre après fermeture
// de la page : son journal d'événements, en JSON, dans un stockage clé-valeur
// (localStorage dans le navigateur).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface GameStore {
/** Le journal enregistré, ou null s'il n'y en a pas de lisible. */
load(): GameJournal | null;
save(journal: GameJournal): void;
}
const KEY = "ustagammon.current-game";
export function createGameStore(storage: KeyValueStorage): GameStore {
return {
load() {
try {
const journal = JSON.parse(storage.getItem(KEY) ?? "null");
const valid =
journal !== null &&
Array.isArray(journal.events) &&
(journal.head === null || typeof journal.head === "string");
return valid ? journal : null;
} catch {
return null;
}
},
save(journal) {
try {
storage.setItem(KEY, JSON.stringify(journal));
} catch {
// Stockage plein ou interdit (navigation privée) : la partie
// continue, elle ne pourra seulement pas être reprise.
}
},
};
}
+91
View File
@@ -0,0 +1,91 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current ») et
// la progression dans les leçons (key « lessons », étape 1g).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Clé de la progression dans les leçons, dans le magasin settings (étape
* 1g : nouvelle clé, même schéma, version 1 de la base). */
const LESSONS = "lessons";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
lessonProgress: async () => (await get<{ value: unknown }>(SETTINGS, LESSONS))?.value,
saveLessonProgress: async (progress) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: LESSONS, value: progress });
await committed(transaction);
},
};
}
+103
View File
@@ -0,0 +1,103 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La progression dans les leçons (étape 1g). */
const LESSONS = "ustagammon.lessons";
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
lessonProgress: async () => read(storage, LESSONS),
saveLessonProgress: async (progress) => storage.setItem(LESSONS, JSON.stringify(progress)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+48
View File
@@ -0,0 +1,48 @@
// La progression dans les leçons (étape 1g), dans le stockage des parties
// (IndexedDB, ou localStorage en repli) : une seule valeur, relue par le
// domaine (readLessonProgress), qui en connaît le format.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { type LessonProgress, readLessonProgress } from "../domain/learning/lessons.js";
import type { LibraryBackend } from "./game-library.js";
export interface LessonStore {
/** La progression enregistrée (vide si rien n'est enregistré ou si le
* stockage est illisible). */
load(): Promise<LessonProgress>;
/** Enregistre la progression ; les enregistrements se font dans l'ordre
* des appels. Un stockage refusé ne fait pas échouer l'appel. */
save(progress: LessonProgress): Promise<void>;
}
export function createLessonStore(
backend: Pick<LibraryBackend, "lessonProgress" | "saveLessonProgress">,
): LessonStore {
let queue: Promise<unknown> = Promise.resolve();
const inTurn = <T>(task: () => Promise<T>): Promise<T> => {
const run = queue.then(task, task);
queue = run.catch(() => undefined);
return run;
};
return {
load: () =>
inTurn(async () => {
try {
return readLessonProgress(await backend.lessonProgress());
} catch {
return readLessonProgress(undefined);
}
}),
save: (progress) =>
inTurn(async () => {
try {
await backend.saveLessonProgress(progress);
} catch {
// Stockage plein ou refusé : la progression reste en
// mémoire pour cette visite.
}
}),
};
}
+111
View File
@@ -0,0 +1,111 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
createGameLibrary,
type GameLibrary,
type LibraryBackend,
type LibraryOptions,
} from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
import { createLessonStore, type LessonStore } from "./lesson-store.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
/** La progression dans les leçons, dans le même stockage. */
readonly lessons: LessonStore;
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return opened(backend, options, "indexeddb");
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return opened(createKeyValueBackend(storage.localStorage), options, "localStorage");
}
return opened(createKeyValueBackend(memoryStorage()), options, "memory");
}
/** Les parties et la progression dans les leçons, sur le même stockage. */
function opened(
backend: LibraryBackend,
options: LibraryOptions,
kind: OpenedLibrary["kind"],
): OpenedLibrary {
return {
library: createGameLibrary(backend, options),
kind,
lessons: createLessonStore(backend),
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+4 -2
View File
@@ -8,7 +8,7 @@
import type { Cube } from "../domain/game/cube.js";
import type { SideBoards } from "../domain/game/game.js";
import type { KeyValueStorage } from "./game-store.js";
import type { KeyValueStorage } from "./key-value-backend.js";
const SETTINGS_KEY = "ustagammon.settings";
const BOARD_KEY = "ustagammon.last-board";
@@ -41,7 +41,9 @@ function isSide(value: unknown): boolean {
);
}
export function createSettingsStore(storage: KeyValueStorage): SettingsStore {
export function createSettingsStore(
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
): SettingsStore {
const write = (key: string, value: string) => {
try {
storage.setItem(key, value);
+53
View File
@@ -0,0 +1,53 @@
// Les textes de l'interface du conseil du maître (ligne, feuille
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
export const ADVICE_TEXTS = {
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
menuSwitch: "Conseil du maître",
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
/** Paysage : la pastille du mot, au lecteur d'écran. */
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
sheetLabel: "Explication du maître",
yourMove: "Votre coup",
masterMove: "Le coup du maître",
choiceLabel: "Coup montré sur le plateau",
legendMine: (move: string) =>
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
legendBest: (move: string) =>
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
legendCube: "La position au moment de la décision.",
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (plies: string) =>
`Écart d'équité à ${plies}, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
noDouble: "Pas de double",
doubleTake: "Double, prend",
doublePass: "Double, passe",
},
glossary: (term: string) => `En savoir plus : « ${term} »`,
close: "Fermer",
missing: "Le maître n'a pas d'explication pour ce coup.",
} as const;
/** La fiche du glossaire d'une décision de videau, et son titre. */
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
double: { file: "doubler", term: "Doubler" },
noDouble: { file: "doubler", term: "Doubler" },
take: { file: "prendre", term: "Prendre" },
pass: { file: "passer", term: "Passer" },
};
/** Un nombre à la française, signé, à trois décimales : « −0,123 » ; la
* même écriture que les prompts (domain/analysis/numbers.ts). */
export { formatEquity } from "../domain/analysis/numbers.js";
+152
View File
@@ -0,0 +1,152 @@
// Le conseil du maître pendant la partie : quelles décisions du joueur
// analyser, quand montrer la ligne, quand l'effacer (charte, 1d « La ligne
// du maître »). Sans DOM : testé sous Node (app/tests/advice-tracker.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les analyses partent dès que la décision est jouée, sans être attendues :
// l'application les confie à un second moteur (main.ts), et le tour de
// l'ordinateur continue sur le premier. Chaque résultat est rattaché à sa
// décision dans le journal, pour le bilan (1e). La ligne apparaît quand
// l'analyse attendue est prête, si le joueur n'a rien fait entre-temps ;
// sinon elle n'apparaît pas, et le coup reste commenté dans le journal.
import { playerDecisions } from "../domain/analysis/analyse.js";
import type { Advice } from "../domain/game/advice.js";
import type { GameEvent, GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
/** Analyse une décision du joueur (null : rien à en dire). */
export type Analyse = (journal: GameJournal, eventId: string) => Promise<Advice | null>;
export interface AdviceTracker {
/** Le conseil à montrer maintenant (null : rien). */
readonly shown: Advice | null;
/** Après une action du joueur sur game : before est la ligne de la
* partie avant l'action. Les décisions ajoutées sont analysées. */
track(game: Game, before: readonly GameEvent[]): void;
/** L'action suivante du joueur : la ligne s'efface, et une analyse
* encore attendue ne s'affichera pas. */
clear(): void;
}
export interface AdviceTrackerOptions {
readonly analyse: Analyse;
/** Un conseil a été rattaché au journal de game (à enregistrer). */
readonly recorded: (game: Game) => void;
/** Le conseil à montrer a changé. */
readonly changed: () => void;
}
export function createAdviceTracker(options: AdviceTrackerOptions): AdviceTracker {
let shown: Advice | null = null;
// Chaque attente de ligne a son jeton : clear() l'annule.
let waiting: object | null = null;
let tracked: Game | null = null;
let pending = new Map<string, Promise<Advice | null>>();
/** Le conseil d'une décision : déjà rattaché, en cours, ou à demander. */
function adviceOf(game: Game, id: string): Promise<Advice | null> {
const known = game.advice[id];
if (known !== undefined) {
return Promise.resolve(known);
}
let running = pending.get(id);
if (running === undefined) {
running = options
.analyse(game.journal, id)
.catch(() => null)
.then((advice) => {
if (advice !== null && game.journal.events.some((e) => e.id === id)) {
game.recordAdvice(advice);
options.recorded(game);
}
return advice;
});
pending.set(id, running);
}
return running;
}
return {
get shown() {
return shown;
},
track(game, before) {
if (game !== tracked) {
tracked = game;
pending = new Map();
}
const known = new Set(before.map((e) => e.id));
const line = game.events;
const added = line.filter((e) => !known.has(e.id));
for (const decision of playerDecisions(added)) {
void adviceOf(game, decision.id);
}
const candidates = toShow(line, added);
if (candidates.length === 0) {
return;
}
const token = {};
waiting = token;
void Promise.all(candidates.map((id) => adviceOf(game, id))).then((results) => {
if (waiting !== token || tracked !== game) {
return; // trop tard : le joueur a déjà agi
}
waiting = null;
shown = choose(results);
if (shown !== null) {
options.changed();
}
});
},
clear() {
waiting = null;
if (shown !== null) {
shown = null;
options.changed();
}
},
};
}
/**
* Les décisions dont la ligne peut parler après une action : le coup du
* joueur et, juste avant lui, son jet sans doubler (il aurait pu doubler) ;
* ou son double, ou sa réponse au double de l'ordinateur. Un jet seul ne
* montre rien : le joueur réfléchit alors à son coup.
*/
function toShow(line: readonly GameEvent[], added: readonly GameEvent[]): string[] {
const cube = added.find(
(e) =>
(e.type === "CubeOffered" || e.type === "CubeTaken" || e.type === "CubeDropped") &&
e.side === "player",
);
if (cube !== undefined) {
return [cube.id];
}
const move = added.find((e) => e.type === "MovePlayed" && e.side === "player");
if (move?.type !== "MovePlayed") {
return [];
}
const index = line.findIndex((e) => e.id === move.id);
const roll = line
.slice(0, index)
.findLast((e) => e.type === "DiceRolled" && e.side === "player");
const ids = move.halfMoves.length > 0 ? [move.id] : [];
return roll === undefined ? ids : [roll.id, ...ids];
}
/** Ce que la ligne montre : un double manqué avant le jet s'il était une
* faute, sinon le conseil sur le coup ou sur la décision de videau. */
function choose(results: readonly (Advice | null)[]): Advice | null {
const advices = results.filter((a): a is Advice => a !== null);
const missedDouble = advices.find(
(a) => a.kind === "cube" && a.action === "noDouble" && a.skill !== "none",
);
if (missedDouble !== undefined) {
return missedDouble;
}
return advices.find((a) => !(a.kind === "cube" && a.action === "noDouble")) ?? null;
}
+349
View File
@@ -0,0 +1,349 @@
// La feuille d'explication du maître (#/explication/<événement>), qui monte
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
// position d'avant, puis les chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { pliesText, rankingPly, rankingRows } from "../domain/analysis/numbers.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { showWithNotation } from "./notation.js";
import { routeHash } from "./router.js";
/** Préfixe des identifiants du plateau de la feuille. */
const PREFIX = "explain-";
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
good: "verdictGood",
doubtful: "verdictDoubtful",
bad: "verdictBad",
veryBad: "verdictBad",
};
export interface AdviceViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
readonly veil: HTMLElement;
readonly game: () => Game | null;
readonly phrases: () => Promise<MasterPhrases>;
readonly reducedMotion: MediaQueryList;
/** Fermer : quitter l'adresse de la feuille. */
readonly close: () => void;
}
export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
/** La feuille, où la page du prompt prend la place du contenu (1f). */
readonly sheet: HTMLElement;
}
type Shown = "mine" | "best";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createAdviceView(options: AdviceViewOptions): AdviceView {
const { veil } = options;
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "explain-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
// fermeture, arrête l'animation en cours.
let opened = 0;
// Le plateau de la feuille joue un coup à la fois.
let player: MovePlayer | null = null;
async function show(eventId: string): Promise<void> {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
clearSheet(sheet);
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const verdictLine = element(head, "p", {
id: "explain-verdict",
className: "explain-verdict",
});
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", ADVICE_TEXTS.close);
close.onclick = () => options.close();
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
if (game === null || advice === null) {
title.textContent = ADVICE_TEXTS.sheetLabel;
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
closeButton();
close.focus();
return;
}
const words = masterWords(await options.phrases(), advice);
if (id !== opened) {
return;
}
verdictLine.dataset.verdict = words.verdict;
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
title.textContent = words.title;
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
// (et disparaît si elle ne disait que lui).
const rest = words.phrase.startsWith(`${words.title}.`)
? words.phrase.slice(words.title.length + 1).trim()
: words.phrase;
if (rest !== "") {
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
}
const body = element(sheet, "div", { className: "explain-body" });
const boardBox = element(body, "div", { className: "explain-board-box" });
if (advice.kind === "move") {
await showMoves(id, game, advice, body, boardBox);
} else {
await showCube(id, game, advice, boardBox);
}
if (id !== opened) {
return;
}
numbers(advice);
if (advice.kind === "cube") {
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
const link = element(sheet, "a", {
id: "explain-glossary",
className: "link-button explain-glossary",
text: ADVICE_TEXTS.glossary(entry.term),
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton(advice.eventId);
close.focus();
}
/** « Demander à une IA » (étape 1f), s'il y a un conseil, et
* « Fermer ». */
function closeButton(eventId: string | null = null): void {
const actions = element(sheet, "div", { className: "explain-actions" });
if (eventId !== null) {
askAiLink(
actions,
"explain-ask-ai",
routeHash({ screen: "explanation", event: eventId, ai: true }),
);
}
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
text: ADVICE_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre, et ce qui y joue les
* coups. */
function sheetBoard(box: HTMLElement): SVGSVGElement {
const svg = boardSvg(box, "explain-board", "explain-board");
player = createMovePlayer(svg, PREFIX, options.reducedMotion);
return svg;
}
async function showMoves(
id: number,
game: Game,
advice: MoveAdvice,
body: HTMLElement,
boardBox: HTMLElement,
): Promise<void> {
const same = advice.rank === 1;
const [mine, best] = await Promise.all([
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
]);
if (id !== opened) {
return;
}
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
const svg = sheetBoard(boardBox);
if (best === null) {
svg.dataset.move = "mine";
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
svg.setAttribute("aria-label", text);
showWithNotation(legend, text, advice.played.notation);
void player?.play(mine);
return;
}
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
body.prepend(choice);
const buttons: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", {
id: "explain-mine",
className: "segment",
text: ADVICE_TEXTS.yourMove,
}),
best: element(choice, "button", {
id: "explain-best",
className: "segment",
text: ADVICE_TEXTS.masterMove,
}),
};
const select = (shown: Shown) => {
for (const key of ["mine", "best"] as const) {
buttons[key].setAttribute("aria-checked", String(key === shown));
}
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
const text =
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
showWithNotation(legend, text, notation);
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
void player?.play(shown === "mine" ? mine : best);
};
for (const key of ["mine", "best"] as const) {
const button = buttons[key];
button.type = "button";
button.setAttribute("role", "radio");
button.onclick = () => select(key);
}
// Flèches gauche et droite entre les deux segments.
choice.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(next);
buttons[next].focus();
}
});
// Par défaut, le coup du maître (maquette).
select("best");
}
async function showCube(
id: number,
game: Game,
advice: CubeAdvice,
boardBox: HTMLElement,
): Promise<void> {
const steps = await game.stepsBefore(advice.eventId, "player", []);
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox, "explain-board", "explain-board");
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
drawSteps(svg, steps, 0, false, PREFIX);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
/** « Voir les chiffres du moteur », replié par défaut. */
function numbers(advice: Advice): void {
const details = element(sheet, "details", {
id: "explain-numbers",
className: "explain-numbers",
});
element(details, "summary", { text: ADVICE_TEXTS.numbers });
const table = element(details, "div", { className: "explain-table" });
const row = (cells: string[], strong: boolean) => {
for (const text of cells) {
element(table, "span", { className: strong ? "strong" : "", text });
}
};
if (advice.kind === "move") {
// Les écarts du classement se lisent par rapport à son premier
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
// l'analyse du coup joué (les mêmes lignes que le prompt).
for (const { move, rank, loss, played: mine } of rankingRows(advice)) {
row(
[
String(rank),
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
formatEquity(loss),
],
mine,
);
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(pliesText(rankingPly(advice))),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
// deux lignes « Double », selon la réponse de l'ordinateur.
const mine = {
noDouble: ["noDouble"],
double: ["doubleTake", "doublePass"],
take: ["doubleTake"],
pass: ["doublePass"],
}[advice.action];
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
const bold = mine.includes(key);
const name = ADVICE_TEXTS.cubeRows[key];
row(
[
"",
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
formatEquity(advice.equities[key]),
],
bold,
);
}
element(details, "p", {
className: "explain-note",
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
});
}
}
return {
show,
sheet,
hide() {
opened++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
clearSheet(sheet);
}
},
};
}
+687
View File
@@ -0,0 +1,687 @@
// L'écran d'analyse d'une partie (#/parties/<id>/analyse/<coup>, étape
// 1e ; charte, ecrans-etape-1.md, « Analyse ») : une page qui défile, avec
// la courbe de vos chances de gagner coup après coup, parcourue du doigt ;
// le visualiseur du coup choisi (votre coup contre le coup du maître,
// « Rejouer d'ici ») ; la liste des coups à revoir, filtrable ; et les
// chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La courbe est dessinée à la main en SVG (aucune bibliothèque). Chaque
// coup y a une bande verticale sur toute la hauteur, cible du doigt : on
// touche ou on glisse, et le coup sous le doigt est choisi. Le coup choisi
// est dans l'adresse ; en changer remplace l'adresse (le retour du
// téléphone quitte l'écran, il ne repasse pas par chaque coup).
import {
chanceCurve,
computerMoveAdvice,
type GameSummary,
gameTurns,
mainAdvice,
replayPoint,
summarizeAnalysis,
type Turn,
type WinChanceCurve,
} from "../domain/analysis/game-analysis.js";
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import { type Advice, type Verdict, verdictOf } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { formatHalfMoves } from "../domain/game/position.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { percent, REVIEW_TEXTS, signed } from "./review-texts.js";
import { routeHash } from "./router.js";
/** Ce que montre l'écran. */
export interface AnalysisData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour rejouer les coups sur le plateau. */
readonly game: Game;
/** Le coup choisi (null : le coup à revoir, ou le premier). */
readonly turn: number | null;
readonly phrases: MasterPhrases | null;
}
export interface AnalysisViewOptions {
/** La page (index.html) ; son contenu est construit ici. */
readonly section: HTMLElement;
readonly reducedMotion: MediaQueryList;
/** Choisir un autre coup : remplacer l'adresse. */
readonly select: (turn: number) => void;
/** « Rejouer d'ici » : revenir à cet événement de la partie. */
readonly replay: (id: string, eventId: string) => void;
/** Le lien « Retour » (quitter l'écran sans empiler d'adresse). */
readonly back: (link: HTMLAnchorElement) => void;
}
export interface AnalysisView {
show(data: AnalysisData | null): void;
hide(): void;
/** ← et → : le coup d'avant ou d'après. */
step(delta: number): void;
}
type Filter = "all" | "errors" | "doubtful";
type Shown = "mine" | "best";
const SVG_NS = "http://www.w3.org/2000/svg";
/** Hauteur de la courbe, et marges du tracé (repère en pixels). */
const CURVE_HEIGHT = 168;
const PAD = { left: 40, right: 10, top: 10, bottom: 10 };
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function svgElement<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attributes: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const child = document.createElementNS(SVG_NS, tag);
for (const [name, value] of Object.entries(attributes)) {
child.setAttribute(name, String(value));
}
parent.append(child);
return child;
}
/** Les verdicts que montre chaque filtre. */
const FILTERS: Readonly<Record<Filter, readonly Verdict[]>> = {
all: ["good", "doubtful", "bad", "veryBad"],
errors: ["bad", "veryBad"],
doubtful: ["doubtful"],
};
export function createAnalysisView(options: AnalysisViewOptions): AnalysisView {
const { section } = options;
let data: AnalysisData | null = null;
let turns: Turn[] = [];
let curve: WinChanceCurve = [];
let selected = 1;
let filter: Filter = "all";
// Ce qui est dessiné dans le visualiseur, pour ne pas rejouer le coup à
// chaque analyse rangée.
let drawnTurn = "";
let player: MovePlayer | null = null;
// Chaque changement de coup a son numéro : un plateau qui arrive trop
// tard n'est pas dessiné.
let request = 0;
// La page : en-tête, courbe, visualiseur, liste, chiffres.
const head = element(section, "div", { className: "analysis-head" });
const back = element(head, "a", {
id: "analysis-back",
className: "button secondary",
text: REVIEW_TEXTS.back,
});
back.href = "#/";
options.back(back);
element(head, "h2", {
id: "analysis-title",
className: "analysis-title",
text: REVIEW_TEXTS.analysisTitle,
});
const message = element(section, "p", {
id: "analysis-message",
className: "analysis-message",
});
message.setAttribute("role", "status");
const curveCard = element(section, "section", { className: "analysis-card curve-card" });
element(curveCard, "h3", { className: "overline", text: REVIEW_TEXTS.curveTitle });
const curveSvg = document.createElementNS(SVG_NS, "svg");
curveSvg.id = "analysis-curve";
curveSvg.setAttribute("class", "analysis-curve");
curveSvg.setAttribute("role", "slider");
curveSvg.setAttribute("tabindex", "0");
curveCard.append(curveSvg);
const viewer = element(section, "section", {
id: "analysis-viewer",
className: "analysis-card viewer",
});
const nav = element(viewer, "div", { className: "viewer-nav" });
const prev = element(nav, "a", { id: "analysis-prev", className: "icon-button viewer-step" });
prev.append(icon("chevron"));
prev.setAttribute("aria-label", REVIEW_TEXTS.previous);
const turnTitle = element(nav, "p", { id: "analysis-turn", className: "viewer-turn" });
turnTitle.setAttribute("aria-live", "polite");
const next = element(nav, "a", { id: "analysis-next", className: "icon-button viewer-step" });
next.append(icon("chevron"));
next.setAttribute("aria-label", REVIEW_TEXTS.next);
for (const [link, delta] of [
[prev, -1],
[next, 1],
] as const) {
link.addEventListener("click", (event) => {
event.preventDefault();
step(delta);
});
}
const viewerBody = element(viewer, "div", { className: "viewer-body" });
const boardBox = element(viewerBody, "div", { className: "viewer-board-box" });
const board = boardSvg(boardBox, "analysis-board", "analysis-board");
player = createMovePlayer(board, "analysis-", options.reducedMotion);
const viewerText = element(viewerBody, "div", { className: "viewer-text" });
const phrase = element(viewerText, "p", { id: "analysis-phrase", className: "viewer-phrase" });
const choice = element(viewerText, "div", { id: "analysis-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", REVIEW_TEXTS.choiceLabel);
const segments: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", { id: "analysis-mine", className: "segment" }),
best: element(choice, "button", {
id: "analysis-best",
className: "segment",
text: REVIEW_TEXTS.masterMove,
}),
};
for (const key of ["mine", "best"] as const) {
segments[key].type = "button";
segments[key].setAttribute("role", "radio");
}
const legend = element(viewerText, "p", { id: "analysis-legend", className: "viewer-legend" });
const chanceLine = element(viewerText, "p", {
id: "analysis-chance",
className: "viewer-chance",
});
const viewerActions = element(viewerText, "div", { className: "viewer-actions" });
const replayButton = element(viewerActions, "button", {
id: "analysis-replay",
text: REVIEW_TEXTS.replayFromHere,
});
replayButton.type = "button";
// « Demander à une IA » (étape 1f) : le prompt de la décision du joueur
// dans ce coup, s'il y en a une.
const slot = element(viewerActions, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
const askAi = askAiLink(slot, "analysis-ask-ai", "#/");
const replayHint = element(viewerText, "p", {
className: "viewer-legend",
text: REVIEW_TEXTS.replayHint,
});
const listCard = element(section, "section", { className: "analysis-card list-card" });
element(listCard, "h3", { className: "overline", text: REVIEW_TEXTS.listTitle });
const filters = element(listCard, "div", { id: "analysis-filters", className: "filters" });
filters.setAttribute("role", "group");
filters.setAttribute("aria-label", REVIEW_TEXTS.filterLabel);
const filterButtons: Record<Filter, HTMLButtonElement> = {
all: element(filters, "button", { className: "filter" }),
errors: element(filters, "button", { className: "filter" }),
doubtful: element(filters, "button", { className: "filter" }),
};
for (const key of ["all", "errors", "doubtful"] as const) {
const button = filterButtons[key];
button.type = "button";
button.dataset.filter = key;
button.onclick = () => {
filter = key;
renderList();
};
}
const list = element(listCard, "ul", { id: "analysis-list", className: "analysis-list" });
const numbers = element(section, "details", {
id: "analysis-numbers",
className: "analysis-card explain-numbers",
});
element(numbers, "summary", { text: REVIEW_TEXTS.numbers });
const table = element(numbers, "div", { className: "explain-table analysis-table" });
element(numbers, "p", { className: "explain-note", text: REVIEW_TEXTS.numbersNote });
// ------------------------------------------------------------ Courbe
/** Le coup sous l'abscisse x (pixels du tracé). */
function turnAt(x: number): number {
const width = curveSvg.getBoundingClientRect().width;
const inner = Math.max(1, width - PAD.left - PAD.right);
const index = Math.floor(((x - PAD.left) / inner) * turns.length);
return Math.min(turns.length, Math.max(1, index + 1));
}
let dragging = false;
curveSvg.addEventListener("pointerdown", (event) => {
dragging = true;
curveSvg.setPointerCapture?.(event.pointerId);
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
});
curveSvg.addEventListener("pointermove", (event) => {
if (dragging) {
choose(turnAt(event.clientX - curveSvg.getBoundingClientRect().left));
}
});
for (const end of ["pointerup", "pointercancel"] as const) {
curveSvg.addEventListener(end, () => {
dragging = false;
});
}
curveSvg.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
step(event.key === "ArrowLeft" ? -1 : 1);
}
});
function renderCurve(): void {
const width = Math.round(curveSvg.getBoundingClientRect().width || 360);
const height = CURVE_HEIGHT;
curveSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
curveSvg.replaceChildren();
const inner = width - PAD.left - PAD.right;
const band = turns.length > 0 ? inner / turns.length : inner;
const x = (turn: number) => PAD.left + (turn - 0.5) * band;
const y = (chance: number) => PAD.top + (1 - chance) * (height - PAD.top - PAD.bottom);
// Axe : 100 %, 50 % (pointillé), 0 %.
for (const level of [1, 0.5, 0]) {
svgElement(curveSvg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(level),
y2: y(level),
class: level === 0.5 ? "curve-half" : "curve-axis",
});
const label = svgElement(curveSvg, "text", {
x: PAD.left - 6,
y: y(level) + 4,
class: "curve-label",
"text-anchor": "end",
});
label.textContent = percent(level);
}
const known = curve.filter((p) => p.chance !== null) as {
turn: number;
chance: number;
verdict: Verdict | null;
side: string;
}[];
if (known.length > 0) {
const line = known.map((p) => `${x(p.turn)},${y(p.chance)}`).join(" ");
const first = known[0] as { turn: number };
const last = known.at(-1) as { turn: number };
svgElement(curveSvg, "polygon", {
points: `${x(first.turn)},${y(0)} ${line} ${x(last.turn)},${y(0)}`,
class: "curve-area",
});
svgElement(curveSvg, "polyline", { points: line, class: "curve-line" });
}
// Le coup choisi : un trait vertical brique.
svgElement(curveSvg, "line", {
x1: x(selected),
x2: x(selected),
y1: PAD.top,
y2: height - PAD.bottom,
class: "curve-cursor",
});
for (const point of known) {
const commented = point.verdict !== null;
svgElement(curveSvg, "circle", {
cx: x(point.turn),
cy: y(point.chance),
r: commented ? 4 : 2,
class: commented ? `curve-dot verdict-${point.verdict}` : "curve-dot other",
});
}
const chosen = curve[selected - 1];
if (chosen?.chance != null) {
svgElement(curveSvg, "circle", {
cx: x(selected),
cy: y(chosen.chance),
r: 7,
class: "curve-ring",
});
}
// Les bandes, sur toute la hauteur : cibles du doigt.
for (const turn of turns) {
const rect = svgElement(curveSvg, "rect", {
x: PAD.left + (turn.number - 1) * band,
y: 0,
width: band,
height,
class: turn.number === selected ? "curve-band selected" : "curve-band",
});
rect.dataset.turn = String(turn.number);
}
const chance = chosen?.chance;
curveSvg.setAttribute("aria-valuemin", "1");
curveSvg.setAttribute("aria-valuemax", String(turns.length));
curveSvg.setAttribute("aria-valuenow", String(selected));
const spoken =
chance === null || chance === undefined ? REVIEW_TEXTS.curveUnknown : percent(chance);
curveSvg.setAttribute("aria-valuetext", spoken);
curveSvg.setAttribute(
"aria-label",
REVIEW_TEXTS.curveLabel(selected, turns.length, spoken),
);
}
// -------------------------------------------------------- Visualiseur
function renderViewer(): void {
const current = data;
const turn = turns[selected - 1];
if (current === null || turn === undefined) {
return;
}
prev.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.max(1, selected - 1),
});
next.href = routeHash({
screen: "analysis",
game: current.id,
turn: Math.min(turns.length, selected + 1),
});
prev.setAttribute("aria-disabled", String(selected <= 1));
next.setAttribute("aria-disabled", String(selected >= turns.length));
const roll = turn.roll === null ? "" : ` · ${REVIEW_TEXTS.roll(turn.roll)}`;
turnTitle.textContent = `${REVIEW_TEXTS.turnOf(selected, turns.length)}${roll}`;
const chance = curve[selected - 1]?.chance;
chanceLine.textContent =
chance === null || chance === undefined
? ""
: REVIEW_TEXTS.chanceAfter(percent(chance));
const main = mainAdvice(current.journal, turn);
const theirs = computerMoveAdvice(current.journal, turn);
const key = [
current.id,
selected,
main?.eventId ?? "",
theirs?.eventId ?? "",
current.phrases !== null,
].join("|");
askAi.hidden = main === null;
askAi.href = routeHash({ screen: "analysis", game: current.id, turn: selected, ai: true });
replayButton.hidden = replayPoint(current.journal, turn) === null;
replayHint.hidden = replayButton.hidden;
replayButton.onclick = () => {
const point = replayPoint(current.journal, turn);
if (point !== null) {
options.replay(current.id, point);
}
};
if (key === drawnTurn) {
return;
}
drawnTurn = key;
player?.stop();
phrase.replaceChildren();
delete phrase.dataset.verdict;
legend.textContent = "";
const id = ++request;
// Ce qu'en dit le maître : la décision du joueur à revoir, ou ce
// qu'a fait l'ordinateur.
if (main !== null && current.phrases !== null) {
const words = masterWords(current.phrases, main);
phrase.dataset.verdict = words.verdict;
const word = document.createElement("strong");
word.append(icon(VERDICT_ICONS[words.verdict]), `${words.word}.`);
const said = document.createElement("em");
said.textContent = words.phrase;
phrase.append(word, " ", said);
} else if (turn.side === "computer") {
phrase.textContent =
turn.move === null
? REVIEW_TEXTS.computerCube
: turn.move.halfMoves.length === 0
? REVIEW_TEXTS.computerCannotMove
: REVIEW_TEXTS.computerPlayed(
theirs?.played.notation ?? formatHalfMoves(turn.move.halfMoves),
);
} else if (turn.move !== null && turn.move.halfMoves.length === 0) {
phrase.textContent = REVIEW_TEXTS.playerCannotMove;
}
// Le plateau : le coup joué et, s'il diffère, le meilleur coup.
void (async () => {
const side = turn.side;
let mine: MoveSteps | null = null;
let best: MoveSteps | null = null;
// Les coups s'écrivent dans la notation du moteur, comme dans le
// bilan et la feuille du maître (« 6/off », pas « 6/5 5/off ») ;
// à défaut d'analyse, à partir des demi-coups.
let names = { mine: "", best: "" };
const moveAdvice: Advice | null = side === "player" ? main : theirs;
try {
if (moveAdvice?.kind === "move") {
mine = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.played.halfMoves,
);
names = {
mine: moveAdvice.played.notation,
best: moveAdvice.best.notation,
};
if (moveAdvice.rank > 1) {
best = await current.game.stepsBefore(
moveAdvice.eventId,
side,
moveAdvice.best.halfMoves,
);
}
} else if (moveAdvice?.kind === "cube") {
mine = await current.game.stepsBefore(moveAdvice.eventId, "player", []);
} else if (turn.move !== null) {
mine = await current.game.stepsBefore(turn.move.id, side, turn.move.halfMoves);
names = { mine: formatHalfMoves(turn.move.halfMoves), best: "" };
} else {
const first = turn.events[0];
mine =
first === undefined
? null
: await current.game.stepsBefore(first.id, side, []);
}
} catch {
mine = null;
}
if (id !== request || mine === null) {
return;
}
showMoves(mine, best, side, names);
})();
}
function showMoves(
mine: MoveSteps,
best: MoveSteps | null,
side: "player" | "computer",
names: { readonly mine: string; readonly best: string },
): void {
const who = side === "player" ? REVIEW_TEXTS.yourMoveSegment : REVIEW_TEXTS.theirMove;
segments.mine.textContent = who;
choice.hidden = best === null;
const select = (shown: Shown) => {
const steps = shown === "best" && best !== null ? best : mine;
for (const key of ["mine", "best"] as const) {
segments[key].setAttribute("aria-checked", String(key === shown));
}
board.dataset.move = shown;
if (steps.halfMoves.length === 0) {
legend.textContent = REVIEW_TEXTS.legendPosition;
board.setAttribute("aria-label", REVIEW_TEXTS.legendPosition);
drawSteps(board, steps, 0, false, "analysis-");
return;
}
const text = REVIEW_TEXTS.legendMove(
shown === "best" ? REVIEW_TEXTS.masterMove : who,
shown === "best" ? names.best : names.mine,
);
legend.textContent = text;
board.setAttribute("aria-label", text);
void player?.play(steps);
};
segments.mine.onclick = () => select("mine");
segments.best.onclick = () => select("best");
choice.onkeydown = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
event.stopPropagation();
const nextShown =
segments.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(nextShown);
segments[nextShown].focus();
}
};
select("mine");
}
// -------------------------------------------------------------- Liste
function renderList(): void {
const current = data;
if (current === null) {
return;
}
const rows = turns
.map((turn) => ({ turn, advice: mainAdvice(current.journal, turn) }))
.filter((row): row is { turn: Turn; advice: Advice } => row.advice !== null);
const count = (f: Filter) =>
rows.filter((r) => FILTERS[f].includes(verdictOf(r.advice.skill))).length;
for (const key of ["all", "errors", "doubtful"] as const) {
filterButtons[key].textContent = `${REVIEW_TEXTS.filters[key]} · ${count(key)}`;
filterButtons[key].setAttribute("aria-pressed", String(key === filter));
}
list.replaceChildren();
const shown = rows.filter((r) => FILTERS[filter].includes(verdictOf(r.advice.skill)));
if (shown.length === 0) {
element(list, "li", { className: "analysis-empty", text: REVIEW_TEXTS.emptyFilter });
return;
}
for (const { turn, advice } of shown) {
const item = element(list, "li");
const link = element(item, "a", { className: "analysis-row" });
link.href = routeHash({ screen: "analysis", game: current.id, turn: turn.number });
link.dataset.turn = String(turn.number);
if (turn.number === selected) {
link.setAttribute("aria-current", "true");
}
const verdict = verdictOf(advice.skill);
const word = current.phrases === null ? "" : masterWords(current.phrases, advice).word;
const roll = turn.roll === null ? "" : REVIEW_TEXTS.roll(turn.roll);
const move =
advice.kind === "move"
? advice.played.notation
: REVIEW_TEXTS.cubeActions[advice.action];
link.setAttribute("aria-label", REVIEW_TEXTS.rowLabel(turn.number, roll, move, word));
element(link, "span", { className: "row-number", text: String(turn.number) });
element(link, "span", { className: "row-roll", text: roll });
element(link, "span", { className: "row-move", text: move });
const mark = element(link, "span", { className: "row-word" });
mark.dataset.verdict = verdict;
mark.append(icon(VERDICT_ICONS[verdict]), word);
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
choose(turn.number);
});
}
}
// ------------------------------------------------- Chiffres du moteur
function renderNumbers(summary: GameSummary): void {
table.replaceChildren();
const cell = (text: string, className = "") => element(table, "span", { className, text });
cell("");
cell(REVIEW_TEXTS.numbersHead.you, "strong");
cell(REVIEW_TEXTS.numbersHead.computer, "strong");
const rows: [string, (s: GameSummary["player"]) => string][] = [
[REVIEW_TEXTS.numbersRows.errorRate, (s) => signed(-s.errorRate.perDecision)],
[REVIEW_TEXTS.numbersRows.decisions, (s) => String(s.errorRate.decisions)],
[REVIEW_TEXTS.numbersRows.level, (s) => MATCH_TEXTS.levels[s.errorRate.level].name],
[REVIEW_TEXTS.numbersRows.luck, (s) => signed(s.luck.perRoll)],
];
for (const [name, value] of rows) {
cell(name);
cell(value(summary.player));
cell(value(summary.computer));
}
}
// ---------------------------------------------------------- Ensemble
function choose(turn: number): void {
if (turn === selected || turn < 1 || turn > turns.length) {
return;
}
selected = turn;
renderCurve();
renderViewer();
renderList();
options.select(turn);
}
function step(delta: number): void {
choose(selected + delta);
}
window.addEventListener("resize", () => {
if (!section.hidden && data !== null) {
renderCurve();
}
});
return {
show(next) {
data = next;
if (next === null) {
message.textContent = REVIEW_TEXTS.missingGame;
curveCard.hidden = true;
viewer.hidden = true;
listCard.hidden = true;
numbers.hidden = true;
return;
}
turns = gameTurns(next.journal);
curve = chanceCurve(next.journal);
const summary = summarizeAnalysis(next.journal);
const fallback = summary.toReview?.turn ?? 1;
selected = Math.min(Math.max(1, next.turn ?? fallback), Math.max(1, turns.length));
message.textContent = turns.length === 0 ? REVIEW_TEXTS.emptyFilter : "";
curveCard.hidden = turns.length === 0;
viewer.hidden = turns.length === 0;
listCard.hidden = turns.length === 0;
numbers.hidden = turns.length === 0;
renderCurve();
renderViewer();
renderList();
renderNumbers(summary);
},
hide() {
data = null;
drawnTurn = "";
player?.stop();
request++;
},
step,
};
}
+1930 -92
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
// 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",
progression: "Progression",
},
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.",
noAnalysedGame:
"Pas encore d'exercices : le maître n'a analysé aucune partie terminée. Ils seront prêts avec la première.",
noMistake:
"Pas d'exercices à proposer : sur vos parties analysées, vous avez joué comme le maître.",
} 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,
+175 -21
View File
@@ -27,6 +27,7 @@ import {
} from "./board-model.js";
import { columnLeft, type Geometry, MAX_STACK, stackY } from "./geometry.js";
import { ICON_PATHS, type IconName } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { TEXTS } from "./texts.js";
/** Un dé affiché, de la couleur du camp qui l'a lancé. */
@@ -49,12 +50,22 @@ export interface BoardControls {
readonly back: "undo" | "replay";
readonly backEnabled: boolean;
readonly menuEnabled: boolean;
/** Le score du match, sous le menu (null : partie libre). */
readonly match: MatchScore | null;
}
/** Le score d'un match, à afficher. */
export interface MatchScore {
readonly length: number;
readonly computer: number;
readonly player: number;
}
/** Ce qui s'ajoute aux pions : dés, videau, mises en évidence. */
export interface BoardDecor {
readonly dice: readonly ShownDie[];
readonly cube: Cube;
/** null : pas de videau (partie Crawford). */
readonly cube: Cube | null;
/** Les pions que le joueur peut déplacer. */
readonly movable: ReadonlySet<Place>;
/** Le pion touché, dont on montre les cases d'arrivée. */
@@ -72,6 +83,21 @@ export interface BoardDecor {
readonly band: string | null;
/** Paysage : la colonne de commandes (null : pas de colonne). */
readonly controls: BoardControls | null;
/** Paysage : le conseil du maître, une pastille avant la consigne dans
* la bande centrale (absent ou null : rien). */
readonly advice?: BoardAdvice | null;
}
/** Le conseil du maître en paysage : son mot, sur une pastille qui mène à
* la feuille d'explication. */
export interface BoardAdvice {
readonly href: string;
/** Appréciation : la couleur et le pictogramme. */
readonly verdict: "good" | "doubtful" | "bad" | "veryBad";
readonly icon: IconName;
readonly word: string;
/** Ce que lit le lecteur d'écran : le mot, la phrase, l'invitation. */
readonly label: string;
}
/** Ce qui ne s'ajoute pas aux pions pendant l'animation d'un coup. */
@@ -166,6 +192,7 @@ export function renderBoard(
// Les pions, dessinés à part puis posés par-dessus les flèches du coup
// de l'ordinateur : une flèche ne cache jamais un pion.
const pieces = document.createElementNS(SVG_NS, "g");
pieces.setAttribute("class", "pieces");
for (const point of board.points) {
drawPoint(svg, pieces, g, hits, board, point.number, decor);
}
@@ -176,11 +203,13 @@ export function renderBoard(
}
svg.append(pieces);
drawLandings(svg, g, board, decor.targets);
const advice = decor.band !== null ? (decor.advice ?? null) : null;
const chip = advice === null ? 0 : adviceChipWidth(g, advice) + 8;
if (decor.band !== null) {
drawBand(svg, g, decor.band);
drawBand(svg, g, decor.band, chip);
}
drawDice(svg, g, decor.dice);
const cube = drawCube(svg, g, decor.cube);
const cube = decor.cube === null ? null : drawCube(svg, g, decor.cube);
if (decor.trace !== null) {
drawTraceRings(svg, g, board, decor.trace);
}
@@ -196,13 +225,16 @@ export function renderBoard(
addAction(g, hits, decor.diceAction, label, { x, y: g.height / 2 }, width);
}
}
if (decor.canDouble) {
if (decor.canDouble && cube !== null && decor.cube !== null) {
cube.classList.add("can-double");
addAction(g, hits, "double", TEXTS.double, cubeCenter(g, decor.cube), g.target);
}
if (decor.controls !== null) {
drawControls(svg, g, hits, decor.controls);
}
if (advice !== null) {
drawAdviceChip(svg, g, hits, advice);
}
// Les zones à toucher passent devant tout le reste.
svg.append(hits);
}
@@ -271,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),
@@ -484,12 +510,18 @@ function addRing(
}
/** Paysage : la consigne courte, dans la moitié gauche de la bande
* centrale, sur une pastille accent-soft. */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
* centrale, sur une pastille accent-soft ; elle laisse la place before à
* gauche (la pastille du conseil du maître). */
function drawBand(svg: SVGSVGElement, g: Geometry, text: string, before = 0): void {
const middle = g.height / 2;
const width = 6 * g.pointWidth - 24;
const width = 6 * g.pointWidth - 24 - before;
const height = Math.max(44, g.bandFont + 20);
const x = g.x0 + 12;
const x = g.x0 + 12 + before;
// Une consigne longue (« Partie Crawford · … ») tient dans la pastille :
// la police se resserre, sans descendre sous 13 px (charte). Largeur
// moyenne d'un caractère d'Alegreya Sans en gras : un peu moins de
// 0,45 em.
const fontSize = Math.max(13, Math.min(g.bandFont, (width - 24) / (0.45 * text.length)));
add(svg, "rect", {
class: "band-pill",
x,
@@ -505,13 +537,113 @@ function drawBand(svg: SVGSVGElement, g: Geometry, text: string): void {
class: "band-text",
id: "band-text",
x: x + width / 2,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
y: middle + fontSize * 0.35,
"font-size": fontSize,
},
text,
);
}
/** Largeur de la pastille du conseil : pictogramme, mot (estimé à 0,55 em
* par lettre, police Alegreya Sans) et marges. */
function adviceChipWidth(g: Geometry, advice: BoardAdvice): number {
return 16 + g.bandFont + 6 + advice.word.length * g.bandFont * 0.55 + 16;
}
/**
* Paysage : le conseil du maître, une pastille au début de la bande
* centrale (pictogramme et mot en couleur d'appréciation), lien vers la
* feuille d'explication (charte 1d, adaptée au paysage : la phrase est dans
* la feuille). Posée parmi les zones à toucher, devant celle des dés.
*/
function drawAdviceChip(svg: SVGSVGElement, g: Geometry, hits: Element, advice: BoardAdvice): void {
const middle = g.height / 2;
const height = Math.max(44, g.bandFont + 20);
const width = adviceChipWidth(g, advice);
const x = g.x0 + 12;
const group = add(svg, "g", { class: `advice-chip verdict-${advice.verdict}` });
add(group, "rect", {
class: "advice-chip-pill",
x,
y: middle - height / 2,
width,
height,
rx: height / 2,
});
const size = g.bandFont;
const icon = add(group, "g", {
transform: `translate(${x + 16},${middle - size / 2}) scale(${size / 24})`,
});
add(icon, "path", { class: "advice-chip-icon", d: ICON_PATHS[advice.icon] });
add(
group,
"text",
{
class: "advice-chip-text",
x: x + 16 + size + 6,
y: middle + g.bandFont * 0.35,
"font-size": g.bandFont,
},
advice.word,
);
const link = add(hits, "a", {
href: advice.href,
id: "advice-chip",
role: "link",
"aria-label": advice.label,
});
add(link, "rect", {
class: "hit action",
x,
y: middle - Math.max(height, g.target) / 2,
width,
height: Math.max(height, g.target),
});
}
/**
* Paysage : le score du match, dans une case sous le menu, dans l'ordre des
* camps à l'écran : l'ordinateur en haut, « sur N », vous en bas
* (ecrans-etape-1.md, 1c). Lu d'une traite par le lecteur d'écran.
*/
function drawMatchBox(svg: Element, g: Geometry, x: number, top: number, match: MatchScore): void {
const line = g.chipFont * 1.2;
const width = g.left - 12;
const group = add(svg, "g", {
class: "match-box",
role: "img",
"aria-label": MATCH_TEXTS.scoreSpoken(match.length, match.computer, match.player),
});
add(group, "rect", {
class: "match-box-frame",
x: x - width / 2,
y: top,
width,
height: 3 * line + 8,
rx: 6,
});
const texts: [string, string][] = [
[String(match.computer), "match-box-score"],
[MATCH_TEXTS.outOf(match.length), "match-box-of"],
[String(match.player), "match-box-score"],
];
for (const [i, [text, cssClass]] of texts.entries()) {
add(
group,
"text",
{
class: cssClass,
x,
y: top + 4 + line * (i + 0.5) + g.chipFont * 0.35,
"font-size": g.chipFont,
"text-anchor": "middle",
"aria-hidden": "true",
},
text,
);
}
}
/** Paysage : la colonne de commandes à gauche, dans le cadre. */
function drawControls(
svg: SVGSVGElement,
@@ -532,11 +664,33 @@ function drawControls(
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
});
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
if (on) {
if (!on) {
return;
}
if (action === "menu") {
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
// la barre haute du portrait.
const link = add(hits, "a", {
href: "#/menu",
"data-action": action,
role: "link",
"aria-label": label,
});
add(link, "rect", {
class: "hit action",
x: Math.max(0, x - g.target / 2),
y: Math.max(0, y - g.target / 2),
width: g.target,
height: g.target,
});
} else {
addAction(g, hits, action, label, { x, y }, g.target);
}
};
control("menu", "menu", TEXTS.menu, top, controls.menuEnabled);
if (controls.match !== null) {
drawMatchBox(svg, g, x, top + g.target / 2 + 6, controls.match);
}
if (controls.back === "replay") {
control("replay", "replay", TEXTS.replayTitle, bottom, controls.backEnabled);
} else {
+67
View File
@@ -0,0 +1,67 @@
// Les textes de « Mes parties » (ADR 0007, point 5), à part de texts.ts le
// temps que l'écran rejoigne le menu de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { Side } from "../domain/game/side.js";
const RESULT_KIND: Record<ResultKind, string> = {
single: "partie simple",
gammon: "gammon",
backgammon: "backgammon",
};
function moves(n: number): string {
return n > 1 ? `${n} coups` : `${n} coup`;
}
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const GAMES_TEXTS = {
button: "Mes parties",
title: "Mes parties",
empty: "Aucune partie enregistrée.",
close: "Fermer",
resume: "Reprendre",
showBranches: "Branches",
summary: "Bilan",
resumeBranch: "Reprendre cette branche",
exportSgf: "Exporter (SGF)",
remove: "Supprimer",
confirmRemove: "Supprimer pour de bon (branches comprises)",
shown: "partie affichée",
busy: "Attendez la fin du coup en cours.",
failed: (message: string) => `Erreur : ${message}`,
/** « 3 nov. 2026, 14:05 ». */
date: (ms: number) =>
new Date(ms).toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
status: (
result: { winner: Side; kind: ResultKind; points: number } | null,
moveCount: number,
) => {
if (result === null) {
return `En cours, ${moves(moveCount)}`;
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
return `${who} (${RESULT_KIND[result.kind]}, ${points(result.points)}), ${moves(moveCount)}`;
},
branchCount: (n: number) => (n > 1 ? `${n} branches` : ""),
/** Une branche : d'où elle part et où elle en est. */
branch: (forkMoveCount: number | null, moveCount: number, over: boolean) => {
const start =
forkMoveCount === null
? "Partie d'origine"
: forkMoveCount === 0
? "Rejouée depuis le début"
: `Rejouée après le coup ${forkMoveCount}`;
return `${start} : ${moves(moveCount)}${over ? ", finie" : ""}`;
},
currentBranch: " (branche courante)",
};
+288
View File
@@ -0,0 +1,288 @@
// « Mes parties » : la liste des parties enregistrées, pour en reprendre une
// (ou l'une de ses branches), l'exporter en SGF ou la supprimer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une feuille qui monte du bas, comme le menu, créée ici même : elle ne
// dépend pas de index.html. Comme tout l'écran, elle ne connaît ni le
// stockage ni le moteur : l'écran de jeu (app.ts) lui passe les opérations
// (GamesViewOptions), et change lui-même de partie quand on en reprend une.
//
// La feuille a son adresse (#/parties), et chaque partie la sienne
// (#/parties/<id>, ses branches dépliées) : l'écran de jeu l'ouvre et la
// ferme selon l'adresse (show, hide), et « Branches » est un vrai lien.
import { branches } from "../domain/game/branches.js";
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { GAMES_TEXTS } from "./games-texts.js";
import { icon } from "./icons.js";
import { routeHash } from "./router.js";
export interface GamesViewOptions {
/** Les parties, la dernière jouée d'abord. */
readonly list: () => Promise<SavedGameSummary[]>;
/** Le journal d'une partie (null si elle a disparu). */
readonly journal: (id: string) => Promise<GameJournal | null>;
/** L'identifiant de la partie affichée à l'écran. */
readonly shownId: () => string | null;
/** Reprend une partie, au bout de la branche branchHead si elle est
* donnée (sinon là où elle en était), et quitte la feuille. */
readonly resume: (id: string, branchHead?: string) => Promise<void>;
readonly remove: (id: string) => Promise<void>;
/** Le fichier SGF d'une partie : son nom et son contenu. */
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
/** La croix ou le voile touché : quitter la feuille (l'écran de jeu
* change d'adresse, puis appelle hide). */
readonly onClose: () => void;
}
export interface GamesView {
/** Ouvre la feuille (la liste, à jour, si elle était fermée), avec les
* branches de la partie expanded dépliées. */
show(expanded: string | null): Promise<void>;
hide(): void;
readonly isOpen: boolean;
}
/** Crée la feuille « Mes parties » (fermée) dans la page. */
export function createGamesView(options: GamesViewOptions): GamesView {
const veil = element("div", "veil sheet-veil");
veil.id = "games-veil";
veil.hidden = true;
const sheet = element("div", "sheet games");
sheet.id = "games";
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "games-title");
const head = element("div", "sheet-head");
const title = element("h2", "sheet-title");
title.id = "games-title";
title.textContent = GAMES_TEXTS.title;
const close = button("", () => options.onClose(), "icon-button");
close.id = "games-close";
close.setAttribute("aria-label", GAMES_TEXTS.close);
close.append(icon("close"));
head.append(title, close);
const list = element("ul", "games-list");
const message = element("p", "games-message");
message.setAttribute("aria-live", "polite");
sheet.append(head, message, list);
veil.append(sheet);
document.body.append(veil);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.onClose();
}
});
// La partie dont les branches sont dépliées (adresse #/parties/<id>).
let expanded: string | null = null;
/** Lance une action ; une erreur s'affiche dans la fenêtre. */
const run = (action: () => Promise<void>) => async () => {
message.textContent = "";
try {
await action();
} catch (error) {
message.textContent = GAMES_TEXTS.failed(
error instanceof Error ? error.message : String(error),
);
}
};
async function render(): Promise<void> {
const games = await options.list();
list.replaceChildren(...games.map((game) => gameItem(game, game.id === options.shownId())));
if (games.length === 0) {
message.textContent = GAMES_TEXTS.empty;
}
}
function gameItem(game: SavedGameSummary, shown: boolean): HTMLElement {
const item = element("li", "games-item");
item.dataset.id = game.id;
const heading = element("p");
heading.textContent = [
GAMES_TEXTS.date(game.startedAt),
GAMES_TEXTS.status(game.result, game.moveCount),
GAMES_TEXTS.branchCount(game.branchCount),
shown ? GAMES_TEXTS.shown : "",
]
.filter((part) => part !== "")
.join(" · ");
const branchList = element("ul", "games-branches");
branchList.hidden = true;
const actions = element("div", "games-actions");
actions.append(
button(
GAMES_TEXTS.resume,
run(() => options.resume(game.id)),
"",
"resume",
),
branchesLink(game.id),
...(game.status === "over" ? [summaryLink(game.id)] : []),
button(
GAMES_TEXTS.exportSgf,
run(async () => download(await options.exportSgf(game.id))),
"secondary",
"export",
),
// Supprimer se fait en deux touchers, sans boîte du navigateur :
// le premier change le bouton en demande de confirmation.
button(
GAMES_TEXTS.remove,
run(async () => {
const remove = item.querySelector<HTMLElement>('[data-action="remove"]');
if (remove?.dataset.armed !== "true") {
if (remove !== null) {
remove.dataset.armed = "true";
remove.textContent = GAMES_TEXTS.confirmRemove;
}
return;
}
await options.remove(game.id);
await render();
await expand();
}),
"secondary",
"remove",
),
);
// La partie à l'écran ne se supprime pas : elle continue d'être
// enregistrée.
const remove = actions.querySelector<HTMLElement>('[data-action="remove"]');
if (remove !== null) {
remove.hidden = shown;
}
item.append(heading, actions, branchList);
return item;
}
/** « Branches » : le lien qui déplie les branches d'une partie (ou les
* replie, quand elles le sont déjà). */
function branchesLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.showBranches;
link.dataset.action = "branches";
link.href = routeHash({ screen: "games", game: id });
link.setAttribute("aria-expanded", "false");
return link;
}
/** « Bilan » : le lien vers le bilan d'une partie finie (étape 1e). */
function summaryLink(id: string): HTMLAnchorElement {
const link = element("a", "button secondary");
link.textContent = GAMES_TEXTS.summary;
link.dataset.action = "summary";
link.href = routeHash({ screen: "summary", game: id });
return link;
}
async function renderBranches(id: string, target: HTMLElement): Promise<void> {
const journal = await options.journal(id);
const all = journal === null ? [] : branches(journal);
target.replaceChildren(
...all.map((branch) => {
const item = element("li", "games-branch");
item.dataset.head = branch.head;
const text = element("span");
text.textContent =
GAMES_TEXTS.branch(
branch.forkedFrom === null ? null : branch.forkMoveCount,
branch.moveCount,
branch.over,
) + (branch.containsHead ? GAMES_TEXTS.currentBranch : "");
item.append(
text,
button(
GAMES_TEXTS.resumeBranch,
run(() => options.resume(id, branch.head)),
"secondary",
"resume-branch",
),
);
return item;
}),
);
}
/** Déplie les branches de la partie expanded, replie les autres. */
async function expand(): Promise<void> {
for (const item of list.querySelectorAll<HTMLElement>(".games-item")) {
const id = item.dataset.id ?? "";
const open = id === expanded;
const link = item.querySelector<HTMLAnchorElement>('[data-action="branches"]');
link?.setAttribute("aria-expanded", String(open));
link?.setAttribute("href", routeHash({ screen: "games", game: open ? null : id }));
const branchList = item.querySelector<HTMLElement>(".games-branches");
if (branchList === null) {
continue;
}
if (open && branchList.hidden) {
await run(() => renderBranches(id, branchList))();
}
branchList.hidden = !open;
}
}
return {
async show(next) {
expanded = next;
if (veil.hidden) {
message.textContent = "";
veil.hidden = false;
await run(render)();
close.focus();
}
await expand();
},
hide() {
veil.hidden = true;
},
get isOpen() {
return !veil.hidden;
},
};
}
function element<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
): HTMLElementTagNameMap[K] {
const created = document.createElement(tag);
if (className !== "") {
created.className = className;
}
return created;
}
function button(
text: string,
onClick: () => unknown,
className = "",
action = "",
): HTMLButtonElement {
const created = element("button", className);
created.type = "button";
created.textContent = text;
if (action !== "") {
created.dataset.action = action;
}
created.onclick = () => void onClick();
return created;
}
/** Fait télécharger un fichier texte au navigateur. */
function download(file: { fileName: string; text: string }): void {
const url = URL.createObjectURL(new Blob([file.text], { type: "application/x-go-sgf" }));
const link = element("a");
link.href = url;
link.download = file.fileName;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
+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,
+32
View File
@@ -0,0 +1,32 @@
// La question de l'aide qui correspond au moment de la partie : le bouton
// « Aide » de l'écran de jeu l'ouvre directement (charte : « fermée par
// défaut sauf celle qui correspond à l'écran d'où l'on vient »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les questions sont les titres des scénarios de docs/scenarios/ (ADR 0008) ;
// leur adresse en est tirée par slug. Un test (app/tests/ui.test.ts) vérifie
// que chaque titre ci-dessous est bien celui d'un scénario : changer un
// titre dans un .feature demande de le changer ici.
import { routeHash, slug } from "./router.js";
/** Le moment de la partie, vu de l'aide. */
export type HelpMoment = "moving" | "stuck" | "doubled" | "over";
export const HELP_QUESTIONS: Readonly<Record<HelpMoment, string>> = {
moving: "Comment jouer un coup ?",
stuck: "Je ne peux rien jouer avec mes dés",
doubled: "L'ordinateur double : que se passe-t-il si je prends ?",
over: "Comment se termine une partie ?",
};
/** L'adresse de l'aide à ouvrir à ce moment (l'onglet « Jouer » sans
* question particulière : null). */
export function helpHash(moment: HelpMoment | null): string {
return routeHash({
screen: "help",
question: moment === null ? null : slug(HELP_QUESTIONS[moment]),
});
}
+54 -6
View File
@@ -14,10 +14,16 @@ export interface HelpFeature {
}
export interface HelpScenario {
/** Le titre, formulé comme la question du joueur. */
readonly title: string;
/** La réponse : le paragraphe sous le titre. */
readonly description: readonly string[];
/** Les étapes, mot-clé compris : « Quand vous prenez ». */
/** Les étapes, mot-clé compris : « Quand vous prenez ». Elles ne
* s'affichent pas dans l'aide : elles prouvent la réponse. */
readonly steps: readonly string[];
/** La position de l'exemple, donnée par l'étiquette
* @position:<Position ID> du scénario (null sans étiquette). */
readonly position: string | null;
}
const STEP = /^(Étant donnée?s?|Etant donnée?s?|Soit|Quand|Lorsque|Alors|Donc|Et|Mais)\s/;
@@ -27,13 +33,20 @@ const FEATURE = /^Fonctionnalité\s*:\s*(.*)$/;
/**
* Lit un fichier Gherkin en français (fichiers .feature). Les lignes de texte
* libre consécutives forment un paragraphe ; les commentaires (#) sont
* ignorés.
* ignorés ; une étiquette @position:<Position ID> donne la position de
* l'exemple du scénario qui la suit.
*/
export function parseFeature(text: string): HelpFeature {
let title = "";
const description: string[] = [];
const scenarios: { title: string; description: string[]; steps: string[] }[] = [];
const scenarios: {
title: string;
description: string[];
steps: string[];
position: string | null;
}[] = [];
let paragraph: string[] = [];
let tags: string[] = [];
// Le paragraphe en cours rejoint la description de la fonctionnalité ou
// du dernier scénario.
@@ -50,12 +63,23 @@ export function parseFeature(text: string): HelpFeature {
const scenario = SCENARIO.exec(line);
if (line === "" || line.startsWith("#")) {
endParagraph();
} else if (line.startsWith("@")) {
endParagraph();
tags.push(...line.split(/\s+/));
} else if (feature) {
endParagraph();
title = feature[1] ?? "";
tags = [];
} else if (scenario) {
endParagraph();
scenarios.push({ title: scenario[2] ?? "", description: [], steps: [] });
const position = tags.find((tag) => tag.startsWith("@position:"));
scenarios.push({
title: scenario[2] ?? "",
description: [],
steps: [],
position: position === undefined ? null : position.slice("@position:".length),
});
tags = [];
} else if (STEP.test(line) && scenarios.length > 0) {
endParagraph();
scenarios.at(-1)?.steps.push(line);
@@ -74,18 +98,42 @@ export function parseGlossaryIndex(readme: string): string[] {
return [...section.matchAll(/\]\(([a-z0-9-]+\.md)\)/g)].map((match) => match[1] ?? "");
}
/** Une fiche du glossaire : le terme et sa phrase « En bref ». */
/** Une fiche du glossaire : le terme, son nom anglais (celui du code), sa
* phrase « En bref », et ses renvois « Voir aussi ». */
export interface GlossaryEntry {
readonly term: string;
readonly english: string;
readonly brief: string;
/** Les fiches citées par « Voir aussi » : le mot, et la fiche sans .md. */
readonly seeAlso: readonly { readonly term: string; readonly name: string }[];
}
/** Lit une fiche du glossaire ; null si elle n'a pas la forme attendue. */
export function parseGlossaryEntry(text: string): GlossaryEntry | null {
const term = /^term: (.+)$/m.exec(text)?.[1]?.trim();
const english = /^english: (.+)$/m.exec(text)?.[1]?.trim() ?? "";
const brief = /^\*\*En bref\*\* : (.+)$/m.exec(text)?.[1]?.trim();
if (term === undefined || brief === undefined) {
return null;
}
return { term, brief: brief.replaceAll("**", "") };
const seeLine = /^Voir aussi : (.+)$/m.exec(text)?.[1] ?? "";
const seeAlso = [...seeLine.matchAll(/\[([^\]]+)\]\(([a-z0-9-]+)\.md\)/g)].map((m) => ({
term: m[1] ?? "",
name: m[2] ?? "",
}));
return { term, english, brief: brief.replaceAll("**", ""), seeAlso };
}
/** Un texte sans accents ni majuscules, pour la recherche dans le
* glossaire. */
export function searchable(text: string): string {
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
}
/** La fiche correspond-elle à la recherche (le terme, son nom anglais ou sa
* phrase « En bref » contiennent chacun des mots cherchés) ? */
export function matchesSearch(entry: GlossaryEntry, query: string): boolean {
const words = searchable(query).split(/\s+/).filter(Boolean);
const haystack = searchable(`${entry.term} ${entry.english} ${entry.brief}`);
return words.every((word) => haystack.includes(word));
}
+297 -51
View File
@@ -1,23 +1,47 @@
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
// (ADR 0008). Rien n'est recopié dans le code.
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
// dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
// est ouvert (show). Le titre d'un scénario est la question, sa
// description la réponse ; ses étapes, la preuve que l'application fait ce
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import { askAiLink } from "./ask-ai.js";
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { HOME_TEXTS } from "./home-texts.js";
import { type HelpRoute, routeHash, slug } from "./router.js";
import { TEXTS } from "./texts.js";
/** Les scénarios montrés dans l'aide, dans l'ordre. */
const FEATURES = [
export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/revoir-ma-partie.feature",
"docs/scenarios/demander-a-une-ia.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/suivre-une-lecon.feature",
"docs/scenarios/suivre-ma-progression.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
"docs/scenarios/installer-l-application.feature",
"docs/scenarios/decouvrir-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
@@ -29,10 +53,19 @@ async function fetchText(url: string): Promise<string> {
return response.text();
}
async function loadGlossary(): Promise<GlossaryEntry[]> {
/** Une fiche du glossaire et son nom de fichier, sans .md. */
interface GlossaryCard extends GlossaryEntry {
readonly name: string;
}
async function loadGlossary(): Promise<GlossaryCard[]> {
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
return texts.flatMap((text, i) => {
const entry = parseGlossaryEntry(text);
const name = (files[i] ?? "").replace(/\.md$/, "");
return entry === null ? [] : [{ ...entry, name }];
});
}
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
@@ -41,6 +74,18 @@ export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | n
return parseGlossaryEntry(await fetchText(GLOSSARY + file));
}
/** Le commit publié (version.json, écrit par make site), en abrégé. */
async function publishedVersion(): Promise<string | null> {
try {
const version = JSON.parse(await fetchText("version.json")) as { commit?: unknown };
return typeof version.commit === "string" && version.commit !== ""
? version.commit.slice(0, 12)
: null;
} catch {
return null;
}
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
@@ -54,56 +99,257 @@ function element<K extends keyof HTMLElementTagNameMap>(
return child;
}
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
element(parent, "h2", feature.title);
for (const paragraph of feature.description) {
element(parent, "p", paragraph);
}
for (const scenario of feature.scenarios) {
element(parent, "h3", scenario.title);
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
interface Question {
readonly id: string;
readonly section: HTMLElement;
readonly link: HTMLAnchorElement;
readonly answer: HTMLElement;
}
export interface HelpView {
/** Montre l'onglet, la question ou le terme que désigne l'adresse
* (l'aide est lue au premier appel). */
show(route: HelpRoute): Promise<void>;
}
/**
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
*/
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
const play = document.createElement("div");
play.id = "help-play";
const glossary = document.createElement("div");
glossary.id = "help-glossary";
const about = document.createElement("div");
about.id = "help-about";
const notice = document.createElement("p");
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<
string,
{ dt: HTMLElement; card: HTMLElement; entry: GlossaryCard; 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;
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
let id = slug(scenario.title);
for (let n = 2; questions.some((q) => q.id === id); n++) {
id = `${slug(scenario.title)}-${n}`;
}
const section = element(parent, "section");
section.className = "question";
section.id = `aide-${id}`;
const heading = element(section, "h3");
const link = element(heading, "a", scenario.title);
link.href = routeHash({ screen: "help", question: id });
link.setAttribute("aria-controls", `aide-${id}-reponse`);
link.setAttribute("aria-expanded", "false");
const answer = element(section, "div");
answer.id = `aide-${id}-reponse`;
answer.className = "answer";
answer.hidden = true;
for (const paragraph of scenario.description) {
element(parent, "p", paragraph);
element(answer, "p", paragraph);
}
const steps = element(parent, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
if (scenario.position !== null) {
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
board.className = "see-board";
board.href = routeHash({
screen: "position",
position: scenario.position,
match: null,
});
}
questions.push({ id, section, link, answer });
}
}
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
element(parent, "h2", TEXTS.glossaryTitle);
const list = element(parent, "dl");
list.className = "glossary";
for (const entry of entries) {
element(list, "dt", entry.term);
element(list, "dd", entry.brief);
}
}
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
* font rien). */
export async function showHelp(container: HTMLElement): Promise<void> {
if (container.dataset.loaded === "true") {
return;
}
container.replaceChildren();
const status = element(container, "p", TEXTS.helpLoading);
try {
const [features, glossary] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
status.remove();
for (const feature of features) {
renderFeature(container, feature);
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph).className = "feature-intro";
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
}
renderGlossary(container, glossary);
const about = element(container, "p", TEXTS.about);
about.className = "about";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const label = element(glossary, "label");
label.className = "glossary-search";
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
const search = element(label, "input");
search.id = "glossary-search";
search.type = "search";
search.autocomplete = "off";
search.spellcheck = false;
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
none.className = "glossary-none muted";
none.hidden = true;
const list = element(glossary, "dl");
list.className = "glossary";
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
for (const entry of sorted) {
const card = element(list, "div");
card.className = "term-card";
const dt = element(card, "dt");
dt.id = `glossaire-${entry.name}`;
const link = element(dt, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
if (entry.english !== "") {
const english = element(dt, "span", entry.english);
english.className = "english";
english.lang = "en";
}
const dd = element(card, "dd");
element(dd, "p", entry.brief);
if (entry.seeAlso.length > 0) {
const see = element(dd, "p");
see.className = "see-also";
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
entry.seeAlso.forEach((other, i) => {
if (i > 0) {
see.append(", ");
}
const a = element(see, "a", other.term);
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
// « 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;
for (const { card, entry } of terms.values()) {
const match = matchesSearch(entry, search.value);
card.hidden = !match;
shown += match ? 1 : 0;
}
none.hidden = shown > 0;
});
}
function renderAbout(version: string | null): void {
element(about, "h2", TEXTS.aboutTitle);
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
element(about, "p", paragraph).className = "about";
}
const line = element(
about,
"p",
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
);
line.id = "about-version";
line.className = "about";
}
function renderTutorialCard(): void {
const card = element(container, "a");
card.id = "help-tutorial";
card.className = "card help-tutorial";
card.href = "#/tutoriel/1";
const text = element(card, "span");
text.className = "help-tutorial-text";
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries, version] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
publishedVersion(),
]);
const intro = element(play, "p", HOME_TEXTS.helpIntro);
intro.className = "help-intro muted";
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout(version);
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
} catch {
notice.textContent = TEXTS.helpFailed;
loading = null; // on réessaiera à la prochaine ouverture
return false;
}
}
return {
async show(route) {
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
if (tab.dataset.screen === route.screen) {
tab.setAttribute("aria-current", "page");
} else {
tab.removeAttribute("aria-current");
}
}
loading ??= load();
if (!(await loading)) {
return;
}
play.hidden = route.screen !== "help";
glossary.hidden = route.screen !== "glossary";
about.hidden = route.screen !== "about";
const question = route.screen === "help" ? route.question : null;
for (const q of questions) {
const open = q.id === question;
q.answer.hidden = !open;
q.link.setAttribute("aria-expanded", String(open));
// Le lien d'une question ouverte la referme.
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, { dt, card, askAi }] of terms) {
askAi.hidden = name !== term;
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
card.hidden = false;
} else {
dt.removeAttribute("aria-current");
}
}
const missing =
(question !== null && !questions.some((q) => q.id === question)) ||
(term !== null && !terms.has(term));
notice.textContent = missing ? TEXTS.helpNotFound : "";
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
closed.section.scrollIntoView({ block: "nearest" });
} else {
window.scrollTo(0, 0);
}
},
};
}
+70
View File
@@ -0,0 +1,70 @@
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
/** Les deux choix du premier lancement : « Je découvre » mène au
* tutoriel, « Je connais les règles » à la partie. */
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
/** Les réglages après l'un des deux choix du premier lancement. */
export function welcomed(): Partial<Settings> {
return { welcomed: true };
}
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
* gardée (null). */
export function launchHash(hash: string): string | null {
return hash === "" || hash === "#" ? "#/accueil" : null;
}
/** L'accueil, au premier lancement ou ensuite. */
export interface HomeModel {
/** Premier lancement : le choix « Je découvre » / « Je connais les
* règles », et rien d'autre. */
readonly welcome: boolean;
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** La miniature du plateau et le compte de points : seulement pour une
* partie à reprendre. Sans partie en cours, la carte ne montre que son
* titre et « Nouvelle partie », jamais la position de la partie finie
* (recette 1h). */
readonly board: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
* encore connu, le moteur se charge). */
export function homeModel(
settings: Settings,
playing: boolean | null,
installed: boolean,
): HomeModel {
const welcome = isFirstLaunch(settings);
return {
welcome,
resume: playing !== false,
board: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
/** La ligne « Installer l'application » du menu : tant que l'application
* n'est pas installée. */
export function installMenuShown(settings: Settings, installed: boolean): boolean {
return !installed && !settings.installed;
}
/** Les réglages après « Plus tard » sur l'invitation à installer. */
export function dismissInstall(settings: Settings): Partial<Settings> {
return {
installDismissals: settings.installDismissals + 1,
installDismissedAt: settings.gamesFinished,
};
}
+130
View File
@@ -0,0 +1,130 @@
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { InstallPlatform } from "./install.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const HOME_TEXTS = {
// Premier lancement
welcomeTitle: "Bienvenue à la table",
welcomeText:
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
discover: "Je découvre",
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
knowRules: "Je connais les règles",
knowRulesDetail: "Une partie tout de suite",
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
// Accueil
homeLink: "Accueil",
homeTitle: "À vous de jouer",
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
gameOverline: "Partie en cours",
noGameOverline: "Pas de partie en cours",
againstComputer: "Contre l'ordinateur",
homePips: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
homeLoading: "Chargement…",
resumeGame: "Reprendre la partie",
rulesTitle: "Les règles du jeu",
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
rulesStart: "Commencer",
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
rulesAgain: "Revoir",
progress: (played: number, won: number) =>
played === 0
? "aucune partie jouée"
: `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
/** La ligne de progression, lien vers l'écran de progression (1h). */
progressLink: (line: string) => `Votre progression : ${line}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
// Installation
installTitle: "Installer Ustagammon",
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
install: "Installer",
installLater: "Plus tard",
installMenu: "Installer l'application",
installLead:
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
installSteps: {
firefox: [
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
],
ios: [
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
"Choisissez « Sur l'écran d'accueil ».",
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
],
} satisfies Record<InstallPlatform, readonly string[]>,
installUnderstood: "J'ai compris",
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
updateLatest: "Vous avez la dernière version.",
updateUnavailable:
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
// Tutoriel
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
tutorialEndTitle: "Vous connaissez les règles",
back: "Retour",
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel:
"Le sens du jeu : 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 jan intérieur, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
helpTutorialTitle: "Nouveau au backgammon ?",
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
helpTutorialLink: "Apprendre les règles",
helpIntro:
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
seeOnBoard: "Voir sur le plateau",
glossarySearch: "Chercher un mot",
glossaryNone: "Aucun mot ne correspond.",
glossarySeeAlso: "Voir aussi :",
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
aboutParagraphs: [
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
"Les textes du tutoriel, des leçons, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
],
} as const;
+312
View File
@@ -0,0 +1,312 @@
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
// installer, la prochaine leçon (1g), les règles et la progression résumée
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La ligne de progression résumée mène à l'écran de progression
// (#/progression, étape 1h).
import type { SideBoards } from "../domain/game/game.js";
import { boardModel, OPENING_BOARDS } from "./board-model.js";
import { NO_MARKS, renderBoard } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { LESSON_TEXTS } from "./lesson-texts.js";
import type { LessonCardModel } from "./lessons-model.js";
import { fillLessonCard, lessonAction } from "./lessons-view.js";
import { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
export interface HomeViewOptions {
readonly section: HTMLElement;
/** « Nouvelle partie » (bouton : une action, pas un écran). */
readonly newGame: () => void;
readonly install: () => void;
readonly dismissInstall: () => void;
/** Un des deux choix du premier lancement a été suivi. */
readonly welcomed: () => void;
}
export interface HomeState {
readonly model: HomeModel;
/** Le plateau de la partie en cours ou de la dernière partie (null :
* jamais joué). */
readonly sides: SideBoards | null;
/** La dernière étape du tutoriel vue. */
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Les leçons : la prochaine (null : toutes terminées), et combien sont
* terminées sur combien (null : pas encore lues). */
readonly lessons: {
readonly next: LessonCardModel | null;
readonly finished: number;
readonly total: number;
} | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
export interface HomeView {
render(state: HomeState): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("aria-hidden", "true");
parent.append(svg);
return svg;
}
function logo(parent: HTMLElement): void {
for (const [variant, file] of [
["logo-light", "logo-horizontal-couleur.svg"],
["logo-dark", "logo-horizontal-sombre.svg"],
] as const) {
const img = element(parent, "img", { className: variant });
img.src = `icons/${file}`;
img.alt = "Ustagammon";
}
}
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
...NO_MARKS,
dice: [],
cube: { value: 1, owner: null },
band: null,
controls: null,
});
}
/** Une grande carte-lien du premier lancement : le choix et son détail. */
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
const link = element(parent, "a", { id, className: "choice" });
link.href = href;
element(link, "strong", { text: label });
element(link, "span", { text: detail });
return link;
}
export function createHomeView(options: HomeViewOptions): HomeView {
const { section } = options;
section.replaceChildren();
// Premier lancement.
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
logo(welcomeLogo);
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
const welcomeText = element(welcome, "div", { className: "welcome-text" });
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
const choices = element(welcome, "div", { className: "welcome-choices" });
const discover = choice(
choices,
"welcome-discover",
WELCOME_LINKS.discover,
HOME_TEXTS.discover,
HOME_TEXTS.discoverDetail,
);
discover.classList.add("primary");
const play = choice(
choices,
"welcome-play",
WELCOME_LINKS.play,
HOME_TEXTS.knowRules,
HOME_TEXTS.knowRulesDetail,
);
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
for (const link of [discover, play]) {
link.addEventListener("click", () => options.welcomed());
}
// Ensuite.
const main = element(section, "div", { id: "home-main", className: "home-main" });
const intro = element(main, "div", { className: "home-intro" });
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
const card = element(main, "article", { id: "home-game", className: "card home-game" });
const cardTop = element(card, "div", { className: "home-game-top" });
const miniBox = element(cardTop, "div", { className: "home-mini" });
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
id: "home-resume",
className: "button",
text: HOME_TEXTS.resumeGame,
});
resume.href = "#/";
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
fresh.type = "button";
fresh.addEventListener("click", () => options.newGame());
const install = element(main, "aside", { id: "install-card", className: "install-card" });
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
const symbol = element(install, "img", { className: "install-symbol" });
symbol.src = "icons/symbole-couleur.svg";
symbol.alt = "";
const installText = element(install, "span", { className: "install-text" });
element(installText, "strong", { text: HOME_TEXTS.installTitle });
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
const installButton = element(install, "button", {
id: "install-card-button",
className: "secondary",
text: HOME_TEXTS.install,
});
installButton.type = "button";
installButton.addEventListener("click", () => options.install());
const later = element(install, "button", { id: "install-later", className: "icon-button" });
later.type = "button";
later.append(icon("close"));
later.setAttribute("aria-label", HOME_TEXTS.installLater);
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
// La prochaine leçon : une seule carte, « Continuer » en secondaire, et
// le lien vers toutes les leçons.
const lesson = element(main, "article", { id: "home-lesson", className: "card home-lesson" });
element(lesson, "span", { className: "overline", text: LESSON_TEXTS.homeOverline });
const lessonInfo = element(lesson, "div", { className: "home-lesson-info" });
const lessonActions = element(lesson, "div", { className: "home-lesson-actions" });
const lessonGo = element(lessonActions, "a", {
id: "home-lesson-go",
className: "button secondary",
});
const lessonAll = element(lessonActions, "a", {
id: "home-lessons",
className: "home-lessons-link",
text: LESSON_TEXTS.allLessons,
});
lessonAll.href = "#/lecons";
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
const rulesText = element(rules, "span", { className: "home-rules-text" });
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
const rulesAction = element(rules, "span", { className: "pill" });
// La ligne de progression mène à l'écran de progression (1h).
const progress = element(main, "a", {
id: "home-progress",
className: "caption home-progress",
});
progress.href = "#/progression";
const progressText = element(progress, "span", { className: "home-progress-text" });
progress.append(icon("chevron"));
const footer = element(main, "footer", { className: "home-footer" });
const footerSymbol = element(footer, "img");
footerSymbol.src = "icons/symbole-couleur.svg";
footerSymbol.alt = "";
element(footer, "span", { text: HOME_TEXTS.footer });
return {
render(state) {
const { model } = state;
welcome.hidden = !model.welcome;
main.hidden = model.welcome;
if (model.welcome) {
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
return;
}
const sides = state.sides ?? OPENING_BOARDS;
miniBox.hidden = !model.board;
pips.hidden = !model.board;
againstLine.hidden = !model.board;
if (model.board) {
drawBoard(mini, sides, miniBox.clientWidth || 132);
}
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
// Une seule action principale : « Reprendre la partie », ou
// « Nouvelle partie » quand il n'y a rien à reprendre.
resume.hidden = !model.resume;
fresh.textContent = TEXTS.newGame;
fresh.classList.toggle("secondary", model.resume);
fresh.disabled = state.busy;
install.hidden = !model.installCard;
const seen = state.tutorialSeen;
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
rulesAction.textContent =
seen === 0
? HOME_TEXTS.rulesStart
: seen >= TUTORIAL_DONE
? HOME_TEXTS.rulesAgain
: HOME_TEXTS.rulesContinue(seen);
// La carte de la leçon, une fois les leçons lues.
lesson.hidden = state.lessons === null;
if (state.lessons !== null) {
const card = state.lessons.next;
if (card === null) {
lessonInfo.replaceChildren();
element(lessonInfo, "strong", {
className: "lesson-card-title",
text: LESSON_TEXTS.homeAllDone,
});
element(lessonInfo, "span", {
className: "muted",
text: LESSON_TEXTS.homeAllDoneDetail,
});
lessonGo.hidden = true;
} else {
fillLessonCard(lessonInfo, card);
lessonGo.hidden = false;
lessonGo.textContent = lessonAction(card);
lessonGo.href = card.href;
lessonGo.setAttribute(
"aria-label",
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
);
}
}
const parts: string[] = [];
if (state.lessons !== null) {
parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total));
}
if (state.progress !== null) {
parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won));
}
const line = parts.join(" · ");
const shown = line.charAt(0).toUpperCase() + line.slice(1);
progressText.textContent = shown;
progress.hidden = parts.length === 0;
progress.setAttribute("aria-label", HOME_TEXTS.progressLink(line));
},
};
}
+5
View File
@@ -18,9 +18,14 @@ export const ICON_PATHS = {
chevron: "M9 6l6 6-6 6",
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
newGame: "M12 5v14M5 12h14",
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
screenOn: "M7 3h10v18H7zM11 18h2",
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
lessons: "M3 9l9-5 9 5-9 5zM7 11.2V16c0 1.6 2.2 3 5 3s5-1.4 5-3v-4.8M21 9v6",
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
verdictDoubtful:
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
+82
View File
@@ -0,0 +1,82 @@
// L'installation de l'application (docs/identite/charte/accueil-tutoriel-
// aide.md, « Installation ») : l'invite du système quand le navigateur en
// donne une (Chrome, événement beforeinstallprompt), sinon une marche à
// suivre en trois étapes (Firefox, iPhone). Plus rien une fois
// l'application installée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** La marche à suivre à montrer quand le navigateur n'a pas d'invite. */
export type InstallPlatform = "ios" | "firefox";
/** D'après le navigateur : l'iPhone (et l'iPad) a sa propre marche à
* suivre ; les autres suivent celle de Firefox (menu ⋮, « Installer »). */
export function installPlatform(userAgent: string): InstallPlatform {
return /iPhone|iPad|iPod/.test(userAgent) ? "ios" : "firefox";
}
/** L'événement beforeinstallprompt de Chrome (absent des types du DOM). */
interface InstallPromptEvent extends Event {
readonly prompt: () => Promise<void>;
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export interface Installer {
/** L'application tourne installée, ou vient de l'être. */
readonly installed: boolean;
/** Le navigateur a donné son invite d'installation. */
readonly canPrompt: boolean;
/** Ouvre l'invite du système ; « unavailable » s'il n'y en a pas. */
offer(): Promise<"accepted" | "dismissed" | "unavailable">;
}
/**
* Écoute le navigateur : garde l'invite reçue avec beforeinstallprompt (et
* empêche le navigateur de l'afficher de lui-même), et note l'installation
* (display-mode: standalone, événement appinstalled). onChange est appelé à
* chaque changement.
*/
export function startInstaller(onChange: (installedNow: boolean) => void): Installer {
const standalone = window.matchMedia("(display-mode: standalone)");
let installedNow = false;
let deferred: InstallPromptEvent | null = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferred = event as InstallPromptEvent;
onChange(false);
});
window.addEventListener("appinstalled", () => {
installedNow = true;
deferred = null;
onChange(true);
});
standalone.addEventListener("change", () => onChange(false));
return {
get installed() {
return installedNow || standalone.matches;
},
get canPrompt() {
return deferred !== null;
},
async offer() {
const event = deferred;
if (event === null) {
return "unavailable";
}
// Une invite ne sert qu'une fois.
deferred = null;
try {
await event.prompt();
const choice = await event.userChoice;
return choice.outcome;
} catch {
return "unavailable";
} finally {
onChange(false);
}
},
};
}
+43
View File
@@ -0,0 +1,43 @@
// Lecture du texte des leçons (docs/lecons/lecon-<n>.md, format décrit dans
// docs/lecons/README.md), copié tel quel dans le site par make site
// (ADR 0008 : une seule source). Même format que le tutoriel, avec un titre
// en tête. Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { LessonOutline } from "../domain/learning/lessons.js";
import { parseTutorial, type Tutorial } from "./tutorial-text.js";
/** Le nombre de leçons (fichiers lecon-1.md à lecon-<n>.md). */
export const LESSON_COUNT = 5;
/** Une leçon : son numéro, son titre, et ses écrans, comme le tutoriel. */
export interface Lesson extends Tutorial {
readonly number: number;
readonly title: string;
}
/** L'adresse, dans le site, du texte de la leçon number. */
export function lessonSource(number: number): string {
return `docs/lecons/lecon-${number}.md`;
}
/** Lit le texte d'une leçon. Erreur si le titre manque ou si les écrans
* sont mal formés (parseTutorial). */
export function parseLesson(number: number, text: string): Lesson {
const title = /^# (.+)$/m.exec(text)?.[1]?.trim();
if (title === undefined || title === "") {
throw new Error(`leçon ${number} : titre absent`);
}
return { ...parseTutorial(text), number, title };
}
/** Ce que la progression retient d'une leçon : les étapes de ses
* exercices. */
export function outlineOf(lesson: Lesson): LessonOutline {
return {
number: lesson.number,
exercises: lesson.steps.filter((s) => s.practice !== null).map((s) => s.number),
};
}
+59
View File
@@ -0,0 +1,59 @@
// Les textes de la liste des leçons, de leurs écrans et de la carte
// « prochaine leçon » de l'accueil (ADR 0007, point 5 ;
// docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »). Le texte des
// leçons elles-mêmes n'est pas ici : il vit dans docs/lecons/.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { LessonState } from "../domain/learning/lessons.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const LESSON_TEXTS = {
// Liste
menu: "Les leçons",
listTitle: "Les leçons",
listLead:
"Cinq leçons courtes, plateau à l'appui, avec des exercices où vous jouez le coup du maître. L'ordre est conseillé, jamais imposé.",
rulesReminder: "Les règles du jeu",
rulesReview: "revoir",
rulesReviewLabel: "Revoir les règles du jeu (le tutoriel)",
number: (n: number) => `Leçon ${n}`,
listLoading: "Chargement des leçons…",
listFailed: "Les leçons n'ont pas pu se charger.",
state: (state: LessonState) =>
state.kind === "done"
? "Terminée"
: state.kind === "toStart"
? `À commencer · ${plural(state.total, "exercice", "exercices")}`
: `${plural(state.solved, "exercice", "exercices")} sur ${state.total}`,
continue: "Continuer",
start: "Commencer",
review: "Revoir",
openLabel: (n: number, title: string) => `Leçon ${n} : ${title}`,
// Écrans d'une leçon
progress: "Étapes de la leçon",
stepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
quit: "Quitter",
quitLabel: "Quitter la leçon",
overline: (lesson: number, n: number, total: number, practice: boolean) =>
`Leçon ${lesson} · ${n} sur ${total}${practice ? " · à vous" : ""}`,
endOverline: (lesson: number) => `Leçon ${lesson} · fin`,
endTitle: (title: string) => title,
loading: "Chargement de la leçon…",
failed: "La leçon n'a pas pu se charger.",
// Accueil
homeOverline: "Prochaine leçon",
homeAllDone: "Toutes les leçons sont terminées",
homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.",
allLessons: "Toutes les leçons",
homeSummary: (finished: number, total: number) =>
finished === 0
? `Aucune leçon terminée sur ${total}`
: `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`,
} as const;
+66
View File
@@ -0,0 +1,66 @@
// Ce que montrent la liste des leçons et la carte « prochaine leçon » de
// l'accueil : fonctions pures, testées sous Node
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type LessonProgress,
type LessonState,
lessonRecord,
lessonState,
nextLesson,
resumeStep,
} from "../domain/learning/lessons.js";
import { type Lesson, outlineOf } from "./lesson-text.js";
import { routeHash } from "./router.js";
/** Une leçon dans la liste, ou sur l'accueil. */
export interface LessonCardModel {
readonly number: number;
readonly title: string;
readonly state: LessonState;
/** Les exercices, dans l'ordre : réussi ou non (barre de progression). */
readonly exercises: readonly boolean[];
/** Où mène la carte : là où reprendre la leçon. */
readonly href: string;
/** La leçon proposée ensuite : carte bordée de brique, seule action
* principale de l'écran. */
readonly current: boolean;
}
/** L'adresse où reprendre la leçon. */
export function resumeHref(progress: LessonProgress, lesson: Lesson): string {
const step = resumeStep(progress, outlineOf(lesson));
return routeHash({ screen: "lesson", lesson: lesson.number, step, practice: false });
}
/** Les cartes de la liste des leçons, dans l'ordre. */
export function lessonCards(
lessons: readonly Lesson[],
progress: LessonProgress,
): LessonCardModel[] {
const outlines = lessons.map(outlineOf);
const next = nextLesson(progress, outlines)?.number ?? null;
return lessons.map((lesson, i) => {
const outline = outlines[i] ?? outlineOf(lesson);
const solved = lessonRecord(progress, lesson.number).solved;
return {
number: lesson.number,
title: lesson.title,
state: lessonState(progress, outline),
exercises: outline.exercises.map((step) => solved.includes(step)),
href: resumeHref(progress, lesson),
current: lesson.number === next,
};
});
}
/** La carte « prochaine leçon » de l'accueil (null : toutes terminées). */
export function nextLessonCard(
lessons: readonly Lesson[],
progress: LessonProgress,
): LessonCardModel | null {
return lessonCards(lessons, progress).find((card) => card.current) ?? null;
}
+135
View File
@@ -0,0 +1,135 @@
// La liste des leçons (#/lecons) et la carte d'une leçon, reprise par la
// carte « prochaine leçon » de l'accueil
// (docs/identite/charte/ecrans-etape-1.md, « 1g. Leçons »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { LESSON_TEXTS } from "./lesson-texts.js";
import type { LessonCardModel } from "./lessons-model.js";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** La pastille ronde au numéro de la leçon : verte une fois terminée,
* brique pour la leçon proposée, au contour sinon. */
function badge(parent: HTMLElement, card: LessonCardModel): void {
const kind = card.state.kind === "done" ? "done" : card.current ? "current" : "todo";
const span = element(parent, "span", {
className: `lesson-badge ${kind}`,
text: String(card.number),
});
span.setAttribute("aria-hidden", "true");
}
/** La barre de progression : un segment par exercice, en brique une fois
* réussi. */
function progressBar(parent: HTMLElement, card: LessonCardModel): void {
const bar = element(parent, "span", { className: "lesson-progress" });
bar.setAttribute("aria-hidden", "true");
for (const done of card.exercises) {
element(bar, "span", { className: done ? "segment done" : "segment" });
}
}
/** Le contenu d'une carte de leçon : pastille, numéro, titre, état et
* progression. */
export function fillLessonCard(target: HTMLElement, card: LessonCardModel): void {
target.replaceChildren();
const top = element(target, "span", { className: "lesson-card-top" });
badge(top, card);
const body = element(top, "span", { className: "lesson-card-body" });
element(body, "span", { className: "overline", text: LESSON_TEXTS.number(card.number) });
element(body, "strong", { className: "lesson-card-title", text: card.title });
element(body, "span", {
className: `lesson-card-state ${card.state.kind}`,
text: LESSON_TEXTS.state(card.state),
});
if (card.state.kind !== "toStart" && card.state.total > 0) {
progressBar(body, card);
}
}
/** Le libellé de l'action d'une leçon. */
export function lessonAction(card: LessonCardModel): string {
return card.state.kind === "toStart"
? LESSON_TEXTS.start
: card.state.kind === "done"
? LESSON_TEXTS.review
: LESSON_TEXTS.continue;
}
export interface LessonsView {
/** Les cartes (null : les leçons se chargent ; erreur : échec). */
render(cards: readonly LessonCardModel[] | null, failed?: boolean): void;
}
/** Crée la liste des leçons dans section (vide dans index.html). */
export function createLessonsView(section: HTMLElement): LessonsView {
section.replaceChildren();
const head = element(section, "div", { className: "lessons-intro" });
element(head, "h2", { text: LESSON_TEXTS.listTitle });
element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead });
// Le rappel des règles : toute la ligne est le lien, haute de 44 px.
const review = element(section, "a", { id: "lessons-rules", className: "lessons-rules" });
review.append(`${LESSON_TEXTS.rulesReminder} · `);
element(review, "span", { text: LESSON_TEXTS.rulesReview });
review.href = "#/tutoriel/1";
review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel);
const status = element(section, "p", { id: "lessons-status", className: "muted" });
status.setAttribute("role", "status");
const list = element(section, "ol", { id: "lesson-list", className: "lesson-list" });
return {
render(cards, failed = false) {
status.textContent =
cards !== null ? "" : failed ? LESSON_TEXTS.listFailed : LESSON_TEXTS.listLoading;
list.replaceChildren();
for (const card of cards ?? []) {
const item = element(list, "li");
// La leçon proposée porte la seule action principale ; les
// autres cartes sont des liens, toutes restent ouvertes.
if (card.current) {
const article = element(item, "article", {
id: `lesson-card-${card.number}`,
className: "card lesson-card current",
});
const info = element(article, "div");
fillLessonCard(info, card);
const action = element(article, "a", {
className: "button lesson-card-action",
text: lessonAction(card),
});
action.href = card.href;
action.setAttribute(
"aria-label",
`${lessonAction(card)} : ${LESSON_TEXTS.openLabel(card.number, card.title)}`,
);
} else {
const link = element(item, "a", {
id: `lesson-card-${card.number}`,
className: "card lesson-card",
});
link.href = card.href;
fillLessonCard(link, card);
}
}
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les textes de l'écran « Nouvelle partie » et du match (ADR 0007, point 5 ;
// docs/identite/charte/ecrans-etape-1.md, chapitre 1c). Les noms et
// descriptions des niveaux sont ceux de la charte ; le reste est une
// première version, à valider par Pierre.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ComputerLevel } from "../domain/game/level.js";
function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
export const MATCH_TEXTS = {
// Écran « Nouvelle partie »
title: "Nouvelle partie",
close: "Fermer sans rien changer",
kindLabel: "Type de partie",
free: "Partie libre",
match: "Match",
/** « Le premier à [3] [5] [7] [11] points ». */
lengthBefore: "Le premier à",
lengthAfter: "points",
lengthLabel: (n: number) => `Match en ${n} points`,
crawfordRule:
"Quand un joueur arrive à un point de la victoire, la partie suivante se joue sans videau : c'est la",
crawfordLink: "règle de Crawford",
jacobyRule:
"Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un point : c'est la",
jacobyLink: "règle de Jacoby",
levelTitle: "Niveau de l'ordinateur",
levels: {
beginner: {
name: "Débutant",
text: "Joue simplement et laisse passer des occasions. Idéal pour vos premières parties.",
},
amateur: {
name: "Amateur",
text: "Un joueur qui connaît les règles et commet encore des erreurs.",
},
intermediate: {
name: "Intermédiaire",
text: "Un habitué de la table : solide, sans être implacable.",
},
advanced: {
name: "Avancé",
text: "Un bon joueur de club. Peu d'erreurs de pions, un videau sérieux.",
},
expert: { name: "Expert", text: "Très peu d'erreurs. Chaque point se gagne." },
champion: {
name: "Champion",
text: "GNU Backgammon à son meilleur niveau sur ce téléphone.",
},
} satisfies Record<ComputerLevel, { name: string; text: string }>,
duringGame: "Pendant la partie",
advice: "Conseil du maître",
adviceDetail: "Une ligne après chacun de vos coups",
startGame: "Commencer la partie",
startMatch: "Commencer le match",
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
/** « Match en 5 points », et « · partie Crawford ». */
matchLine: (length: number, crawford: boolean) =>
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** Ce que lit le lecteur d'écran. */
scoreSpoken: (length: number, computer: number, player: number) =>
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
/** Paysage : « sur 5 ». */
outOf: (length: number) => `sur ${length}`,
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// Fin d'une partie
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
nextGame: "Partie suivante",
nextGameStarted: "Partie suivante du match.",
matchWonTitle: (winner: "player" | "computer") =>
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
matchDetail: (
winner: "player" | "computer",
n: number,
length: number,
player: number,
computer: number,
over: boolean,
) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
over ? "Score final" : `Match en ${length} points`
} : vous ${player}, l'ordinateur ${computer}.`,
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
};
+109
View File
@@ -0,0 +1,109 @@
// Un petit plateau qui rejoue un coup pion par pion, sur la position
// d'avant le coup, puis laisse ses traces : la feuille d'explication du
// maître (1d), le coup à revoir du bilan et le visualiseur de l'analyse
// (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { MoveSteps } from "../domain/game/game.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un plateau SVG vide, dans son cadre. */
export function boardSvg(box: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
/**
* Dessine le plateau d'un coup après index demi-coups (0 : avant le
* coup), avec ses traces si traced. Les flèches des traces renvoient à leur
* pointe par identifiant : prefix les distingue de celles du plateau de jeu.
*/
export function drawSteps(
svg: SVGSVGElement,
steps: MoveSteps,
index: number,
traced: boolean,
prefix: string,
): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(boards), {
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
});
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `${prefix}${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", `url(#${prefix}`));
}
}
/** Un plateau qui joue des coups ; un coup demandé arrête le précédent. */
export interface MovePlayer {
/** Joue le coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
play(steps: MoveSteps): Promise<void>;
/** Arrête le coup en cours. */
stop(): void;
}
export function createMovePlayer(
svg: SVGSVGElement,
prefix: string,
reducedMotion: MediaQueryList,
): MovePlayer {
let playing = 0;
return {
async play(steps) {
const id = ++playing;
drawSteps(svg, steps, 0, false, prefix);
if (!reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
drawSteps(svg, steps, i, false, prefix);
const start = checkerSpot(svg, from);
const bar = steps.side === "player" ? "bar-computer" : "bar-player";
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
drawSteps(svg, steps, i + 1, false, prefix);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, bar, victim, SLIDE_MS),
]);
}
}
if (id === playing) {
drawSteps(svg, steps, steps.halfMoves.length, true, prefix);
}
},
stop() {
playing++;
},
};
}
+167
View File
@@ -0,0 +1,167 @@
// La navigation entre les écrans : l'adresse de la page est la source de
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
// dessine l'écran que désigne l'adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
// l'onglet garde, dans history.state, la suite des adresses visitées
// jusqu'à elle. Elle survit au rechargement de la page.
import { parseRoute, type Route, stepsBack } from "./router.js";
/** Au plus autant d'adresses gardées par entrée de l'historique. */
const TRAIL_MAX = 50;
export interface Navigation {
/** L'écran que désigne l'adresse actuelle. */
readonly route: Route;
/** Va à une adresse, comme un lien (empile une entrée). */
go(hash: string): void;
/** Remplace l'adresse actuelle, sans empiler. */
replace(hash: string): void;
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
* nouvel onglet), remplace l'adresse par fallback (celle de la partie,
* par défaut). */
leave(stay: (route: Route) => boolean, fallback?: string): void;
}
interface TrailState {
readonly ustagammonTrail: readonly string[];
}
function trailOf(state: unknown): readonly string[] | null {
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
return Array.isArray(trail) ? trail : null;
}
/** Le fragment actuel, « #/ » s'il est vide. */
function currentHash(): string {
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
}
/**
* Branche la navigation sur la page : onChange est appelé à chaque
* changement d'adresse (pas au démarrage : l'appelant lit route).
*/
export function startNavigation(onChange: (route: Route) => void): Navigation {
let trail: readonly string[] = [];
// Un retour dans l'historique est en cours (history.go est asynchrone) :
// une adresse demandée entre-temps attend son arrivée.
let leaving: ReturnType<typeof setTimeout> | null = null;
let queued: string | null = null;
const remember = (next: readonly string[]) => {
trail = next.slice(-TRAIL_MAX);
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
} catch {
// Historique indisponible (page dans un cadre) : la navigation
// marche quand même, « Retour » ramène alors à la partie.
}
};
// L'entrée actuelle : déjà connue (rechargement), ou la première.
const known = trailOf(history.state);
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
window.addEventListener("hashchange", () => {
const hash = currentHash();
const saved = trailOf(history.state);
if (saved !== null && saved.at(-1) === hash) {
// Retour ou avance dans l'historique : une entrée déjà connue.
trail = saved;
} else {
// Un lien suivi : une nouvelle entrée.
remember([...trail, hash]);
}
onChange(parseRoute(hash));
if (leaving !== null) {
clearTimeout(leaving);
leaving = null;
const next = queued;
queued = null;
if (next !== null) {
go(next);
}
}
});
function go(hash: string): void {
if (leaving !== null) {
queued = hash;
} else if (hash !== currentHash()) {
location.hash = hash;
}
}
function replace(hash: string): void {
if (hash === currentHash()) {
return;
}
const url = new URL(location.href);
url.hash = hash;
const next = [...trail.slice(0, -1), hash];
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
} catch {
location.replace(url);
return;
}
trail = next;
// replaceState ne déclenche pas hashchange.
onChange(parseRoute(hash));
}
return {
get route() {
return parseRoute(currentHash());
},
go,
replace,
leave(stay, fallback = "#/") {
if (leaving !== null) {
return; // déjà en route
}
const steps = stepsBack(trail, stay);
if (steps === null) {
replace(fallback);
return;
}
// Au cas où le navigateur ne reviendrait pas (historique
// indisponible), on n'attend pas plus d'une seconde.
leaving = setTimeout(() => {
leaving = null;
queued = null;
}, 1000);
history.go(steps);
},
};
}
/**
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
* du milieu ou une touche, il reste un lien ordinaire.
*/
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
link.addEventListener("click", (event) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
navigation.replace(new URL(link.href).hash);
});
}
+210
View File
@@ -0,0 +1,210 @@
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
// en cours ; seul « Commencer » en lance une.
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { MATCH_LENGTHS } from "../domain/game/match.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { routeHash } from "./router.js";
import type { Settings } from "./settings.js";
export interface NewGameViewOptions {
readonly section: HTMLElement;
readonly settings: () => Settings;
/** Un choix a changé : le garder. */
readonly change: (changes: Partial<Settings>) => void;
/** « Commencer la partie » ou « Commencer le match ». */
readonly start: () => void;
}
export interface NewGameView {
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
readonly closeLink: HTMLAnchorElement;
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
render(busy: boolean): void;
/** Le premier élément à recevoir le focus à l'ouverture. */
focus(): void;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
const button = element(parent, "button", {
id,
className,
...(text === undefined ? {} : { text }),
});
button.type = "button";
button.setAttribute("role", "radio");
return button;
}
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
const p = element(parent, "p", { id, className: "new-game-rule" });
p.append(`${text} `);
const a = element(p, "a", { text: link });
a.href = routeHash({ screen: "glossary", term });
p.append(".");
return p;
}
export function createNewGameView(options: NewGameViewOptions): NewGameView {
const { section } = options;
section.replaceChildren();
section.setAttribute("aria-labelledby", "new-game-title");
const head = element(section, "div", { className: "new-game-head" });
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
closeLink.href = "#/";
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
closeLink.append(icon("close"));
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(kindColumn, "div", { className: "segmented" });
kinds.setAttribute("role", "radiogroup");
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
const lengths = element(matchOptions, "div", { className: "lengths" });
lengths.setAttribute("role", "radiogroup");
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
const lengthButtons = MATCH_LENGTHS.map((n) => {
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
button.addEventListener("click", () => options.change({ matchLength: n }));
return button;
});
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
rule(
matchOptions,
"new-game-crawford",
MATCH_TEXTS.crawfordRule,
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
rule(
freeOptions,
"new-game-jacoby",
MATCH_TEXTS.jacobyRule,
MATCH_TEXTS.jacobyLink,
"regle-de-jacoby",
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "div", { className: "level-bars" });
bars.setAttribute("role", "radiogroup");
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
button.dataset.level = level;
element(button, "span", { className: "level-bar" });
button.addEventListener("click", () => options.change({ level }));
return button;
});
const levelName = element(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
advice.type = "button";
advice.setAttribute("role", "switch");
const adviceLabel = element(advice, "span", { className: "item-label" });
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
advice.addEventListener("click", () =>
options.change({ masterAdvice: !options.settings().masterAdvice }),
);
// 4. Barre d'action collée en bas.
const actions = element(section, "div", { className: "new-game-actions" });
const start = element(actions, "button", { id: "new-game-start" });
start.type = "button";
start.addEventListener("click", () => options.start());
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
return {
closeLink,
render(busy) {
const s = options.settings();
const isMatch = s.gameKind === "match";
free.setAttribute("aria-checked", String(!isMatch));
match.setAttribute("aria-checked", String(isMatch));
matchOptions.hidden = !isMatch;
freeOptions.hidden = isMatch;
for (const [i, button] of lengthButtons.entries()) {
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
}
const chosen = COMPUTER_LEVELS.indexOf(s.level);
for (const [i, button] of levelButtons.entries()) {
button.setAttribute("aria-checked", String(i === chosen));
button.classList.toggle("reached", i <= chosen);
}
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).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));
}
+50
View File
@@ -0,0 +1,50 @@
// Les textes de l'écran de progression (étape 1h ; ADR 0007, point 5). La
// phrase de synthèse, elle, est une phrase du maître : elle vit dans
// docs/maitre/phrases.md (sections progression.…, une seule source,
// ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameResult } from "../domain/game/state.js";
const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`;
const RESULT_KIND = {
single: "",
gammon: "gammon",
backgammon: "backgammon",
} as const;
export const PROGRESSION_TEXTS = {
title: "Votre progression",
loading: "Lecture de vos parties…",
failed: "Vos parties n'ont pas pu être lues.",
/** Aucune partie terminée. */
empty: "Aucune partie terminée pour l'instant : votre progression commencera avec la première.",
newGame: "Nouvelle partie",
curveTitle: "Votre taux d'erreur · plus bas, c'est mieux",
curveLabel: (rates: readonly number[]) =>
`Votre taux d'erreur, partie après partie, de la plus ancienne à la plus récente : ${rates.join(", ")}.`,
whatIsIt: "Qu'est-ce que le taux d'erreur ?",
analysing: (n: number) =>
`Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`,
exercises: "Demander des exercices à une IA",
listTitle: "Vos parties",
/** Le résultat court : « Gagnée, 1 point », « Perdue, gammon, 2 points »
* (jamais « partie simple »). */
result: (result: GameResult | null) => {
if (result === null) {
return "Partie terminée";
}
const who = result.winner === "player" ? "Gagnée" : "Perdue";
const kind = result.kind === "single" ? "" : `${RESULT_KIND[result.kind]}, `;
return `${who}, ${kind}${plural(result.points, "point", "points")}`;
},
level: (name: string) => `contre ${name}`,
rateLabel: "taux d'erreur",
rateLoading: "en analyse",
openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`,
/** La date d'une partie : « 3 nov. 2026 ». */
date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }),
} as const;
+307
View File
@@ -0,0 +1,307 @@
// L'écran de progression (#/progression, étape 1h ; charte,
// ecrans-etape-1.md, « 1h ») : une phrase du maître, la courbe du taux
// d'erreur partie après partie (SVG fait main), « Créer des exercices », et
// la liste des parties terminées, chacune menant à son bilan et à son
// analyse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// La vue ne connaît ni le stockage ni le moteur : l'écran de jeu (app.ts)
// lui passe les parties et leur taux, lus dans les analyses rangées
// (domain/analysis/progression.ts), et la redessine quand une analyse en
// arrière-plan se termine.
import type { MasterPhrases } from "../domain/analysis/phrases.js";
import { progressionSentence } from "../domain/analysis/phrases.js";
import {
CURVE_MIN_GAMES,
errorRateMillis,
type ProgressionEntry,
progressionCurve,
progressionTrend,
} from "../domain/analysis/progression.js";
import { computerLevelOf } from "../domain/game/level.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { PROGRESSION_TEXTS } from "./progression-texts.js";
import { routeHash } from "./router.js";
/** Ce que montre l'écran. */
export interface ProgressionData {
/** Les parties terminées, de la plus ancienne à la plus récente. */
readonly entries: readonly ProgressionEntry[];
/** Les phrases du maître (null : pas encore lues). */
readonly phrases: MasterPhrases | null;
}
export interface ProgressionViewOptions {
readonly section: HTMLElement;
}
export interface ProgressionView {
/** Dessine l'écran (null : les parties se lisent ; failed : échec). */
render(data: ProgressionData | null, failed?: boolean): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus
* des points, le dernier point plus gros). */
const CURVE_HEIGHT = 168;
const PAD = { left: 12, right: 12, top: 30, bottom: 12 };
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function svgElement<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attributes: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const child = document.createElementNS(SVG_NS, tag);
for (const [name, value] of Object.entries(attributes)) {
child.setAttribute(name, String(value));
}
parent.append(child);
return child;
}
/** Le nom du niveau de l'ordinateur d'une partie. */
function levelName(entry: ProgressionEntry): string {
const level = computerLevelOf(entry.level);
return level === null ? entry.level : MATCH_TEXTS.levels[level].name;
}
export function createProgressionView(options: ProgressionViewOptions): ProgressionView {
const { section } = options;
section.replaceChildren();
// Pas de « Retour » (charte 1h) : la barre du haut et le retour du
// téléphone suffisent, comme sur la liste des leçons.
element(section, "h1", {
id: "progression-title",
className: "progression-title",
text: PROGRESSION_TEXTS.title,
});
const summary = element(section, "p", {
id: "progression-summary",
className: "progression-summary",
});
summary.setAttribute("role", "status");
const body = element(section, "div", { className: "progression-body" });
// La courbe se redessine à la largeur de la page (rotation).
let drawnCurve: (() => void) | null = null;
window.addEventListener("resize", () => {
if (!section.hidden) {
drawnCurve?.();
}
});
// Ce qui est dessiné : chaque analyse rangée en arrière-plan redemande
// l'écran, qui ne change que quand le taux d'une partie arrive.
let drawn = "";
function render(data: ProgressionData | null, failed = false): void {
const key = JSON.stringify(
data === null
? { failed }
: {
phrases: data.phrases !== null,
entries: data.entries.map((e) => [
e.id,
e.result,
e.level,
e.errorRate?.perDecision ?? null,
]),
},
);
if (key === drawn) {
return;
}
drawn = key;
body.replaceChildren();
drawnCurve = null;
delete section.dataset.ready;
if (data === null) {
summary.textContent = failed ? PROGRESSION_TEXTS.failed : PROGRESSION_TEXTS.loading;
return;
}
if (data.entries.length === 0) {
summary.textContent = PROGRESSION_TEXTS.empty;
const actions = element(body, "div", {
className: "progression-actions progression-empty",
});
const start = element(actions, "a", {
id: "progression-new-game",
className: "button",
text: PROGRESSION_TEXTS.newGame,
});
start.href = routeHash({ screen: "newGame" });
section.dataset.ready = "true";
return;
}
const points = progressionCurve(data.entries);
const trend = progressionTrend(points);
summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend);
if (points.length >= CURVE_MIN_GAMES) {
const card = element(body, "section", {
id: "progression-curve-card",
className: "analysis-card",
});
element(card, "h3", { className: "overline", text: PROGRESSION_TEXTS.curveTitle });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = "progression-curve";
svg.setAttribute("class", "progression-curve");
svg.setAttribute("role", "img");
svg.setAttribute(
"aria-label",
PROGRESSION_TEXTS.curveLabel(points.map((p) => errorRateMillis(p.perDecision))),
);
card.append(svg);
const link = element(card, "a", {
id: "progression-glossary",
className: "link-button",
text: PROGRESSION_TEXTS.whatIsIt,
});
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
// Le vert dit « mieux », jamais « dernier » : seulement si le
// taux baisse.
const better = trend.kind === "down";
drawnCurve = () => drawCurve(svg, points, better);
drawnCurve();
}
const waiting = data.entries.filter((e) => e.errorRate === null).length;
if (waiting > 0) {
element(body, "p", {
id: "progression-analysing",
className: "muted",
text: PROGRESSION_TEXTS.analysing(waiting),
}).setAttribute("role", "status");
}
// « Demander des exercices à une IA » : la feuille du prompt, étape
// 1f ; bouton secondaire avec l'icône de copie.
if (points.length > 0) {
const actions = element(body, "div", { className: "progression-actions" });
const exercises = element(actions, "a", {
id: "progression-exercises",
className: "button secondary ask-ai",
});
exercises.href = routeHash({ screen: "progression", ai: true });
exercises.append(icon("copy"), PROGRESSION_TEXTS.exercises);
}
element(body, "h3", { className: "overline", text: PROGRESSION_TEXTS.listTitle });
const list = element(body, "ol", { id: "progression-list", className: "progression-list" });
// La plus récente d'abord, comme « Mes parties ».
for (const entry of [...data.entries].reverse()) {
list.append(item(entry));
}
section.dataset.ready = String(waiting === 0);
}
function item(entry: ProgressionEntry): HTMLElement {
const li = document.createElement("li");
li.className = "progression-item";
li.dataset.id = entry.id;
const date = PROGRESSION_TEXTS.date(entry.startedAt);
const result = PROGRESSION_TEXTS.result(entry.result);
// Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis
// lui), chevron à droite.
const game = element(li, "a", { className: "progression-game" });
game.href = routeHash({ screen: "summary", game: entry.id });
game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date));
const text = element(game, "span", { className: "progression-text" });
element(text, "strong", { text: result });
element(text, "span", {
className: "caption",
text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`,
});
const rate = element(game, "span", { className: "progression-rate" });
if (entry.errorRate === null) {
rate.classList.add("pending");
rate.textContent = PROGRESSION_TEXTS.rateLoading;
} else {
rate.dataset.rate = String(entry.errorRate.perDecision);
element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) });
element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel });
}
game.append(icon("chevron"));
return li;
}
return { render };
}
/** La courbe : un point par partie analysée, de gauche (la plus ancienne)
* à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le
* dernier point est plus gros, à l'encre du texte, en verdict-good si le
* taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du
* premier et du dernier point seulement (charte 1h). */
function drawCurve(
svg: SVGSVGElement,
points: readonly { readonly id: string; readonly perDecision: number }[],
better: boolean,
): void {
const width = Math.round(svg.getBoundingClientRect().width || 360);
const height = CURVE_HEIGHT;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.replaceChildren();
const millis = points.map((p) => errorRateMillis(p.perDecision));
const top = Math.max(1, ...millis);
const inner = width - PAD.left - PAD.right;
const step = points.length > 1 ? inner / (points.length - 1) : 0;
const x = (i: number) => PAD.left + i * step;
const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom);
svgElement(svg, "line", {
x1: PAD.left,
x2: width - PAD.right,
y1: y(0),
y2: y(0),
class: "curve-axis",
});
const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" ");
svgElement(svg, "polyline", { points: line, class: "curve-line" });
const lastIndex = points.length - 1;
points.forEach((point, i) => {
const last = i === lastIndex;
const cy = y(point.perDecision * 1000);
const dot = svgElement(svg, "circle", {
cx: x(i),
cy,
r: last ? 7 : 4,
class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot",
"data-id": point.id,
"data-rate": String(point.perDecision),
});
dot.setAttribute("aria-hidden", "true");
if (i === 0 || last) {
const value = svgElement(svg, "text", {
x: x(i),
y: cy - (last ? 14 : 11),
class: last
? `progression-value last${better ? " better" : ""}`
: "progression-value",
"text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle",
});
value.textContent = String(millis[i]);
}
});
}
+150
View File
@@ -0,0 +1,150 @@
// 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,
exercisesPrompt,
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>;
/** La position de la plus coûteuse des erreurs de ces parties (null :
* aucune imprécision ni erreur). */
readonly exercises: (journals: readonly GameJournal[]) => Promise<PromptPosition | null>;
}
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>;
/** Le prompt « Créer des exercices » (progression, étape 1h), sur les
* parties analysées, de la plus ancienne à la plus récente. */
exercises(journals: readonly GameJournal[]): 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 exercises(journals) {
const [all, phrases, position] = await Promise.all([
templates(),
options.phrases(),
options.access.exercises(journals),
]);
if (position === null) {
return { message: ASK_AI_TEXTS.noMistake };
}
return { text: exercisesPrompt(all, phrases, { journals, 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,
}),
};
},
};
}
+87
View File
@@ -0,0 +1,87 @@
// L'analyse d'une partie en arrière-plan (étape 1e) : les analyses qui
// manquent au bilan et à l'écran d'analyse sont demandées une à une au
// second moteur, rangées au fur et à mesure, et l'ensemble s'arrête dès
// qu'on n'en a plus besoin (une autre partie, une nouvelle partie). Sans
// DOM : testé sous Node (app/tests/review-runner.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Une analyse déjà partie vers le moteur ne s'interrompt pas : l'arrêt
// prend effet entre deux analyses, et le résultat d'une analyse arrêtée
// n'est pas rangé.
import { type AnalysisTask, missingAnalyses } from "../domain/analysis/game-analysis.js";
import type { GameJournal } from "../domain/game/events.js";
/** Fait une analyse et renvoie le journal qui la contient. */
export type RunAnalysis = (journal: GameJournal, task: AnalysisTask) => Promise<GameJournal>;
export interface ReviewRunner {
/** La partie en cours d'analyse (null : aucune). */
readonly running: string | null;
/**
* Analyse ce qui manque au journal de la partie id ; record reçoit le
* journal après chaque analyse rangée, done une fois tout analysé.
* Une analyse en cours d'une autre partie est arrêtée ; la même partie
* continue sans repartir.
*/
start(
id: string,
journal: GameJournal,
record: (journal: GameJournal) => void,
done?: () => void,
): void;
/** Arrête l'analyse en cours. */
stop(): void;
}
export function createReviewRunner(run: RunAnalysis): ReviewRunner {
// Chaque analyse lancée a son jeton : stop() ou une autre partie
// l'annulent.
let token: object | null = null;
let running: string | null = null;
return {
get running() {
return running;
},
start(id, journal, record, done) {
if (running === id && token !== null) {
return;
}
const mine = {};
token = mine;
running = id;
void (async () => {
let current = journal;
try {
for (;;) {
const task = missingAnalyses(current)[0];
if (task === undefined) {
break;
}
const next = await run(current, task);
if (token !== mine) {
return;
}
current = next;
record(current);
}
} catch {
// Le moteur a échoué : l'analyse s'arrête là, ce qui est
// rangé reste, et la prochaine demande reprendra.
}
if (token === mine) {
token = null;
running = null;
done?.();
}
})();
},
stop() {
token = null;
running = null;
},
};
}
+95
View File
@@ -0,0 +1,95 @@
// Les textes de l'interface du bilan et de l'écran d'analyse (étape 1e).
// Les phrases du maître elles-mêmes (lecture du bilan, dés en mots) sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
import type { Roll } from "../domain/game/roll.js";
const plural = (n: number, one: string, many: string) => (n > 1 ? many : one);
/** « 41 % » et « +0,021 » : la même écriture que les prompts
* (domain/analysis/numbers.ts). */
export {
formatEquity as signed,
formatPercent as percent,
} from "../domain/analysis/numbers.js";
export const REVIEW_TEXTS = {
// Bilan
summaryLabel: "Bilan de la partie",
analysing: "Le maître analyse la partie…",
progress: (done: number, total: number) => `Analyse : ${done} sur ${total}`,
notOver: "Cette partie n'est pas finie : son bilan viendra à la fin.",
missingGame: "Cette partie n'est plus sur ce téléphone.",
counts: {
good: (n: number) => plural(n, "Bon coup", "Bons coups"),
doubtful: (n: number) => plural(n, "Imprécision", "Imprécisions"),
bad: (n: number) => plural(n, "Erreur", "Erreurs"),
},
countsLabel: "Vos décisions, par appréciation",
yourPlay: "Votre jeu",
theDice: "Les dés",
playGauge: (level: string) => `Votre jeu : niveau ${level}`,
diceGauge: (words: string) => `Les dés : ${words}`,
howToRead: "Comment lire ces mesures",
toReview: "Le coup à revoir",
yourMove: (notation: string) => `Votre coup : ${notation}`,
yourDecision: (action: string) => `Votre décision : ${action}`,
reviewMove: "Revoir ce coup",
seeAnalysis: "Voir l'analyse",
newGame: "Nouvelle partie",
close: "Fermer",
cubeActions: {
double: "doubler",
noDouble: "lancer sans doubler",
take: "prendre",
pass: "passer",
} satisfies Record<CubeAction, string>,
toReviewBoard: "La position au moment de la décision à revoir.",
// Analyse
analysisTitle: "Analyse de la partie",
back: "Retour",
curveTitle: "Vos chances de gagner",
curveLabel: (turn: number, total: number, chance: string) =>
`Courbe de vos chances de gagner : coup ${turn} sur ${total}, ${chance}. Flèches gauche et droite pour changer de coup.`,
curveUnknown: "pas encore calculé",
turnOf: (turn: number, total: number) => `Coup ${turn} sur ${total}`,
roll: (roll: Roll) => `${roll[0]}-${roll[1]}`,
previous: "Coup précédent",
next: "Coup suivant",
who: { player: "Vous", computer: "L'ordinateur" },
computerPlayed: (notation: string) => `L'ordinateur a joué ${notation}.`,
computerCannotMove: "L'ordinateur ne pouvait pas jouer.",
playerCannotMove: "Vous ne pouviez pas jouer.",
computerCube: "L'ordinateur a doublé.",
chanceAfter: (chance: string) => `Vos chances de gagner après ce coup : ${chance}`,
theirMove: "Son coup",
masterMove: "Le coup du maître",
yourMoveSegment: "Votre coup",
choiceLabel: "Coup montré sur le plateau",
legendMove: (who: string, notation: string) =>
`${who} : ${notation}, joué pion par pion sur la position d'avant le coup.`,
legendPosition: "La position avant la décision.",
replayFromHere: "Rejouer d'ici",
replayHint: "Une nouvelle branche : la partie d'origine reste intacte.",
listTitle: "Les coups à revoir",
filters: { all: "Tout", errors: "Erreurs", doubtful: "Imprécisions" },
filterLabel: "Filtrer les coups",
emptyFilter: "Aucun coup de ce genre dans la partie.",
rowLabel: (turn: number, roll: string, move: string, word: string) =>
`Coup ${turn}, ${roll}, ${move} : ${word}`,
numbers: "Voir les chiffres du moteur",
numbersRows: {
errorRate: "Erreur par décision",
decisions: "Décisions comptées",
luck: "Chance par tour",
level: "Niveau",
},
numbersHead: { you: "Vous", computer: "L'ordinateur" },
numbersNote:
"Calculés comme GNU Backgammon : équité perdue par décision non forcée, chance des dés par tour, à 2 plis.",
} as const;
+374
View File
@@ -0,0 +1,374 @@
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
// testées sous Node (app/tests/router.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un écran de l'application, tel que le désigne son adresse. */
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil. */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md> ; 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 » :
* #/tutoriel/<n>/a-vous. */
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/lecons : la liste des leçons (étape 1g). */
| { readonly screen: "lessons" }
/** #/lecons/<n> (son premier écran), une étape de la leçon n :
* #/lecons/<n>/<étape>, et son exercice « à vous » :
* #/lecons/<n>/<étape>/a-vous. */
| {
readonly screen: "lesson";
readonly lesson: number;
readonly step: number | null;
readonly practice: boolean;
}
/** #/lecons/<n>/fin : la fin de la leçon n. */
| { readonly screen: "lessonEnd"; readonly lesson: number }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/parties/<identifiant>/bilan : le bilan de fin de partie (1e) ;
* son prompt pour une IA : #/parties/<identifiant>/bilan/ia (1f). */
| { readonly screen: "summary"; readonly game: string; readonly ai?: boolean }
/** #/parties/<identifiant>/analyse/<coup> : l'écran d'analyse, placé
* sur un coup (un tour de la partie, à partir de 1) ; sans coup :
* #/parties/<identifiant>/analyse ; le prompt du coup pour une IA :
* #/parties/<identifiant>/analyse/<coup>/ia (1f). */
| {
readonly screen: "analysis";
readonly game: string;
readonly turn: number | null;
readonly ai?: boolean;
}
/** #/progression : l'historique des parties et la courbe du taux
* d'erreur (étape 1h) ; le prompt « Créer des exercices » :
* #/progression/ia. */
| { readonly screen: "progression"; readonly ai?: boolean }
/** #/menu. */
| { readonly screen: "menu" }
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
| { readonly screen: "newGame" }
/** #/explication/<événement> : la feuille d'explication du maître sur
* une décision de la partie en cours (étape 1d) ; son prompt pour une
* IA : #/explication/<événement>/ia (1f). */
| { readonly screen: "explanation"; readonly event: string; readonly ai?: boolean }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
| { readonly screen: "unknown"; readonly hash: string };
export type Screen = Route["screen"];
export const GAME_ROUTE: Route = { screen: "game" };
/** Longueur d'un Position ID de gnubg. */
const POSITION_ID_LENGTH = 14;
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
* chiffres et tirets. */
const NAME = /^[a-z0-9][a-z0-9-]*$/;
/** L'écran que désigne un fragment (avec ou sans # au début). */
export function parseRoute(hash: string): Route {
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
if (fragment === "" || fragment === "/") {
return GAME_ROUTE;
}
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
if (!fragment.startsWith("/")) {
return unknown;
}
const [head = "", ...rest] = fragment.slice(1).split("/");
// Une barre finale (#/aide/) ne change rien.
if (rest.at(-1) === "") {
rest.pop();
}
// La feuille du prompt pour une IA (étape 1f) : « /ia » au bout de
// l'adresse de l'écran d'où on la demande.
if (rest.length > 1 && rest.at(-1) === "ia") {
return aiRoute(parseRoute(`#/${[head, ...rest.slice(0, -1)].join("/")}`)) ?? unknown;
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (head === "lecons") {
return lessonRoute(rest) ?? unknown;
}
if (head === "parties" && rest.length > 1) {
return gameReviewRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
const name = rest[0] ?? null;
if (name !== null && !NAME.test(name)) {
return unknown;
}
switch (head) {
case "":
return name === null ? GAME_ROUTE : unknown;
case "accueil":
return name === null ? { screen: "home" } : unknown;
case "aide":
return { screen: "help", question: name };
case "glossaire":
return { screen: "glossary", term: name };
case "a-propos":
return name === null ? { screen: "about" } : unknown;
case "menu":
return name === null ? { screen: "menu" } : unknown;
case "progression":
return name === null
? { screen: "progression" }
: name === "ia"
? { screen: "progression", ai: true }
: unknown;
case "nouvelle-partie":
return name === null ? { screen: "newGame" } : unknown;
case "explication":
return name === null ? unknown : { screen: "explanation", event: name };
case "parties":
return { screen: "games", game: name };
case "installer":
return name === null ? { screen: "install" } : unknown;
default:
return unknown;
}
}
/** Les écrans qui ont une feuille de prompt pour une IA. */
export type AiRoute = Extract<
Route,
{ screen: "explanation" | "summary" | "analysis" | "glossary" | "progression" }
>;
/** 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":
case "progression":
return route.ai === true ? null : { ...route, ai: true };
case "glossary":
return route.term === null || route.ai === true ? null : { ...route, ai: true };
default:
return null;
}
}
/** La feuille du prompt pour une IA est-elle ouverte ? */
export function isAiRoute(route: Route): route is AiRoute {
return "ai" in route && route.ai === true;
}
/** L'écran sous la feuille du prompt. */
export function withoutAi(route: AiRoute): AiRoute {
const { ai: _, ...below } = route;
return below;
}
/** #/parties/<id>/bilan, #/parties/<id>/analyse, #/parties/<id>/analyse/<n>. */
function gameReviewRoute(rest: readonly string[]): Route | null {
const [game = "", part, turn, ...more] = rest;
if (!NAME.test(game) || more.length > 0) {
return null;
}
if (part === "bilan") {
return turn === undefined ? { screen: "summary", game } : null;
}
if (part !== "analyse") {
return null;
}
if (turn === undefined) {
return { screen: "analysis", game, turn: null };
}
return /^[1-9][0-9]{0,3}$/.test(turn) ? { screen: "analysis", game, turn: Number(turn) } : null;
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
if (name === undefined) {
return { screen: "tutorial", step: null, practice: false };
}
if (more.length > 0) {
return null;
}
if (name === "fin") {
return part === undefined ? { screen: "tutorialEnd" } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/** #/lecons, #/lecons/<n>, #/lecons/<n>/<étape>, #/lecons/<n>/<étape>/a-vous,
* #/lecons/<n>/fin. */
function lessonRoute(rest: readonly string[]): Route | null {
const [number, name, part, ...more] = rest;
if (number === undefined) {
return { screen: "lessons" };
}
if (!/^[1-9]$/.test(number) || more.length > 0) {
return null;
}
const lesson = Number(number);
if (name === undefined) {
return { screen: "lesson", lesson, step: null, practice: false };
}
if (name === "fin") {
return part === undefined ? { screen: "lessonEnd", lesson } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "lesson", lesson, step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
* comprise aussi : le texte est décodé en entier, puis découpé à la
* longueur du Position ID. Leur validité est l'affaire du moteur.
*/
function positionRoute(text: string): Route | null {
let decoded: string;
try {
decoded = decodeURIComponent(text);
} catch {
return null;
}
if (decoded === "") {
return null;
}
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
const match = decoded.slice(POSITION_ID_LENGTH + 1);
return {
screen: "position",
position: decoded.slice(0, POSITION_ID_LENGTH),
match: match === "" ? null : match,
};
}
return { screen: "position", position: decoded, match: null };
}
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
const hash = screenHash(route);
return isAiRoute(route) ? `${hash}/ia` : hash;
}
function screenHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
case "home":
return "#/accueil";
case "help":
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
case "glossary":
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "lessons":
return "#/lecons";
case "lesson":
return route.step === null
? `#/lecons/${route.lesson}`
: `#/lecons/${route.lesson}/${route.step}${route.practice ? "/a-vous" : ""}`;
case "lessonEnd":
return `#/lecons/${route.lesson}/fin`;
case "install":
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "summary":
return `#/parties/${route.game}/bilan`;
case "analysis":
return route.turn === null
? `#/parties/${route.game}/analyse`
: `#/parties/${route.game}/analyse/${route.turn}`;
case "progression":
return "#/progression";
case "menu":
return "#/menu";
case "newGame":
return "#/nouvelle-partie";
case "explanation":
return `#/explication/${route.event}`;
case "position": {
const position = `#/position/${encodeURIComponent(route.position)}`;
return route.match === null
? position
: `${position}/${encodeURIComponent(route.match)}`;
}
case "unknown":
return route.hash;
}
}
/** Les écrans de l'aide : on passe de l'un à l'autre par ses onglets. */
export type HelpRoute = Extract<Route, { screen: "help" | "glossary" | "about" }>;
/** L'adresse est-elle celle d'un écran de l'aide ? */
export function isHelpScreen(route: Route): route is HelpRoute {
return route.screen === "help" || route.screen === "glossary" || route.screen === "about";
}
/** L'identifiant d'une question de l'aide : son titre, en minuscules sans
* accents, les mots séparés par des tirets. */
export function slug(title: string): string {
return title
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Où revenir en quittant un écran (« Retour », Échap, la croix d'une
* feuille) : la dernière adresse de l'historique de l'onglet qui n'est pas
* de cet écran. trail est la suite des adresses de l'onglet jusqu'à
* l'actuelle (comprise). Renvoie le nombre de pas à reculer (négatif), ou
* null si aucune adresse ne convient : il faut alors remplacer l'adresse
* par celle de la partie, sans rien empiler.
*/
export function stepsBack(
trail: readonly string[],
stay: (route: Route) => boolean,
): number | null {
for (let i = trail.length - 2; i >= 0; i--) {
const hash = trail[i];
if (hash !== undefined && !stay(parseRoute(hash))) {
return i - (trail.length - 1);
}
}
return null;
}
+124 -5
View File
@@ -3,6 +3,14 @@
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Format durable (ADR 0007, amendement du 2026-11-03) : un champ ajouté a
// toujours une valeur par défaut, et un réglage enregistré par une version
// précédente se relit sans perte.
import type { NewGame } from "../domain/game/game.js";
import { COMPUTER_LEVELS, type ComputerLevel, ENGINE_LEVELS } from "../domain/game/level.js";
import { firstGameOf, MATCH_LENGTHS } from "../domain/game/match.js";
export type Theme = "auto" | "light" | "dark";
@@ -15,18 +23,60 @@ export interface Settings {
/** Parties commencées : la consigne est longue pendant les trois
* premières, courte ensuite. */
readonly gamesStarted: number;
/** Parties terminées (l'invitation à installer vient après la
* première). */
readonly gamesFinished: number;
/** Le choix du premier lancement (« Je découvre », « Je connais les
* règles ») a été fait : il ne revient plus. */
readonly welcomed: boolean;
/** La dernière étape du tutoriel déjà vue (0 : aucune ; TUTORIAL_DONE :
* la fin). La barre de progression ne mène qu'aux étapes vues. */
readonly tutorialSeen: number;
/** Combien de fois « Plus tard » a masqué l'invitation à installer, et
* le nombre de parties terminées à la dernière fois. */
readonly installDismissals: number;
readonly installDismissedAt: number;
/** L'application a été installée (événement appinstalled). */
readonly installed: boolean;
/** Les choix de l'écran « Nouvelle partie », gardés pour la suivante :
* partie libre ou match, longueur du match, niveau de l'ordinateur,
* conseil du maître. */
readonly gameKind: GameKind;
readonly matchLength: number;
readonly level: ComputerLevel;
readonly masterAdvice: boolean;
}
/** Partie libre ou match. */
export type GameKind = "free" | "match";
export const DEFAULT_SETTINGS: Settings = {
vibrations: true,
keepScreenOn: true,
theme: "auto",
gamesStarted: 0,
gamesFinished: 0,
welcomed: false,
tutorialSeen: 0,
installDismissals: 0,
installDismissedAt: 0,
installed: false,
gameKind: "free",
matchLength: 5,
level: "intermediate",
masterAdvice: true,
};
/** Nombre de parties pendant lesquelles la consigne garde sa forme longue. */
export const LONG_PROMPT_GAMES = 3;
/** tutorialSeen quand la fin du tutoriel a été vue. */
export const TUTORIAL_DONE = 99;
/** Parties terminées de plus avant que l'invitation à installer, masquée
* par « Plus tard », revienne (une seule fois). */
export const INSTALL_AGAIN_AFTER = 3;
/** Les réglages lus (JSON), complétés par les valeurs par défaut ; une
* valeur illisible garde sa valeur par défaut. */
export function parseSettings(text: string | null): Settings {
@@ -39,19 +89,54 @@ export function parseSettings(text: string | null): Settings {
} catch {
// Réglages illisibles : valeurs par défaut.
}
const bool = (key: "vibrations" | "keepScreenOn") =>
typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key];
const bool = (
key: "vibrations" | "keepScreenOn" | "welcomed" | "installed" | "masterAdvice",
) => (typeof raw[key] === "boolean" ? (raw[key] as boolean) : DEFAULT_SETTINGS[key]);
const count = (
key:
| "gamesStarted"
| "gamesFinished"
| "tutorialSeen"
| "installDismissals"
| "installDismissedAt",
) => {
const value = raw[key];
return typeof value === "number" && Number.isInteger(value) && value >= 0
? value
: DEFAULT_SETTINGS[key];
};
const theme = raw.theme === "light" || raw.theme === "dark" ? raw.theme : "auto";
const games = raw.gamesStarted;
return {
vibrations: bool("vibrations"),
keepScreenOn: bool("keepScreenOn"),
theme,
gamesStarted:
typeof games === "number" && Number.isInteger(games) && games >= 0 ? games : 0,
gamesStarted: count("gamesStarted"),
gamesFinished: count("gamesFinished"),
welcomed: bool("welcomed"),
tutorialSeen: count("tutorialSeen"),
installDismissals: count("installDismissals"),
installDismissedAt: count("installDismissedAt"),
installed: bool("installed"),
gameKind: raw.gameKind === "match" ? "match" : "free",
matchLength: (MATCH_LENGTHS as readonly unknown[]).includes(raw.matchLength)
? (raw.matchLength as number)
: DEFAULT_SETTINGS.matchLength,
level: (COMPUTER_LEVELS as readonly unknown[]).includes(raw.level)
? (raw.level as ComputerLevel)
: DEFAULT_SETTINGS.level,
masterAdvice: bool("masterAdvice"),
};
}
/** La partie que lancent les choix de l'écran « Nouvelle partie » : le
* niveau de gnubg du niveau choisi, et la première partie d'un match. */
export function chosenGame(settings: Settings): NewGame {
const level = ENGINE_LEVELS[settings.level];
return settings.gameKind === "match"
? { level, match: firstGameOf(settings.matchLength) }
: { level };
}
/** Le thème suivant, dans l'ordre du menu : automatique, clair, sombre. */
export function nextTheme(theme: Theme): Theme {
return theme === "auto" ? "light" : theme === "light" ? "dark" : "auto";
@@ -61,3 +146,37 @@ export function nextTheme(theme: Theme): Theme {
export function longPrompts(settings: Settings): boolean {
return settings.gamesStarted <= LONG_PROMPT_GAMES;
}
/**
* Premier lancement : rien n'a encore été fait dans l'application (le choix
* d'accueil, une partie terminée, une étape du tutoriel). La partie
* commencée d'office au chargement compte pour une : jusqu'à une partie
* commencée, c'est encore le premier lancement.
*/
export function isFirstLaunch(settings: Settings): boolean {
return (
!settings.welcomed &&
settings.gamesStarted <= 1 &&
settings.gamesFinished === 0 &&
settings.tutorialSeen === 0
);
}
/**
* L'accueil montre-t-il l'invitation à installer ? Jamais une fois
* l'application installée ; à partir de la première partie terminée ;
* « Plus tard » la masque, et elle ne revient qu'une fois, après
* INSTALL_AGAIN_AFTER parties terminées de plus.
*/
export function installCardShown(settings: Settings, installed: boolean): boolean {
if (installed || settings.installed || settings.gamesFinished < 1) {
return false;
}
if (settings.installDismissals === 0) {
return true;
}
return (
settings.installDismissals === 1 &&
settings.gamesFinished >= settings.installDismissedAt + INSTALL_AGAIN_AFTER
);
}
+374
View File
@@ -0,0 +1,374 @@
// Le bilan de fin de partie (#/parties/<id>/bilan, étape 1e ; charte,
// ecrans-etape-1.md, « Bilan ») : un panneau centré sur un voile, à la
// place du panneau de fin de partie. Le résultat et sa lecture, les
// décisions par appréciation, deux jauges (votre jeu, les dés), le coup à
// revoir, puis « Voir l'analyse » et l'action qui suit la partie.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout ce qu'il affiche vient de l'analyse du moteur (game-analysis.ts) et
// des phrases du maître (docs/maitre/phrases.md) ; tant que l'analyse
// n'est pas prête, il dit où elle en est.
import {
type GameSummary,
readyForSummary,
summarizeAnalysis,
} from "../domain/analysis/game-analysis.js";
import {
diceWords,
type MasterPhrases,
masterWords,
nothingToReview,
summaryReading,
} from "../domain/analysis/phrases.js";
import { masterCubeAction } from "../domain/game/advice.js";
import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
import { REVIEW_TEXTS } from "./review-texts.js";
import { routeHash } from "./router.js";
import { TEXTS } from "./texts.js";
/** Ce que montre le bilan. */
export interface SummaryData {
readonly id: string;
readonly journal: GameJournal;
/** La partie, pour dessiner la position du coup à revoir. */
readonly game: Game;
/** Le titre et le détail du résultat (null : partie pas finie). */
readonly end: { readonly title: string; readonly detail: string } | null;
/** L'action qui suit la partie, si c'est la partie à l'écran :
* « Rejouer » ou « Partie suivante ». Sinon, « Nouvelle partie » mène à
* l'écran des choix. */
readonly afterLabel: string | null;
readonly phrases: MasterPhrases | null;
}
export interface SummaryViewOptions {
/** Le voile du bilan (index.html) ; le panneau est construit ici. */
readonly veil: HTMLElement;
/** La croix : revenir au plateau. */
readonly close: () => void;
/** « Rejouer » ou « Partie suivante ». */
readonly again: () => void;
}
export interface SummaryView {
/** Ouvre (ou met à jour) le bilan. */
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
/** Le panneau, où la page du prompt prend la place du bilan (1f). */
readonly panel: HTMLElement;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
const DICE_SIDE = {
veryLucky: "for-2",
lucky: "for-1",
none: "even",
unlucky: "against-1",
veryUnlucky: "against-2",
} as const;
export function createSummaryView(options: SummaryViewOptions): SummaryView {
const { veil } = options;
const panel = element(veil, "div", { id: "summary", className: "panel summary-panel" });
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "summary-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Ce qui est dessiné, pour ne redessiner qu'en cas de changement (le
// bilan est mis à jour à chaque analyse rangée).
let drawn = "";
function render(data: SummaryData | null): void {
const ready = data !== null && data.phrases !== null && readyForSummary(data.journal);
const summary = ready ? summarizeAnalysis(data.journal) : null;
// Seulement ce que le bilan montre : les analyses qui arrivent
// ensuite en arrière-plan pour l'écran d'analyse (coups de
// l'ordinateur, chances de gagner) ne le redessinent pas, sinon le
// panneau et son mini-plateau sauteraient sous les yeux du joueur.
const shown =
summary === null || data === null || data.phrases === null
? null
: {
counts: summary.counts,
level: summary.player.errorRate.level,
dice: summary.player.luck.rating,
reading: summaryReading(data.phrases, summary),
toReview: summary.toReview?.advice.eventId ?? null,
turn: summary.toReview?.turn ?? null,
};
const key =
data === null
? "missing"
: [
data.id,
data.end?.title ?? "",
data.end?.detail ?? "",
data.afterLabel ?? "",
ready,
JSON.stringify(shown),
].join("|");
if (key === drawn) {
return;
}
drawn = key;
clearSheet(panel);
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
const close = element(head, "button", { id: "summary-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", REVIEW_TEXTS.close);
close.onclick = () => options.close();
const title = element(panel, "h2", { id: "summary-title", className: "summary-title" });
if (data === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.missingGame });
return;
}
if (data.end === null) {
title.textContent = REVIEW_TEXTS.summaryLabel;
element(panel, "p", { className: "summary-reading", text: REVIEW_TEXTS.notOver });
actions(data);
return;
}
title.textContent = data.end.title;
const reading = element(panel, "p", {
id: "summary-reading",
className: "summary-reading",
});
reading.textContent =
summary !== null && data.phrases !== null
? `${data.end.detail} ${summaryReading(data.phrases, summary)}`
: data.end.detail;
if (summary === null || data.phrases === null) {
const status = element(panel, "p", {
id: "summary-status",
className: "summary-status",
text: REVIEW_TEXTS.analysing,
});
status.setAttribute("role", "status");
actions(data);
return;
}
panel.dataset.ready = "true";
counts(summary);
gauges(summary, data.phrases);
toReview(data, summary, data.phrases);
actions(data);
}
function counts(summary: GameSummary): void {
const box = element(panel, "div", { id: "summary-counts", className: "summary-counts" });
box.setAttribute("role", "list");
box.setAttribute("aria-label", REVIEW_TEXTS.countsLabel);
const cells = [
{ verdict: "good", n: summary.counts.good, word: REVIEW_TEXTS.counts.good },
{
verdict: "doubtful",
n: summary.counts.doubtful,
word: REVIEW_TEXTS.counts.doubtful,
},
{
verdict: "bad",
n: summary.counts.bad + summary.counts.veryBad,
word: REVIEW_TEXTS.counts.bad,
},
] as const;
for (const cell of cells) {
const item = element(box, "div", { className: "summary-count" });
item.setAttribute("role", "listitem");
item.dataset.verdict = cell.verdict;
element(item, "span", { className: "summary-number", text: String(cell.n) });
const word = element(item, "span", { className: "summary-word" });
word.append(icon(VERDICT_ICONS[cell.verdict]), cell.word(cell.n));
}
}
function gauges(summary: GameSummary, phrases: MasterPhrases): void {
const box = element(panel, "div", { className: "summary-gauges" });
const level = summary.player.errorRate.level;
const levelName = MATCH_TEXTS.levels[level].name;
const play = element(box, "div", { id: "gauge-play", className: "gauge" });
play.setAttribute("aria-label", REVIEW_TEXTS.playGauge(levelName));
play.setAttribute("role", "img");
// Le taux d'erreur du bilan, que la courbe de la progression (1h)
// reprend tel quel (vérifié par tests/browser/progression.mjs).
play.dataset.errorRate = String(summary.player.errorRate.perDecision);
element(play, "span", { className: "gauge-name", text: REVIEW_TEXTS.yourPlay });
const track = element(play, "span", { className: "gauge-track" });
element(track, "span", {
className: `gauge-fill level-${COMPUTER_LEVELS.indexOf(level) + 1}`,
});
element(play, "span", { className: "gauge-value", text: levelName });
const words = diceWords(phrases, summary.player.luck.rating);
const dice = element(box, "div", { id: "gauge-dice", className: "gauge" });
dice.setAttribute("aria-label", REVIEW_TEXTS.diceGauge(words));
dice.setAttribute("role", "img");
element(dice, "span", { className: "gauge-name", text: REVIEW_TEXTS.theDice });
const centred = element(dice, "span", { className: "gauge-track centred" });
element(centred, "span", {
className: `gauge-fill dice ${DICE_SIDE[summary.player.luck.rating]}`,
});
element(dice, "span", { className: "gauge-value", text: words });
const link = element(box, "a", {
id: "summary-glossary",
className: "link-button summary-glossary",
text: REVIEW_TEXTS.howToRead,
});
link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" });
}
function toReview(data: SummaryData, summary: GameSummary, phrases: MasterPhrases): void {
const card = element(panel, "section", {
id: "summary-review",
className: "summary-review",
});
card.setAttribute("aria-labelledby", "summary-review-title");
element(card, "h3", {
id: "summary-review-title",
className: "overline",
text: REVIEW_TEXTS.toReview,
});
const reviewed = summary.toReview;
if (reviewed === null) {
element(card, "p", { className: "summary-none", text: nothingToReview(phrases) });
return;
}
const advice = reviewed.advice;
const words = masterWords(phrases, advice);
const body = element(card, "div", { className: "summary-review-body" });
const box = element(body, "div", { className: "summary-board-box" });
const svg = boardSvg(box, "summary-board", "summary-board");
svg.setAttribute("aria-label", REVIEW_TEXTS.toReviewBoard);
const halfMoves = advice.kind === "move" ? advice.played.halfMoves : [];
void data.game
.stepsBefore(advice.eventId, "player", halfMoves)
.then((steps) => drawSteps(svg, steps, steps.halfMoves.length, true, "summary-"))
.catch(() => undefined);
const text = element(body, "div", { className: "summary-review-text" });
const verdict = element(text, "p", { className: "summary-verdict" });
verdict.dataset.verdict = words.verdict;
verdict.append(icon(VERDICT_ICONS[words.verdict]), `${words.word} · ${words.context}`);
element(text, "p", {
className: "summary-played",
text:
advice.kind === "move"
? REVIEW_TEXTS.yourMove(advice.played.notation)
: REVIEW_TEXTS.yourDecision(REVIEW_TEXTS.cubeActions[advice.action]),
});
element(text, "p", {
className: "summary-master",
text:
advice.kind === "move" || masterCubeAction(advice) !== advice.action
? words.title
: words.phrase,
});
const links = element(card, "div", { className: "summary-review-actions" });
const revisit = element(links, "a", {
id: "summary-revisit",
className: "button secondary",
text: REVIEW_TEXTS.reviewMove,
});
revisit.href = routeHash({ screen: "analysis", game: data.id, turn: reviewed.turn });
// « Demander à une IA » (étape 1f) : le prompt du bilan.
const slot = element(links, "div", { className: "ask-ai-slot" });
slot.dataset.slot = "ask-ai";
askAiLink(
slot,
"summary-ask-ai",
routeHash({ screen: "summary", game: data.id, ai: true }),
);
}
function actions(data: SummaryData): void {
// Les actions et le lien des réglages, ensemble : en paysage, en bas
// de la colonne de droite.
const end = element(panel, "div", { className: "summary-end" });
const bar = element(end, "div", { className: "panel-actions summary-actions" });
const analysis = element(bar, "a", {
id: "summary-analysis",
className: "button secondary",
text: REVIEW_TEXTS.seeAnalysis,
});
analysis.href = routeHash({ screen: "analysis", game: data.id, turn: null });
if (data.afterLabel !== null) {
const again = element(bar, "button", {
id: "summary-primary",
text: data.afterLabel,
});
again.type = "button";
again.onclick = () => options.again();
const settings = element(end, "a", {
id: "summary-settings",
className: "panel-link",
text: MATCH_TEXTS.changeSettings,
});
settings.href = routeHash({ screen: "newGame" });
} else {
const start = element(bar, "a", {
id: "summary-primary",
className: "button",
text: REVIEW_TEXTS.newGame,
});
start.href = routeHash({ screen: "newGame" });
}
}
return {
show(data) {
const opening = veil.hidden;
veil.hidden = false;
render(data);
if (opening) {
panel.querySelector<HTMLElement>("#summary-close")?.focus();
}
},
hide() {
if (!veil.hidden) {
veil.hidden = true;
clearSheet(panel);
delete panel.dataset.ready;
drawn = "";
}
},
get isOpen() {
return !veil.hidden;
},
panel,
};
}
+60 -9
View File
@@ -136,15 +136,18 @@ export const TEXTS = {
// Fin de partie
gameOver: "Partie finie",
endOverline: "Fin de partie",
/** Titre du panneau de fin : qui gagne, et comment. */
endTitle: (winner: "player" | "computer", kind: ResultKind, dropped: boolean) => {
if (dropped) {
return winner === "player" ? "L'ordinateur passe" : "Vous passez";
}
return winner === "player"
/** Titre du panneau de fin et du bilan : toujours l'issue, qui gagne
* et comment (un double passé se dit dans le détail). */
endTitle: (winner: "player" | "computer", kind: ResultKind) =>
winner === "player"
? `Vous gagnez ${RESULT_KIND[kind]}`
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
},
: `L'ordinateur gagne ${RESULT_KIND[kind]}`,
/** Avant le détail des points, quand la partie finit sur un double
* passé : « Vous avez passé son double : 1 point pour lui. ». */
droppedDouble: (winner: "player" | "computer") =>
winner === "player"
? "L'ordinateur a passé votre double : "
: "Vous avez passé son double : ",
/** « 8 points pour lui, videau à 4. » */
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
@@ -153,8 +156,56 @@ export const TEXTS = {
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpTabs: "Rubriques de l'aide",
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
glossaryTitle: "Les mots du jeu",
aboutTitle: "À propos",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
// Adresses (ADR 0013)
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
// Lien vers une position (ADR 0013)
copyPositionLink: "Copier le lien de la position",
linkCopied: "Lien de la position copié.",
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
positionTitle: "Position partagée",
unreadablePositionTitle: "Lien illisible",
unreadablePosition:
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
backToGame: "Retour à ma partie",
playFromHere: "Jouer à partir d'ici",
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
/** Ce que montre la vue d'une position, sous le plateau. */
positionDetail: (
pips: Readonly<Record<"player" | "computer", number>>,
cube: { value: number; owner: "player" | "computer" | null },
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
winner: "player" | "computer" | null,
) =>
[
winner === null
? "Vous avez la main : vos pions blancs, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
cube.value > 1 || cube.owner !== null
? `Videau à ${cube.value}, ${
cube.owner === null
? "au centre"
: cube.owner === "player"
? "chez vous"
: "chez l'ordinateur"
}.`
: "",
match === null
? ""
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
]
.filter((part) => part !== "")
.join(" "),
} as const;
+119
View File
@@ -0,0 +1,119 @@
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
// de progression (charte : « les segments sont des liens vers les étapes
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Route } from "./router.js";
import { TUTORIAL_DONE } from "./settings.js";
import type { Tutorial } from "./tutorial-text.js";
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
* étape hors du tutoriel mène à la première, un « à vous » absent à
* l'explication de son étape. */
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
if (route.screen === "tutorialEnd") {
return "end";
}
return route.screen === "tutorial"
? existingPlace(tutorial, route.step, route.practice)
: { step: 1, practice: false };
}
/** Le même écran qu'une adresse d'étape, s'il existe ; sinon la première
* étape (étape absente), ou l'explication (« à vous » absent). */
function existingPlace(tutorial: Tutorial, at: number | null, practice: boolean): TutorialPlace {
const step = at === null ? undefined : tutorial.steps[at - 1];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
export function placeRoute(place: TutorialPlace): Route {
return place === "end"
? { screen: "tutorialEnd" }
: { screen: "tutorial", step: place.step, practice: place.practice };
}
/** L'écran d'une leçon que désigne une adresse (#/lecons/<n>/...), ramené à
* un écran qui existe, comme placeOf pour le tutoriel. */
export function lessonPlaceOf(lesson: Tutorial, route: Route): TutorialPlace {
if (route.screen === "lessonEnd") {
return "end";
}
return route.screen === "lesson"
? existingPlace(lesson, route.step, route.practice)
: { step: 1, practice: false };
}
/** L'adresse d'un écran de la leçon number. */
export function lessonPlaceRoute(number: number, place: TutorialPlace): Route {
return place === "end"
? { screen: "lessonEnd", lesson: number }
: { screen: "lesson", lesson: number, step: place.step, practice: place.practice };
}
/** L'écran suivant (la fin après la dernière étape). */
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
if (place === "end") {
return "end";
}
const step = tutorial.steps[place.step - 1];
if (!place.practice && step?.practice != null) {
return { step: place.step, practice: true };
}
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
}
/** L'écran précédent (null avant la première étape). */
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
if (place === "end") {
const last = tutorial.steps.at(-1);
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
}
if (place.practice) {
return { step: place.step, practice: false };
}
const before = tutorial.steps[place.step - 2];
return before === undefined
? null
: { step: before.number, practice: before.practice !== null };
}
/** Ce que retient la progression quand on arrive sur un écran : la
* dernière étape vue (TUTORIAL_DONE à la fin). */
export function seenAfter(seen: number, place: TutorialPlace): number {
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
}
/** Un segment de la barre de progression. */
export interface ProgressSegment {
readonly step: number;
/** Étape faite ou en cours : le segment est en brique. */
readonly done: boolean;
/** Étape déjà vue : le segment est un lien vers elle. */
readonly link: boolean;
readonly current: boolean;
}
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
* seen ont déjà été vues. */
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
const current = place === "end" ? total + 1 : place.step;
const reached = Math.max(seen, current);
return Array.from({ length: total }, (_, i) => {
const step = i + 1;
return {
step,
done: step <= current,
link: step <= reached && step !== current,
current: step === current,
};
});
}
+219
View File
@@ -0,0 +1,219 @@
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
// docs/tutoriel/README.md), copié tel quel dans le site par make site
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
* glossaire (nom de fichier sans .md). */
export interface Inline {
readonly text: string;
readonly strong?: boolean;
readonly term?: string;
}
export type Paragraph = readonly Inline[];
/** Un écran du tutoriel : explication, ou « à vous ». */
export interface TutorialScreen {
/** Identifiant de position de gnubg, vu du camp au trait. */
readonly position: string;
readonly onRoll: "player" | "computer";
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu, deux flèches circulaires. */
readonly path: boolean;
/** Les comptes de points de la position que cite le texte (vous,
* l'ordinateur), vérifiés par les tests ; null s'il n'en cite pas. */
readonly pips: readonly [number, number] | null;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
/** « À vous » de videau : la bonne réponse au double. */
readonly answer: "take" | "pass" | null;
readonly right: Paragraph;
readonly wrong: Paragraph;
readonly paragraphs: readonly Paragraph[];
}
export interface TutorialStep {
readonly number: number;
readonly title: string;
readonly explain: TutorialScreen;
readonly practice: TutorialScreen | null;
}
export interface Tutorial {
readonly steps: readonly TutorialStep[];
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
}
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
export function parseInline(text: string): Paragraph {
const parts: Inline[] = [];
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
let last = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > last) {
parts.push({ text: text.slice(last, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
parts.push({ text: match[1], term: match[2] });
} else {
parts.push({ text: match[3] ?? "", strong: true });
}
last = index + match[0].length;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
interface RawScreen {
keys: Map<string, string[]>;
paragraphs: string[];
}
function emptyScreen(): RawScreen {
return { keys: new Map(), paragraphs: [] };
}
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^([1-6])-([1-6])$/.exec(text);
if (match === null) {
throw new Error(`${where} : jet illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function pipsOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^(\d+)\/(\d+)$/.exec(text);
if (match === null) {
throw new Error(`${where} : compte illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function screenOf(raw: RawScreen, where: string): TutorialScreen {
const one = (key: string) => raw.keys.get(key)?.[0];
const position = one("position");
if (position === undefined) {
throw new Error(`${where} : position absente`);
}
const trait = one("trait");
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
throw new Error(`${where} : trait inconnu « ${trait} »`);
}
const answer = one("réponse");
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
throw new Error(`${where} : réponse inconnue « ${answer} »`);
}
return {
position,
onRoll: trait === "ordinateur" ? "computer" : "player",
roll: rollOf(one("jet"), where),
move: one("coup") ?? null,
path: one("chemin") === "oui",
pips: pipsOf(one("compte"), where),
expected: raw.keys.get("attendu") ?? [],
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
right: parseInline(one("juste") ?? ""),
wrong: parseInline(one("refus") ?? ""),
paragraphs: raw.paragraphs.map(parseInline),
};
}
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: {
number: number;
title: string;
explain: RawScreen;
practice: RawScreen | null;
}[] = [];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
const endParagraph = () => {
if (paragraph.length > 0 && current !== null) {
current.paragraphs.push(paragraph.join(" "));
}
paragraph = [];
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const step = /^## (\d+)\. (.+)$/.exec(line);
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
if (line === "") {
endParagraph();
} else if (step !== null) {
endParagraph();
current = emptyScreen();
steps.push({
number: Number(step[1]),
title: step[2] ?? "",
explain: current,
practice: null,
});
} else if (line === "## Fin") {
endParagraph();
current = emptyScreen();
end = current;
} else if (line === "### À vous") {
endParagraph();
const last = steps.at(-1);
if (last === undefined) {
throw new Error("« À vous » hors d'une étape");
}
current = emptyScreen();
last.practice = current;
} else if (current === null) {
// Notice avant la première étape : non affichée.
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
const values = current.keys.get(key[1] ?? "") ?? [];
values.push((key[2] ?? "").trim());
current.keys.set(key[1] ?? "", values);
} else {
paragraph.push(line);
}
}
endParagraph();
steps.forEach((step, i) => {
if (step.number !== i + 1) {
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
}
});
if (end === null) {
throw new Error("écran de fin absent");
}
const finish: RawScreen = end;
return {
steps: steps.map((step) => ({
number: step.number,
title: step.title,
explain: screenOf(step.explain, `étape ${step.number}`),
practice:
step.practice === null
? null
: screenOf(step.practice, `étape ${step.number}, à vous`),
})),
end: {
action: finish.keys.get("action")?.[0] ?? "",
paragraphs: finish.paragraphs.map(parseInline),
},
};
}
+813
View File
@@ -0,0 +1,813 @@
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
// Les leçons (#/lecons/<n>/..., étape 1g) reprennent le même écran, avec
// leur texte et leurs adresses (Course).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte vient de docs/tutoriel/regles.md ou de docs/lecons/ (copies du
// site) ; les plateaux,
// les coups illustrés et le jugement des gestes viennent du domaine, branché
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
// écrite ici.
import type { MoveSteps } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
homeBoard,
moveTargets,
moveTrace,
onlyHalfMove,
outerBoard,
type Place,
placeOf as placeOfChecker,
pointSlot,
} from "./board-model.js";
import {
type BoardDecor,
checkerSpot,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { type Route, routeHash } from "./router.js";
import { nextPlace, previousPlace, progress, type TutorialPlace } from "./tutorial-nav.js";
import type { Paragraph, Tutorial, TutorialScreen } from "./tutorial-text.js";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un pion refusé reste un instant à sa place avant de revenir. */
const REFUSED_MS = 450;
/** Le texte du tutoriel, copié dans le site (make site). */
export const TUTORIAL_SOURCE = "docs/tutoriel/regles.md";
/**
* Un texte du site, lu une fois puis gardé : la fonction renvoyée le
* télécharge à son premier appel et le relit avec parse. Un échec n'est
* pas gardé : l'appel suivant réessaie.
*/
export function cachedText<T>(source: string, parse: (text: string) => T): () => Promise<T> {
let loaded: Promise<T> | null = null;
return () => {
loaded ??= fetch(source)
.then((response) => {
if (!response.ok) {
throw new Error(`${source} : ${response.status}`);
}
return response.text();
})
.then(parse)
.catch((error: unknown) => {
loaded = null;
throw error;
});
return loaded;
};
}
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
board(): Promise<{
player: TutorialBoard["sides"]["player"];
computer: TutorialBoard["sides"]["computer"];
}>;
nextHalfMoves(): Promise<HalfMove[]>;
play(halfMove: HalfMove): Promise<PracticeVerdict>;
reset(): void;
readonly diceToPlay: readonly { value: number; used: boolean }[];
}
/** Ce que le tutoriel demande au domaine et au moteur. */
export interface TutorialAccess {
board(position: string, onRoll: Side): Promise<TutorialBoard>;
illustrate(
position: string,
roll: Roll,
move: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
shouldTake(position: string): Promise<boolean>;
}
/**
* Un parcours d'écrans courts : le tutoriel des règles, ou une leçon (étape
* 1g), qui en reprennent la forme (charte, « 1g. Leçons »). Ce qui les
* distingue : leur texte, leurs adresses, leurs libellés, et ce que retient
* la progression.
*/
export interface Course {
/** Préfixe des identifiants des éléments de l'écran (« tutorial »,
* « lesson ») : deux parcours peuvent coexister dans la page. */
readonly id: string;
/** Le texte du parcours que désigne l'adresse. */
load(route: Route): Promise<Tutorial>;
/** L'écran que désigne l'adresse, ramené à un écran qui existe. */
placeOf(text: Tutorial, route: Route): TutorialPlace;
/** L'adresse d'un écran du même parcours que route. */
routeOf(route: Route, place: TutorialPlace): Route;
/** La dernière étape déjà vue : la barre de progression n'en mène pas
* plus loin. */
seen(route: Route): number;
/** L'arrivée sur un écran. */
visit(route: Route, place: TutorialPlace): void;
/** Un écran « à vous » réussi. */
solved(route: Route, place: TutorialPlace): void;
/** « Quitter » : où il mène. */
quitHref(route: Route): string;
/** « Retour » sur le premier écran. */
firstBackHref(route: Route): string;
/** L'action de l'écran de fin. */
endHref(route: Route): string;
readonly texts: {
readonly progress: string;
readonly stepLink: (n: number, title: string) => string;
readonly quit: string;
readonly quitLabel: string;
readonly overline: (text: Tutorial, n: number, total: number, practice: boolean) => string;
readonly endOverline: (text: Tutorial) => string;
readonly endTitle: (text: Tutorial) => string;
readonly loading: string;
readonly failed: string;
};
}
export interface TutorialViewOptions {
readonly section: HTMLElement;
readonly access: TutorialAccess;
readonly engineReady: Promise<unknown>;
readonly course: Course;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
export interface TutorialView {
show(route: Route): Promise<void>;
/** La taille de l'écran a changé : redessiner le plateau. */
relayout(): void;
/** L'écran est quitté : arrêter le coup illustré. */
hide(): void;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
target.replaceChildren();
for (const part of paragraph) {
if (part.term !== undefined) {
const link = document.createElement("a");
link.href = routeHash({ screen: "glossary", term: part.term });
link.textContent = part.text;
target.append(link);
} else if (part.strong === true) {
const strong = document.createElement("strong");
strong.textContent = part.text;
target.append(strong);
} else {
target.append(part.text);
}
}
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu : 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 group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
// 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);
}
const tip = document.createElementNS(SVG_NS, "path");
tip.setAttribute("class", `direction-head direction-${side}`);
tip.setAttribute(
"d",
`M ${end - head} ${to - half} L ${end} ${to} L ${end - head} ${to + half} Z`,
);
tip.setAttribute("stroke-width", String(px(2)));
group.append(tip);
}
// Sous les pions ; les zones à toucher restent devant tout.
svg.insertBefore(group, svg.querySelector(":scope > g.pieces"));
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access, course } = options;
const idOf = (name: string) => `${course.id}-${name}`;
section.replaceChildren();
const head = element(section, "header", { className: "tutorial-head" });
const bar = element(head, "nav", { id: idOf("progress"), className: "tutorial-progress" });
bar.setAttribute("aria-label", course.texts.progress);
const skip = element(head, "a", { id: idOf("skip"), className: "tutorial-skip" });
skip.href = "#/";
skip.textContent = course.texts.quit;
skip.setAttribute("aria-label", course.texts.quitLabel);
const overline = element(section, "p", { id: idOf("overline"), className: "overline" });
// Le titre, et « Revoir » à sa droite (en portrait comme en paysage).
const titleRow = element(section, "div", { className: "tutorial-title-row" });
const title = element(titleRow, "h2", { id: idOf("title"), className: "tutorial-title" });
const boardBox = element(section, "div", { className: "tutorial-board-box" });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = idOf("board");
svg.setAttribute("class", "board tutorial-board");
svg.setAttribute("role", "img");
boardBox.append(svg);
const replay = element(titleRow, "button", {
id: idOf("replay"),
className: "icon-button tutorial-replay",
});
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: idOf("text"), className: "tutorial-text" });
const task = element(body, "p", { id: idOf("task"), className: "tutorial-task" });
const feedback = element(body, "p", {
id: idOf("feedback"),
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: idOf("cube"), className: "tutorial-cube" });
const drop = element(cube, "button", {
id: idOf("drop"),
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: idOf("take"),
className: "secondary",
text: HOME_TEXTS.take,
});
take.type = "button";
const nav = element(section, "div", { className: "tutorial-nav" });
const back = element(nav, "a", {
id: idOf("back"),
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: idOf("next"),
className: "button",
text: HOME_TEXTS.next,
});
// Le dernier texte lu : tant qu'il n'y en a pas, l'écran dit qu'il se
// charge.
let lastText: Tutorial | null = null;
// L'adresse affichée.
let shownRoute: Route | null = null;
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
// à l'écran (le joueur est déjà ailleurs).
let shown = 0;
let autoplay: ReturnType<typeof setTimeout> | null = null;
// Un coup illustré se joue, ou un geste est jugé : les touchers
// attendent (data-busy, pour les tests dans un navigateur).
let busy = false;
const setBusy = (value: boolean) => {
busy = value;
section.toggleAttribute("data-busy", value);
};
// L'écran affiché.
let screen: TutorialScreen | null = null;
let place: TutorialPlace | null = null;
let boards: TutorialBoard | null = null;
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
let illustrated = false;
let practice: PracticeSession | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let solved = false;
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
function shownDice(): ShownDie[] {
if (practice !== null) {
return practice.diceToPlay.map((die) => ({
value: die.value,
side: "player",
blocked: false,
used: die.used,
}));
}
const roll = screen?.move != null ? screen.roll : null;
if (roll === null || roll === undefined) {
return [];
}
const [a, b] = roll;
return [a, b].map((value) => ({
value,
side: "player" as const,
blocked: false,
used: false,
}));
}
/** Redessine le plateau dans son état courant. */
function draw(sides: TutorialBoard["sides"] | null = null): void {
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
if (current === undefined || current === null) {
return;
}
const g = geometry();
const decor: BoardDecor = {
...NO_MARKS,
dice: shownDice(),
cube: { value: 1, owner: null },
band: null,
controls: null,
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
illustrated && illustration !== null
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
: null,
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawDirections(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
function clearTimer(): void {
if (autoplay !== null) {
clearTimeout(autoplay);
autoplay = null;
}
}
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
* puis ses traces ; sans mouvement si le système le demande. */
async function playIllustration(id: number): Promise<void> {
const shownMove = illustration;
if (shownMove === null || id !== shown) {
return;
}
setBusy(true);
replay.disabled = true;
illustrated = false;
try {
if (!options.reducedMotion.matches) {
const { steps } = shownMove;
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== shown) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOfChecker("player", halfMove.from);
const to = placeOfChecker("player", halfMove.to);
draw(steps.boards[i] ?? null);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(steps.boards[i + 1] ?? null);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
} finally {
if (id === shown) {
setBusy(false);
illustrated = true;
replay.disabled = false;
draw();
const result = shownMove.after.result;
feedback.textContent =
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
feedback.dataset.verdict = result === null ? "" : "info";
}
}
}
async function refreshTargets(): Promise<void> {
targets =
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
feedback.dataset.verdict = verdict;
fillParagraph(feedback, words);
if (verdict === "right") {
feedback.prepend(icon("verdictGood"));
}
}
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
options.placed();
if (shownRoute !== null && place !== null) {
course.solved(shownRoute, place);
}
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
const session = practice;
const current = screen;
if (session === null || current === null || busy || solved) {
return;
}
const id = shown;
setBusy(true);
selected = null;
try {
const verdict = await session.play(halfMove);
if (id !== shown) {
return;
}
draw(await session.board());
if (verdict === "right") {
markSolved();
setVerdict("right", current.right);
} else if (verdict === "wrong") {
// Le pion revient, et une phrase dit pourquoi.
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
if (id !== shown) {
return;
}
session.reset();
setVerdict("wrong", current.wrong);
} else {
feedback.textContent = "";
feedback.dataset.verdict = "";
}
await refreshTargets();
} finally {
if (id === shown) {
setBusy(false);
draw(practice === null ? null : await practice.board());
}
}
}
svg.addEventListener("click", (event) => {
if (practice === null || busy || solved) {
return;
}
const target = event.target as Element | null;
const placeName = target
?.closest("[data-place]")
?.getAttribute("data-place") as Place | null;
if (placeName == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
if (halfMove !== undefined) {
void playHalfMove(halfMove);
return;
}
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
if (only !== null) {
void playHalfMove(only);
return;
}
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
void practice.board().then((sides) => draw(sides));
});
replay.addEventListener("click", () => {
clearTimer();
void playIllustration(shown);
});
async function answer(takes: boolean): Promise<void> {
const current = screen;
if (current === null || solved || busy) {
return;
}
const id = shown;
setBusy(true);
try {
const right = (await access.shouldTake(current.position)) === takes;
if (id !== shown) {
return;
}
if (right) {
markSolved();
}
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
} finally {
setBusy(false);
}
}
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(route: Route, at: TutorialPlace, loaded: Tutorial): void {
const steps = loaded.steps;
const total = steps.length;
bar.replaceChildren();
for (const segment of progress(total, at, course.seen(route))) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = course.texts.stepLink(segment.step, stepTitle);
const item = segment.link
? document.createElement("a")
: document.createElement("span");
item.className = `segment${segment.done ? " done" : ""}`;
if (item instanceof HTMLAnchorElement) {
item.href = routeHash(
course.routeOf(route, { step: segment.step, practice: false }),
);
item.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
}
if (segment.current) {
item.setAttribute("aria-current", "step");
}
bar.append(item);
}
bar.setAttribute(
"aria-description",
at === "end"
? course.texts.endOverline(loaded)
: course.texts.overline(loaded, at.step, total, at.practice),
);
}
return {
async show(route) {
const id = ++shown;
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
delete section.dataset.ready;
clearTimer();
setBusy(false);
practice = null;
illustration = null;
illustrated = false;
screen = null;
boards = null;
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
cube.hidden = true;
replay.hidden = true;
take.disabled = false;
drop.disabled = false;
let loaded: Tutorial;
try {
if (lastText === null) {
title.textContent = course.texts.loading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await course.load(route);
} catch {
if (id === shown) {
title.textContent = course.texts.failed;
}
return;
}
if (id !== shown) {
return;
}
lastText = loaded;
const at = course.placeOf(loaded, route);
const wanted = routeHash(course.routeOf(route, at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
shownRoute = route;
course.visit(route, at);
const total = loaded.steps.length;
renderProgress(route, at, loaded);
skip.href = course.quitHref(route);
const before = previousPlace(loaded, at);
back.href =
before === null
? course.firstBackHref(route)
: routeHash(course.routeOf(route, before));
if (at === "end") {
overline.textContent = course.texts.endOverline(loaded);
title.textContent = course.texts.endTitle(loaded);
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = course.endHref(route);
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
const step = loaded.steps[at.step - 1];
const current = at.practice ? step?.practice : step?.explain;
if (step === undefined || current == null) {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = course.texts.overline(loaded, at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
for (const paragraph of paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
if (at.practice) {
task.hidden = false;
task.replaceChildren();
for (const [i, paragraph] of current.paragraphs.entries()) {
if (i > 0) {
task.append(" ");
}
const span = document.createElement("span");
fillParagraph(span, paragraph);
task.append(span);
}
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(course.routeOf(route, after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
if (currentScreen === null) {
return;
}
// Le plateau : la position, puis le coup illustré ou le geste à
// faire, tous lus par le moteur.
try {
await options.engineReady;
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
if (id !== shown) {
return;
}
boards = shownBoard;
if (
at !== "end" &&
at.practice &&
currentScreen.roll !== null &&
currentScreen.expected.length > 0
) {
practice = await access.practice(
currentScreen.position,
currentScreen.roll,
currentScreen.expected,
);
if (id !== shown) {
return;
}
await refreshTargets();
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
cube.hidden = false;
feedback.textContent = HOME_TEXTS.computerDoubles;
feedback.dataset.verdict = "info";
} else if (
at !== "end" &&
currentScreen.move !== null &&
currentScreen.roll !== null
) {
illustration = await access.illustrate(
currentScreen.position,
currentScreen.roll,
currentScreen.move,
);
if (id !== shown) {
return;
}
replay.hidden = false;
replay.disabled = true;
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
}
if (id !== shown) {
return;
}
draw(practice === null ? null : await practice.board());
section.dataset.ready = String(id);
} catch (error) {
if (id === shown) {
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
}
}
},
relayout() {
if (place === null || busy) {
return;
}
if (practice !== null) {
void practice.board().then((sides) => draw(sides));
} else {
draw();
}
},
hide() {
shown++;
clearTimer();
setBusy(false);
},
};
}
+150
View File
@@ -0,0 +1,150 @@
// La mise à jour de l'application : le service worker (web/sw.js) installe
// chaque nouvelle version publiée dans son propre cache, puis attend
// l'accord du joueur. L'application affiche alors, entre deux tours, le
// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour »
// active la nouvelle version et recharge la page, qui reprend la partie
// enregistrée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export interface Updates {
/** Une nouvelle version est installée et attend l'accord du joueur. */
readonly available: boolean;
/** « Rechercher une mise à jour » : demande au navigateur de relire le
* service worker publié. found : une nouvelle version attend (elle a pu
* demander de télécharger tout le site) ; latest : rien de nouveau ;
* unavailable : pas de service worker (http hors localhost, navigateur
* qui le refuse) ou réseau absent. */
check(): Promise<"found" | "latest" | "unavailable">;
/** Active la nouvelle version : la page se recharge dès qu'elle a pris
* la main. */
apply(): void;
}
/** Message au service worker en attente : prendre la main (sw.js). */
export const SKIP_WAITING = "skip-waiting";
/**
* Est-on entre deux tours ? Oui quand la partie attend un lancer de dés
* (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à
* jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction
* pure, testée sous Node.
*/
export function betweenTurns(phase: string, pendingHalfMoves: number): boolean {
return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0);
}
/**
* Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version
* attend, seulement entre deux tours (jamais pendant un coup en cours, ni
* quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre
* l'ancienne », qui occupe la même place. Fonction pure, testée sous Node.
*/
export function updateBannerShown(
available: boolean,
betweenTwoTurns: boolean,
otherBanner: boolean,
): boolean {
return available && betweenTwoTurns && !otherBanner;
}
/**
* Enregistre le service worker une fois ready résolue (le moteur chargé :
* voir main.ts), et suit ses mises à jour. onAvailable est appelé quand une
* nouvelle version attend.
*/
export function startUpdates(
script: URL,
ready: Promise<unknown>,
onAvailable: () => void,
): Updates {
const container = "serviceWorker" in navigator ? navigator.serviceWorker : null;
let registration: ServiceWorkerRegistration | null = null;
let waiting: ServiceWorker | null = null;
/** Une version installée attend, et une version plus ancienne a la
* main : c'est une mise à jour (à la toute première installation,
* aucune page n'est encore contrôlée, et rien n'attend). */
const notice = (worker: ServiceWorker | null) => {
if (worker !== null && worker.state === "installed" && container?.controller) {
waiting = worker;
onAvailable();
}
};
/** Suit une version en cours d'installation ; résolue quand elle est
* installée (vrai) ou abandonnée (faux). */
const follow = (worker: ServiceWorker): Promise<boolean> =>
new Promise((resolve) => {
const settle = () => {
if (worker.state === "installed" || worker.state === "activated") {
notice(worker);
resolve(true);
} else if (worker.state === "redundant") {
resolve(false);
}
};
worker.addEventListener("statechange", settle);
settle();
});
if (container !== null) {
ready
.then(() => container.register(script))
.then((reg) => {
registration = reg;
notice(reg.waiting);
if (reg.installing !== null) {
void follow(reg.installing);
}
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) {
void follow(reg.installing);
}
});
})
.catch(() => {
// Refusé (http hors localhost) : l'application marche sans.
});
}
return {
get available() {
return waiting !== null;
},
async check() {
const reg = registration;
if (reg === null) {
return "unavailable";
}
try {
await reg.update();
} catch {
return "unavailable";
}
if (reg.waiting !== null) {
notice(reg.waiting);
return "found";
}
if (reg.installing !== null) {
return (await follow(reg.installing)) ? "found" : "latest";
}
return "latest";
},
apply() {
const worker = waiting ?? registration?.waiting ?? null;
if (container === null || worker === null) {
return;
}
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!reloading) {
reloading = true;
location.reload();
}
});
worker.postMessage({ type: SKIP_WAITING });
},
};
}
+141
View File
@@ -0,0 +1,141 @@
// Le conseil du maître pendant la partie (app/src/ui/advice-tracker.ts) :
// les analyses ne retardent pas le tour de l'ordinateur, la ligne
// n'apparaît qu'après la décision et pas si le joueur a déjà agi, et
// chaque conseil est rattaché au journal.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { analyseDecision } from "../src/domain/analysis/analyse.js";
import type { Advice } from "../src/domain/game/advice.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { createAdviceTracker } from "../src/ui/advice-tracker.js";
import { DOUBLE_TAKE_POSITION } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
/** Une analyse qu'on libère à la main. */
function heldAnalyses() {
const held: { id: string; release: () => Promise<void> }[] = [];
const analyse = (journal: GameJournal, eventId: string) =>
new Promise<Advice | null>((resolve) => {
held.push({
id: eventId,
release: async () => {
resolve(await analyseDecision(engine, journal, eventId));
await new Promise((r) => setTimeout(r, 0));
},
});
});
return { held, analyse };
}
let engine: Awaited<ReturnType<typeof loadNodeEngine>>;
async function opening(dice: number[]): Promise<Game> {
engine = await loadNodeEngine();
return Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
dice: fixedDice(dice),
});
}
async function play(game: Game, move: string): Promise<void> {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
}
test("le tour de l'ordinateur n'attend pas l'analyse ; la ligne vient ensuite", async () => {
const game = await opening([3, 1, 6, 5, 4, 2]);
const { held, analyse } = heldAnalyses();
let saved = 0;
let changes = 0;
const tracker = createAdviceTracker({
analyse,
recorded: () => saved++,
changed: () => changes++,
});
let before = game.events;
await game.roll();
tracker.track(game, before);
// Un jet seul ne montre rien : le joueur réfléchit.
assert.equal(held.length, 1, "le jet est analysé (aurait-il fallu doubler ?)");
before = game.events;
await play(game, "24/23 13/10");
tracker.track(game, before);
// Les analyses sont en attente : l'ordinateur joue quand même.
await game.playComputerTurn();
assert.equal(game.state.turn, "player");
assert.equal(tracker.shown, null);
for (const h of held) {
await h.release();
}
const shown = tracker.shown as Advice | null;
assert.equal(shown?.kind, "move");
assert.equal(shown?.skill, "veryBad");
assert.equal(changes, 1);
// Le coup est rattaché au journal (le jet aussi s'il y avait à dire).
assert.ok(saved >= 1);
assert.ok(game.advice[shown?.eventId ?? ""] !== undefined);
// L'action suivante du joueur efface la ligne.
tracker.clear();
assert.equal(tracker.shown, null);
});
test("si le joueur a déjà agi, la ligne n'apparaît pas, mais le conseil reste au journal", async () => {
const game = await opening([3, 1, 6, 5]);
const { held, analyse } = heldAnalyses();
const tracker = createAdviceTracker({ analyse, recorded: () => {}, changed: () => {} });
await game.roll();
const before = game.events;
await play(game, "8/5 6/5");
tracker.track(game, before);
tracker.clear(); // le joueur a relancé avant l'analyse
for (const h of held) {
await h.release();
}
assert.equal(tracker.shown, null);
const move = game.events.findLast((e) => e.type === "MovePlayed");
assert.equal(game.advice[move?.id ?? ""]?.skill, "none");
});
test("un double manqué avant le jet passe avant le coup dans la ligne", async () => {
engine = await loadNodeEngine();
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
const tracker = createAdviceTracker({
analyse: (journal, id) => analyseDecision(engine, journal, id),
recorded: () => {},
changed: () => {},
});
let before = game.events;
await game.roll();
tracker.track(game, before);
const best = await engine.bestMove(
game.state.position,
{ value: 1, owner: "centered" },
[6, 5],
0,
);
assert.ok(best !== null);
before = game.events;
for (const halfMove of best.halfMoves) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
tracker.track(game, before);
for (let i = 0; i < 50 && tracker.shown === null; i++) {
await new Promise((r) => setTimeout(r, 20));
}
const shown = tracker.shown as Advice | null;
assert.equal(shown?.kind, "cube");
assert.equal(shown?.kind === "cube" ? shown.action : null, "noDouble");
});
+273
View File
@@ -0,0 +1,273 @@
// Le conseil du maître (étape 1d) : analyses du moteur sur des décisions
// connues, phrases rendues sur ces analyses, conseil rattaché au journal et
// gardé à l'enregistrement.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
LINE_LIMIT,
masterLine,
masterWords,
PHRASE_KEYS,
} from "../src/domain/analysis/phrases.js";
import { ADVICE_VERSION, type Advice, type MoveAdvice } from "../src/domain/game/advice.js";
import { Game } from "../src/domain/game/game.js";
import { createGameLibrary } from "../src/storage/game-library.js";
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
import { memoryStorage } from "../src/storage/open-library.js";
import {
adviceOn,
loadPhrases,
playerAnswers,
playerDoubles,
playerRollsWithoutDoubling,
playOpening,
} from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
const phrases = loadPhrases();
test("la source des phrases a toutes ses sections, sans point d'exclamation", () => {
for (const key of PHRASE_KEYS) {
assert.ok((phrases.get(key)?.length ?? 0) > 0, `section ${key} absente ou vide`);
}
for (const [key, templates] of phrases) {
assert.ok(PHRASE_KEYS.includes(key), `section ${key} inconnue`);
for (const template of templates) {
assert.ok(!template.includes("!"), `« ! » dans ${key} : ${template}`);
}
}
});
// Des décisions réelles, analysées par le moteur à 2 plis. Les coups
// d'ouverture et leurs écarts viennent du classement de gnubg.
const cases: { name: string; play: () => Promise<Advice>; word: string }[] = [
{
name: "3-1 joué 8/5 6/5 : le meilleur coup",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [3, 1], "8/5 6/5"),
),
word: "Bon coup",
},
{
name: "2-1 joué 13/11 6/5 : tout près du meilleur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "13/11 6/5"),
),
word: "Bon coup",
},
{
name: "2-1 joué 13/10 : imprécision",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "13/11 11/10"),
),
word: "Imprécision",
},
{
name: "2-1 joué 24/22 6/5 : erreur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [2, 1], "24/22 6/5"),
),
word: "Erreur",
},
{
name: "3-1 joué 24/23 13/10 : grosse erreur",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playOpening(await loadNodeEngine(), [3, 1], "24/23 13/10"),
),
word: "Grosse erreur",
},
{
name: "double dans une position de double et prise",
play: async () =>
adviceOn(await loadNodeEngine(), await playerDoubles(await loadNodeEngine())),
word: "Bonne décision",
},
{
name: "lancer sans doubler dans une position de double",
play: async () =>
adviceOn(
await loadNodeEngine(),
await playerRollsWithoutDoubling(await loadNodeEngine()),
),
word: "Erreur",
},
{
name: "prendre un double juste",
play: async () =>
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), true)),
word: "Bonne décision",
},
{
name: "passer un double qu'il fallait prendre",
play: async () =>
adviceOn(await loadNodeEngine(), await playerAnswers(await loadNodeEngine(), false)),
word: "Erreur",
},
];
const analysed: Advice[] = [];
for (const c of cases) {
test(`conseil : ${c.name}`, async () => {
const advice = await c.play();
analysed.push(advice);
const words = masterWords(phrases, advice);
assert.equal(words.word, c.word, JSON.stringify(advice));
// Chaque gabarit tourne avec l'identifiant de l'événement : la ligne
// est rendue pour plusieurs identifiants.
for (let id = 1; id <= 40; id++) {
const line = masterLine(masterWords(phrases, { ...advice, eventId: String(id) }));
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
assert.ok(!line.includes("!"), line);
assert.ok(!/[{}]/.test(line), `repère non rempli : ${line}`);
}
});
}
test("le coup du maître est nommé, et le fait qui distingue les coups vient du plateau", async () => {
const engine = await loadNodeEngine();
const best = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "8/5 6/5"),
)) as MoveAdvice;
assert.equal(best.kind, "move");
assert.equal(best.version, ADVICE_VERSION);
assert.equal(best.rank, 1);
assert.equal(best.opening, true);
assert.deepEqual(best.playedFacts, { hits: 0, madePoints: [5] });
assert.equal(best.ranking[0]?.notation, "8/5 6/5");
const words = masterWords(phrases, best);
assert.equal(words.title, "C'est aussi le coup que j'aurais joué");
assert.equal(words.context, "votre 3-1 d'ouverture");
const worst = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "24/23 13/10"),
)) as MoveAdvice;
assert.equal(worst.skill, "veryBad");
assert.equal(worst.best.notation, "8/5 6/5");
assert.deepEqual(worst.bestFacts.madePoints, [5]);
const said = masterWords(phrases, worst);
assert.equal(said.title, "J'aurais joué 8/5 6/5");
assert.equal(said.phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
});
test("le rang n'est cité que pour un bon coup non meilleur ou une imprécision, au 2e ou 3e choix", async () => {
const engine = await loadNodeEngine();
const worst = (await adviceOn(
engine,
await playOpening(engine, [3, 1], "24/23 13/10"),
)) as MoveAdvice;
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
for (const rank of [2, 3, 4, 7, 15]) {
const { phrase } = masterWords(phrases, { ...worst, skill, rank });
const cited = (skill === "none" || skill === "doubtful") && rank <= 3;
if (cited) {
assert.ok(phrase.includes(`le ${rank}e choix`), `${skill} ${rank} : ${phrase}`);
} else {
assert.ok(!phrase.includes("choix"), `${skill} ${rank} : ${phrase}`);
assert.equal(phrase, "J'aurais joué 8/5 6/5. Mon coup fait votre point 5.");
}
}
}
});
test("décisions de videau : la décision du maître et le contexte", async () => {
const engine = await loadNodeEngine();
const missed = await adviceOn(engine, await playerRollsWithoutDoubling(engine));
const words = masterWords(phrases, missed);
assert.equal(words.phrase, "Ici, j'aurais doublé.");
assert.equal(words.title, "J'aurais doublé");
assert.equal(words.context, "avant votre jet");
const passed = masterWords(phrases, await adviceOn(engine, await playerAnswers(engine, false)));
assert.equal(passed.phrase, "Ici, j'aurais pris.");
assert.equal(passed.context, "le double de l'ordinateur");
});
test("tous les gabarits, remplis avec les plus longues notations, restent sous la limite", () => {
const long = "bar/22* 13/9*(2) 6/off";
const move = (equity: number) => ({ notation: long, halfMoves: [], equity, ply: 2 });
for (const skill of ["none", "doubtful", "bad", "veryBad"] as const) {
for (const rank of [1, 2, 23]) {
for (const facts of [
{ hits: 0, madePoints: [] },
{ hits: 1, madePoints: [4, 5, 7] },
{ hits: 3, madePoints: [] },
]) {
const advice: MoveAdvice = {
version: ADVICE_VERSION,
kind: "move",
eventId: "7",
ply: 2,
position: "4HPwATDgc/ABMA",
roll: [6, 6],
opening: false,
moveCount: 87,
rank,
played: move(-0.2),
best: move(0),
playedFacts: rank === 1 ? facts : { hits: 0, madePoints: [] },
bestFacts: facts,
ranking: [],
equityLoss: rank === 1 ? 0 : -0.2,
skill: rank === 1 ? "none" : skill,
};
for (let id = 0; id < 20; id++) {
const line = masterLine(
masterWords(phrases, { ...advice, eventId: String(id) }),
);
assert.ok(line.length < LINE_LIMIT, `trop long (${line.length}) : ${line}`);
}
}
}
}
});
test("le conseil est rattaché au journal et gardé à l'enregistrement", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const advice = await adviceOn(engine, played);
played.game.recordAdvice(advice);
// Le tour de l'ordinateur, puis l'enregistrement : le conseil reste.
await played.game.playComputerTurn();
assert.deepEqual(played.game.advice[played.eventId], advice);
const library = createGameLibrary(createKeyValueBackend(memoryStorage()));
await library.save("g", played.game.journal, { level: "2ply" });
const loaded = await library.load("g");
assert.ok(loaded !== null);
const resumed = Game.resume(engine, loaded.journal);
assert.deepEqual(resumed.advice[played.eventId], advice);
// Revenir en arrière garde le conseil, rattaché à son événement.
resumed.goBackTo(played.eventId);
assert.deepEqual(resumed.advice[played.eventId], advice);
});
test("migration : un journal sans conseil, ou avec un conseil d'une autre version", async () => {
const engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "8/5 6/5");
const { advice: _, ...withoutAdvice } = played.game.journal;
assert.deepEqual(Game.resume(engine, withoutAdvice).advice, {});
const advice = await adviceOn(engine, played);
const future = { ...advice, version: ADVICE_VERSION + 1 };
const orphan = { ...advice, eventId: "999" };
const resumed = Game.resume(engine, {
...played.game.journal,
advice: { [played.eventId]: future, "999": orphan },
});
assert.deepEqual(resumed.advice, {});
assert.throws(() => resumed.recordAdvice(orphan), /introuvable/);
});
+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, []);
});
+172
View File
@@ -0,0 +1,172 @@
// Tests du domaine : les branches d'une partie et son export SGF, sur des
// journaux écrits à la main (sans moteur, sauf un faux pour une position de
// départ particulière). La relecture des SGF par le vrai GNU Backgammon est
// faite par tests/test_web.sh (tests/sgf/check.py).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { branches, switchToBranch } from "../src/domain/game/branches.js";
import type { Board, Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
type GameJournal,
goBackTo,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { exportSgf } from "../src/domain/game/sgf.js";
function add(journal: GameJournal, ...events: NewGameEvent[]): GameJournal {
return events.reduce(appendEvent, journal);
}
const start: NewGameEvent = {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
};
const move = (side: "player" | "computer", text: string, position = "P"): NewGameEvent => ({
type: "MovePlayed",
side,
halfMoves: parseHalfMoves(text),
position,
});
const roll = (side: "player" | "computer" | null, a: number, b: number): NewGameEvent => ({
type: "DiceRolled",
side,
roll: [a, b],
});
/**
* Une partie et ses branches :
* 1 début, 2 ouverture 3-1 (joueur), 3 coup 8/5 6/5, 4 dés ordinateur 6-4,
* 5 coup 24/18 13/9, 6 dés joueur 2-1 ;
* rejouée après 3 : 7 dés ordinateur 5-5 ; puis rejouée après 2 (même
* ouverture, autre coup) : 8 coup 24/23 13/10.
*/
function branchedGame(): GameJournal {
let j = add(
EMPTY_JOURNAL,
start,
roll(null, 3, 1),
move("player", "8/5 6/5"),
roll("computer", 6, 4),
move("computer", "24/18 13/9"),
roll("player", 2, 1),
);
j = add(goBackTo(j, "3"), roll("computer", 5, 5));
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
return j;
}
test("les branches : feuilles de l'arbre, avec leur point de départ", () => {
const j = branchedGame();
const all = branches(j);
assert.deepEqual(
all.map((b) => [b.head, b.forkedFrom, b.forkMoveCount, b.moveCount, b.containsHead]),
[
["6", null, 0, 2, false],
["7", "3", 1, 1, false],
["8", "2", 0, 1, true],
],
);
assert.deepEqual(branches(EMPTY_JOURNAL), []);
// Passer d'une branche à l'autre ne perd rien.
const back = switchToBranch(j, "6");
assert.equal(back.events, j.events);
assert.deepEqual(
currentLine(back).map((e) => e.id),
["1", "2", "3", "4", "5", "6"],
);
assert.throws(() => switchToBranch(j, "4"), /branche introuvable/);
});
test("une branche rejouée puis continuée garde son ordre de création", () => {
// La ligne d'origine continue après la création d'une branche : elle
// reste la première.
let j = add(EMPTY_JOURNAL, start, roll(null, 3, 1), move("player", "8/5 6/5"));
j = add(goBackTo(j, "2"), move("player", "24/23 13/10"));
j = add(goBackTo(j, "3"), roll("computer", 6, 4));
assert.deepEqual(
branches(j).map((b) => [b.head, b.forkedFrom]),
[
["5", null],
["4", "2"],
],
);
});
/** Un faux moteur qui ne sait que décoder un plateau. */
function boardEngine(board: Board): Engine {
return { board: async () => board } as unknown as Engine;
}
const noEngine = boardEngine({} as Board);
const names = { playerName: "Vous", computerName: "Ordi]nateur", date: "2026-11-03" };
test("SGF : les coups, les variantes, la branche courante d'abord", async () => {
const sgf = await exportSgf(noEngine, branchedGame(), names);
assert.equal(
sgf,
"(;FF[4]GM[6]CA[UTF-8]AP[Ustagammon]MI[length:0][game:0][ws:0][bs:0]" +
"PW[Ordi\\]nateur]PB[Vous]DT[2026-11-03](" +
// Branche courante : même ouverture, autre coup.
"\n;B[31ablo])(" +
// Ligne d'origine : 8/5 6/5 (B : 'x' - (case - 1)).
"\n;B[31qtst](" +
// Puis 24/18 13/9 de l'ordinateur (W : 'a' + (case - 1)), et
// des dés lancés, pas joués.
"\n;W[64xrmi]\n;PL[B]DI[21])(" +
"\n;PL[W]DI[55])))\n",
);
});
test("SGF : barre, sortie, coup impossible, videau et résultat", async () => {
const j = add(
EMPTY_JOURNAL,
start,
roll(null, 1, 6),
move("computer", "bar/19 6/off"),
{ type: "CubeOffered", side: "player" },
{ type: "CubeTaken", side: "computer" },
roll("player", 6, 6),
move("player", ""),
{ type: "CubeOffered", side: "computer" },
{ type: "CubeDropped", side: "player" },
{ type: "GameEnded", winner: "computer", kind: "single", points: 2 },
);
const sgf = await exportSgf(noEngine, j, names);
assert.ok(sgf.includes("RE[W+2]"));
assert.ok(
sgf.endsWith("\n;W[16ysfz]\n;B[double]\n;W[take]\n;B[66]\n;W[double]\n;B[drop])\n"),
sgf,
);
});
test("SGF : une position de départ particulière est posée", async () => {
// Le joueur a la main : 2 pions sur sa case 1, 1 sur la barre ;
// l'ordinateur : 3 pions sur sa case 24. Videau à 4 chez l'ordinateur.
const points = (entries: Record<number, number>) =>
Array.from({ length: 24 }, (_, i) => entries[i + 1] ?? 0);
const engine = boardEngine({
onRoll: { points: points({ 1: 2 }), bar: 1, off: 12, pipCount: 27 },
opponent: { points: points({ 24: 3 }), bar: 0, off: 12, pipCount: 72 },
swappedPosition: "X",
result: null,
});
const j = add(EMPTY_JOURNAL, {
type: "GameStarted",
position: "ABC",
cube: { value: 4, owner: "computer" },
firstTurn: "player",
});
const sgf = await exportSgf(engine, j, names);
assert.ok(sgf.endsWith("\n;PL[B]AE[a:y]AW[x][x][x]AB[x][x][y]\n;CV[4]\n;CP[w])\n"), sgf);
await assert.rejects(exportSgf(noEngine, EMPTY_JOURNAL, names), /vide/);
});
+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 () => {
+5 -5
View File
@@ -52,13 +52,13 @@ async function playToTheEnd(game: Game, engine: Awaited<ReturnType<typeof loadNo
return actions;
}
for (const [seed, ply] of [
[1, 0],
[7, 2],
for (const [seed, level] of [
[1, "0ply"],
[7, "2ply"],
] as const) {
test(`partie complète, dés ${seed}, ordinateur à ${ply} pli(s)`, async () => {
test(`partie complète, dés ${seed}, ordinateur au niveau ${level}`, async () => {
const engine = await loadNodeEngine();
const game = Game.start(engine, { dice: seededDice(seed), ply });
const game = Game.start(engine, { dice: seededDice(seed), level });
await playToTheEnd(game, engine);
const events = game.events;
+344
View File
@@ -0,0 +1,344 @@
// L'analyse de partie (étape 1e) : les tours, le format durable de
// l'analyse et sa migration, les chiffres du bilan à la manière de gnubg,
// et une partie complète analysée par le vrai moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
chanceCurve,
gameTurns,
levelOfErrorRate,
luckRatingOf,
mainAdvice,
missingAnalyses,
readyForSummary,
replayPoint,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { ADVICE_VERSION, type Advice, type CubeAdvice } from "../src/domain/game/advice.js";
import type { Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
migrateJournal,
type NewGameEvent,
withAnalysesOf,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, REVIEW_VERSION } from "../src/domain/game/review.js";
import { loadNodeEngine } from "./support/node-engine.js";
import { playToTheEnd, seededDice } from "./support/play.js";
/** Un journal fait d'événements donnés, à la suite. */
function journalOf(events: readonly NewGameEvent[]): GameJournal {
return events.reduce(appendEvent, EMPTY_JOURNAL);
}
const START: NewGameEvent = {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
};
const P = START_POSITION;
/** Une petite partie : ouverture (égalité puis 3-1 pour le joueur), un
* coup de chaque camp, puis le joueur double et l'ordinateur passe. */
const SHORT: NewGameEvent[] = [
START,
{ type: "DiceRolled", side: null, roll: [2, 2] },
{ type: "DiceRolled", side: null, roll: [3, 1] },
{ type: "MovePlayed", side: "player", halfMoves: [{ from: 8, to: 5 }], position: P },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [{ from: 24, to: 13 }], position: P },
{ type: "CubeOffered", side: "player" },
{ type: "CubeDropped", side: "computer" },
{ type: "GameEnded", winner: "player", kind: "single", points: 1 },
];
test("les tours d'une partie : ouverture, coups, double passé", () => {
const journal = journalOf(SHORT);
const turns = gameTurns(journal);
assert.deepEqual(
turns.map((t) => [t.number, t.side, t.roll, t.move?.id ?? null, t.end?.id ?? null]),
[
[1, "player", [3, 1], "4", null],
[2, "computer", [6, 5], "6", null],
[3, "player", null, null, "9"],
],
);
// L'égalité d'ouverture appartient au premier tour.
assert.deepEqual(
turns[0]?.events.map((e) => e.id),
["2", "3", "4"],
);
// Le jet d'ouverture qui désigne le joueur a une chance, l'égalité non.
const luck = missingAnalyses(journal).filter((t) => t.kind === "luck");
assert.deepEqual(
luck.map((t) => t.eventId),
["3", "5"],
);
});
/** Un conseil fictif sur un coup du joueur. */
function moveAdvice(eventId: string, equityLoss: number, moveCount = 10): Advice {
const move = { notation: "8/5", halfMoves: [], equity: 0, ply: 2 };
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply: 2,
position: P,
roll: [3, 1],
opening: true,
moveCount,
rank: equityLoss === 0 ? 1 : 2,
played: move,
best: move,
playedFacts: { hits: 0, madePoints: [] },
bestFacts: { hits: 0, madePoints: [] },
ranking: [],
equityLoss,
skill:
equityLoss < -0.12
? "veryBad"
: equityLoss < -0.06
? "bad"
: equityLoss < -0.03
? "doubtful"
: "none",
};
}
function cubeAdvice(eventId: string, action: CubeAdvice["action"], equityLoss: number): Advice {
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply: 2,
action,
position: P,
equities: { noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 },
equityLoss,
skill: equityLoss < -0.06 ? "bad" : "none",
};
}
test("le taux d'erreur, le niveau et la chance à la manière de gnubg", () => {
// arThrsRating de gnubg : 0,032, 0,020, 0,013, 0,008, 0,005 ; les six
// classements de gnubg prennent, dans l'ordre, les noms des six niveaux
// de l'ordinateur (Advanced de gnubg : Intermédiaire).
assert.equal(levelOfErrorRate(0.05), "beginner");
assert.equal(levelOfErrorRate(0.032), "beginner");
assert.equal(levelOfErrorRate(0.031), "amateur");
assert.equal(levelOfErrorRate(0.015), "intermediate");
assert.equal(levelOfErrorRate(0.01), "advanced");
assert.equal(levelOfErrorRate(0.006), "expert");
assert.equal(levelOfErrorRate(0.001), "champion");
// getLuckRating : chance par tour × 10, seuils 0,3 et 0,6.
assert.equal(luckRatingOf(0.07), "veryLucky");
assert.equal(luckRatingOf(0.04), "lucky");
assert.equal(luckRatingOf(0.02), "none");
assert.equal(luckRatingOf(-0.04), "unlucky");
assert.equal(luckRatingOf(-0.07), "veryUnlucky");
const base = journalOf(SHORT);
const journal: GameJournal = {
...base,
advice: {
"4": moveAdvice("4", -0.08),
"7": cubeAdvice("7", "double", 0),
},
review: {
...emptyReview(2),
luck: {
"3": { value: 0.2, rating: "none" },
"5": { value: -0.1, rating: "none" },
},
computer: { "6": moveAdvice("6", 0) },
},
};
assert.ok(readyForSummary(journal));
const summary = summarizeAnalysis(journal);
// Un coup non forcé (0,08) et un double (compté toujours) : 0,04 par
// décision.
assert.equal(summary.player.errorRate.decisions, 2);
assert.ok(Math.abs(summary.player.errorRate.perDecision - 0.04) < 1e-9);
assert.equal(summary.player.errorRate.level, "beginner");
assert.deepEqual(summary.counts, { good: 1, doubtful: 0, bad: 1, veryBad: 0 });
assert.equal(summary.toReview?.advice.eventId, "4");
assert.equal(summary.toReview?.turn, 1);
// Une seule fois la main pour chacun : chance par tour égale au total.
assert.equal(summary.player.luck.rolls, 1);
assert.equal(summary.player.luck.rating, "veryLucky");
assert.equal(summary.computer.luck.rating, "veryUnlucky");
// Gagné 1 point, avec 0,2 de chance contre −0,1 pour l'ordinateur :
// 1 − 0,2 − 0,1.
assert.equal(summary.result, 1);
assert.ok(Math.abs(summary.luckAdjustedResult - 0.7) < 1e-9);
// Rejouer le premier tour : juste avant le coup, avec le même jet.
const first = gameTurns(journal)[0];
assert.ok(first !== undefined);
assert.equal(mainAdvice(journal, first)?.eventId, "4");
assert.equal(replayPoint(journal, first), "3");
// La courbe : le dernier tour finit la partie, gagnée.
assert.deepEqual(
chanceCurve(journal).map((p) => [p.turn, p.chance, p.verdict]),
[
[1, null, "bad"],
[2, null, null],
[3, 1, "good"],
],
);
});
test("un jet sans doubler ne compte que si la décision est serrée (isCloseCubedecision)", () => {
const base = journalOf([
{ ...START, firstTurn: "player" },
{ type: "DiceRolled", side: "player", roll: [3, 1] },
]);
const noDouble = (equities: CubeAdvice["equities"]): GameJournal => ({
...base,
advice: { "2": { ...(cubeAdvice("2", "noDouble", 0) as CubeAdvice), equities } },
});
// Doubler coûterait 0,2 par rapport à l'optimum : pas serré.
const far = noDouble({ noDouble: 0.5, doubleTake: 0.3, doublePass: 1, optimal: 0.5 });
assert.equal(summarizeAnalysis(far).player.errorRate.decisions, 0);
// Doubler est l'optimum : serré, compté.
const close = noDouble({ noDouble: 0.5, doubleTake: 0.6, doublePass: 1, optimal: 0.6 });
assert.equal(summarizeAnalysis(close).player.errorRate.decisions, 1);
// Un bon jet sans doubler n'est pas une décision visible du bilan.
assert.deepEqual(summarizeAnalysis(close).counts, {
good: 0,
doubtful: 0,
bad: 0,
veryBad: 0,
});
});
test("l'analyse de partie enregistrée : migration du format durable", () => {
const journal = journalOf(SHORT);
const review = {
...emptyReview(2),
luck: { "3": { value: 0.1, rating: "none" as const } },
winChances: { "4": 0.55, "99": 0.5 },
unanalysed: ["5", "98"],
};
// Un journal d'avant l'étape 1e se reprend tel quel.
assert.equal(migrateJournal(journal), journal);
// Les entrées rattachées à un événement absent sont oubliées.
const migrated = migrateJournal({ ...journal, review });
assert.deepEqual(migrated.review?.winChances, { "4": 0.55 });
assert.deepEqual(migrated.review?.unanalysed, ["5"]);
assert.deepEqual(migrated.review?.luck, review.luck);
// Une analyse complète et connue ne change pas.
const clean = { ...journal, review: migrated.review };
assert.equal(migrateJournal(clean), clean);
// Une autre version, ou une analyse mal formée, est oubliée.
const other = migrateJournal({
...journal,
review: { ...review, version: REVIEW_VERSION + 1 },
});
assert.equal(other.review, undefined);
const broken = { ...journal, review: { version: REVIEW_VERSION } } as unknown as GameJournal;
assert.equal(migrateJournal(broken).review, undefined);
// Les analyses d'une copie s'ajoutent au journal sans toucher aux
// conseils déjà là.
const live: GameJournal = { ...journal, advice: { "4": moveAdvice("4", 0) } };
const copy: GameJournal = {
...journal,
advice: { "4": moveAdvice("4", -0.5), "7": cubeAdvice("7", "double", 0) },
review,
};
const merged = withAnalysesOf(live, copy);
assert.equal(merged.advice?.["4"]?.equityLoss, 0);
assert.ok(merged.advice?.["7"] !== undefined);
assert.equal(merged.review, review);
});
/** Toutes les analyses qui manquent, une à une. */
async function analyseAll(engine: Engine, journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (let i = 0; i < 500; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
test("une partie complète analysée par le moteur, comme une partie d'avant 1d", async () => {
const engine = await loadNodeEngine();
// Une course courte (tests/reference/positions.json, race-1).
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "2ply",
dice: seededDice(7),
});
await playToTheEnd(game, engine, 1);
// Aucun conseil n'a été rangé pendant la partie (comme avant l'étape
// 1d) : tout est à analyser.
assert.equal(game.journal.advice, undefined);
assert.ok(!readyForSummary(game.journal));
const analysed = await analyseAll(engine, game.journal);
assert.ok(readyForSummary(analysed));
assert.deepEqual(missingAnalyses(analysed), []);
const summary = summarizeAnalysis(analysed);
const advices = Object.values(analysed.advice ?? {});
assert.ok(advices.length > 0);
// Le coup à revoir est la plus grosse perte d'équité du joueur, s'il en
// a une.
const worst = advices
.filter((a) => a.skill !== "none")
.reduce<Advice | null>((w, a) => (w === null || a.equityLoss < w.equityLoss ? a : w), null);
assert.equal(summary.toReview?.advice.eventId, worst?.eventId);
// La chance de chaque tour d'un camp est comptée.
const moves = game.events.filter((e) => e.type === "MovePlayed");
assert.equal(summary.player.luck.rolls + summary.computer.luck.rolls, moves.length);
// Les chances de gagner sont des probabilités, et la courbe finit sur
// le résultat.
const curve = chanceCurve(analysed);
assert.ok(curve.every((p) => p.chance !== null && p.chance >= 0 && p.chance <= 1));
const won = game.state.result?.winner === "player";
assert.equal(curve.at(-1)?.chance, won ? 1 : 0);
// Les coups de l'ordinateur sont analysés aussi.
assert.ok(Object.keys(analysed.review?.computer ?? {}).length > 0);
// Le journal analysé se reprend : l'analyse fait partie du format.
const resumed = Game.resume(engine, JSON.parse(JSON.stringify(analysed)));
assert.deepEqual(resumed.journal.review, analysed.review);
// Chaque coup s'écrit pareil sur l'écran de jeu (« L'ordinateur a
// joué … », moveNotation) et dans l'analyse : la notation du moteur.
let compared = 0;
let rewritten = 0;
for (const move of moves) {
if (move.type !== "MovePlayed" || move.halfMoves.length === 0) {
continue;
}
const advice =
move.side === "computer"
? analysed.review?.computer[move.id]
: analysed.advice?.[move.id];
if (advice?.kind !== "move") {
continue;
}
const notation = await resumed.moveNotation(move.id);
assert.equal(notation, advice.played.notation, `coup ${move.id}`);
compared++;
if (notation !== formatHalfMoves(move.halfMoves)) {
rewritten++;
}
}
assert.ok(compared > 5, `${compared} coups comparés`);
// La course finit en sortie : au moins un coup s'écrit autrement que
// ses demi-coups (« 6/off » pour « 6/2 2/off »…).
assert.ok(rewritten > 0, "aucun coup réécrit par le moteur");
});
+220 -16
View File
@@ -10,14 +10,24 @@ import type {
Board,
CubeDecision,
Engine,
EngineCube,
HalfMovesCheck,
Level,
Move,
} from "../src/domain/game/engine.js";
import { appendEvent, currentLine, EMPTY_JOURNAL, goBackTo } from "../src/domain/game/events.js";
import {
appendEvent,
currentLine,
EMPTY_JOURNAL,
EVENT_VERSION,
type GameJournal,
goBackTo,
migrateJournal,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { formatHalfMoves, parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { replay } from "../src/domain/game/state.js";
import { DEFAULT_LEVEL, replay } from "../src/domain/game/state.js";
/** Un faux moteur : chaque réponse est réglable, et les positions sont des
* textes sans signification. */
@@ -51,6 +61,17 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
board: async () => notOver,
levelMove: async () => move,
levelCubeDecision: async () => noDouble,
levelDouble: async () => false,
levelTake: async () => true,
matchAfterGame: async (match, winner, points) => {
const score = { ...match.score };
score[winner] += points;
return {
score,
over: score[winner] >= match.length,
crawford: score[winner] === match.length - 1,
};
},
analyseMove: async () => null,
analyseCube: async () => ({
analysed: false,
@@ -59,6 +80,17 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
evaluate: async () => ({ win: 0.5, equity: 0 }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
score: [0, 0],
cubeValue: 1,
cubeOwner: -1,
playerOnRoll: 0,
crawford: false,
jacoby: true,
}),
...overrides,
};
}
@@ -227,10 +259,8 @@ test("le joueur ne peut pas laisser passer son tour s'il a un coup", async () =>
assert.equal(game.state.phase, "moving");
});
test("l'ordinateur double si le moteur le recommande ; le joueur prend", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
test("l'ordinateur double si le moteur le dit à son niveau ; le joueur prend", async () => {
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, { setup: { position: "X", firstTurn: "computer" } });
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
@@ -241,22 +271,18 @@ test("l'ordinateur double si le moteur le recommande ; le joueur prend", async (
});
test("le joueur passe : il perd la partie à la valeur d'avant le double", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: true }),
});
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "computer", cube: { value: 2, owner: "computer" } },
});
await game.playComputerTurn();
game.drop();
await game.drop();
assert.equal(game.state.phase, "over");
assert.deepEqual(game.state.result, { winner: "computer", kind: "single", points: 2 });
});
test("le joueur double : l'ordinateur passe si le moteur le dit", async () => {
const engine = fakeEngine({
cubeDecision: async () => ({ available: true, double: true, take: false }),
});
test("le joueur double : l'ordinateur passe si le moteur le dit à son niveau", async () => {
const engine = fakeEngine({ levelTake: async () => false });
const game = Game.start(engine, { setup: { position: "X", firstTurn: "player" } });
await game.double();
assert.deepEqual(
@@ -273,7 +299,7 @@ test("le joueur ne peut pas doubler si le videau est chez l'ordinateur", async (
const game = Game.start(engine, {
setup: { position: "X", firstTurn: "player", cube: { value: 2, owner: "computer" } },
});
await assert.rejects(game.double(), /videau/);
await assert.rejects(game.double(), /pas doubler/);
assert.equal(game.events.length, 1);
});
@@ -329,7 +355,7 @@ test("les événements sont immuables et versionnés ; le rejeu est déterminist
});
journal = appendEvent(journal, { type: "DiceRolled", side: null, roll: [5, 2] });
const [started, rolled] = currentLine(journal);
assert.equal(started?.version, 1);
assert.equal(started?.version, 2);
assert.equal(started?.parent, null);
assert.equal(rolled?.parent, started?.id);
assert.throws(() => {
@@ -403,3 +429,181 @@ test("le plateau affiché est donné du point de vue de chaque camp", async () =
assert.equal(swapped.player.pipCount, 2);
assert.equal(swapped.computer.pipCount, 1);
});
// ------------------------------------------------------------- Match (1c)
test("une partie de match garde son niveau et son match, et les donne au moteur vus du camp au trait", async () => {
const cubes: EngineCube[] = [];
const levels: Level[] = [];
const engine = fakeEngine({
levelDouble: async (_position, cube, level) => {
cubes.push(cube);
levels.push(level);
return false;
},
});
const match = { length: 5, score: { player: 1, computer: 3 }, crawford: false };
const game = Game.start(engine, {
dice: fixedDice([2, 5, 3, 1, 4, 2]),
level: "intermediate",
match,
});
const start = game.events[0];
assert.ok(start?.type === "GameStarted");
assert.equal(start.level, "intermediate");
assert.deepEqual(start.match, match);
assert.equal(game.state.level, "intermediate");
// L'ordinateur commence (2-5) et joue, puis le joueur ; ensuite
// l'ordinateur pense à doubler, et le match lui est donné vu de lui.
await game.playComputerTurn();
await game.roll();
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
await game.playComputerTurn();
assert.deepEqual(levels, ["intermediate"]);
assert.deepEqual(cubes[0]?.match, {
length: 5,
score: { onRoll: 3, opponent: 1 },
crawford: false,
});
});
test("une partie de match finie : le moteur dit le score, la fin du match et la partie Crawford suivante", async () => {
const winning: Board = {
onRoll: { points: [], bar: 0, off: 15, pipCount: 0 },
opponent: { points: [], bar: 0, off: 0, pipCount: 100 },
swappedPosition: "SWAPPED",
result: { winner: "onRoll", kind: "gammon", points: 2 },
};
const engine = fakeEngine({ board: async () => winning });
const game = Game.start(engine, {
dice: fixedDice([5, 2]),
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
assert.equal(game.nextGame, null);
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const end = game.events.at(-1);
assert.ok(end?.type === "GameEnded");
assert.deepEqual(end.match, { score: { player: 2, computer: 0 }, over: false, crawford: true });
const next = game.nextGame;
assert.deepEqual(next, {
level: "advanced",
match: { length: 3, score: { player: 2, computer: 0 }, crawford: true },
});
// La partie Crawford, gagnée : le match est fini, plus de partie suivante.
const crawford = Game.start(engine, { dice: fixedDice([5, 2]), ...(game.nextGame ?? {}) });
await crawford.playHalfMove({ from: 8, to: 5 });
await crawford.playHalfMove({ from: 6, to: 5 });
await crawford.confirmMove();
assert.deepEqual(crawford.state.matchAfter, {
score: { player: 4, computer: 0 },
over: true,
crawford: false,
});
assert.equal(crawford.nextGame, null);
// « Rejouer » après le match : un nouveau match de même longueur, à 0 à
// 0, au même niveau.
assert.deepEqual(crawford.rematch, {
level: "advanced",
match: { length: 3, score: { player: 0, computer: 0 }, crawford: false },
});
});
test("passer un double dans un match : le moteur compte le match", async () => {
const engine = fakeEngine({ levelDouble: async () => true });
const game = Game.start(engine, {
dice: fixedDice([2, 5, 3, 1]),
match: { length: 3, score: { player: 0, computer: 1 }, crawford: false },
});
// L'ordinateur joue son jet d'ouverture, le joueur son coup, puis
// l'ordinateur double.
await game.playComputerTurn();
await game.roll();
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
await game.drop();
assert.deepEqual(game.state.matchAfter, {
score: { player: 0, computer: 2 },
over: false,
crawford: true,
});
});
test("une partie libre n'a ni match ni partie suivante", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]) });
assert.equal(game.state.match, null);
assert.equal(game.state.level, DEFAULT_LEVEL);
assert.equal(game.nextGame, null);
});
test("« Rejouer » une partie libre : une partie libre au même niveau, depuis le départ", () => {
const game = Game.start(fakeEngine(), { dice: fixedDice([3, 1]), level: "0ply" });
assert.deepEqual(game.rematch, { level: "0ply" });
});
test("migration : un journal de la version 1 se reprend en version 2, au niveau 2 plis, en partie libre", async () => {
// Un journal tel que l'étape 1b l'enregistrait : version 1, GameStarted
// sans level ni match, un coup joué, une branche.
const v1 = {
events: [
{
id: "1",
parent: null,
version: 1,
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
},
{ id: "2", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [3, 1] },
{
id: "3",
parent: "2",
version: 1,
type: "MovePlayed",
side: "player",
halfMoves: [
{ from: 8, to: 5 },
{ from: 6, to: 5 },
],
position: "SWAPPED",
},
{ id: "4", parent: "1", version: 1, type: "DiceRolled", side: null, roll: [5, 2] },
],
head: "3",
} as unknown as GameJournal;
const before = replay(currentLine(v1));
const migrated = migrateJournal(v1);
assert.ok(migrated.events.every((event) => event.version === EVENT_VERSION));
assert.equal(migrated.head, "3");
const start = migrated.events[0];
assert.ok(start?.type === "GameStarted");
assert.equal(start.level, "2ply");
assert.equal(start.match, undefined);
// Rien d'autre ne change : mêmes événements, même arbre, même état.
const strip = (journal: GameJournal) =>
journal.events.map(({ version: _, ...event }) => {
const { level: _level, ...rest } = event as typeof event & { level?: unknown };
return rest;
});
assert.deepEqual(strip(migrated), strip(v1));
assert.deepEqual(replay(currentLine(migrated)), { ...before, level: "2ply" });
// Une version courante passe telle quelle.
assert.equal(migrateJournal(migrated), migrated);
const resumed = Game.resume(fakeEngine(), JSON.parse(JSON.stringify(v1)));
assert.equal(resumed.state.level, "2ply");
assert.equal(resumed.state.match, null);
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
// La partie continue en version 2.
await resumed.playComputerTurn();
assert.ok(resumed.journal.events.every((event) => event.version === EVENT_VERSION));
});
+148
View File
@@ -0,0 +1,148 @@
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
// premier lancement, règles d'apparition de la carte d'installation,
// réglages enregistrés par une version précédente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { homeModel } from "../src/ui/home-model.js";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
INSTALL_AGAIN_AFTER,
installCardShown,
isFirstLaunch,
parseSettings,
type Settings,
TUTORIAL_DONE,
} from "../src/ui/settings.js";
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
import { parseTutorial } from "../src/ui/tutorial-text.js";
const root = new URL("../../../", import.meta.url);
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
assert.equal(old.welcomed, false);
assert.equal(old.gamesFinished, 0);
assert.equal(old.tutorialSeen, 0);
assert.equal(old.installed, false);
const saved = parseSettings(
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
'"installDismissedAt": 2, "installed": true}',
);
assert.deepEqual(saved, {
...DEFAULT_SETTINGS,
welcomed: true,
gamesFinished: 2,
tutorialSeen: 5,
installDismissals: 1,
installDismissedAt: 2,
installed: true,
});
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => {
const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 };
// Partie en cours, ou moteur qui se charge : la miniature et le compte.
assert.deepEqual(
[homeModel(settings, true, false).board, homeModel(settings, true, false).resume],
[true, true],
);
assert.equal(homeModel(settings, null, false).board, true);
// Partie finie : le titre et « Nouvelle partie » seulement (recette 1h).
const over = homeModel(settings, false, false);
assert.equal(over.board, false);
assert.equal(over.resume, false);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
});
test("invitation à installer : après la première partie terminée, une seule relance", () => {
const at = (changes: Partial<Settings>, installed = false) =>
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
assert.ok(!at({}), "jamais avant la première partie terminée");
assert.ok(at({ gamesFinished: 1 }));
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
assert.ok(!at({ gamesFinished: 1, installed: true }));
// « Plus tard » : masquée, puis de retour après trois parties de plus.
const later = { installDismissals: 1, installDismissedAt: 1 };
assert.equal(INSTALL_AGAIN_AFTER, 3);
assert.ok(!at({ ...later, gamesFinished: 1 }));
assert.ok(!at({ ...later, gamesFinished: 3 }));
assert.ok(at({ ...later, gamesFinished: 4 }));
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
});
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
const explain = (step: number) => ({ step, practice: false });
const practice = (step: number) => ({ step, practice: true });
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
assert.equal(nextPlace(tutorial, practice(8)), "end");
assert.equal(previousPlace(tutorial, explain(1)), null);
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
explain(1),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
explain(2),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
explain(1),
);
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
assert.equal(seenAfter(5, explain(3)), 5);
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
const bar = progress(8, explain(3), 5);
assert.deepEqual(
bar.filter((s) => s.link).map((s) => s.step),
[1, 2, 4, 5],
);
assert.deepEqual(
bar.filter((s) => s.done).map((s) => s.step),
[1, 2, 3],
);
assert.deepEqual(
bar.filter((s) => s.current).map((s) => s.step),
[3],
);
});
test("la marche à suivre pour installer dépend du navigateur", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
const chrome =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/130.0 Mobile Safari/537.36";
assert.equal(installPlatform(iphone), "ios");
assert.equal(installPlatform(ipad), "ios");
assert.equal(installPlatform(firefox), "firefox");
// Sans invite du système, Chrome suit la marche à suivre de Firefox
// (menu ⋮, « Installer »), la plus proche de la sienne.
assert.equal(installPlatform(chrome), "firefox");
});
+34 -6
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", () => {
@@ -218,14 +242,18 @@ function sources(dir: string): string[] {
}
test("aucune boîte native du navigateur (confirm, alert, prompt) dans l'application", () => {
// Une méthode d'un autre objet ne compte pas : event.prompt() est l'invite
// d'installation de Chrome (install.ts), pas une boîte de dialogue.
for (const path of sources("app/src/")) {
const text = read(path);
assert.doesNotMatch(
text,
/\b(window\.)?(confirm|alert|prompt)\s*\(/,
/(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m,
`${path} appelle une boîte native`,
);
}
assert.match("window.alert(1)", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
assert.match("x = confirm('?')", /(^|[^.\w])(window\.)?(confirm|alert|prompt)\s*\(/m);
});
test("les deux blocs du thème sombre de app.css sont identiques", () => {
+440
View File
@@ -0,0 +1,440 @@
// Tests des leçons (étape 1g) : le texte des cinq leçons (docs/lecons/) se
// lit ; chaque position est valide pour le vrai moteur ; chaque coup attendu
// d'un exercice est le meilleur coup du moteur à 2 plis, avec plus de 0,04
// d'avance sur le deuxième, et seul accepté ; chaque coup illustré est aussi
// le meilleur coup ; chaque compte de points cité est celui du moteur. Puis
// la progression : sa lecture (formats durables, ADR 0007), son
// enregistrement, les états affichés et la leçon proposée.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { test } from "node:test";
import type { Engine, EngineCube } from "../src/domain/game/engine.js";
import type { Roll } from "../src/domain/game/roll.js";
import {
finishedLessons,
LESSON_END,
LESSON_PROGRESS_VERSION,
type LessonOutline,
lessonRecord,
lessonState,
NO_PROGRESS,
nextLesson,
readLessonProgress,
resumeStep,
withSolved,
withVisit,
} from "../src/domain/learning/lessons.js";
import { MovePractice, tutorialBoard } from "../src/domain/learning/tutorial.js";
import { createKeyValueBackend } from "../src/storage/key-value-backend.js";
import { createLessonStore } from "../src/storage/lesson-store.js";
import { memoryStorage } from "../src/storage/open-library.js";
import { LESSON_COUNT, type Lesson, outlineOf, parseLesson } from "../src/ui/lesson-text.js";
import { LESSON_TEXTS } from "../src/ui/lesson-texts.js";
import { lessonCards, nextLessonCard } from "../src/ui/lessons-model.js";
import { lessonPlaceOf, lessonPlaceRoute } from "../src/ui/tutorial-nav.js";
import type { TutorialScreen } from "../src/ui/tutorial-text.js";
import { loadNodeEngine } from "./support/node-engine.js";
const root = new URL("../../../", import.meta.url);
const lessons: Lesson[] = Array.from({ length: LESSON_COUNT }, (_, i) =>
parseLesson(i + 1, readFileSync(new URL(`docs/lecons/lecon-${i + 1}.md`, root), "utf8")),
);
/** Niveau d'analyse des réponses attendues (décision de Pierre). */
const PLY = 2;
/** Avance minimale du coup attendu sur le deuxième : un exercice n'a
* qu'une bonne réponse nette. */
const MIN_GAP = 0.04;
const CENTERED: EngineCube = { value: 1, owner: "centered" };
/** Les écrans d'une leçon, avec leur nom pour les messages. */
function screens(lesson: Lesson): [string, TutorialScreen][] {
return lesson.steps.flatMap((s) => [
[`leçon ${lesson.number}, étape ${s.number}`, s.explain] as [string, TutorialScreen],
...(s.practice === null
? []
: [
[`leçon ${lesson.number}, étape ${s.number}, à vous`, s.practice] as [
string,
TutorialScreen,
],
]),
]);
}
const allScreens = () => lessons.flatMap(screens);
/** Le classement du moteur à 2 plis pour un coup : son rang, et l'écart
* avec le meilleur coup suivant (positif : le coup a cette avance). */
async function rankOf(
engine: Engine,
position: string,
roll: Roll,
notation: string,
): Promise<{ rank: number; best: string; gap: number; second: string }> {
const legal = await engine.legalMoves(position, roll);
const move = legal.find((m) => m.notation === notation);
assert.ok(move !== undefined, `${notation} n'est pas un coup légal de ${roll.join("-")}`);
const analysis = await engine.analyseMove(position, CENTERED, roll, move.position, PLY);
assert.ok(analysis !== null, `${notation} : aucune analyse`);
const second = analysis.ranking.find((m) => m.position !== analysis.best.position);
assert.ok(second !== undefined, `${notation} : un seul coup possible, rien à choisir`);
// Le deuxième coup analysé à son tour : l'analyse le réévalue au même
// niveau que le meilleur, ce qui donne l'écart exact.
const other = await engine.analyseMove(position, CENTERED, roll, second.position, PLY);
assert.ok(other !== null);
return {
rank: analysis.rank,
best: analysis.best.notation,
gap: -other.equityLoss,
second: second.notation,
};
}
test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fin", () => {
assert.equal(lessons.length, 5);
assert.deepEqual(
readdirSync(new URL("docs/lecons/", root))
.filter((f) => f.startsWith("lecon-"))
.sort(),
["lecon-1.md", "lecon-2.md", "lecon-3.md", "lecon-4.md", "lecon-5.md"],
);
assert.deepEqual(
lessons.map((l) => l.title),
[
"Les ouvertures",
"Faire des points",
"Frapper ou ne pas frapper",
"La course et le compte de points",
"Sortir sans se faire frapper",
],
);
for (const lesson of lessons) {
const exercises = outlineOf(lesson).exercises.length;
assert.ok(
exercises >= 2 && exercises <= 4,
`leçon ${lesson.number} : ${exercises} exercices`,
);
assert.ok(lesson.end.action !== "" && lesson.end.paragraphs.length > 0);
for (const step of lesson.steps) {
assert.ok(
step.explain.paragraphs.length > 0,
`leçon ${lesson.number}, étape ${step.number} sans texte`,
);
const practice = step.practice;
if (practice !== null) {
assert.ok(
practice.paragraphs.length > 0,
`leçon ${lesson.number} : consigne absente`,
);
assert.ok(practice.right.length > 0 && practice.wrong.length > 0);
assert.equal(practice.expected.length, 1, "un seul coup attendu par exercice");
assert.ok(practice.roll !== null);
}
}
}
});
test("le texte des leçons garde le ton du maître et les mots du glossaire", () => {
for (const lesson of lessons) {
const text = readFileSync(new URL(`docs/lecons/lecon-${lesson.number}.md`, root), "utf8");
// Pas de point d'exclamation, pas de « camp » (remplacé par les
// jans), pas de reproche.
assert.ok(!text.includes("!"), `leçon ${lesson.number} : point d'exclamation`);
assert.ok(!/\bcamp\b/i.test(text), `leçon ${lesson.number} : « camp »`);
assert.ok(
!/vous auriez dû|il fallait|erreur/i.test(text),
`leçon ${lesson.number} : reproche`,
);
assert.ok(text.includes("CC BY-SA 4.0"), `leçon ${lesson.number} : licence`);
const inlines = screens(lesson).flatMap(([, s]) => [
...s.paragraphs.flat(),
...s.right,
...s.wrong,
]);
for (const term of inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]))) {
assert.ok(
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
`fiche ${term}.md absente`,
);
}
}
const terms = new Set(
lessons
.flatMap((l) => screens(l).flatMap(([, s]) => s.paragraphs.flat()))
.flatMap((i) => (i.term === undefined ? [] : [i.term])),
);
for (const term of ["jan-interieur", "jan-exterieur", "point", "blot", "compte-de-points"]) {
assert.ok(terms.has(term), `aucun lien vers ${term}.md`);
}
});
test("chaque position des leçons est valide, et ses comptes de points sont ceux du moteur", async () => {
const engine = await loadNodeEngine();
let counts = 0;
for (const [name, screen] of allScreens()) {
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
for (const side of ["player", "computer"] as const) {
const s = board.sides[side];
assert.equal(
s.points.reduce((a, b) => a + b, 0) + s.bar + s.off,
15,
`${name} : ${side}`,
);
}
assert.equal(board.result, null, `${name} : partie finie`);
if (screen.pips !== null) {
counts++;
assert.deepEqual(
[board.sides.player.pipCount, board.sides.computer.pipCount],
screen.pips,
`${name} : comptes de points`,
);
}
}
assert.ok(counts >= 4, "la leçon 4 cite les comptes de points");
});
test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => {
const engine = await loadNodeEngine();
const report: string[] = [];
for (const [name, screen] of allScreens()) {
const notation = screen.expected[0];
if (notation === undefined || screen.roll === null) {
continue;
}
const ranked = await rankOf(engine, screen.position, screen.roll, notation);
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${notation}`);
assert.ok(
ranked.gap > MIN_GAP,
`${name} : ${notation} devant ${ranked.second} de ${ranked.gap.toFixed(3)} seulement`,
);
report.push(`${name} : ${notation} (+${ranked.gap.toFixed(3)} sur ${ranked.second})`);
}
assert.equal(report.length, 16);
});
// Couvre aussi le « nettement devant tous les autres » de la leçon 1 (étape
// 1) : chaque coup illustré a plus de MIN_GAP d'avance sur le deuxième.
test("chaque coup illustré est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => {
const engine = await loadNodeEngine();
let illustrated = 0;
for (const [name, screen] of allScreens()) {
if (screen.move === null || screen.roll === null) {
continue;
}
illustrated++;
const ranked = await rankOf(engine, screen.position, screen.roll, screen.move);
assert.equal(ranked.rank, 1, `${name} : le moteur préfère ${ranked.best} à ${screen.move}`);
assert.ok(
ranked.gap > MIN_GAP,
`${name} : ${screen.move} seulement +${ranked.gap.toFixed(3)}`,
);
}
assert.ok(illustrated >= 5);
});
test("chaque exercice accepte le coup du moteur et refuse tous les autres", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of allScreens()) {
if (screen.expected.length === 0 || screen.roll === null) {
continue;
}
const legal = await engine.legalMoves(screen.position, screen.roll);
assert.ok(legal.length > 1, `${name} : rien à refuser`);
for (const move of legal) {
const practice = await MovePractice.start(
engine,
screen.position,
screen.roll,
screen.expected,
);
let verdict = "continue";
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
if (verdict !== "continue") {
break;
}
}
const right = screen.expected.includes(move.notation);
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
}
}
});
// ------------------------------------------------------- Progression
const outlines: LessonOutline[] = [
{ number: 1, exercises: [1, 2, 3, 4] },
{ number: 2, exercises: [1, 2, 3] },
{ number: 3, exercises: [1, 2] },
];
test("la progression enregistrée se relit, même absente, abîmée ou d'une autre version", () => {
assert.deepEqual(readLessonProgress(undefined), NO_PROGRESS);
assert.deepEqual(readLessonProgress("texte"), NO_PROGRESS);
assert.deepEqual(readLessonProgress({ version: 99, lessons: {} }), NO_PROGRESS);
assert.deepEqual(readLessonProgress({ version: 1, lessons: null }), NO_PROGRESS);
const saved = {
version: LESSON_PROGRESS_VERSION,
lessons: {
1: { last: 3, reached: 3, solved: [2, 1, 2, -1, 1.5] },
2: { last: "x", reached: 1, solved: [] },
x: { last: 1, reached: 1, solved: [] },
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
},
};
assert.deepEqual(readLessonProgress(JSON.parse(JSON.stringify(saved))), {
version: 1,
lessons: {
1: { last: 3, reached: 3, solved: [1, 2] },
4: { last: LESSON_END, reached: LESSON_END, solved: [1] },
},
});
});
test("les états d'une leçon : à commencer, « 2 exercices sur 4 », terminée", () => {
let progress = NO_PROGRESS;
const first = outlines[0] as LessonOutline;
assert.deepEqual(lessonState(progress, first), { kind: "toStart", total: 4 });
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "À commencer · 4 exercices");
progress = withVisit(progress, 1, 1);
assert.deepEqual(lessonState(progress, first), { kind: "inProgress", solved: 0, total: 4 });
progress = withSolved(withSolved(withVisit(progress, 1, 2), 1, 1), 1, 2);
progress = withSolved(progress, 1, 2);
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "2 exercices sur 4");
assert.deepEqual(lessonRecord(progress, 1), { last: 2, reached: 2, solved: [1, 2] });
// Revenir en arrière garde l'étape la plus loin vue.
progress = withVisit(progress, 1, 1);
assert.deepEqual(lessonRecord(progress, 1), { last: 1, reached: 2, solved: [1, 2] });
progress = withSolved(withSolved(progress, 1, 3), 1, 4);
assert.deepEqual(lessonState(progress, first), { kind: "done", total: 4 });
assert.equal(LESSON_TEXTS.state(lessonState(progress, first)), "Terminée");
assert.equal(
LESSON_TEXTS.state({ kind: "inProgress", solved: 1, total: 3 }),
"1 exercice sur 3",
);
});
test("la leçon proposée : la première en cours, sinon la première à commencer", () => {
assert.equal(nextLesson(NO_PROGRESS, outlines)?.number, 1);
let progress = withVisit(NO_PROGRESS, 2, 1);
assert.equal(nextLesson(progress, outlines)?.number, 2);
for (const step of [1, 2, 3]) {
progress = withSolved(progress, 2, step);
}
assert.equal(nextLesson(progress, outlines)?.number, 1, "leçon 2 terminée");
assert.equal(finishedLessons(progress, outlines), 1);
for (const [number, steps] of [
[1, [1, 2, 3, 4]],
[3, [1, 2]],
] as const) {
for (const step of steps) {
progress = withSolved(progress, number, step);
}
}
assert.equal(nextLesson(progress, outlines), null, "toutes terminées");
assert.equal(finishedLessons(progress, outlines), 3);
});
test("« Continuer » reprend là où l'on a quitté la leçon", () => {
const first = outlines[0] as LessonOutline;
assert.equal(resumeStep(NO_PROGRESS, first), 1);
let progress = withVisit(NO_PROGRESS, 1, 3);
assert.equal(resumeStep(progress, first), 3);
// Après la fin, le premier exercice pas encore réussi.
progress = withSolved(withVisit(progress, 1, LESSON_END), 1, 1);
assert.equal(resumeStep(progress, first), 2);
});
test("la progression est enregistrée dans le stockage des parties et relue", async () => {
const storage = memoryStorage();
const store = createLessonStore(createKeyValueBackend(storage));
assert.deepEqual(await store.load(), NO_PROGRESS);
const progress = withSolved(withVisit(NO_PROGRESS, 3, 2), 3, 2);
void store.save(progress);
// Les enregistrements passent avant la lecture qui suit.
assert.deepEqual(await store.load(), progress);
assert.deepEqual(JSON.parse(storage.getItem("ustagammon.lessons") ?? "null"), progress);
const again = createLessonStore(createKeyValueBackend(storage));
assert.deepEqual(await again.load(), progress);
// Une valeur illisible n'empêche rien.
storage.setItem("ustagammon.lessons", "{abîmé");
assert.deepEqual(await again.load(), NO_PROGRESS);
// Un stockage qui refuse l'écriture non plus.
const refused = createLessonStore({
lessonProgress: async () => {
throw new Error("refusé");
},
saveLessonProgress: async () => {
throw new Error("refusé");
},
});
await refused.save(progress);
assert.deepEqual(await refused.load(), NO_PROGRESS);
});
test("la liste des leçons et la carte de l'accueil", () => {
const empty = lessonCards(lessons, NO_PROGRESS);
assert.deepEqual(
empty.map((c) => [c.number, c.state.kind, c.current, c.href]),
[
[1, "toStart", true, "#/lecons/1/1"],
[2, "toStart", false, "#/lecons/2/1"],
[3, "toStart", false, "#/lecons/3/1"],
[4, "toStart", false, "#/lecons/4/1"],
[5, "toStart", false, "#/lecons/5/1"],
],
);
let progress = withSolved(withVisit(NO_PROGRESS, 2, 2), 2, 1);
const card = nextLessonCard(lessons, progress);
assert.equal(card?.number, 2);
assert.equal(card?.href, "#/lecons/2/2");
assert.deepEqual(card?.exercises, [true, false, false]);
assert.equal(
LESSON_TEXTS.state(card?.state ?? { kind: "toStart", total: 0 }),
"1 exercice sur 3",
);
for (const lesson of lessons) {
for (const step of outlineOf(lesson).exercises) {
progress = withSolved(progress, lesson.number, step);
}
}
assert.equal(nextLessonCard(lessons, progress), null);
});
test("les écrans d'une leçon ont leur adresse, ramenée à un écran qui existe", () => {
const lesson = lessons[0] as Lesson;
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 2, practice: true }),
{
step: 2,
practice: true,
},
);
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: null, practice: false }),
{
step: 1,
practice: false,
},
);
assert.deepEqual(
lessonPlaceOf(lesson, { screen: "lesson", lesson: 1, step: 9, practice: false }),
{
step: 1,
practice: false,
},
);
assert.equal(lessonPlaceOf(lesson, { screen: "lessonEnd", lesson: 1 }), "end");
assert.deepEqual(lessonPlaceRoute(3, { step: 2, practice: true }), {
screen: "lesson",
lesson: 3,
step: 2,
practice: true,
});
assert.deepEqual(lessonPlaceRoute(3, "end"), { screen: "lessonEnd", lesson: 3 });
});
+128
View File
@@ -0,0 +1,128 @@
// Tests du choix de la partie et du match, sans navigateur : réglages de
// l'écran « Nouvelle partie », correspondance des niveaux avec ceux de
// GNU Backgammon, résumé d'une partie de match enregistrée, adaptateur du
// moteur (Match ID d'un match) sur le vrai moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { Game } from "../src/domain/game/game.js";
import { COMPUTER_LEVELS, computerLevelOf, ENGINE_LEVELS } from "../src/domain/game/level.js";
import { firstGameOf, MATCH_LENGTHS, nextGameOf } from "../src/domain/game/match.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { levelOf, summarizeGame } from "../src/domain/game/saved-game.js";
import { chosenGame, DEFAULT_SETTINGS, parseSettings } from "../src/ui/settings.js";
import { loadNodeEngine } from "./support/node-engine.js";
test("les six niveaux montrés au joueur, du plus faible au plus fort, et leur niveau de gnubg", () => {
assert.deepEqual(COMPUTER_LEVELS, [
"beginner",
"amateur",
"intermediate",
"advanced",
"expert",
"champion",
]);
assert.deepEqual(
COMPUTER_LEVELS.map((level) => ENGINE_LEVELS[level]),
["beginner", "casual", "intermediate", "advanced", "0ply", "2ply"],
);
assert.equal(computerLevelOf("casual"), "amateur");
assert.equal(computerLevelOf("3ply"), null);
});
test("réglages de la nouvelle partie : partie libre, Intermédiaire, conseil activé par défaut ; valeurs illisibles ignorées", () => {
assert.equal(DEFAULT_SETTINGS.gameKind, "free");
assert.equal(DEFAULT_SETTINGS.level, "intermediate");
assert.equal(DEFAULT_SETTINGS.masterAdvice, true);
// Réglages d'avant l'étape 1c : les valeurs par défaut.
const old = parseSettings('{"vibrations": false, "gamesStarted": 4}');
assert.equal(old.gameKind, "free");
assert.equal(old.matchLength, 5);
assert.equal(old.level, "intermediate");
const saved = parseSettings(
'{"gameKind": "match", "matchLength": 7, "level": "champion", "masterAdvice": false}',
);
assert.deepEqual(
[saved.gameKind, saved.matchLength, saved.level, saved.masterAdvice],
["match", 7, "champion", false],
);
const wrong = parseSettings('{"gameKind": "x", "matchLength": 4, "level": "4ply"}');
assert.deepEqual([wrong.gameKind, wrong.matchLength, wrong.level], ["free", 5, "intermediate"]);
assert.deepEqual([...MATCH_LENGTHS], [3, 5, 7, 11]);
});
test("la partie choisie : le niveau de gnubg, et la première partie d'un match à 0-0", () => {
assert.deepEqual(chosenGame(DEFAULT_SETTINGS), { level: "intermediate" });
assert.deepEqual(chosenGame({ ...DEFAULT_SETTINGS, gameKind: "match", level: "expert" }), {
level: "0ply",
match: { length: 5, score: { player: 0, computer: 0 }, crawford: false },
});
assert.throws(() => firstGameOf(0), /longueur/);
const match = firstGameOf(3);
assert.equal(
nextGameOf(match, { score: { player: 3, computer: 0 }, over: true, crawford: false }),
null,
);
assert.deepEqual(
nextGameOf(match, { score: { player: 2, computer: 0 }, over: false, crawford: true }),
{ length: 3, score: { player: 2, computer: 0 }, crawford: true },
);
});
test("le résumé d'une partie de match enregistrée porte le match ; celui d'une partie libre n'en a pas", async () => {
const engine = await loadNodeEngine();
const times = { startedAt: 1, updatedAt: 2 };
const free = Game.start(engine, { dice: fixedDice([3, 1]), level: "beginner" });
assert.equal(levelOf(free.journal), "beginner");
const freeSummary = summarizeGame("a", free.journal, { level: "beginner" }, times);
assert.equal(freeSummary.match, undefined);
assert.equal(freeSummary.level, "beginner");
const match = Game.start(engine, {
dice: fixedDice([3, 1]),
level: "2ply",
match: { length: 5, score: { player: 4, computer: 2 }, crawford: true },
});
const summary = summarizeGame("b", match.journal, { level: levelOf(match.journal) }, times);
assert.deepEqual(summary.match, {
length: 5,
score: { player: 4, computer: 2 },
crawford: true,
over: false,
});
});
test("adaptateur : le Match ID d'un match code la longueur, le score vu du joueur au trait et Crawford", async () => {
const engine = await loadNodeEngine();
const id = await engine.encodeMatch({
value: 1,
owner: "centered",
match: { length: 5, score: { onRoll: 4, opponent: 2 }, crawford: true },
});
const decoded = await engine.decodeMatch(id);
assert.deepEqual(
[decoded.matchLength, decoded.score, decoded.crawford, decoded.jacoby],
[5, [4, 2], true, false],
);
// En partie Crawford, le moteur refuse le videau au joueur au trait.
const crawford = await engine.cubeDecision(
START_POSITION,
{
value: 1,
owner: "centered",
match: { length: 5, score: { onRoll: 2, opponent: 4 }, crawford: true },
},
0,
);
assert.equal(crawford.available, false);
const after = await engine.matchAfterGame(
{ length: 5, score: { onRoll: 3, opponent: 2 }, crawford: false },
"onRoll",
1,
);
assert.deepEqual(after, { score: { onRoll: 4, opponent: 2 }, over: false, crawford: true });
});
+99
View File
@@ -0,0 +1,99 @@
// Tests des liens de position (app/src/domain/game/position-link.ts, ADR
// 0013) sur le vrai moteur WebAssembly : Match ID écrit puis relu, videau
// vu du joueur au trait, identifiants illisibles.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import type { EngineCube } from "../src/domain/game/engine.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import {
linkOf,
readPosition,
setupOf,
UnreadableLinkError,
} from "../src/domain/game/position-link.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadNodeEngine } from "./support/node-engine.js";
test("le moteur relit le Match ID qu'il écrit, pour chaque état du videau", async () => {
const engine = await loadNodeEngine();
const cases: [EngineCube, -1 | 0 | 1][] = [
[{ value: 1, owner: "centered" }, -1],
[{ value: 2, owner: "onRoll" }, 0],
[{ value: 4, owner: "opponent" }, 1],
];
for (const [cube, owner] of cases) {
const id = await engine.encodeMatch(cube);
assert.equal(id.length, 12);
const match = await engine.decodeMatch(id);
assert.equal(match.matchLength, 0);
assert.equal(match.playerOnRoll, 0);
assert.equal(match.cubeValue, cube.value);
assert.equal(match.cubeOwner, owner);
}
});
test("sans Match ID : partie libre, videau au centre", async () => {
const engine = await loadNodeEngine();
const shared = await readPosition(engine, { position: START_POSITION, matchId: null });
assert.deepEqual(shared.cube, { value: 1, owner: null });
assert.equal(shared.match, null);
assert.equal(shared.result, null);
assert.equal(shared.sides.player.pipCount, 167);
assert.equal(shared.sides.computer.pipCount, 167);
assert.deepEqual(setupOf(shared), {
position: START_POSITION,
firstTurn: "player",
cube: { value: 1, owner: null },
});
});
test("des identifiants illisibles donnent une erreur claire", async () => {
const engine = await loadNodeEngine();
for (const link of [
{ position: "abc", matchId: null },
{ position: "4HPwATDgc/ABM*", matchId: null },
{ position: START_POSITION, matchId: "pas un id" },
{ position: START_POSITION, matchId: "" },
]) {
await assert.rejects(readPosition(engine, link), UnreadableLinkError, JSON.stringify(link));
}
});
test("le lien d'une partie rouvre sa position, son videau compris, par l'adresse", async () => {
const engine = await loadNodeEngine();
// Videau chez l'ordinateur, à 2 : une partie commencée ainsi.
const game = Game.start(engine, {
dice: fixedDice([3, 1]),
setup: {
position: "jGfkASjg8wcBMA",
firstTurn: "player",
cube: { value: 2, owner: "computer" },
},
});
const link = await linkOf(engine, game.state);
const route = parseRoute(
routeHash({ screen: "position", position: link.position, match: link.matchId }),
);
assert.ok(route.screen === "position");
const shared = await readPosition(engine, { position: route.position, matchId: route.match });
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, { value: 2, owner: "computer" });
});
test("le lien pris pendant le tour de l'ordinateur le met au trait, en bas", async () => {
const engine = await loadNodeEngine();
// Jet d'ouverture 1-6 : l'ordinateur commence.
const game = Game.start(engine, { dice: fixedDice([1, 6]) });
assert.equal(game.state.turn, "computer");
const link = await linkOf(engine, game.state);
assert.equal(link.position, game.state.position);
const shared = await readPosition(engine, link);
const board = await engine.board(game.state.position, { value: 1, owner: "centered" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
});
+245
View File
@@ -0,0 +1,245 @@
// Tests de la progression (étape 1h) : le taux d'erreur de chaque partie
// terminée est celui du bilan, lu dans les analyses rangées sans rien
// recalculer ; la courbe, sa tendance et la phrase du maître ; le prompt
// « Créer des exercices » sur plusieurs parties (thèmes des erreurs, niveau
// du joueur à partir de 20 décisions cumulées).
//
// 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,
readyForSummary,
runAnalysis,
summarizeAnalysis,
} from "../src/domain/analysis/game-analysis.js";
import { PHRASE_KEYS, progressionSentence } from "../src/domain/analysis/phrases.js";
import {
CURVE_MIN_GAMES,
errorRateMillis,
type FinishedGame,
progressionCurve,
progressionEntries,
progressionTrend,
} from "../src/domain/analysis/progression.js";
import type { Engine } from "../src/domain/game/engine.js";
import type { GameJournal } from "../src/domain/game/events.js";
import { currentLine } from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { replay } from "../src/domain/game/state.js";
import {
exercisesPosition,
exercisesPrompt,
LEVEL_MIN_DECISIONS,
LEVEL_NAMES,
mistakeThemes,
} from "../src/domain/prompts/prompts.js";
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadPhrases } 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();
const point = (id: string, perDecision: number) => ({ id, perDecision });
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 (le joueur prend le choice-ième
* coup légal), non analysée. */
async function race(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 game.journal;
}
function finished(id: string, startedAt: number, journal: GameJournal): FinishedGame {
return {
id,
startedAt,
level: "intermediate",
result: replay(currentLine(journal)).result,
journal,
};
}
test("l'adresse #/progression et celle de « Créer des exercices »", () => {
assert.deepEqual(parseRoute("#/progression"), { screen: "progression" });
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
});
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
assert.equal(errorRateMillis(0.0142), 14);
assert.equal(errorRateMillis(0.0075), 8);
assert.equal(errorRateMillis(0), 0);
});
test("la tendance : moyennes des deux moitiés, arrondies en millièmes", () => {
assert.deepEqual(progressionTrend([]), { kind: "waiting" });
assert.deepEqual(progressionTrend([point("a", 0.02), point("b", 0.01)]), { kind: "waiting" });
assert.equal(CURVE_MIN_GAMES, 3);
// Trois parties : la première contre la dernière, celle du milieu ne
// compte pas.
assert.deepEqual(progressionTrend([point("a", 0.014), point("b", 0.05), point("c", 0.007)]), {
kind: "down",
games: 3,
from: 14,
to: 7,
});
assert.deepEqual(
progressionTrend([
point("a", 0.01),
point("b", 0.012),
point("c", 0.02),
point("d", 0.016),
]),
{ kind: "up", games: 4, from: 11, to: 18 },
);
// 0,0101 et 0,0104 s'écrivent tous deux 10 : stable.
assert.equal(
progressionTrend([point("a", 0.0101), point("b", 0.03), point("c", 0.0104)]).kind,
"steady",
);
});
test("la phrase du maître : des faits, sans point d'exclamation, sous 140 caractères", () => {
for (const key of ["progression.baisse", "progression.hausse", "progression.stable"]) {
assert.ok(PHRASE_KEYS.includes(key), key);
}
assert.equal(
progressionSentence(phrases, { kind: "down", games: 8, from: 14, to: 7 }),
"Sur vos 8 dernières parties, vous faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7.",
);
assert.equal(
progressionSentence(phrases, { kind: "waiting" }),
"Votre courbe apparaîtra après trois parties.",
);
for (const trend of [
{ kind: "down", games: 20, from: 140, to: 70 },
{ kind: "up", games: 20, from: 70, to: 140 },
{ kind: "steady", games: 20, from: 12, to: 12 },
] as const) {
const sentence = progressionSentence(phrases, trend);
assert.ok(sentence.length < 140, sentence);
assert.ok(!sentence.includes("!"), sentence);
assert.ok(sentence.includes(String(trend.to)), sentence);
}
assert.ok(
progressionSentence(phrases, { kind: "up", games: 5, from: 7, to: 9 }).includes(
"Les leçons sont là",
),
);
});
test("la progression lit le taux du bilan, sans rien recalculer ; une partie non analysée attend", async () => {
const engine = await loadNodeEngine();
const raw = [await race(engine, 3, 3), await race(engine, 7, 2), await race(engine, 11, 1)];
const analysed = await Promise.all(raw.map((j) => analyseAll(engine, j)));
// Données dans le désordre : la progression les range par date.
const games = [
finished("c", 3000, analysed[2] as GameJournal),
finished("a", 1000, analysed[0] as GameJournal),
finished("b", 2000, analysed[1] as GameJournal),
finished("d", 4000, raw[0] as GameJournal),
];
const entries = progressionEntries(games);
assert.deepEqual(
entries.map((e) => e.id),
["a", "b", "c", "d"],
);
for (const [i, entry] of entries.slice(0, 3).entries()) {
assert.deepEqual(
entry.errorRate,
summarizeAnalysis(analysed[i] as GameJournal).player.errorRate,
);
}
assert.equal(readyForSummary(raw[0] as GameJournal), false);
assert.equal(entries[3]?.errorRate, null, "partie non analysée : pas de taux");
const curve = progressionCurve(entries);
assert.deepEqual(
curve.map((p) => p.id),
["a", "b", "c"],
);
assert.equal(progressionTrend(curve).kind === "waiting", false);
});
test("« Créer des exercices » : erreurs, thèmes comptés sur les faits du moteur, niveau à 20 décisions cumulées", async () => {
const engine = await loadNodeEngine();
const template = parsePromptTemplate(
readFileSync(new URL(`docs/prompts/${PROMPT_FILES.exercises}`, root), "utf8"),
);
const templates = { move: "", cube: "", summary: "", concept: "", exercises: template };
const journals: GameJournal[] = [];
for (const [seed, choice] of [
[3, 3],
[7, 2],
[11, 1],
[5, 4],
[9, 3],
] as const) {
journals.push(await analyseAll(engine, await race(engine, seed, choice)));
}
const decisions = journals.map((j) => summarizeAnalysis(j).player.errorRate.decisions);
assert.ok((decisions[0] ?? 0) < LEVEL_MIN_DECISIONS, `décisions : ${decisions.join()}`);
let cumulated = 0;
for (let n = 1; n <= journals.length; n++) {
const some = journals.slice(0, n);
cumulated += decisions[n - 1] ?? 0;
const position = await exercisesPosition(engine, some);
if (position === null) {
continue;
}
const prompt = exercisesPrompt(templates, phrases, { journals: some, position });
assert.ok(!/[{}]/.test(prompt), prompt);
assert.ok(prompt.includes("Ce qui revient dans ces erreurs"), prompt);
assert.match(prompt, /- \d+ coups? et \d+ décisions? de videau/);
if (cumulated >= LEVEL_MIN_DECISIONS) {
assert.match(prompt, /Je suis un joueur de niveau /);
assert.ok(Object.values(LEVEL_NAMES).some((name) => prompt.includes(name)));
} else {
assert.ok(prompt.includes("Je débute au backgammon."), prompt);
}
}
assert.ok(cumulated >= LEVEL_MIN_DECISIONS, `décisions cumulées : ${cumulated}`);
});
test("les thèmes des erreurs : faits du moteur seulement, une ligne par fait présent", () => {
const facts = (hits: number, madePoints: number[]) => ({ hits, madePoints });
const move = (best: ReturnType<typeof facts>, played: ReturnType<typeof facts>) => ({
advice: { kind: "move", bestFacts: best, playedFacts: played } as never,
});
const cube = { advice: { kind: "cube" } as never };
assert.deepEqual(
mistakeThemes([
move(facts(1, []), facts(0, [])),
move(facts(0, [5]), facts(0, [])),
move(facts(0, []), facts(1, [])),
cube,
]),
[
"- 3 coups et 1 décision de videau",
"- 1 fois, le coup de GNU Backgammon frappait un pion et le mien non",
"- 1 fois, le coup de GNU Backgammon faisait un point que le mien ne faisait pas",
"- 1 fois, mon coup frappait un pion et celui de GNU Backgammon non",
],
);
assert.deepEqual(mistakeThemes([cube]), ["- 0 coup et 1 décision de videau"]);
});
+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);
});
+151
View File
@@ -0,0 +1,151 @@
// L'analyse d'une partie en arrière-plan (app/src/ui/review-runner.ts) :
// une analyse à la fois, rangée au fur et à mesure, arrêtée à la demande
// ou par une autre partie ; et les phrases du bilan.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
type AnalysisTask,
type GameSummary,
missingAnalyses,
} from "../src/domain/analysis/game-analysis.js";
import { diceWords, nothingToReview, summaryReading } from "../src/domain/analysis/phrases.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
type NewGameEvent,
} from "../src/domain/game/events.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { emptyReview, type GameReview } from "../src/domain/game/review.js";
import { createReviewRunner } from "../src/ui/review-runner.js";
import { loadPhrases } from "./support/advice.js";
const P = START_POSITION;
/** Un tour de l'ordinateur, sans décision du joueur. */
const EVENTS: NewGameEvent[] = [
{ type: "GameStarted", position: P, cube: { value: 1, owner: null }, firstTurn: "computer" },
{ type: "DiceRolled", side: "computer", roll: [6, 5] },
{ type: "MovePlayed", side: "computer", halfMoves: [], position: P },
];
const JOURNAL: GameJournal = EVENTS.reduce(appendEvent, EMPTY_JOURNAL);
/** Une fausse analyse : elle range la tâche sans moteur, quand on la
* libère. */
function fakeRun() {
const waiting: (() => void)[] = [];
const run = async (journal: GameJournal, task: AnalysisTask): Promise<GameJournal> => {
await new Promise<void>((resolve) => waiting.push(resolve));
const review: GameReview = journal.review ?? emptyReview(2);
if (task.kind === "luck") {
return {
...journal,
review: { ...review, luck: { [task.eventId]: { value: 0, rating: "none" } } },
};
}
if (task.kind === "chance") {
return {
...journal,
review: { ...review, winChances: { ...review.winChances, [task.eventId]: 0.5 } },
};
}
return { ...journal, review: { ...review, unanalysed: [task.eventId] } };
};
const release = async () => {
while (waiting.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
waiting.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 1));
};
return { run, release };
}
test("l'analyse en arrière-plan range chaque analyse, puis s'arrête une fois tout fait", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: GameJournal[] = [];
let done = false;
// La chance du jet, le jet sans doubler de l'ordinateur, ses chances
// après le coup.
const total = missingAnalyses(JOURNAL).length;
assert.equal(total, 3);
runner.start(
"a",
JOURNAL,
(j) => recorded.push(j),
() => {
done = true;
},
);
assert.equal(runner.running, "a");
// La même partie redemandée ne repart pas.
runner.start("a", JOURNAL, () => assert.fail("deuxième analyse"));
for (let i = 0; i < total; i++) {
await release();
}
assert.equal(recorded.length, total);
assert.deepEqual(missingAnalyses(recorded.at(-1) as GameJournal), []);
assert.ok(done);
assert.equal(runner.running, null);
});
test("l'analyse s'arrête à la demande, ou pour une autre partie, sans ranger la dernière", async () => {
const { run, release } = fakeRun();
const runner = createReviewRunner(run);
const recorded: string[] = [];
runner.start("a", JOURNAL, () => recorded.push("a"));
runner.stop();
assert.equal(runner.running, null);
await release();
assert.equal(recorded.length, 0);
runner.start("b", JOURNAL, () => recorded.push("b"));
runner.start("c", JOURNAL, () => recorded.push("c"));
assert.equal(runner.running, "c");
await release();
await release();
assert.deepEqual(recorded, ["c"]);
});
/** Un bilan réduit à ce que lisent les phrases. */
function summaryWith(result: number, luckAdjustedResult: number): GameSummary {
const side = {
errorRate: { total: 0, decisions: 0, perDecision: 0, level: "champion" as const },
luck: { total: 0, rolls: 1, perRoll: -0.04, rating: "unlucky" as const, points: 0 },
};
return {
player: side,
computer: side,
counts: { good: 0, doubtful: 0, bad: 0, veryBad: 0 },
toReview: null,
result,
luckAdjustedResult,
};
}
test("les phrases du bilan : ce qui a fait la différence, et les dés en mots", () => {
const phrases = loadPhrases();
assert.equal(diceWords(phrases, "unlucky"), "un peu contre vous");
assert.equal(
summaryReading(phrases, summaryWith(1, 1.2)),
"D'après mon analyse, votre jeu a fait la différence, avec des dés un peu contre vous.",
);
assert.equal(
summaryReading(phrases, summaryWith(-2, -1.5)),
"D'après mon analyse, le jeu de l'ordinateur a fait la différence, avec des dés un peu contre vous.",
);
// Gagnée grâce aux dés, ou perdue à cause d'eux : les dés.
for (const [result, adjusted] of [
[1, -0.3],
[-1, 0.2],
] as const) {
assert.equal(
summaryReading(phrases, summaryWith(result, adjusted)),
"D'après mon analyse, les dés ont fait la différence, plus que le jeu.",
);
}
assert.ok(nothingToReview(phrases).startsWith("Aucun coup à revoir"));
});
+204
View File
@@ -0,0 +1,204 @@
// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de
// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran
// redonne son adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
isAiRoute,
isHelpScreen,
parseRoute,
type Route,
routeHash,
slug,
stepsBack,
withoutAi,
} from "../src/ui/router.js";
const START = "4HPwATDgc/ABMA";
const MATCH = "cAkAAAAAAAAA";
/** Les adresses du tableau de l'ADR, et l'écran de chacune. */
const TABLE: [string, Route][] = [
["#/", { screen: "game" }],
["#/accueil", { screen: "home" }],
["#/aide", { screen: "help", question: null }],
["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }],
["#/glossaire", { screen: "glossary", term: null }],
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
["#/a-propos", { screen: "about" }],
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
["#/tutoriel/fin", { screen: "tutorialEnd" }],
// Les leçons (amendement du 2026-11-12, 1g).
["#/lecons", { screen: "lessons" }],
["#/lecons/2", { screen: "lesson", lesson: 2, step: null, practice: false }],
["#/lecons/2/3", { screen: "lesson", lesson: 2, step: 3, practice: false }],
["#/lecons/2/3/a-vous", { screen: "lesson", lesson: 2, step: 3, practice: true }],
["#/lecons/2/fin", { screen: "lessonEnd", lesson: 2 }],
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/parties/mh2k3x-a1b2c3/bilan", { screen: "summary", game: "mh2k3x-a1b2c3" }],
["#/parties/mh2k3x-a1b2c3/analyse", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: null }],
["#/parties/mh2k3x-a1b2c3/analyse/7", { screen: "analysis", game: "mh2k3x-a1b2c3", turn: 7 }],
// La progression (étape 1h) et son prompt « Créer des exercices ».
["#/progression", { screen: "progression" }],
["#/progression/ia", { screen: "progression", ai: true }],
["#/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 },
],
["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }],
];
test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => {
for (const [hash, route] of TABLE) {
assert.deepEqual(parseRoute(hash), route, hash);
assert.equal(routeHash(route), hash, hash);
}
});
test("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" });
assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null });
assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" });
});
test("une adresse inconnue est reconnue comme telle", () => {
for (const hash of [
"#/inconnu",
"#aide",
"#/aide/a/b",
"#/aide/Majuscules",
"#/menu/plus",
"#/tutoriel/0",
"#/tutoriel/x",
"#/tutoriel/3/b",
"#/tutoriel/3/a-vous/x",
"#/tutoriel/fin/a-vous",
"#/lecons/0",
"#/lecons/12",
"#/lecons/x",
"#/lecons/2/0",
"#/lecons/2/x",
"#/lecons/2/3/b",
"#/lecons/2/3/a-vous/x",
"#/lecons/2/fin/a-vous",
"#/installer/x",
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
"#/parties/ab/autre",
"#/parties/ab/bilan/2",
"#/parties/ab/analyse/0",
"#/parties/ab/analyse/x",
"#/parties/ab/analyse/3/plus",
"#/parties/AB/bilan",
"#/aide/blot/ia",
"#/menu/ia",
"#/parties/ab/ia",
"#/explication/12/ia/ia",
"#/progression/x",
"#/progression/ia/ia",
]) {
assert.equal(parseRoute(hash).screen, "unknown", hash);
}
assert.equal(routeHash(parseRoute("#/inconnu")), "#/inconnu");
});
test("un lien de position collé sans encodage (+ et / du base64) est compris", () => {
assert.deepEqual(parseRoute(`#/position/${START}/${MATCH}`), {
screen: "position",
position: START,
match: MATCH,
});
assert.deepEqual(parseRoute("#/position/jGfkASjg8wcBMA/cAkAAAAAAAAA"), {
screen: "position",
position: "jGfkASjg8wcBMA",
match: MATCH,
});
// + reste un + (pas une espace), encodé ou non.
const plus = "AAAA+AAAAAAAAA";
assert.equal(
routeHash({ screen: "position", position: plus, match: null }),
"#/position/AAAA%2BAAAAAAAAA",
);
assert.deepEqual(parseRoute("#/position/AAAA%2BAAAAAAAAA"), {
screen: "position",
position: plus,
match: null,
});
assert.deepEqual(parseRoute(`#/position/${plus}`), {
screen: "position",
position: plus,
match: null,
});
});
test("un identifiant de position de mauvaise longueur arrive tel quel au moteur, qui jugera", () => {
assert.deepEqual(parseRoute("#/position/abc"), {
screen: "position",
position: "abc",
match: null,
});
});
test("l'identifiant d'une question : le titre sans accents ni ponctuation", () => {
assert.equal(slug("Jouer un coup, puis l'annuler"), "jouer-un-coup-puis-l-annuler");
assert.equal(slug(" Été : déjà vu ? "), "ete-deja-vu");
assert.equal(
slug("Rejouer d'ici sans perdre l'original"),
"rejouer-d-ici-sans-perdre-l-original",
);
});
test("les écrans de l'aide : jouer, les mots du jeu, à propos", () => {
assert.ok(isHelpScreen(parseRoute("#/aide/x")));
assert.ok(isHelpScreen(parseRoute("#/glossaire")));
assert.ok(isHelpScreen(parseRoute("#/a-propos")));
assert.ok(!isHelpScreen(parseRoute("#/menu")));
});
test("quitter un écran revient à la dernière adresse qui n'en est pas", () => {
const help = (route: Route) => isHelpScreen(route);
// Partie, aide, une question : « Retour » remonte de deux pas.
assert.equal(stepsBack(["#/", "#/aide", "#/aide/q"], help), -2);
// Le menu ouvert depuis l'aide : on revient à l'aide.
const menu = (route: Route) => route.screen === "menu";
assert.equal(stepsBack(["#/", "#/aide", "#/menu"], menu), -1);
// Ouvert directement dans un nouvel onglet : rien avant, on remplace.
assert.equal(stepsBack(["#/aide/q"], help), null);
assert.equal(stepsBack(["#/aide", "#/glossaire/blot"], help), null);
});
+181
View File
@@ -0,0 +1,181 @@
// Étapes des scénarios docs/scenarios/decouvrir-l-application.feature et
// installer-l-application.feature (ADR 0008) : l'accueil et l'invitation à
// installer passent par les modules de l'interface qui les décident
// (home-model.ts, settings.ts, install.ts), la partie par le domaine et le
// vrai moteur, le tutoriel par son texte source.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import {
dismissInstall,
homeModel,
installMenuShown,
launchHash,
WELCOME_LINKS,
welcomed,
} from "../../src/ui/home-model.js";
import { HOME_TEXTS } from "../../src/ui/home-texts.js";
import { installPlatform } from "../../src/ui/install.js";
import { parseRoute } from "../../src/ui/router.js";
import { parseSettings, type Settings } from "../../src/ui/settings.js";
import { placeOf } from "../../src/ui/tutorial-nav.js";
import { parseTutorial } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { seededDice, step } from "../support/play.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let settings: Settings;
let game: Game | null = null;
let installed = false;
let route = "";
let installSteps: readonly string[] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Ce que montre l'accueil, la partie étant celle du scénario. */
function home() {
const playing = game === null ? false : game.state.phase !== "over";
return homeModel(settings, playing, installed);
}
// ------------------------------------------------------- Premier lancement
Given("vous ouvrez Ustagammon pour la toute première fois", () => {
settings = parseSettings(null);
game = null;
installed = false;
});
Then("l'accueil vous propose de découvrir les règles ou de jouer tout de suite", () => {
assert.equal(launchHash(""), "#/accueil");
assert.equal(parseRoute(launchHash("") ?? "").screen, "home");
assert.ok(home().welcome);
assert.equal(parseRoute(WELCOME_LINKS.play).screen, "game");
});
When("vous choisissez de découvrir les règles", () => {
settings = { ...settings, ...welcomed() };
route = WELCOME_LINKS.discover;
});
Then("le tutoriel s'ouvre à sa première étape", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
assert.deepEqual(placeOf(tutorial, parseRoute(route)), { step: 1, practice: false });
});
Then("ce choix ne vous est plus proposé", () => {
assert.ok(!home().welcome);
});
Given("vous avez choisi de jouer tout de suite", () => {
settings = { ...parseSettings(null), ...welcomed() };
installed = false;
});
Given("une partie est en cours", () => {
game = Game.start(engine, { dice: seededDice(11), level: "0ply" });
settings = { ...settings, gamesStarted: settings.gamesStarted + 1 };
});
When("vous rouvrez Ustagammon", () => {
route = launchHash("") ?? "";
});
Then("l'accueil vous propose de reprendre votre partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(home().resume);
});
When("la partie se termine", async () => {
assert.ok(game !== null);
for (let i = 0; game.state.phase !== "over"; i++) {
assert.ok(i < 2000, "la partie ne finit pas");
await step(game, engine);
}
});
Then("l'accueil vous propose une nouvelle partie", () => {
assert.equal(parseRoute(route).screen, "home");
assert.ok(!home().resume);
});
// ------------------------------------------------------------- Installation
Given("votre navigateur ne propose pas lui-même l'installation", () => {
installSteps = [];
});
When("vous demandez à installer l'application avec Firefox", () => {
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
installSteps = HOME_TEXTS.installSteps[installPlatform(firefox)];
});
When("vous demandez à installer l'application sur iPhone", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
installSteps = HOME_TEXTS.installSteps[installPlatform(iphone)];
});
Then("la marche à suivre compte trois étapes, en commençant par le menu du navigateur", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /menu du navigateur/);
});
Then("la marche à suivre compte trois étapes, en commençant par le bouton de partage", () => {
assert.equal(installSteps.length, 3);
assert.match(installSteps[0] ?? "", /bouton de partage/);
});
Then("l'accueil ne vous propose pas d'installer l'application", () => {
assert.ok(!home().installCard);
});
Then("l'accueil vous propose d'installer l'application", () => {
assert.ok(home().installCard);
});
When("vous terminez une partie", () => {
settings = { ...settings, gamesFinished: settings.gamesFinished + 1 };
});
When("vous terminez {int} parties de plus", (n: number) => {
settings = { ...settings, gamesFinished: settings.gamesFinished + n };
});
When("vous choisissez « Plus tard »", () => {
settings = { ...settings, ...dismissInstall(settings) };
});
Then("le menu vous le propose toujours", () => {
assert.ok(installMenuShown(settings, installed));
});
Given("vous avez terminé une partie", () => {
settings = { ...parseSettings(null), welcomed: true, gamesFinished: 1 };
installed = false;
assert.ok(home().installCard);
});
When("vous installez l'application", () => {
// L'événement appinstalled du navigateur, retenu dans les réglages.
installed = true;
settings = { ...settings, installed: true };
});
Then("le menu non plus", () => {
assert.ok(!installMenuShown(settings, installed));
// Même dans un onglet ouvert plus tard, hors de l'application installée.
assert.ok(!installMenuShown(settings, false));
assert.ok(!homeModel(settings, true, false).installCard);
});
@@ -0,0 +1,132 @@
// Étapes du scénario docs/scenarios/apprendre-les-regles.feature (ADR 0008) :
// le texte du tutoriel est lu dans sa source (docs/tutoriel/regles.md), et
// chaque geste est jugé par le domaine branché sur le vrai moteur, comme
// dans l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import {
MovePractice,
type PracticeVerdict,
shouldTake,
tutorialBoard,
} from "../../src/domain/learning/tutorial.js";
import { progress, seenAfter, type TutorialPlace } from "../../src/ui/tutorial-nav.js";
import { parseTutorial, type Tutorial, type TutorialScreen } from "../../src/ui/tutorial-text.js";
import { loadNodeEngine } from "../support/node-engine.js";
const root = new URL("../../../../", import.meta.url);
let engine: Engine;
let tutorial: Tutorial;
let seen = 0;
let place: TutorialPlace = { step: 1, practice: false };
let screen: TutorialScreen;
let practice: MovePractice | null = null;
let verdict: PracticeVerdict = "continue";
let answeredRight = false;
BeforeAll(async () => {
engine = await loadNodeEngine();
tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
});
Given("vous commencez le tutoriel", () => {
seen = 0;
place = { step: 1, practice: false };
seen = seenAfter(seen, place);
});
Then("il compte huit étapes, dont quatre où c'est à vous de jouer", () => {
assert.equal(tutorial.steps.length, 8);
assert.equal(tutorial.steps.filter((s) => s.practice !== null).length, 4);
});
Then("chaque position du tutoriel se lit sur le plateau", async () => {
for (const step of tutorial.steps) {
for (const current of [step.explain, step.practice]) {
if (current !== null) {
const board = await tutorialBoard(engine, current.position, current.onRoll);
assert.equal(board.sides.player.points.length, 24);
}
}
}
});
When("vous arrivez à l'étape {int}", (n: number) => {
for (let step = 1; step <= n; step++) {
place = { step, practice: false };
seen = seenAfter(seen, place);
}
});
Then("la barre de progression vous ramène aux étapes 1 et 2, mais pas aux suivantes", () => {
const links = progress(tutorial.steps.length, place, seen)
.filter((segment) => segment.link)
.map((segment) => segment.step);
assert.deepEqual(links, [1, 2]);
});
Given("vous êtes à l'écran « à vous » de l'étape {int}", async (n: number) => {
const found = tutorial.steps[n - 1]?.practice;
assert.ok(found != null, `l'étape ${n} n'a pas d'écran « à vous »`);
screen = found;
practice =
screen.roll === null || screen.expected.length === 0
? null
: await MovePractice.start(engine, screen.position, screen.roll, screen.expected);
verdict = "continue";
answeredRight = false;
});
When(
"vous déplacez un pion de la flèche {int} à la flèche {int}",
async (from: number, to: number) => {
assert.ok(practice !== null);
verdict = await practice.play({ from, to });
if (verdict === "wrong") {
practice.reset();
}
},
);
Then("le pion revient, et une phrase vous dit pourquoi", () => {
assert.equal(verdict, "wrong");
assert.deepEqual(practice?.pendingHalfMoves, []);
assert.ok(screen.wrong.map((part) => part.text).join("").length > 20);
});
When("vous réunissez deux pions sur la flèche 5", async () => {
assert.ok(practice !== null);
verdict = await practice.play({ from: 8, to: 5 });
assert.equal(verdict, "continue");
verdict = await practice.play({ from: 6, to: 5 });
});
Then("le tutoriel salue votre coup", () => {
assert.equal(verdict, "right");
assert.ok(screen.right.length > 0);
});
When("vous répondez « Passer »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === false;
});
When("vous répondez « Prendre »", async () => {
answeredRight = (await shouldTake(engine, screen.position)) === true;
});
Then("le maître dit qu'il aurait pris", () => {
assert.ok(!answeredRight);
assert.match(screen.wrong.map((part) => part.text).join(""), /Le maître prendrait/);
});
Then("le tutoriel salue votre réponse", () => {
assert.ok(answeredRight);
assert.match(screen.right.map((part) => part.text).join(""), /Le maître prend aussi/);
});
@@ -0,0 +1,145 @@
// Étapes du scénario docs/scenarios/comprendre-mes-coups.feature (ADR 0008) :
// les décisions passent par le domaine et le vrai moteur, le conseil par
// l'analyse du domaine, ses mots par la source des phrases du maître, et
// la ligne par le suivi du conseil de l'interface (advice-tracker.ts) avec
// le réglage de l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import { masterWords } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createAdviceTracker } from "../../src/ui/advice-tracker.js";
import { parseSettings, type Settings } from "../../src/ui/settings.js";
import { DOUBLE_TAKE_POSITION, loadPhrases } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
const phrases = loadPhrases();
let engine: Engine;
let game: Game;
let settings: Settings;
let tracker: ReturnType<typeof createAdviceTracker>;
let ready: Promise<void>;
let markReady: () => void;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Before(() => {
settings = parseSettings(null);
ready = new Promise((resolve) => {
markReady = resolve;
});
tracker = createAdviceTracker({
analyse: (journal, eventId) => analyseDecision(engine, journal, eventId),
recorded: () => {},
changed: () => markReady(),
});
});
/** Joue une décision du joueur comme l'écran de jeu : la ligne d'avant
* s'efface, la décision part à l'analyse. */
async function decide(action: (g: Game) => Promise<void> | void): Promise<void> {
tracker.clear();
const before = game.events;
await action(game);
tracker.track(game, before);
}
/** Ce que montre la ligne du maître (null : rien), comme l'écran : le
* conseil prêt, si le réglage le permet. */
async function line(): Promise<{ word: string; phrase: string; advice: Advice } | null> {
await Promise.race([ready, new Promise((resolve) => setTimeout(resolve, 10_000))]);
const advice = tracker.shown;
if (!settings.masterAdvice || advice === null) {
return null;
}
const words = masterWords(phrases, advice);
return { word: words.word, phrase: words.phrase, advice };
}
Given("vous avez {int}-{int} à jouer à la position de départ", async (a: number, b: number) => {
game = Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
dice: fixedDice([a, b, 6, 5]),
});
await decide((g) => g.roll());
});
Given("vous avez coupé le conseil du maître", () => {
settings = { ...settings, masterAdvice: false };
});
When("vous validez le coup {}", async (move: string) => {
await decide(async (g) => {
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await g.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await g.confirmMove();
});
// L'ordinateur joue aussitôt, sans attendre l'analyse.
await game.playComputerTurn();
});
Then("le maître dit « {}. »", async (word: string) => {
const shown = await line();
assert.equal(shown?.word, word);
});
Then("sa phrase dit que {} fait votre point {int}", async (move: string, point: number) => {
const shown = await line();
assert.ok(shown?.phrase.includes(`${move} fait votre point ${point}.`), shown?.phrase);
});
Then("sa phrase commence par « {} »", async (start: string) => {
const shown = await line();
assert.ok(shown?.phrase.startsWith(start), shown?.phrase);
});
Then("sa phrase dit « {} »", async (phrase: string) => {
const shown = await line();
assert.equal(shown?.phrase, phrase);
});
Then("l'explication peut montrer votre coup et le coup du maître sur le plateau", async () => {
const shown = await line();
assert.ok(shown !== null && shown.advice.kind === "move");
const advice = shown.advice;
const mine = await game.stepsBefore(advice.eventId, "player", advice.played.halfMoves);
const best = await game.stepsBefore(advice.eventId, "player", advice.best.halfMoves);
// Les deux coups partent de la même position, celle d'avant le coup.
assert.deepEqual(mine.boards[0], best.boards[0]);
assert.equal(best.halfMoves.length, 2);
assert.notDeepEqual(mine.boards.at(-1), best.boards.at(-1));
});
Then("le maître ne dit rien", async () => {
assert.equal(await line(), null);
// Le coup est tout de même analysé, pour le bilan de fin de partie.
const move = game.events.find((e) => e.type === "MovePlayed" && e.side === "player");
assert.ok(game.advice[move?.id ?? ""] !== undefined);
});
Given("l'ordinateur vous propose de doubler dans une position où il faut prendre", async () => {
game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
level: "2ply",
dice: fixedDice([]),
});
await game.playComputerTurn();
assert.equal(game.state.phase, "doubled");
});
When("vous passez son double", async () => {
await decide((g) => g.drop());
});
+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+ %/);
},
);
+324
View File
@@ -0,0 +1,324 @@
// Étapes du scénario docs/scenarios/jouer-un-match.feature (ADR 0008) : le
// choix de la partie passe par les réglages de l'écran « Nouvelle partie »
// (settings.ts) et la session d'enregistrement ; les parties et le match,
// par le domaine branché sur le vrai moteur, avec des dés imposés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import { engineCube, Game, type NewGame } from "../../src/domain/game/game.js";
import { COMPUTER_LEVELS, ENGINE_LEVELS } from "../../src/domain/game/level.js";
import { firstGameOf, type Match } from "../../src/domain/game/match.js";
import { START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import { createGameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { chosenGame, parseSettings, type Settings } from "../../src/ui/settings.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
setDefaultTimeout(120_000);
// Course : le joueur au trait n'a plus que deux dames, sur ses points 6 et
// 5 ; son adversaire a ses quinze dames sur son point 1, aucune sortie. Avec
// 6-5, le joueur au trait gagne un gammon.
const GAMMON_NEXT_ROLL = "/38AAABQAAAAAA";
// La même, mais l'adversaire a déjà sorti quatre dames : le joueur au trait
// gagne une partie simple avec 6-5.
const SINGLE_NEXT_ROLL = "/wcAAAAFAAAAAA";
let engine: Engine;
let settings: Settings;
let session: GameSession;
let game: Game;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
/** Joue le jet du joueur : le premier coup légal, demi-coup par demi-coup. */
async function playFirstMove(g: Game): Promise<void> {
await g.roll();
const state = g.state;
assert.ok(state.roll !== null);
const [move] = await engine.legalMoves(state.position, state.roll);
assert.ok(move !== undefined);
for (const halfMove of move.halfMoves) {
assert.ok(await g.playHalfMove(halfMove));
}
await g.confirmMove();
}
// ------------------------------------------------------------ Le niveau
Given("vous ouvrez l'écran « Nouvelle partie » pour la première fois", () => {
settings = parseSettings(null);
session = createGameSession(createGameLibrary(createKeyValueBackend(memoryStorage())), {
start: (newGame) => Game.start(engine, { dice: fixedDice([3, 1, 4, 2]), ...newGame }),
resume: (journal) => Game.resume(engine, journal),
});
});
Then(
"une partie libre contre un ordinateur Intermédiaire, avec le conseil du maître, est proposée",
() => {
assert.equal(settings.gameKind, "free");
assert.equal(settings.level, "intermediate");
assert.equal(settings.masterAdvice, true);
assert.deepEqual(chosenGame(settings), { level: "intermediate" });
},
);
When("vous choisissez le niveau Champion et commencez la partie", () => {
settings = { ...settings, level: "champion" };
game = session.newGame(chosenGame(settings));
session.save(game.journal);
});
Then("l'ordinateur joue au niveau {int} plis de GNU Backgammon", async (plies: number) => {
assert.equal(game.state.level, `${plies}ply`);
// Le joueur commence avec 3-1 ; l'ordinateur répond avec 4-2, et joue
// le coup que le moteur donne pour ce niveau.
await game.playHalfMove({ from: 8, to: 5 });
await game.playHalfMove({ from: 6, to: 5 });
await game.confirmMove();
const before = game.state;
await game.playComputerTurn();
const played = game.events.findLast((e) => e.type === "MovePlayed");
assert.ok(played?.type === "MovePlayed" && played.side === "computer");
const roll = game.events.findLast((e) => e.type === "DiceRolled");
assert.ok(roll?.type === "DiceRolled");
const expected = await engine.levelMove(
before.position,
engineCube(before, "computer"),
roll.roll,
"2ply",
);
assert.deepEqual(played.halfMoves, expected?.halfMoves ?? []);
session.save(game.journal);
});
Then("la partie reprise garde ce niveau", async () => {
const resumed = await session.restore();
assert.ok(resumed !== null);
assert.equal(resumed.state.level, "2ply");
});
Then("vos choix sont proposés à la prochaine nouvelle partie", () => {
const again = parseSettings(JSON.stringify(settings));
assert.equal(again.level, "champion");
assert.equal(chosenGame(again).level, "2ply");
});
// ------------------------------------------------------- Un match entier
// Les parties du match, dans l'ordre.
let games: Game[] = [];
Given("vous commencez un match en {int} points contre un ordinateur Débutant", (n: number) => {
settings = { ...parseSettings(null), gameKind: "match", matchLength: n, level: "beginner" };
games = [];
});
When("le match se joue jusqu'au bout, partie après partie", async () => {
// Des dés reproductibles, partagés par toutes les parties du match.
const dice = seededDice(3);
let next: NewGame | null = chosenGame(settings);
while (next !== null) {
assert.ok(games.length < 20, "le match ne finit pas");
const g = Game.start(engine, { dice, ...next });
await playToTheEnd(g, engine);
games.push(g);
next = g.nextGame;
}
});
Then("le match se termine dès qu'un joueur atteint {int} points", (n: number) => {
const last = games.at(-1)?.state;
assert.ok(last?.matchAfter);
assert.equal(last.matchAfter.over, true);
const { player, computer } = last.matchAfter.score;
assert.ok(Math.max(player, computer) >= n);
// Avant la dernière partie, personne n'avait encore les points.
for (const g of games.slice(0, -1)) {
assert.equal(g.state.matchAfter?.over, false);
const score = g.state.matchAfter?.score;
assert.ok(score !== undefined && Math.max(score.player, score.computer) < n);
}
});
Then("chaque partie commence avec le score laissé par la précédente", () => {
assert.deepEqual(games[0]?.state.match?.score, { player: 0, computer: 0 });
for (let i = 1; i < games.length; i++) {
assert.deepEqual(games[i]?.state.match?.score, games[i - 1]?.state.matchAfter?.score);
}
});
Then("la partie Crawford s'est jouée sans videau", () => {
const crawford = games.filter((g) => g.state.match?.crawford === true);
assert.equal(crawford.length, 1, "une partie Crawford, et une seule");
for (const g of crawford) {
assert.ok(!g.events.some((e) => e.type === "CubeOffered"));
assert.equal(g.state.cube.value, 1);
}
});
// ------------------------------------------------------------- Rejouer
/** Le niveau de gnubg d'un niveau nommé comme à l'écran (« Avancé »). */
function engineLevelNamed(name: string) {
const level = COMPUTER_LEVELS.find((l) => MATCH_TEXTS.levels[l].name === name);
assert.ok(level !== undefined, `niveau inconnu : ${name}`);
return ENGINE_LEVELS[level];
}
Given("une partie libre contre un ordinateur {word} vient de se terminer", async (name: string) => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.phase, "over");
assert.equal(game.nextGame, null);
});
Given(
"un match en {int} points contre un ordinateur {word} vient de se terminer",
async (n: number, name: string) => {
// Le joueur mène 1 à 0 et gagne un gammon : 3 points, le match est
// fini.
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
level: engineLevelNamed(name),
match: { length: n, score: { player: n - 2, computer: 0 }, crawford: false },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
assert.equal(game.state.matchAfter?.over, true);
assert.equal(game.nextGame, null);
},
);
When("vous rejouez", () => {
game = Game.start(engine, { dice: fixedDice([3, 1]), ...game.rematch });
});
Then("une partie libre commence, contre un ordinateur {word}", (name: string) => {
assert.equal(game.state.phase, "moving");
assert.equal(game.state.position, START_POSITION);
assert.equal(game.state.match, null);
assert.equal(game.state.level, engineLevelNamed(name));
});
Then(
"un match en {int} points commence, à 0 à 0, contre un ordinateur {word}",
(n: number, name: string) => {
assert.equal(game.state.position, START_POSITION);
assert.deepEqual(game.state.match, firstGameOf(n));
assert.equal(game.state.level, engineLevelNamed(name));
},
);
// ------------------------------------------------------- La partie Crawford
let crawfordGame: Game;
Given("un match en 3 points commence et que l'ordinateur va gagner un gammon", () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "computer" },
level: "beginner",
match: firstGameOf(3),
dice: fixedDice([6, 5]),
});
});
When("l'ordinateur gagne la partie", async () => {
await game.playComputerTurn();
assert.deepEqual(game.state.result, { winner: "computer", kind: "gammon", points: 2 });
});
Then("l'ordinateur mène le match {int} à {int}", (computer: number, player: number) => {
assert.deepEqual(game.state.matchAfter?.score, { player, computer });
});
Then("la partie suivante est la partie Crawford", () => {
assert.equal(game.nextGame?.match?.crawford, true);
});
When("la partie Crawford commence", () => {
const next = game.nextGame;
assert.ok(next?.match !== undefined);
crawfordGame = Game.start(engine, {
...next,
setup: { position: SINGLE_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
});
Then("personne ne peut doubler pendant cette partie", async () => {
assert.equal(await crawfordGame.canDouble(), false);
// L'ordinateur non plus, dans la même position vue de lui.
const state = crawfordGame.state;
const board = await engine.board(state.position, engineCube(state, "player"));
assert.equal(
await engine.levelDouble(board.swappedPosition, engineCube(state, "computer"), state.level),
false,
);
});
When("vous gagnez la partie Crawford", async () => {
await playFirstMove(crawfordGame);
assert.deepEqual(crawfordGame.state.matchAfter, {
score: { player: 1, computer: 2 },
over: false,
crawford: false,
});
});
Then("vous pouvez de nouveau doubler dans la partie suivante", async () => {
const next = crawfordGame.nextGame;
assert.ok(next?.match !== undefined);
const match: Match = next.match;
assert.equal(match.crawford, false);
const after = Game.start(engine, {
...next,
setup: { position: START_POSITION, firstTurn: "player" },
});
assert.equal(await after.canDouble(), true);
});
// ------------------------------------------------------- La règle Jacoby
Given("vous allez gagner un gammon sans que personne ait doublé", () => {
// La position est choisie à chaque partie, ci-dessous.
});
When("vous gagnez une partie libre", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
When("vous gagnez la même partie dans un match", async () => {
game = Game.start(engine, {
setup: { position: GAMMON_NEXT_ROLL, firstTurn: "player" },
match: firstGameOf(5),
dice: fixedDice([6, 5]),
});
await playFirstMove(game);
});
Then("vous marquez {int} point(s)", (points: number) => {
assert.equal(game.state.result?.winner, "player");
assert.equal(game.state.result?.kind, "gammon");
assert.equal(game.state.result?.points, points);
});
+32 -2
View File
@@ -426,8 +426,8 @@ When("vous prenez", () => {
game.take();
});
When("vous passez", () => {
game.drop();
When("vous passez", async () => {
await game.drop();
});
Then("la partie continue à valeur doublée", () => {
@@ -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é.
+203
View File
@@ -0,0 +1,203 @@
// Étapes du scénario docs/scenarios/mes-parties.feature (ADR 0008) : elles
// pilotent le domaine, branché sur le vrai moteur WebAssembly, et le
// stockage des parties sur un localStorage simulé en mémoire. Fermer puis
// rouvrir l'application revient à rouvrir ce stockage et à reprendre la
// partie en cours, comme main.ts au démarrage.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal, MovePlayed } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { exportSgf } from "../../src/domain/game/sgf.js";
import type { GameLibrary } from "../../src/storage/game-library.js";
import type { KeyValueStorage } from "../../src/storage/key-value-backend.js";
import { memoryStorage, openGameLibrary } from "../../src/storage/open-library.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, seededDice } from "../support/play.js";
let engine: Engine;
let storage: KeyValueStorage;
let library: GameLibrary;
let game: Game;
let shownId: string;
/** Les parties jouées, dans l'ordre : identifiant et journal laissé. */
let played: { id: string; journal: GameJournal }[];
/** La partie d'origine, avant « Rejouer d'ici ». */
let original: { head: string; ids: string[] };
/** Le coup du joueur d'où l'on rejoue. */
let replayed: MovePlayed;
let sgf: string;
let nextSeed = 1;
Before(async () => {
engine = await loadNodeEngine();
storage = memoryStorage();
library = (await openGameLibrary({ localStorage: storage })).library;
played = [];
});
/** Commence une partie, comme l'application : un nouvel identifiant. */
function startGame(): void {
game = Game.start(engine, { dice: seededDice(nextSeed), level: "0ply" });
shownId = `partie-${nextSeed++}`;
}
/** Joue des coups, en enregistrant après chacun comme l'application. */
async function play(moves: number): Promise<void> {
for (let i = 0; i < moves; i++) {
await playMoves(game, engine, 1);
await library.save(shownId, game.journal, { level: "0ply" });
}
}
async function reopen(): Promise<void> {
library = (await openGameLibrary({ localStorage: storage })).library;
const saved = await library.current();
assert.ok(saved !== null, "aucune partie en cours");
game = Game.resume(engine, saved.journal, { level: "0ply" });
shownId = saved.summary.id;
}
/** « Rejouer d'ici » : revient juste avant le deuxième coup du joueur (ses
* dés déjà lancés). Renvoie ce coup. */
function goBackToSecondMove(): MovePlayed {
original = { head: game.journal.head as string, ids: game.events.map((e) => e.id) };
const second = game.events.filter(
(e): e is MovePlayed => e.type === "MovePlayed" && e.side === "player",
)[1];
assert.ok(second?.parent != null, "moins de deux coups du joueur");
game.goBackTo(second.parent);
return second;
}
/** Joue, avec les mêmes dés, un coup légal qui mène ailleurs que first. */
async function playAnotherMove(first: MovePlayed): Promise<void> {
const state = game.state;
assert.ok(state.roll !== null);
const moves = await engine.legalMoves(state.position, state.roll);
// Move.position est vue de celui qui joue, MovePlayed.position de son
// adversaire : on compare les demi-coups.
const same = (m: (typeof moves)[number]) =>
m.halfMoves.length === first.halfMoves.length &&
m.halfMoves.every((h, i) => h.from === first.halfMoves[i]?.from);
const move = moves.find((m) => !same(m));
assert.ok(move !== undefined, "un seul coup possible");
for (const halfMove of move.halfMoves) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
await library.save(shownId, game.journal, { level: "0ply" });
const last = game.events.at(-1);
assert.ok(last?.type === "MovePlayed" && last.position !== first.position);
}
// ------------------------------------------- Reprendre une partie parmi plusieurs
Given("vous avez joué deux parties, l'une après l'autre", async () => {
for (const moves of [5, 3]) {
startGame();
await play(moves);
played.push({ id: shownId, journal: game.journal });
}
});
When("vous fermez l'application puis la rouvrez", reopen);
Then("la dernière partie jouée est à l'écran", () => {
const last = played.at(-1);
assert.equal(shownId, last?.id);
assert.deepEqual(game.journal, last?.journal);
});
Then("« Mes parties » montre vos deux parties, la dernière jouée en premier", async () => {
const ids = (await library.list()).map((s) => s.id);
assert.deepEqual(ids, played.map((p) => p.id).reverse());
});
When("vous reprenez la première partie", async () => {
await library.setCurrent(played[0]?.id as string);
await reopen();
});
Then("elle est à l'écran, telle que vous l'aviez laissée", () => {
assert.equal(shownId, played[0]?.id);
assert.deepEqual(game.journal, played[0]?.journal);
});
// ------------------------------------------- Rejouer d'ici sans perdre l'original
Given("vous avez joué plusieurs coups", async () => {
startGame();
await play(8);
});
When("vous rejouez d'ici à partir de votre deuxième coup", () => {
// Le retour seul : le coup suivant ouvrira la branche.
replayed = goBackToSecondMove();
});
When("vous choisissez un autre coup", async () => {
await playAnotherMove(replayed);
});
Then("la partie a deux branches", () => {
assert.equal(game.branches.length, 2);
});
Then("la partie d'origine est intacte", () => {
const origin = game.branches.find((b) => b.forkedFrom === null);
assert.equal(origin?.head, original.head);
const copy = Game.resume(engine, game.journal);
copy.switchBranch(original.head);
assert.deepEqual(
copy.events.map((e) => e.id),
original.ids,
);
});
Then("vous êtes sur la nouvelle branche", () => {
const current = game.branches.find((b) => b.containsHead);
assert.ok(current !== undefined && current.forkedFrom !== null);
assert.equal(current.head, game.journal.head);
});
Then("vous pouvez revenir à la partie d'origine, au coup près", () => {
const branch = game.branches.find((b) => b.forkedFrom === null);
assert.ok(branch !== undefined);
game.switchBranch(branch.head);
assert.deepEqual(
game.events.map((e) => e.id),
original.ids,
);
});
// ------------------------------------------------------------ Exporter une partie
Given("vous avez rejoué d'ici une autre suite", async () => {
await playAnotherMove(goBackToSecondMove());
await play(2);
});
When("vous exportez la partie", async () => {
sgf = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ustagammon",
});
});
Then("vous obtenez un fichier SGF de GNU Backgammon", () => {
assert.ok(sgf.startsWith("(;FF[4]GM[6]"), sgf.slice(0, 40));
assert.ok(sgf.trimEnd().endsWith(")"));
});
Then("il contient vos coups, et l'autre branche en variante", () => {
// Un nœud par coup joué, toutes branches comprises (B ou W, puis les
// dés), et deux variantes à l'embranchement.
const moves = game.journal.events.filter((e) => e.type === "MovePlayed").length;
assert.equal(sgf.match(/;[BW]\[\d\d/g)?.length, moves);
assert.equal(sgf.match(/\(\n;/g)?.length, 2);
});
@@ -0,0 +1,166 @@
// Étapes du scénario docs/scenarios/partager-une-position.feature (ADR 0008,
// ADR 0013) : le lien passe par le routeur de l'interface (adresse écrite
// puis relue), la position par le domaine et le vrai moteur WebAssembly, et
// les parties par le même enregistrement que l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { Before, BeforeAll, Given, Then, When } from "@cucumber/cucumber";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import {
linkOf,
readPosition,
type SharedPosition,
setupOf,
UnreadableLinkError,
} from "../../src/domain/game/position-link.js";
import { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { parseRoute, routeHash } from "../../src/ui/router.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, seededDice, step } from "../support/play.js";
let engine: Engine;
let library: GameLibrary;
let session: GameSession;
let game: Game;
let gameId: string | null;
let journalBefore: GameJournal;
let address: string;
let shared: SharedPosition | null;
let failure: unknown;
/** Une position de milieu de partie (gnubg), vue du joueur au trait. */
const MIDDLE_GAME = "jGfkASjg8wcBMA";
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Before(() => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
session = createGameSession(library, {
start: (newGame) => Game.start(engine, { dice: seededDice(7), level: "0ply", ...newGame }),
resume: (journal) => Game.resume(engine, journal, { level: "0ply" }),
});
shared = null;
failure = null;
});
/** Ouvre une adresse comme l'application : le routeur la lit, le moteur
* lit la position. */
async function open(hash: string): Promise<void> {
const route = parseRoute(hash);
assert.equal(route.screen, "position", `adresse non reconnue : ${hash}`);
if (route.screen !== "position") {
return;
}
try {
shared = await readPosition(engine, { position: route.position, matchId: route.match });
} catch (error) {
failure = error;
}
}
Given("votre partie en cours compte quelques coups", async () => {
game = session.newGame();
// Des coups jusqu'à ce que ce soit à vous de lancer les dés.
await playMoves(game, engine, 4);
const yourRoll = () => game.state.turn === "player" && game.state.phase === "rolling";
while (!yourRoll() && game.state.phase !== "over") {
await step(game, engine);
}
assert.ok(yourRoll(), "partie finie trop tôt");
session.save(game.journal);
gameId = session.shownId;
journalBefore = game.journal;
});
When("vous copiez le lien de la position", async () => {
const link = await linkOf(engine, game.state);
address = routeHash({ screen: "position", position: link.position, match: link.matchId });
});
Then("le lien contient l'identifiant de la position et celui du match", () => {
const route = parseRoute(address);
assert.ok(route.screen === "position");
assert.equal(route.position, game.state.position);
assert.equal(route.match?.length, 12);
assert.match(address, /^#\/position\/[A-Za-z0-9%]{14,}\/[A-Za-z0-9%]{12,}$/);
});
When("vous ouvrez ce lien", async () => {
await open(address);
});
Then("vous voyez exactement la même position, avec le même videau", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
assert.deepEqual(shared.sides, await game.displayedBoard());
assert.deepEqual(shared.cube, game.state.cube);
assert.equal(shared.match, null);
});
When("vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur", async () => {
const matchId = await engine.encodeMatch({ value: 2, owner: "opponent" });
address = routeHash({ screen: "position", position: MIDDLE_GAME, match: matchId });
await open(address);
});
Then("vous voyez cette position, avec la main", async () => {
assert.ok(shared !== null, `lien illisible : ${failure}`);
const board = await engine.board(MIDDLE_GAME, { value: 2, owner: "opponent" });
assert.deepEqual(shared.sides, { player: board.onRoll, computer: board.opponent });
assert.equal(shared.result, null);
});
Then("votre partie n'a pas changé", () => {
assert.equal(session.shownId, gameId);
assert.deepEqual(game.journal, journalBefore);
});
When("vous choisissez « Jouer à partir d'ici »", () => {
assert.ok(shared !== null);
game = session.newGame({ setup: setupOf(shared) });
session.save(game.journal);
});
Then("une nouvelle partie commence à cette position, à vous de lancer les dés", () => {
assert.notEqual(session.shownId, gameId);
assert.equal(game.state.position, MIDDLE_GAME);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
Then("le videau est à 2, chez l'ordinateur", () => {
assert.deepEqual(game.state.cube, { value: 2, owner: "computer" });
});
Then("votre partie précédente est toujours enregistrée", async () => {
assert.ok(gameId !== null);
const saved = await library.load(gameId);
assert.deepEqual(saved?.journal, journalBefore);
});
When("vous ouvrez un lien dont l'identifiant de position est abîmé", async () => {
// Coupé d'un caractère, puis un caractère hors du base64.
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FAB%2A/cAkAAAAAAAAA");
});
When("vous ouvrez un lien dont l'identifiant de match est abîmé", async () => {
shared = null;
failure = null;
await open("#/position/4HPwATDgc%2FABMA/cAk*");
});
Then("le lien est déclaré illisible", () => {
assert.equal(shared, null);
assert.ok(failure instanceof UnreadableLinkError, `erreur inattendue : ${failure}`);
});

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