Compare commits

..
Author SHA1 Message Date
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 6408b4b03e Fusion de main dans revision-ux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:03:47 +01:00
Pierre MARTINandClaude Opus 5.5 4d9a6d69db Test navigateur : menu essayé au moment de lancer les dés ; menu ouvert devant un panneau
Le panneau du double de l'ordinateur ne s'affiche qu'en paysage (en portrait,
la barre d'actions pose la question) : la rotation change alors l'écran, à
raison. Le test ouvrait le menu juste après un coup, parfois pendant un double.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:00:24 +01:00
Pierre MARTINandClaude Opus 5.5 d268111609 Fusion de la MR !34 : une adresse pour chaque écran, de vrais liens (ADR 0013)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:56:04 +01:00
Pierre MARTINandClaude Opus 5.5 342011ad0d ADR 0013 amendé : une adresse pour chaque écran, de vrais liens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:55:57 +01:00
Pierre MARTINandClaude Opus 5.5 0b1bd97342 Révision UX : plan clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:49:12 +01:00
Pierre MARTIN c04479c18f Fusion de main dans revision-ux 2026-11-03 15:49:12 +01:00
Pierre MARTINandClaude Opus 5.5 155a953c3b Plein écran : attente bornée, repli tourné si l'écran reste en portrait, paysage forcé en mode immersif ; plan : résultat et corrections de la recette
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:40:53 +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
Pierre MARTINandClaude Opus 5.5 b8c2997ec0 Recette design : consigne en texte nu, cases d'arrivée en triangle et anneau, flèches sous les pions, panneau de fin structuré
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:24:03 +01:00
Pierre MARTINandClaude Opus 5.5 ebaa3b0020 Tests : suppression synchrone du conteneur du site avant celle de son image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:18:26 +01:00
Pierre MARTINandClaude Opus 5.5 2c56b0a2b1 Recette automatisée de la révision UX dans make test-browser ; chiffres du plateau à 13 px au moins
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:17:16 +01:00
Pierre MARTINandClaude Opus 5.5 5f66535051 Fusion de la MR !33 : plans 1c et 1d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:15:05 +01:00
Pierre MARTINandClaude Opus 5.5 7fe3d53034 Plans 1c (match, niveaux, réglages) et 1d (conseil du maître)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:14:51 +01:00
Pierre MARTINandClaude Opus 5.5 08cb89735f Fusion de la MR !32 : moteur pour l'analyse et les niveaux (étape 1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:09:37 +01:00
Pierre MARTINandClaude Opus 5.5 091cb4a8b0 Fusion de main dans moteur-analyse ; plan 1a clos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:06:09 +01:00
Pierre MARTINandClaude Opus 5.5 c6649f94c0 Révision UX : portrait stable, bandeaux, menu, paysage plateau = écran, vibrations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:05:27 +01:00
Pierre MARTINandClaude Opus 5.5 2be5bdcb4f Tests : image du site propre à chaque copie de travail, supprimée en fin de test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:05:27 +01:00
Pierre MARTINandClaude Opus 5.5 fa558667f7 Plan 1a : résultat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:04:50 +01:00
Pierre MARTINandClaude Opus 5.5 cbef27ae9c Fusion de la MR !31 : plan 0e.5, accueil, installation, tutoriel, aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:02:19 +01:00
Pierre MARTINandClaude Opus 5.5 c061b6d41b Plan 0e.5 : accueil, installation, tutoriel, aide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:02:01 +01:00
Pierre MARTINandClaude Opus 5.5 6b5e0d2b5e Mesure : temps de l'analyse du coup joué à 2 plis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:00:39 +01:00
Pierre MARTINandClaude Opus 5.5 23f4d47fcf Domaine et adaptateur : niveaux, analyses et chance ; cache vidé à chaque calcul
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:59:07 +01:00
pierre 31159a5707 Fusion de la MR !30 : accueil, tutoriel, aide 2026-11-03 13:58:27 +00:00
Pierre MARTINandClaude Opus 5.5 f24315fb98 Charte : accueil, installation, tutoriel des règles, aide en questions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:58:25 +01:00
Pierre MARTINandClaude Opus 5.5 2f6eb86972 Tests du moteur : niveaux, analyses et chance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:08 +01:00
Pierre MARTINandClaude Opus 5.5 7e958fab8e Références : format des analyses documenté et vérifié
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:41 +01:00
Pierre MARTINandClaude Opus 5.5 e2249ecb85 Révision UX : ajouts A, B et C au plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:25 +01:00
Pierre MARTINandClaude Opus 5.5 7c1163054a Fusion de la MR !29 : ADR 0013, position dans l'adresse ; plan 0e.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:51:02 +01:00
Pierre MARTINandClaude Opus 5.5 b28a37d411 ADR 0013 : position dans l'adresse ; plan 0e.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:25:50 +01:00
Pierre MARTINandClaude Opus 5.5 242a2433d2 Références : analyses de coups, de videau, chance et niveaux, comparées à gnubg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:23:18 +01:00
Pierre MARTINandClaude Opus 5.5 07e66dd2c3 Fusion de la MR !28 : saisie du coup, un toucher, validation, Annuler, dés joués, videau confirmé
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:17:35 +01:00
Pierre MARTINandClaude Opus 5.5 34c3eaeca5 Moteur : niveaux de jeu, analyse d'un coup et du videau, chance d'un jet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:04:51 +01:00
186 changed files with 26563 additions and 1305 deletions
+9 -6
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,10 @@ SITE_ICONS := icone.svg icone-192.png icone-512.png icone-maskable-192.png icone
# docs/identite/logo/ (une seule source, ADR 0010) ;
# - l'application compilée (build/web/src/) sous app/ ;
# - le moteur sous wasm/ ;
# - les copies du glossaire et des scénarios sous docs/, lues par l'aide de
# l'application (ADR 0008 : jamais commitées ailleurs) ;
# - les copies du glossaire, des scénarios, du tutoriel et des phrases du
# maître sous docs/, lues par l'aide, le tutoriel et le conseil du maître
# (ADR 0008 : jamais commitées
# ailleurs) ;
# - version.json (commit publié) ;
# - sw.js, complété par la liste de tous ces fichiers (scripts/service-worker.sh).
site: engine-wasm web ## Assemble le site publié dans build/site/
@@ -139,7 +142,7 @@ site: engine-wasm web ## Assemble le site publié dans build/site/
cp -r $(BUILD_DIR)/web/src $(BUILD_DIR)/site/app
cp $(BUILD_DIR)/wasm/engine.mjs $(BUILD_DIR)/wasm/engine.wasm $(BUILD_DIR)/wasm/engine.data \
$(BUILD_DIR)/wasm/positions.json $(BUILD_DIR)/site/wasm/
cp -r docs/glossaire docs/scenarios $(BUILD_DIR)/site/docs/
cp -r docs/glossaire docs/scenarios docs/tutoriel docs/maitre $(BUILD_DIR)/site/docs/
printf '{"commit": "%s", "dirty": %s}\n' "$$(git rev-parse HEAD)" \
"$$(git diff --quiet HEAD && echo false || echo true)" > $(BUILD_DIR)/site/version.json
scripts/service-worker.sh $(BUILD_DIR)/site
@@ -163,8 +166,8 @@ reference-image: ## Construit l'image Docker du gnubg de référence (cache Dock
docker build -t $(REFERENCE_IMAGE) docker/gnubg-reference
@echo "Image $(REFERENCE_IMAGE) prête."
reference: reference-image ## Régénère tests/reference/results/ avec le gnubg de référence
rm -rf tests/reference/results
reference: reference-image ## Régénère tests/reference/results/ et analysis/ avec le gnubg de référence
rm -rf tests/reference/results tests/reference/analysis
docker run --rm --user "$$(id -u):$$(id -g)" -e HOME=/tmp \
-v "$(CURDIR)/tests/reference:/reference" $(REFERENCE_IMAGE) \
gnubg --tty --quiet --no-rc --python=/reference/generate.py
+166
View File
@@ -0,0 +1,166 @@
// L'analyse des décisions du joueur par le moteur, pour le conseil du
// maître (étape 1d, ADR 0011) et le bilan de fin de partie (1e).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Tout vient du moteur : l'analyse du coup joué (rang, meilleur coup, équité
// perdue, appréciation), le classement des coups, l'analyse de la décision
// de videau, et les plateaux décodés, où se lisent les pions frappés et les
// points faits. L'application lance ces analyses sur un second moteur, en
// arrière-plan : elles ne retardent jamais la partie.
import { ADVICE_VERSION, type Advice, type AdvisedMove, type MoveFacts } from "../game/advice.js";
import type { BoardSide, CubeAction, Engine, EvaluatedMove, Ply } from "../game/engine.js";
import { currentLine, type GameEvent, type GameJournal } from "../game/events.js";
import { engineCube, STANDARD_PLY } from "../game/game.js";
import { replay } from "../game/state.js";
/** Niveau de l'analyse : 2 plis, comme l'analyse de partie de gnubg par
* défaut, et le niveau « standard » (ADR 0009). */
export const ANALYSIS_PLY: Ply = STANDARD_PLY;
/** Nombre de coups gardés du classement du moteur, pour « Voir les
* chiffres du moteur ». */
export const RANKING_SIZE = 5;
/**
* Les décisions du joueur à analyser parmi des événements : ses coups (pas
* un tour passé faute de coup possible), ses jets (il aurait pu doubler
* avant), ses doubles et ses réponses aux doubles de l'ordinateur.
*/
export function playerDecisions(events: readonly GameEvent[]): GameEvent[] {
return events.filter((event) => {
switch (event.type) {
case "MovePlayed":
return event.side === "player" && event.halfMoves.length > 0;
case "DiceRolled":
case "CubeOffered":
case "CubeTaken":
case "CubeDropped":
return event.side === "player";
default:
return false;
}
});
}
/**
* Le conseil du moteur sur la décision du joueur eventId, au niveau ply ;
* null s'il n'y a rien à analyser (pas une décision du joueur, aucun coup
* possible, videau inaccessible ou position de départ pour gnubg).
*/
export async function analyseDecision(
engine: Engine,
journal: GameJournal,
eventId: string,
ply: Ply = ANALYSIS_PLY,
): Promise<Advice | null> {
const line = currentLine({ ...journal, head: eventId });
const event = line.at(-1);
if (event === undefined || playerDecisions([event]).length === 0) {
return null;
}
const earlier = line.slice(0, -1);
const before = replay(earlier);
if (event.type === "MovePlayed") {
if (before.roll === null) {
return null;
}
const cube = engineCube(before, "player");
// Le coup joué est enregistré vu de l'ordinateur : le moteur donne la
// même position vue du joueur.
const after = await engine.board(event.position, engineCube(before, "computer"));
const analysis = await engine.analyseMove(
before.position,
cube,
before.roll,
after.swappedPosition,
ply,
);
if (analysis === null) {
return null;
}
const ranking = await engine.rankMoves(before.position, cube, before.roll, ply);
const start = await engine.board(before.position, cube);
const facts = async (move: EvaluatedMove) =>
factsOf(start.onRoll, start.opponent, await engine.board(move.position, cube));
return {
version: ADVICE_VERSION,
kind: "move",
eventId,
ply,
position: before.position,
roll: before.roll,
opening: !earlier.some((e) => e.type === "MovePlayed"),
moveCount: analysis.moveCount,
rank: analysis.rank,
played: advised(analysis.played),
best: advised(analysis.best),
playedFacts: await facts(analysis.played),
bestFacts: await facts(analysis.best),
ranking: ranking.slice(0, RANKING_SIZE).map(advised),
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
let action: CubeAction;
let decider: "player" | "computer" = "player";
if (event.type === "DiceRolled") {
if (before.phase !== "rolling") {
return null;
}
action = "noDouble";
} else if (event.type === "CubeOffered") {
action = "double";
} else {
action = event.type === "CubeTaken" ? "take" : "pass";
decider = "computer";
}
const analysis = await engine.analyseCube(
before.position,
engineCube(before, decider),
action,
ply,
);
if (!analysis.analysed || analysis.equities === null) {
return null;
}
return {
version: ADVICE_VERSION,
kind: "cube",
eventId,
ply,
action,
position: before.position,
equities: analysis.equities,
equityLoss: analysis.equityLoss,
skill: analysis.skill,
};
}
function advised(move: EvaluatedMove): AdvisedMove {
return {
notation: move.notation,
halfMoves: move.halfMoves.map((h) => ({ from: h.from, to: h.to })),
equity: move.equity,
ply: move.ply,
};
}
/** Ce que le coup a changé sur le plateau, lu sur les plateaux du moteur
* (vus du joueur) : pions adverses envoyés sur la barre, cases où le
* joueur a désormais deux pions ou plus. */
function factsOf(
mine: BoardSide,
theirs: BoardSide,
after: { readonly onRoll: BoardSide; readonly opponent: BoardSide },
): MoveFacts {
const madePoints: number[] = [];
for (let i = 0; i < 24; i++) {
if ((mine.points[i] ?? 0) < 2 && (after.onRoll.points[i] ?? 0) >= 2) {
madePoints.push(i + 1);
}
}
return { hits: Math.max(0, after.opponent.bar - theirs.bar), madePoints };
}
+267
View File
@@ -0,0 +1,267 @@
// Les mots et les phrases du maître : lecture de leur source
// (docs/maitre/phrases.md, ADR 0008 : une seule source) et remplissage des
// gabarits avec un conseil du moteur. Fonctions pures, testées sous Node
// sur de vraies analyses (app/tests/advice.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
type MoveFacts,
masterCubeAction,
type Verdict,
verdictOf,
} from "../game/advice.js";
import type { CubeAction } from "../game/engine.js";
/** Les gabarits, par clé de section (« coup.meilleur »…). */
export type MasterPhrases = ReadonlyMap<string, readonly string[]>;
/** Longueur maximale de la ligne du maître (mot et phrase), en caractères
* (charte, « Les phrases du maître ») : elle doit rester en dessous. */
export const LINE_LIMIT = 140;
/** Ce que le maître dit d'une décision. */
export interface MasterWords {
readonly verdict: Verdict;
/** Le mot d'appréciation, sans point : « Bon coup », « Erreur »… */
readonly word: string;
/** La phrase du maître. */
readonly phrase: string;
/** Le titre de la feuille d'explication. */
readonly title: string;
/** Le contexte de la décision : « votre 3-1 d'ouverture »… */
readonly context: string;
}
/** Lit la source des phrases : les sections « ## clé » à partir de la
* première « ## mot.… », un gabarit par ligne « - … ». */
export function parsePhrases(text: string): MasterPhrases {
const phrases = new Map<string, string[]>();
let key: string | null = null;
let started = false;
for (const raw of text.split("\n")) {
const line = raw.trim();
const heading = /^## (\S+)$/.exec(line);
if (heading !== null) {
const name = heading[1] as string;
started ||= name.startsWith("mot.");
key = started ? name : null;
if (key !== null) {
phrases.set(key, []);
}
} else if (key !== null && line.startsWith("- ")) {
phrases.get(key)?.push(line.slice(2).trim());
}
}
return phrases;
}
/** Les clés que la source doit contenir (vérifié par les tests). */
export const PHRASE_KEYS: readonly string[] = [
"mot.coup.bon",
"mot.videau.bon",
"mot.douteux",
"mot.mauvais",
"mot.tres-mauvais",
"coup.meilleur",
"coup.seul",
"coup.proche",
"coup.douteux",
"coup.mauvais",
"coup.tres-mauvais",
"coup.loin",
"fait.frappe",
"fait.frappe-plusieurs",
"fait.point",
"fait.frappe-et-point",
"fait.maitre-frappe",
"fait.maitre-point",
"fait.joueur-frappe",
...["bon", "proche", "autre"].flatMap((group) =>
["double", "pas-de-double", "prendre", "passer"].map((a) => `videau.${group}.${a}`),
),
"titre.coup.meme",
"titre.coup.autre",
"titre.videau.meme",
...["double", "pas-de-double", "prendre", "passer"].map((a) => `titre.videau.${a}`),
"contexte.coup",
"contexte.coup.ouverture",
"contexte.videau.double",
"contexte.videau.pas-de-double",
"contexte.videau.reponse",
];
/** Ce que le maître dit d'un conseil du moteur. */
export function masterWords(phrases: MasterPhrases, advice: Advice): MasterWords {
return advice.kind === "move" ? moveWords(phrases, advice) : cubeWords(phrases, advice);
}
/** La ligne du maître telle qu'elle s'affiche : « Erreur. J'aurais… ». */
export function masterLine(words: MasterWords): string {
return `${words.word}. ${words.phrase}`;
}
const ACTION_KEYS: Readonly<Record<CubeAction, string>> = {
double: "double",
noDouble: "pas-de-double",
take: "prendre",
pass: "passer",
};
function wordKey(verdict: Verdict, kind: Advice["kind"]): string {
switch (verdict) {
case "good":
return kind === "move" ? "mot.coup.bon" : "mot.videau.bon";
case "doubtful":
return "mot.douteux";
case "bad":
return "mot.mauvais";
case "veryBad":
return "mot.tres-mauvais";
}
}
function moveWords(phrases: MasterPhrases, advice: MoveAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const word = pick(phrases, wordKey(verdict, "move"), advice.eventId);
const values = {
best: advice.best.notation,
played: advice.played.notation,
rank: String(advice.rank),
roll: `${advice.roll[0]}-${advice.roll[1]}`,
};
const fill = (key: string, extra: Readonly<Record<string, string>> = {}) =>
fillIn(pick(phrases, key, advice.eventId), { ...values, ...extra });
let base: string;
let fact: string | null;
if (advice.moveCount <= 1) {
base = fill("coup.seul");
fact = null;
} else if (advice.rank === 1) {
base = fill("coup.meilleur");
fact = selfFact(phrases, advice.eventId, advice.played.notation, advice.playedFacts);
} else {
// Le rang n'est cité que pour un bon coup non meilleur ou une
// imprécision, au 2e ou 3e choix : il mesure mal la gravité d'une
// erreur, et le classement est dans les chiffres du moteur.
const near = advice.rank <= 3;
const key = {
good: near ? "coup.proche" : "coup.loin",
doubtful: near ? "coup.douteux" : "coup.loin",
bad: "coup.mauvais",
veryBad: "coup.tres-mauvais",
}[verdict];
base = fill(key);
fact = differenceFact(phrases, advice);
}
const withFact = fact === null ? base : `${base} ${fact}`;
const phrase = `${word}. ${withFact}`.length < LINE_LIMIT ? withFact : base;
return {
verdict,
word,
phrase,
title: fill(advice.rank === 1 ? "titre.coup.meme" : "titre.coup.autre"),
context: fill(advice.opening ? "contexte.coup.ouverture" : "contexte.coup"),
};
}
/** Ce que fait le coup lui-même : pions frappés, points faits. */
function selfFact(
phrases: MasterPhrases,
id: string,
move: string,
facts: MoveFacts,
): string | null {
const values = { move, points: pointsText(facts.madePoints), hits: String(facts.hits) };
const made = facts.madePoints.length > 0;
if (facts.hits === 1 && made) {
return fillIn(pick(phrases, "fait.frappe-et-point", id), values);
}
if (facts.hits > 1) {
return fillIn(pick(phrases, "fait.frappe-plusieurs", id), values);
}
if (facts.hits === 1) {
return fillIn(pick(phrases, "fait.frappe", id), values);
}
return made ? fillIn(pick(phrases, "fait.point", id), values) : null;
}
/** Un fait qui distingue le coup du maître du coup joué, s'il y en a un. */
function differenceFact(phrases: MasterPhrases, advice: MoveAdvice): string | null {
const best = advice.bestFacts;
const played = advice.playedFacts;
if (best.hits > 0 && played.hits === 0) {
return pick(phrases, "fait.maitre-frappe", advice.eventId);
}
const onlyBest = best.madePoints.filter((p) => !played.madePoints.includes(p));
if (onlyBest.length > 0) {
return fillIn(pick(phrases, "fait.maitre-point", advice.eventId), {
points: pointsText(onlyBest),
});
}
if (played.hits > 0 && best.hits === 0) {
return pick(phrases, "fait.joueur-frappe", advice.eventId);
}
return null;
}
function cubeWords(phrases: MasterPhrases, advice: CubeAdvice): MasterWords {
const verdict = verdictOf(advice.skill);
const master = masterCubeAction(advice);
const same = master === advice.action;
const group = verdict !== "good" ? "autre" : same ? "bon" : "proche";
const shown = group === "bon" ? advice.action : master;
const context =
advice.action === "take" || advice.action === "pass"
? "contexte.videau.reponse"
: `contexte.videau.${ACTION_KEYS[advice.action]}`;
return {
verdict,
word: pick(phrases, wordKey(verdict, "cube"), advice.eventId),
phrase: pick(phrases, `videau.${group}.${ACTION_KEYS[shown]}`, advice.eventId),
title: pick(
phrases,
same ? "titre.videau.meme" : `titre.videau.${ACTION_KEYS[master]}`,
advice.eventId,
),
context: pick(phrases, context, advice.eventId),
};
}
/** « votre point 5 », « vos points 5 et 7 », « vos points 4, 5 et 7 ». */
function pointsText(points: readonly number[]): string {
const sorted = [...points].sort((a, b) => a - b).map(String);
if (sorted.length <= 1) {
return `votre point ${sorted[0] ?? ""}`;
}
return `vos points ${sorted.slice(0, -1).join(", ")} et ${sorted.at(-1)}`;
}
/** Un gabarit de la section key ; plusieurs tournent selon l'identifiant
* de l'événement. Erreur si la section manque. */
function pick(phrases: MasterPhrases, key: string, id: string): string {
const choices = phrases.get(key) ?? [];
if (choices.length === 0) {
throw new Error(`phrases du maître : section « ${key} » absente`);
}
const n = Number.parseInt(id, 10);
// Les identifiants des coups du joueur vont souvent de quatre en quatre :
// un brassage (hachage de Knuth, bits de poids fort) les fait tourner.
const index = (Math.imul(Number.isNaN(n) ? 0 : n, 2654435761) >>> 16) % choices.length;
return choices[index] as string;
}
/** Remplace les {repères} ; erreur sur un repère inconnu. */
function fillIn(template: string, values: Readonly<Record<string, string>>): string {
return template.replace(/\{([a-z]+)\}/g, (_, name: string) => {
const value = values[name];
if (value === undefined) {
throw new Error(`phrases du maître : repère {${name}} inconnu`);
}
return value;
});
}
+124
View File
@@ -0,0 +1,124 @@
// Le conseil du maître sur une décision du joueur : ce que le moteur en dit,
// rattaché à l'événement de la décision dans le journal (étape 1d).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Ce fichier ne contient que des données : tout ce qu'elles disent vient du
// moteur (analyse du coup, classement des coups, analyse de la décision de
// videau, plateaux décodés). Le calcul est dans domain/analysis/ ; le
// stockage, dans le journal (events.ts, champ advice). Le format est
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
/** Version du format d'un conseil enregistré. Un conseil d'une autre
* version est ignoré à la reprise (il peut être recalculé). */
export const ADVICE_VERSION = 1;
/**
* Le conseil du maître sur une décision du joueur : un coup ou une décision
* de videau, avec l'appréciation du moteur.
*
* Glossaire : docs/glossaire/conseil-du-maitre.md
*/
export type Advice = MoveAdvice | CubeAdvice;
/** Un coup, tel que le conseil le retient. */
export interface AdvisedMove {
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
readonly notation: string;
readonly halfMoves: readonly HalfMove[];
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
* le moteur l'a évaluée. */
readonly equity: number;
readonly ply: Ply;
}
/** Ce que l'on lit sur les plateaux décodés par le moteur, avant et après
* un coup : de quoi écrire une phrase sans rien inventer. */
export interface MoveFacts {
/** Pions adverses envoyés sur la barre. */
readonly hits: number;
/** Cases (numérotation du joueur) où il n'avait pas deux pions avant
* le coup et en a deux ou plus après : les points qu'il fait. */
readonly madePoints: readonly number[];
}
export interface MoveAdvice {
readonly version: number;
readonly kind: "move";
/** L'événement MovePlayed du coup. */
readonly eventId: string;
/** Niveau de l'analyse, en plis. */
readonly ply: Ply;
/** La position avant le coup, vue du joueur, et son jet. */
readonly position: PositionId;
readonly roll: Roll;
/** Le premier coup de la partie (après le jet d'ouverture). */
readonly opening: boolean;
/** Nombre de coups légaux et rang du coup joué (à partir de 1). */
readonly moveCount: number;
readonly rank: number;
readonly played: AdvisedMove;
readonly best: AdvisedMove;
readonly playedFacts: MoveFacts;
readonly bestFacts: MoveFacts;
/** Les meilleurs coups du classement du moteur, du meilleur au moins
* bon (cinq au plus). */
readonly ranking: readonly AdvisedMove[];
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
export interface CubeAdvice {
readonly version: number;
readonly kind: "cube";
/** L'événement de la décision : DiceRolled (le joueur lance sans
* doubler), CubeOffered (il double), CubeTaken ou CubeDropped (il
* répond au double de l'ordinateur). */
readonly eventId: string;
readonly ply: Ply;
/** La décision du joueur. */
readonly action: CubeAction;
/** La position, vue de celui qui double ou non (l'ordinateur quand le
* joueur prend ou passe). */
readonly position: PositionId;
/** Équités de celui qui double ou non, pour un videau de 1. */
readonly equities: CubeEquities;
/** Équité perdue par le joueur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
/** L'appréciation dite au joueur : celle du moteur, « rien à signaler »
* devenant « bon ». */
export type Verdict = "good" | "doubtful" | "bad" | "veryBad";
export function verdictOf(skill: Skill): Verdict {
return skill === "none" ? "good" : skill;
}
/** L'autre choix d'une décision de videau. */
export function otherCubeAction(action: CubeAction): CubeAction {
switch (action) {
case "double":
return "noDouble";
case "noDouble":
return "double";
case "take":
return "pass";
case "pass":
return "take";
}
}
/** La décision que le maître aurait prise : celle du joueur s'il n'a perdu
* aucune équité, l'autre sinon (une décision de videau n'a que deux
* choix). */
export function masterCubeAction(advice: CubeAdvice): CubeAction {
return advice.equityLoss < 0 ? otherCubeAction(advice.action) : advice.action;
}
+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);
}
+214 -1
View File
@@ -38,6 +38,200 @@ export interface Engine {
/** Le plateau d'une position, et le résultat si la partie y est finie. */
board(position: PositionId, cube: EngineCube): Promise<Board>;
/** Le coup que joue l'ordinateur de gnubg à ce niveau (bruit compris,
* reproductible), ou null si aucun coup n'est possible. */
levelMove(
position: PositionId,
cube: EngineCube,
roll: Roll,
level: Level,
): Promise<Move | null>;
/** 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. */
analyseMove(
position: PositionId,
cube: EngineCube,
roll: Roll,
played: PositionId,
ply: Ply,
): Promise<MoveAnalysis | null>;
/** Les coups légaux d'un jet classés du meilleur au moins bon, au
* niveau ply, comme la commande « hint » de gnubg (les coups écartés
* par ses filtres gardent l'évaluation d'un niveau inférieur, voir leur
* ply). */
rankMoves(
position: PositionId,
cube: EngineCube,
roll: Roll,
ply: Ply,
): Promise<EvaluatedMove[]>;
/** L'analyse d'une décision de videau, position vue du joueur au trait
* (celui qui double ou non ; take et pass sont la réponse de son
* adversaire). */
analyseCube(
position: PositionId,
cube: EngineCube,
action: CubeAction,
ply: Ply,
): Promise<CubeAnalysis>;
/** La chance d'un jet pour le joueur au trait. */
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
/** 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;
}
/**
* Niveau de jeu de l'ordinateur : un réglage prédéfini de GNU Backgammon
* (aecSettings dans eval.c ; voir engine/include/engine.h). Les quatre
* premiers jouent à 0 pli avec un bruit qui leur fait commettre des erreurs ;
* les suivants sont sans bruit, de 0 à 4 plis.
*
* Glossaire : docs/glossaire/niveau-de-jeu.md
*/
export type Level =
| "beginner"
| "casual"
| "intermediate"
| "advanced"
| "0ply"
| "1ply"
| "2ply"
| "3ply"
| "4ply";
/**
* Appréciation d'un coup ou d'une décision de videau par le moteur, selon
* l'équité perdue : rien à signaler, douteux, mauvais, très mauvais.
*
* Glossaire : docs/glossaire/appreciation.md
*/
export type Skill = "none" | "doubtful" | "bad" | "veryBad";
/**
* Seuils de GNU Backgammon (arSkillLevel dans gnubg.c) : une perte
* d'équité strictement plus grande fait passer à l'appréciation. Le moteur
* classe lui-même ; ces valeurs servent à l'expliquer.
*/
export const SKILL_THRESHOLDS = { doubtful: 0.03, bad: 0.06, veryBad: 0.12 } as const;
/** Catégorie de chance d'un jet, de très malchanceux à très chanceux. */
export type LuckRating = "veryUnlucky" | "unlucky" | "none" | "lucky" | "veryLucky";
/**
* Seuils de GNU Backgammon (arLuckLevel dans gnubg.c) sur la chance d'un
* jet, dans un sens ou dans l'autre.
*/
export const LUCK_THRESHOLDS = { lucky: 0.3, veryLucky: 0.6 } as const;
/**
* La chance d'un jet : l'équité qu'il fait gagner (positive) ou perdre au
* joueur par rapport à la moyenne des jets.
*
* Glossaire : docs/glossaire/chance.md
*/
export interface Luck {
readonly value: number;
readonly rating: LuckRating;
}
/** Un coup avec son équité (avec videau), du point de vue de celui qui le joue. */
export interface EvaluatedMove extends Move {
readonly equity: number;
/** Niveau auquel le coup a réellement été évalué. */
readonly ply: Ply;
}
/** Ce que le moteur dit d'un coup joué, comme l'analyse de partie de gnubg. */
export interface MoveAnalysis {
/** Nombre de coups légaux. */
readonly moveCount: number;
/** Place du coup joué dans le classement, à partir de 1. */
readonly rank: number;
readonly played: EvaluatedMove;
readonly best: EvaluatedMove;
/** Équité du coup joué moins celle du meilleur : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
/** Décision de videau : doubler ou non (joueur au trait), prendre ou passer
* (son adversaire). */
export type CubeAction = "noDouble" | "double" | "take" | "pass";
/** Ce que le moteur dit d'une décision de videau. */
export interface CubeAnalysis {
/** gnubg analyse-t-il cette décision ? Pas quand le joueur au trait ne
* peut pas doubler, ni à la position de départ. */
readonly analysed: boolean;
/** Équités du joueur au trait, normalisées pour un videau de 1 (null si
* non analysée). */
readonly equities: CubeEquities | null;
/** Équité perdue par celui qui décide : 0 ou négative. */
readonly equityLoss: number;
readonly skill: Skill;
}
export interface CubeEquities {
readonly noDouble: number;
readonly doubleTake: number;
readonly doublePass: number;
/** Après les meilleures décisions des deux joueurs. */
readonly optimal: number;
}
/**
@@ -47,10 +241,29 @@ export interface Engine {
*/
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;
}
/**
+95 -7
View File
@@ -12,14 +12,80 @@
// Chaque événement contient tout ce qu'il faut pour rejouer la partie sans
// le moteur : l'état se recalcule par une simple boucle (state.ts).
import { ADVICE_VERSION, type Advice } from "./advice.js";
import type { Cube } from "./cube.js";
import type { ResultKind } from "./engine.js";
import type { Level, ResultKind } from "./engine.js";
import type { Match, MatchAfterGame } from "./match.js";
import type { HalfMove, PositionId } from "./position.js";
import type { Roll } from "./roll.js";
import type { Side } from "./side.js";
/** Version du format des événements. */
export const EVENT_VERSION = 1;
/**
* Version du format des événements :
* - 1 : jusqu'à l'étape 1b, partie libre au niveau 2 plis ;
* - 2 (étape 1c) : GameStarted porte le niveau de l'ordinateur (level) et,
* pour une partie de match, le match (match) ; GameEnded porte le match
* après la partie. migrateJournal passe un journal de la version 1 à la 2.
*/
export const EVENT_VERSION = 2;
/** Le niveau des parties de la version 1 : 2 plis (STANDARD_PLY, sans
* bruit), le seul qui existait. */
const VERSION_1_LEVEL: Level = "2ply";
/**
* Un journal enregistré, à la version courante du format. Version 1 : le
* niveau, implicite, devient explicite (2 plis) ; rien d'autre ne change,
* une partie de la version 1 étant une partie libre. Un événement d'une
* version inconnue est laissé tel quel (Game.resume le refuse).
*/
export function migrateJournal(saved: GameJournal): GameJournal {
const journal = keepKnownAdvice(saved);
if (journal.events.every((event) => event.version === EVENT_VERSION)) {
return journal;
}
const events = journal.events.map((event): GameEvent => {
if (event.version !== 1) {
return event;
}
if (event.type === "GameStarted") {
return Object.freeze({
...event,
level: event.level ?? VERSION_1_LEVEL,
version: EVENT_VERSION,
});
}
return Object.freeze({ ...event, version: EVENT_VERSION });
});
return { ...journal, events };
}
/**
* Les conseils du maître d'un journal enregistré : seuls restent ceux de la
* version courante (ADVICE_VERSION), rattachés à un événement du journal.
* Les autres sont oubliés : ce ne sont que des analyses du moteur, qui
* peuvent être refaites. Un journal sans conseils (enregistré avant l'étape
* 1d) reste tel quel.
*/
function keepKnownAdvice(journal: GameJournal): GameJournal {
const advice = journal.advice;
if (advice === undefined) {
return journal;
}
const ids = new Set(journal.events.map((event) => event.id));
const kept = Object.entries(advice).filter(
([id, a]) =>
typeof a === "object" &&
a !== null &&
a.version === ADVICE_VERSION &&
a.eventId === id &&
ids.has(id),
);
if (kept.length === Object.keys(advice).length) {
return journal;
}
return { ...journal, advice: Object.fromEntries(kept) };
}
interface EventBase {
/** Identifiant, unique dans la partie. */
@@ -31,12 +97,18 @@ interface EventBase {
/** Début de partie : la position, le videau, et qui commence (null : le jet
* d'ouverture le dira). La position est vue de celui qui commence ; celle de
* départ est la même pour les deux. */
* départ est la même pour les deux. level et match viennent avec la version
* 2 du format (étape 1c). */
export interface GameStarted extends EventBase {
readonly type: "GameStarted";
readonly position: PositionId;
readonly cube: Cube;
readonly firstTurn: Side | null;
/** Le niveau de l'ordinateur ; absent : DEFAULT_LEVEL (state.ts). Les
* journaux de la version 1 l'ont après migrateJournal. */
readonly level?: Level;
/** Le match dont c'est une partie ; absent : partie libre. */
readonly match?: Match;
}
/** Des dés tirés. Pour le jet d'ouverture, side vaut null et chacun lance un
@@ -80,6 +152,9 @@ export interface GameEnded extends EventBase {
readonly winner: Side;
readonly kind: ResultKind;
readonly points: number;
/** Dans un match : le match après cette partie, dit par le moteur
* (absent en partie libre). */
readonly match?: MatchAfterGame;
}
/**
@@ -104,10 +179,14 @@ export type NewGameEvent = WithoutBase<GameEvent>;
type WithoutBase<E> = E extends unknown ? Omit<E, keyof EventBase> : never;
/** Tous les événements d'une partie, branches comprises, et l'événement
* courant (head) : la partie en est là. */
* courant (head) : la partie en est là. advice (étape 1d, facultatif) : le
* conseil du maître sur les décisions du joueur, par identifiant de
* l'événement de la décision. Ce n'est pas un événement : il arrive après
* coup, quand l'analyse du moteur est prête, et ne change pas la partie. */
export interface GameJournal {
readonly events: readonly GameEvent[];
readonly head: string | null;
readonly advice?: Readonly<Record<string, Advice>>;
}
export const EMPTY_JOURNAL: GameJournal = { events: [], head: null };
@@ -120,7 +199,16 @@ export function appendEvent(journal: GameJournal, event: NewGameEvent): GameJour
parent: journal.head,
version: EVENT_VERSION,
}) as GameEvent;
return { events: [...journal.events, added], head: added.id };
return { ...journal, events: [...journal.events, added], head: added.id };
}
/** Rattache un conseil du maître à son événement (il remplace un conseil
* déjà rattaché). Erreur si l'événement n'est pas dans le journal. */
export function withAdvice(journal: GameJournal, advice: Advice): GameJournal {
if (!journal.events.some((event) => event.id === advice.eventId)) {
throw new Error(`événement introuvable : ${advice.eventId}`);
}
return { ...journal, advice: { ...journal.advice, [advice.eventId]: advice } };
}
/** Les événements du début jusqu'à l'événement courant, dans l'ordre. */
@@ -144,5 +232,5 @@ export function goBackTo(journal: GameJournal, id: string): GameJournal {
if (!journal.events.some((event) => event.id === id)) {
throw new Error(`événement introuvable : ${id}`);
}
return { events: journal.events, head: id };
return { ...journal, head: id };
}
+190 -81
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,13 @@
// Le joueur et l'ordinateur agissent chacun leur tour ; chaque action ajoute
// des événements au journal (events.ts), d'où l'état est recalculé
// (state.ts). Les règles viennent toutes du moteur : coups légaux, coup de
// l'ordinateur, videau, fin de partie et points.
// l'ordinateur, videau (Crawford compris), fin de partie, points et score du
// match.
import type { Advice } from "./advice.js";
import { type Branch, branches, switchToBranch } from "./branches.js";
import { CENTERED_CUBE, type Cube } from "./cube.js";
import type { BoardSide, Engine, EngineCube, Ply } from "./engine.js";
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
import {
appendEvent,
currentLine,
@@ -18,16 +21,19 @@ import {
type GameEvent,
type GameJournal,
goBackTo,
migrateJournal,
type NewGameEvent,
withAdvice,
} from "./events.js";
import { firstGameOf, type Match, nextGameOf } from "./match.js";
import { BAR, type HalfMove, OFF, type PositionId, START_POSITION } from "./position.js";
import { type DiceSource, type Roll, randomDice, rollDice } from "./roll.js";
import { otherSide, type Side } from "./side.js";
import { type GameState, replay } from "./state.js";
/**
* Niveau « standard » de l'ordinateur : 2 plis. Sur le Fairphone 4 (Chrome),
* le meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
* Niveau « standard » d'analyse : 2 plis. Sur le Fairphone 4 (Chrome), le
* meilleur coup à ce niveau prend au plus 348 ms (ADR 0009).
*/
export const STANDARD_PLY: Ply = 2;
@@ -61,12 +67,19 @@ export interface MoveSteps {
readonly hits: readonly boolean[];
}
export interface GameOptions {
/** Ce que l'on choisit pour une nouvelle partie. */
export interface NewGame {
/** Une position de départ particulière (sans jet d'ouverture). */
readonly setup?: GameSetup;
/** Niveau de l'ordinateur ; DEFAULT_LEVEL (state.ts) par défaut. */
readonly level?: Level;
/** Le match dont ce sera une partie ; absent : partie libre. */
readonly match?: Match;
}
export interface GameOptions extends NewGame {
/** Source des dés ; le hasard ordinaire par défaut. */
readonly dice?: DiceSource;
/** Niveau de l'ordinateur ; STANDARD_PLY par défaut. */
readonly ply?: Ply;
readonly setup?: GameSetup;
}
/**
@@ -77,7 +90,6 @@ export interface GameOptions {
export class Game {
readonly #engine: Engine;
readonly #dice: DiceSource;
readonly #ply: Ply;
#journal: GameJournal = EMPTY_JOURNAL;
// Coup du joueur en cours de saisie : pas encore dans le journal (il n'y
// entre qu'à sa validation, confirmMove). Pour chaque demi-coup saisi, la
@@ -87,11 +99,11 @@ export class Game {
private constructor(engine: Engine, options: GameOptions) {
this.#engine = engine;
this.#dice = options.dice ?? randomDice;
this.#ply = options.ply ?? STANDARD_PLY;
}
/** Commence une partie : à la position de départ avec le jet d'ouverture,
* ou à la position de options.setup. */
* ou à la position de options.setup ; au niveau options.level, et dans
* le match options.match s'il est donné. */
static start(engine: Engine, options: GameOptions = {}): Game {
const game = new Game(engine, options);
const setup = options.setup;
@@ -100,6 +112,8 @@ export class Game {
position: setup?.position ?? START_POSITION,
cube: setup?.cube ?? CENTERED_CUBE,
firstTurn: setup?.firstTurn ?? null,
...(options.level === undefined ? {} : { level: options.level }),
...(options.match === undefined ? {} : { match: options.match }),
});
while (game.state.phase === "opening") {
game.#append({ type: "DiceRolled", side: null, roll: rollDice(game.#dice) });
@@ -111,11 +125,13 @@ export class Game {
* Reprend une partie enregistrée, à partir de son journal. Erreur si le
* journal est d'un autre format ou incohérent.
*/
static resume(engine: Engine, journal: GameJournal, options: GameOptions = {}): Game {
const first = journal.events[0];
if (first?.type !== "GameStarted" || journal.head === null) {
static resume(engine: Engine, saved: GameJournal, options: GameOptions = {}): Game {
const first = saved.events[0];
if (first?.type !== "GameStarted" || saved.head === null) {
throw new Error("journal de partie vide ou mal formé");
}
// Un journal d'une version précédente passe à la version courante.
const journal = migrateJournal(saved);
if (journal.events.some((event) => event.version !== EVENT_VERSION)) {
throw new Error("journal de partie d'une autre version");
}
@@ -140,6 +156,29 @@ export class Game {
return replay(this.events);
}
/** La partie finie appartient à un match qui continue : ce que sera la
* partie suivante (score et Crawford dits par le moteur à la fin de
* celle-ci) ; null sinon. */
get nextGame(): NewGame | null {
const state = this.state;
if (state.phase !== "over" || state.match === null || state.matchAfter === null) {
return null;
}
const match = nextGameOf(state.match, state.matchAfter);
return match === null ? null : { level: state.level, match };
}
/** « Rejouer » : une nouvelle partie aux mêmes réglages que celle-ci,
* au même niveau ; pour un match, un nouveau match de même longueur,
* à 0 à 0. Une partie commencée à une position donnée se rejoue depuis
* la position de départ. */
get rematch(): NewGame {
const state = this.state;
return state.match === null
? { level: state.level }
: { level: state.level, match: firstGameOf(state.match.length) };
}
/** Les demi-coups déjà saisis du coup en cours. */
get pendingHalfMoves(): readonly HalfMove[] {
return this.#pending.map((p) => p.halfMove);
@@ -181,7 +220,7 @@ export class Game {
const state = this.state;
// La position est vue du camp qui a la main (le joueur pendant le jet
// d'ouverture : la position de départ est la même pour les deux).
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state.cube);
return this.#sideBoards(this.displayedPosition, state.turn ?? "player", state);
}
/**
@@ -190,36 +229,59 @@ export class Game {
* (checkHalfMoves, puis board) : aucune règle n'est rejouée ici.
*/
async lastMoveSteps(): Promise<MoveSteps | null> {
const events = this.events;
const index = events.findLastIndex((e) => e.type === "MovePlayed");
const played = events[index];
if (played?.type !== "MovePlayed") {
return null;
}
// L'état juste avant le coup : la position vue de celui qui joue, et
// son jet.
const before = replay(events.slice(0, index));
const roll = rollToPlay(before);
const played = this.events.findLast((e) => e.type === "MovePlayed");
return played?.type === "MovePlayed"
? this.stepsBefore(played.id, played.side, played.halfMoves)
: null;
}
/**
* Un coup de side joué demi-coup par demi-coup à partir de la position
* d'avant l'événement eventId (le coup réellement joué, ou un autre coup
* du même jet : celui du maître). Les plateaux viennent du moteur ;
* erreur s'il refuse un demi-coup. Sans demi-coup, boards ne contient
* que la position d'avant l'événement.
*/
async stepsBefore(
eventId: string,
side: Side,
halfMoves: readonly HalfMove[],
): Promise<MoveSteps> {
const line = currentLine({ ...this.#journal, head: eventId });
// L'état juste avant l'événement : la position vue de celui qui a la
// main, et son jet.
const before = replay(line.slice(0, -1));
const positions = [before.position];
for (let i = 1; i <= played.halfMoves.length; i++) {
for (let i = 1; i <= halfMoves.length; i++) {
const check = await this.#engine.checkHalfMoves(
before.position,
roll,
played.halfMoves.slice(0, i),
rollToPlay(before),
halfMoves.slice(0, i),
);
if (check.position === null) {
throw new Error("coup enregistré refusé par le moteur");
throw new Error("coup refusé par le moteur");
}
positions.push(check.position);
}
const boards = await Promise.all(
positions.map((p) => this.#sideBoards(p, played.side, before.cube)),
);
const opponent = otherSide(played.side);
const hits = played.halfMoves.map(
const onRoll = before.turn ?? side;
const boards = await Promise.all(positions.map((p) => this.#sideBoards(p, onRoll, before)));
const opponent = otherSide(side);
const hits = halfMoves.map(
(_, i) => (boards[i + 1]?.[opponent].bar ?? 0) > (boards[i]?.[opponent].bar ?? 0),
);
return { side: played.side, halfMoves: played.halfMoves, boards, hits };
return { side, halfMoves, boards, hits };
}
/** Les conseils du maître déjà rattachés aux décisions du joueur, par
* identifiant d'événement. */
get advice(): Readonly<Record<string, Advice>> {
return this.#journal.advice ?? {};
}
/** Rattache le conseil du maître à sa décision dans le journal (il sera
* enregistré avec la partie). Erreur si l'événement n'existe pas. */
recordAdvice(advice: Advice): void {
this.#journal = withAdvice(this.#journal, advice);
}
/**
@@ -259,10 +321,11 @@ export class Game {
if (state.turn !== "player" || state.phase !== "rolling") {
return false;
}
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit.
// Le droit de doubler ne dépend pas du niveau : 0 pli suffit. Le
// moteur le refuse aussi pendant la partie Crawford.
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
engineCube(state, "player"),
0,
);
return decision.available;
@@ -348,62 +411,61 @@ export class Game {
}
/** Le joueur double, avant de lancer les dés ; l'ordinateur prend ou passe
* selon le moteur. */
* à son niveau, selon le moteur. */
async double(): Promise<void> {
const state = this.#expect("player", "rolling");
const decision = await this.#engine.cubeDecision(
state.position,
engineCube(state.cube, "player"),
this.#ply,
);
const cube = engineCube(state, "player");
const decision = await this.#engine.cubeDecision(state.position, cube, 0);
if (!decision.available) {
throw new Error("vous ne pouvez pas doubler : le videau est chez l'ordinateur");
throw new Error("vous ne pouvez pas doubler maintenant");
}
const take = await this.#engine.levelTake(state.position, cube, state.level);
this.#append({ type: "CubeOffered", side: "player" });
this.#answerDouble("computer", decision.take);
await this.#answerDouble("computer", take);
}
/** Le joueur prend le double de l'ordinateur. */
take(): void {
this.#expect("computer", "doubled");
this.#answerDouble("player", true);
this.#append({ type: "CubeTaken", side: "player" });
}
/** Le joueur passe le double de l'ordinateur : il perd la partie. */
drop(): void {
async drop(): Promise<void> {
this.#expect("computer", "doubled");
this.#answerDouble("player", false);
await this.#answerDouble("player", false);
}
// --------------------------------------------------------- Ordinateur
/**
* Le tour de l'ordinateur : il double si le moteur le recommande (le
* tour s'arrête alors, en attendant la réponse du joueur), sinon il lance
* les dés et joue le meilleur coup du moteur.
* Le tour de l'ordinateur, à son niveau : il double si le moteur le dit
* (le tour s'arrête alors, en attendant la réponse du joueur), sinon il
* lance les dés et joue son coup. Le déroulement est celui de
* l'ordinateur de gnubg (ComputerTurn, play.c), repris par le moteur.
*/
async playComputerTurn(): Promise<void> {
let state = this.#expect("computer", "rolling", "moving");
if (state.phase === "rolling") {
const decision = await this.#engine.cubeDecision(
const doubles = await this.#engine.levelDouble(
state.position,
engineCube(state.cube, "computer"),
this.#ply,
engineCube(state, "computer"),
state.level,
);
if (decision.double) {
if (doubles) {
this.#append({ type: "CubeOffered", side: "computer" });
return;
}
this.#append({ type: "DiceRolled", side: "computer", roll: rollDice(this.#dice) });
state = this.state;
}
const best = await this.#engine.bestMove(
const move = await this.#engine.levelMove(
state.position,
engineCube(state.cube, "computer"),
engineCube(state, "computer"),
rollToPlay(state),
this.#ply,
state.level,
);
await this.#playMove("computer", best?.halfMoves ?? [], best?.position ?? null);
await this.#playMove("computer", move?.halfMoves ?? [], move?.position ?? null);
}
// ------------------------------------------------------------ Retour
@@ -415,6 +477,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 +506,8 @@ export class Game {
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur. */
async #sideBoards(position: PositionId, onRoll: Side, cube: Cube): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(cube, onRoll));
async #sideBoards(position: PositionId, onRoll: Side, state: GameState): Promise<SideBoards> {
const board = await this.#engine.board(position, engineCube(state, onRoll));
return onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
@@ -460,10 +535,7 @@ export class Game {
after: PositionId | null,
): Promise<void> {
const state = this.state;
const board = await this.#engine.board(
after ?? state.position,
engineCube(state.cube, side),
);
const board = await this.#engine.board(after ?? state.position, engineCube(state, side));
this.#append({
type: "MovePlayed",
side,
@@ -471,28 +543,54 @@ export class Game {
position: board.swappedPosition,
});
if (board.result !== null) {
this.#append({
type: "GameEnded",
winner: board.result.winner === "onRoll" ? side : otherSide(side),
kind: board.result.kind,
points: board.result.points,
});
await this.#endGame(
board.result.winner === "onRoll" ? side : otherSide(side),
board.result.kind,
board.result.points,
);
}
}
/** taker prend ou passe le double proposé par l'autre camp. */
#answerDouble(taker: Side, take: boolean): void {
async #answerDouble(taker: Side, take: boolean): Promise<void> {
if (take) {
this.#append({ type: "CubeTaken", side: taker });
return;
}
// Passer : on perd la partie à la valeur du videau d'avant le double.
this.#append({ type: "CubeDropped", side: taker });
await this.#endGame(otherSide(taker), "single", this.state.cube.value);
}
/** Fin de partie ; dans un match, le moteur dit ce que devient le match
* (score, fin du match, partie Crawford suivante). */
async #endGame(winner: Side, kind: ResultKind, points: number): Promise<void> {
const match = this.state.match;
if (match === null) {
this.#append({ type: "GameEnded", winner, kind, points });
return;
}
// Le joueur est « au trait » pour le moteur : les scores restent à
// leur place.
const after = await this.#engine.matchAfterGame(
{
length: match.length,
score: { onRoll: match.score.player, opponent: match.score.computer },
crawford: match.crawford,
},
winner === "player" ? "onRoll" : "opponent",
points,
);
this.#append({
type: "GameEnded",
winner: otherSide(taker),
kind: "single",
points: this.state.cube.value,
winner,
kind,
points,
match: {
score: { player: after.score.onRoll, computer: after.score.opponent },
over: after.over,
crawford: after.crawford,
},
});
}
}
@@ -562,10 +660,21 @@ function occupiedPoints(side: BoardSide): number[] {
return points;
}
/** Le videau vu du camp au trait, pour le moteur. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
if (cube.owner === null) {
return { value: cube.value, owner: "centered" };
/** Le videau et le match vus du camp au trait, pour le moteur. */
export function engineCube(state: Pick<GameState, "cube" | "match">, onRoll: Side): EngineCube {
const { cube, match } = state;
const owner = cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent";
if (match === null) {
return { value: cube.value, owner };
}
return { value: cube.value, owner: cube.owner === onRoll ? "onRoll" : "opponent" };
const opponent = otherSide(onRoll);
return {
value: cube.value,
owner,
match: {
length: match.length,
score: { onRoll: match.score[onRoll], opponent: match.score[opponent] },
crawford: match.crawford,
},
};
}
+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);
}
+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,
};
}
}
+241
View File
@@ -0,0 +1,241 @@
// Le tutoriel des règles, côté domaine : les positions illustrées et les
// écrans « à vous », vérifiés par le moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte du tutoriel vit dans docs/tutoriel/ (ADR 0008 : une seule
// source) ; il donne pour chaque écran une position par son identifiant
// gnubg (ADR 0013), un jet, et les coups attendus en notation de gnubg.
// Rien n'est jugé ici : le moteur dit quels coups sont légaux, où mène
// chaque demi-coup, et s'il faut prendre un double. Le domaine ne fait que
// comparer ce qu'a joué le joueur aux coups attendus que le moteur a
// reconnus.
import { CENTERED_CUBE, type Cube } from "../game/cube.js";
import type { BoardResult, Engine, EngineCube, Move } from "../game/engine.js";
import { Game, type MoveSteps, type SideBoards, STANDARD_PLY } from "../game/game.js";
import type { HalfMove, PositionId } from "../game/position.js";
import { fixedDice, type Roll } from "../game/roll.js";
import type { Side } from "../game/side.js";
/** Un plateau du tutoriel, décodé par le moteur, et le résultat si la
* partie y est finie (vu du camp « player »). */
export interface TutorialBoard {
readonly sides: SideBoards;
readonly result: { readonly winner: Side; readonly kind: BoardResult["kind"] } | null;
}
/** Le videau vu du camp au trait. */
function engineCube(cube: Cube, onRoll: Side): EngineCube {
return {
value: cube.value,
owner: cube.owner === null ? "centered" : cube.owner === onRoll ? "onRoll" : "opponent",
};
}
/** Le plateau d'une position vue de onRoll, décodé par le moteur (erreur si
* l'identifiant est illisible). */
export async function tutorialBoard(
engine: Engine,
position: PositionId,
onRoll: Side = "player",
cube: Cube = CENTERED_CUBE,
): Promise<TutorialBoard> {
const board = await engine.board(position, engineCube(cube, onRoll));
const sides =
onRoll === "player"
? { player: board.onRoll, computer: board.opponent }
: { player: board.opponent, computer: board.onRoll };
const result =
board.result === null
? null
: {
winner:
(board.result.winner === "onRoll") === (onRoll === "player")
? ("player" as const)
: ("computer" as const),
kind: board.result.kind,
};
return { sides, result };
}
/** Les coups du jet que le moteur déclare légaux et dont la notation est
* l'une des notations données. Erreur si l'une d'elles n'est pas un coup
* légal : le texte du tutoriel ne correspond plus au moteur. */
export async function expectedMoves(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<Move[]> {
const legal = await engine.legalMoves(position, roll);
return notations.map((notation) => {
const move = legal.find((m) => m.notation === notation);
if (move === undefined) {
throw new Error(
`${notation} n'est pas un coup légal de ${roll.join("-")} dans ${position}`,
);
}
return move;
});
}
/** Le coup illustré d'un écran d'explication, demi-coup par demi-coup :
* les plateaux successifs, donnés par le moteur, et le résultat si le coup
* finit la partie. */
export async function illustratedMove(
engine: Engine,
position: PositionId,
roll: Roll,
notation: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }> {
const [move] = await expectedMoves(engine, position, roll, [notation]);
if (move === undefined) {
throw new Error(`coup illustré introuvable : ${notation}`);
}
const positions = [position];
for (let i = 1; i <= move.halfMoves.length; i++) {
const check = await engine.checkHalfMoves(position, roll, move.halfMoves.slice(0, i));
if (check.position === null) {
throw new Error(`demi-coup refusé par le moteur : ${notation}`);
}
positions.push(check.position);
}
const boards = await Promise.all(positions.map((p) => tutorialBoard(engine, p)));
const hits = move.halfMoves.map(
(_, i) => (boards[i + 1]?.sides.computer.bar ?? 0) > (boards[i]?.sides.computer.bar ?? 0),
);
const after = boards.at(-1) ?? boards[0];
if (after === undefined) {
throw new Error("aucun plateau");
}
return {
steps: {
side: "player",
halfMoves: move.halfMoves,
boards: boards.map((b) => b.sides),
hits,
},
after,
};
}
/**
* Les demi-coups saisis peuvent-ils encore mener à l'un des coups attendus ?
* Oui s'ils font tous partie (comme multiensemble) des demi-coups de l'un
* d'eux, tels que le moteur les donne. Un coup complet est jugé sur sa
* position (verdict de MovePractice).
*/
export function canStillLeadTo(played: readonly HalfMove[], expected: readonly Move[]): boolean {
return expected.some((move) => {
const left = [...move.halfMoves];
return played.every((h) => {
const i = left.findIndex((m) => m.from === h.from && m.to === h.to);
if (i < 0) {
return false;
}
left.splice(i, 1);
return true;
});
});
}
/** Ce que devient un écran « à vous » après un demi-coup. */
export type PracticeVerdict = "continue" | "right" | "wrong";
/**
* Un écran « à vous » où le joueur joue un coup : une partie d'un seul jet,
* commencée à la position de l'écran, jamais enregistrée. Le joueur saisit
* ses demi-coups comme en partie (le moteur dit lesquels sont permis) ; le
* coup est juste s'il mène à la position de l'un des coups attendus.
*/
export class MovePractice {
readonly #game: Game;
readonly #expected: readonly Move[];
readonly roll: Roll;
private constructor(game: Game, expected: readonly Move[], roll: Roll) {
this.#game = game;
this.#expected = expected;
this.roll = roll;
}
/** Prépare l'écran : la position, le jet, et les coups attendus,
* reconnus par le moteur (erreur sinon). */
static async start(
engine: Engine,
position: PositionId,
roll: Roll,
notations: readonly string[],
): Promise<MovePractice> {
const expected = await expectedMoves(engine, position, roll, notations);
const game = Game.start(engine, {
setup: { position, firstTurn: "player" },
dice: fixedDice(roll),
level: "0ply",
});
await game.roll();
return new MovePractice(game, expected, roll);
}
/** Le plateau affiché, demi-coups saisis compris. */
board(): Promise<SideBoards> {
return this.#game.displayedBoard();
}
/** Les demi-coups permis maintenant (le moteur le dit). */
nextHalfMoves(): Promise<HalfMove[]> {
return this.#game.nextHalfMoves();
}
get pendingHalfMoves(): readonly HalfMove[] {
return this.#game.pendingHalfMoves;
}
/** Les dés du jet, et ceux déjà utilisés. */
get diceToPlay(): { value: number; used: boolean }[] {
return this.#game.diceToPlay;
}
/**
* Joue un demi-coup. « wrong » si le moteur le refuse, ou s'il ne peut
* plus mener à un coup attendu, ou si le coup complet n'est pas l'un
* d'eux : le demi-coup reste affiché, à l'appelant de revenir en
* arrière (reset). « right » quand le coup complet est l'un des coups
* attendus.
*/
async play(halfMove: HalfMove): Promise<PracticeVerdict> {
if (!(await this.#game.playHalfMove(halfMove))) {
return "wrong";
}
if (this.#game.moveComplete) {
const position = this.#game.displayedPosition;
return this.#expected.some((m) => m.position === position) ? "right" : "wrong";
}
return canStillLeadTo(this.#game.pendingHalfMoves, this.#expected) ? "continue" : "wrong";
}
/** Le pion revient : tous les demi-coups saisis sont oubliés. */
reset(): void {
this.#game.cancelHalfMoves();
}
}
/**
* Un écran « à vous » de videau : l'ordinateur, au trait dans position (vue
* de lui), double. Le moteur dit, au niveau standard de l'ordinateur, si le
* joueur doit prendre (vrai) ou passer (faux). Erreur si l'ordinateur ne
* peut pas doubler dans cette position.
*/
export async function shouldTake(engine: Engine, position: PositionId): Promise<boolean> {
const decision = await engine.cubeDecision(
position,
{ value: 1, owner: "centered" },
STANDARD_PLY,
);
if (!decision.available) {
throw new Error(`l'ordinateur ne peut pas doubler dans ${position}`);
}
return decision.take;
}
+218 -7
View File
@@ -12,14 +12,30 @@ import type {
Board,
BoardResult,
BoardSide,
CubeAction,
CubeAnalysis,
CubeDecision,
Engine,
EngineCube,
EngineMatch,
EvaluatedMove,
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
@@ -32,6 +48,16 @@ interface CliMove {
position_id: string;
half_moves: [number, number][];
}
interface CliEvaluatedMove extends CliMove {
ply: number;
equity: number;
}
type CliSkill = "none" | "doubtful" | "bad" | "very_bad";
interface CliCubeDecision {
available: boolean;
double: boolean;
take: boolean;
}
interface CliSide {
points: number[];
bar: number;
@@ -50,13 +76,23 @@ export function createWasmEngine(run: RunCommand): Engine {
const matchIds = new Map<string, string>();
async function matchId(cube: EngineCube): Promise<string> {
// Partie libre, joueur 0 au trait : le propriétaire 0 est le joueur au
// trait, 1 son adversaire, -1 le centre.
// Joueur 0 au trait : le propriétaire 0 est le joueur au trait, 1 son
// adversaire, -1 le centre ; le score 0 est le sien.
const owner = { centered: -1, onRoll: 0, opponent: 1 }[cube.owner];
const key = `${cube.value} ${owner}`;
const args = ["matchid", String(cube.value), String(owner)];
const match = cube.match;
if (match !== undefined) {
args.push(
String(match.length),
String(match.score.onRoll),
String(match.score.opponent),
match.crawford ? "1" : "0",
);
}
const key = args.join(" ");
let id = matchIds.get(key);
if (id === undefined) {
const answer = (await run(["matchid", String(cube.value), String(owner)])) as {
const answer = (await run(args)) as {
match_id: string;
};
id = answer.match_id;
@@ -86,8 +122,171 @@ export function createWasmEngine(run: RunCommand): Engine {
ply: Ply,
): Promise<CubeDecision> {
const args = ["--ply", String(ply), "cube", position, await matchId(cube)];
const answer = (await run(args)) as CubeDecision;
return { available: answer.available, double: answer.double, take: answer.take };
return toCubeDecision((await run(args)) as CliCubeDecision);
},
async levelMove(position: PositionId, cube: EngineCube, roll: Roll, level: Level) {
const args = ["--level", level, "bestmove", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as { best_move: CliMove | null };
return answer.best_move === null ? null : toMove(answer.best_move);
},
async levelCubeDecision(
position: PositionId,
cube: EngineCube,
level: Level,
): Promise<CubeDecision> {
const args = ["--level", level, "cube", position, await matchId(cube)];
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,
roll: Roll,
played: PositionId,
ply: Ply,
): Promise<MoveAnalysis | null> {
const args = ["--ply", String(ply), "analysemove", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll), played])) as {
move_count: number;
rank: number | null;
played: CliEvaluatedMove | null;
best: CliEvaluatedMove | null;
equity_loss: number;
skill: CliSkill;
};
if (answer.rank === null || answer.played === null || answer.best === null) {
return null;
}
return {
moveCount: answer.move_count,
rank: answer.rank,
played: toEvaluatedMove(answer.played),
best: toEvaluatedMove(answer.best),
equityLoss: answer.equity_loss,
skill: toSkill(answer.skill),
};
},
async rankMoves(position: PositionId, cube: EngineCube, roll: Roll, ply: Ply) {
const args = ["--ply", String(ply), "moves", position, await matchId(cube)];
const answer = (await run([...args, diceText(roll)])) as {
moves: CliEvaluatedMove[];
};
return answer.moves.map(toEvaluatedMove);
},
async analyseCube(
position: PositionId,
cube: EngineCube,
action: CubeAction,
ply: Ply,
): Promise<CubeAnalysis> {
const cliAction = {
noDouble: "no_double",
double: "double",
take: "take",
pass: "pass",
}[action];
const args = ["--ply", String(ply), "analysecube", position, await matchId(cube)];
const answer = (await run([...args, cliAction])) as {
analysed: boolean;
cube: {
no_double: number;
double_take: number;
double_pass: number;
optimal: number;
} | null;
equity_loss: number;
skill: CliSkill;
};
return {
analysed: answer.analysed,
equities:
answer.cube === null
? null
: {
noDouble: answer.cube.no_double,
doubleTake: answer.cube.double_take,
doublePass: answer.cube.double_pass,
optimal: answer.cube.optimal,
},
equityLoss: answer.equity_loss,
skill: toSkill(answer.skill),
};
},
async luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck> {
const args = ["luck", position, await matchId(cube), diceText(roll)];
const answer = (await run(args)) as { luck: number; rating: string };
const ratings: Record<string, LuckRating> = {
very_unlucky: "veryUnlucky",
unlucky: "unlucky",
none: "none",
lucky: "lucky",
very_lucky: "veryLucky",
};
const rating = ratings[answer.rating];
if (rating === undefined) {
throw new Error(`chance inconnue : ${answer.rating}`);
}
return { value: answer.luck, rating };
},
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(
@@ -139,6 +338,18 @@ function toMove(move: CliMove): Move {
};
}
function toEvaluatedMove(move: CliEvaluatedMove): EvaluatedMove {
return { ...toMove(move), equity: move.equity, ply: move.ply };
}
function toCubeDecision(answer: CliCubeDecision): CubeDecision {
return { available: answer.available, double: answer.double, take: answer.take };
}
function toSkill(skill: CliSkill): Skill {
return skill === "very_bad" ? "veryBad" : skill;
}
function toSide(side: CliSide): BoardSide {
return { points: side.points, bar: side.bar, off: side.off, pipCount: side.pip_count };
}
+136 -27
View File
@@ -5,44 +5,153 @@
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { analyseDecision } from "./domain/analysis/analyse.js";
import { Game } from "./domain/game/game.js";
import { linkOf, readPosition } from "./domain/game/position-link.js";
import { exportSgf } from "./domain/game/sgf.js";
import {
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "./domain/learning/tutorial.js";
import { createWasmEngine } from "./engine/wasm-engine.js";
import { startEngineWorker } from "./engine/worker-run.js";
import { createGameStore } from "./storage/game-store.js";
import { type EngineWorker, startEngineWorker } from "./engine/worker-run.js";
import { createGameSession } from "./storage/game-session.js";
import { memoryStorage, openGameLibrary } from "./storage/open-library.js";
import { createSettingsStore } from "./storage/settings-store.js";
import { startApp } from "./ui/app.js";
import { startUpdates } from "./ui/updates.js";
// Ce fichier est servi sous app/ ; le Worker et le service worker sont à la
// racine du site.
const worker = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
const engine = createWasmEngine(worker.run);
const store = createGameStore(window.localStorage);
// Le conseil du maître (étape 1d) : les analyses à 2 plis tournent sur un
// second moteur, dans son propre Web Worker. Le premier ne traite qu'une
// commande à la fois : une analyse y retarderait le coup de l'ordinateur.
// Le second démarre à la première analyse demandée, une fois le premier
// chargé et le service worker installé (au plus 20 s d'attente) : si deux
// téléchargements des données du moteur se croisent, Chromium fait échouer
// l'installation hors ligne (voir startUpdates plus bas). Ensuite, les
// données viennent du cache.
let analysisWorker: Promise<EngineWorker> | null = null;
function startAnalysisWorker(): Promise<EngineWorker> {
analysisWorker ??= worker.ready
.then(() => {
const installed =
"serviceWorker" in navigator ? navigator.serviceWorker.ready : Promise.resolve();
return Promise.race([installed, new Promise((resolve) => setTimeout(resolve, 20_000))]);
})
.then(() => {
const started = startEngineWorker(new URL("../engine-worker.mjs", import.meta.url));
return started.ready.then(() => started);
});
return analysisWorker;
}
const analysisEngine = createWasmEngine(async (args) => (await startAnalysisWorker()).run(args));
// Chaque accès au stockage du navigateur peut lever une exception (stockage
// interdit) : on fait alors sans.
const localStorage = attempt(() => window.localStorage);
const settings = createSettingsStore(localStorage ?? memoryStorage());
// Les parties enregistrées (IndexedDB, ou localStorage en repli), ouvertes
// pendant que le moteur se charge : le plateau est dessiné sans les
// attendre. L'écran de jeu ne s'en sert qu'une fois engineReady résolue.
const opening = (async () => {
const { library } = await openGameLibrary({
indexedDB: attempt(() => window.indexedDB),
localStorage,
});
// Le niveau et le match de chaque partie viennent de l'écran « Nouvelle
// partie » (ou de la partie précédente du match).
const session = createGameSession(library, {
start: (newGame) => Game.start(engine, newGame ?? {}),
resume: (journal) => Game.resume(engine, journal),
});
const saved = await session.restore();
return { library, session, saved };
})();
let stored: Awaited<typeof opening> | null = null;
/** Le stockage, ouvert (toujours le cas une fois engineReady résolue). */
function storage(): Awaited<typeof opening> {
if (stored === null) {
throw new Error("stockage pas encore ouvert");
}
return stored;
}
startApp({
engineReady: worker.ready,
newGame: () => Game.start(engine),
savedGame: () => {
const journal = store.load();
if (journal === null) {
return null;
}
try {
return Game.resume(engine, journal);
} catch {
return null; // partie illisible : on en commence une nouvelle
}
engineReady: Promise.all([
worker.ready,
opening.then((opened) => {
stored = opened;
}),
]),
newGame: (newGame) => storage().session.newGame(newGame),
positions: {
read: (link) => readPosition(engine, link),
linkOf: (game) => linkOf(engine, game.state),
},
saveGame: (journal) => store.save(journal),
confirm: (question) => window.confirm(question),
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),
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 };
},
},
cachedBoard: () => settings.loadBoard(),
saveBoard: (board) => settings.saveBoard(board),
loadSettings: () => settings.loadSettings(),
saveSettings: (json) => settings.saveSettings(json),
// Le tutoriel : positions, coups illustrés et gestes jugés par le
// domaine, sur le moteur.
tutorial: {
board: (position, onRoll) => tutorialBoard(engine, position, onRoll),
illustrate: (position, roll, move) => illustratedMove(engine, position, roll, move),
practice: (position, roll, expected) =>
MovePractice.start(engine, position, roll, expected),
shouldTake: (position) => shouldTake(engine, position),
},
// Hors ligne : le service worker met tout le site en cache. Le navigateur
// le refuse hors https (sauf localhost) : l'application marche alors sans.
// Il est enregistré une fois le moteur chargé : si le Worker et le service
// worker téléchargent les données du moteur (8,7 Mo) en même temps,
// Chromium fait échouer l'installation (« Cache.add() encountered a
// network error »), et l'application n'est pas disponible hors ligne.
// Cela évite aussi de télécharger deux fois ces données à la première
// visite.
analyse: (journal, eventId) => analyseDecision(analysisEngine, journal, eventId),
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;
}
}
+140
View File
@@ -0,0 +1,140 @@
// 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>;
/** Supprime une partie (rien si elle n'existe pas). */
remove(id: string): Promise<void>;
/** La partie en cours : la dernière enregistrée ou choisie (setCurrent),
* si elle existe encore ; sinon la dernière jouée ; null s'il n'y en a
* aucune. */
current(): Promise<SavedGame | null>;
/** Fait d'une partie enregistrée la partie en cours, sans la modifier
* (pour la reprendre). */
setCurrent(id: string): Promise<void>;
}
/** Ce que chaque stockage sait faire ; GameLibrary ajoute l'ordre des
* opérations, les dates et la validation. */
export interface LibraryBackend {
summaries(): Promise<SavedGameSummary[]>;
summary(id: string): Promise<SavedGameSummary | undefined>;
journal(id: string): Promise<unknown>;
/** Écrit le résumé et le journal ensemble. */
write(summary: SavedGameSummary, journal: GameJournal): Promise<void>;
remove(id: string): Promise<void>;
currentId(): Promise<string | undefined>;
setCurrentId(id: string): Promise<void>;
}
export interface LibraryOptions {
/** L'horloge, en millisecondes ; Date.now par défaut. */
readonly now?: () => number;
}
/** Un identifiant de partie : la date et du hasard, sans dépendre de
* crypto.randomUUID, absent hors https. */
export function newGameId(now: number = Date.now()): string {
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `${now.toString(36)}-${random}`;
}
/** Un journal lisible : la forme attendue, sans juger de son contenu (le
* domaine le vérifie en reprenant la partie). */
export function isJournal(value: unknown): value is GameJournal {
if (typeof value !== "object" || value === null) {
return false;
}
const journal = value as { events?: unknown; head?: unknown };
return (
Array.isArray(journal.events) && (journal.head === null || typeof journal.head === "string")
);
}
export function createGameLibrary(
backend: LibraryBackend,
options: LibraryOptions = {},
): GameLibrary {
const now = options.now ?? Date.now;
let queue: Promise<unknown> = Promise.resolve();
/** Exécute op après toutes les opérations déjà demandées. */
const enqueue = <T>(op: () => Promise<T>): Promise<T> => {
const result = queue.then(op, op);
queue = result.catch(() => {});
return result;
};
const load = async (id: string): Promise<SavedGame | null> => {
const summary = await backend.summary(id);
const journal = await backend.journal(id);
return summary === undefined || !isJournal(journal) ? null : { summary, journal };
};
const list = async () => (await backend.summaries()).sort((a, b) => b.updatedAt - a.updatedAt);
return {
list: () => enqueue(list),
load: (id) => enqueue(() => load(id)),
save: (id, journal, details) =>
enqueue(async () => {
const time = now();
const startedAt = (await backend.summary(id))?.startedAt ?? time;
const summary = summarizeGame(id, journal, details, {
startedAt,
updatedAt: Math.max(time, startedAt),
});
await backend.write(summary, journal);
await backend.setCurrentId(id);
return summary;
}),
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.
}
},
};
}
+81
View File
@@ -0,0 +1,81 @@
// Les parties dans IndexedDB, le stockage de données du navigateur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Base « ustagammon », trois magasins d'objets :
// - games : les résumés des parties (SavedGameSummary), clé id ;
// - journals : leurs journaux, { id, journal }, clé id ;
// - settings : { key, value }, dont la partie en cours (key « current »).
// Le résumé et le journal d'une partie sont écrits dans la même transaction.
// L'API IndexedDB est utilisée directement, sans bibliothèque.
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import type { LibraryBackend } from "./game-library.js";
const DATABASE = "ustagammon";
const VERSION = 1;
const GAMES = "games";
const JOURNALS = "journals";
const SETTINGS = "settings";
/** Le résultat d'une requête IndexedDB, en promesse. */
function done<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** La fin d'une transaction (toutes ses écritures faites), en promesse. */
function committed(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error("transaction annulée"));
});
}
/** Ouvre la base, en la créant au besoin. Rejetée si IndexedDB est
* indisponible ou refusé. */
export async function openIndexedDbBackend(factory: IDBFactory): Promise<LibraryBackend> {
const request = factory.open(DATABASE, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(GAMES, { keyPath: "id" });
db.createObjectStore(JOURNALS, { keyPath: "id" });
db.createObjectStore(SETTINGS, { keyPath: "key" });
};
const db = await done(request);
const get = async <T>(store: string, key: string): Promise<T | undefined> =>
done(db.transaction(store).objectStore(store).get(key));
return {
summaries: async () => done(db.transaction(GAMES).objectStore(GAMES).getAll()),
summary: (id) => get<SavedGameSummary>(GAMES, id),
journal: async (id) => (await get<{ journal: unknown }>(JOURNALS, id))?.journal,
write: async (summary: SavedGameSummary, journal: GameJournal) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(JOURNALS).put({ id: summary.id, journal });
transaction.objectStore(GAMES).put(summary);
await committed(transaction);
},
remove: async (id) => {
const transaction = db.transaction([GAMES, JOURNALS], "readwrite");
transaction.objectStore(GAMES).delete(id);
transaction.objectStore(JOURNALS).delete(id);
await committed(transaction);
},
currentId: async () => {
const setting = await get<{ value: unknown }>(SETTINGS, "current");
return typeof setting?.value === "string" ? setting.value : undefined;
},
setCurrentId: async (id) => {
const transaction = db.transaction(SETTINGS, "readwrite");
transaction.objectStore(SETTINGS).put({ key: "current", value: id });
await committed(transaction);
},
};
}
+99
View File
@@ -0,0 +1,99 @@
// Les parties dans un stockage clé-valeur (localStorage), en JSON : le
// repli quand IndexedDB est indisponible, et la lecture de la partie
// enregistrée par les versions précédentes, pour la migrer.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { GameJournal } from "../domain/game/events.js";
import type { SavedGameSummary } from "../domain/game/saved-game.js";
import { type GameLibrary, isJournal, type LibraryBackend, newGameId } from "./game-library.js";
/** Ce qu'il faut d'un stockage clé-valeur (localStorage en a davantage). */
export interface KeyValueStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const SUMMARIES = "ustagammon.games";
const CURRENT = "ustagammon.current-game-id";
const journalKey = (id: string) => `ustagammon.game.${id}`;
/** La partie unique des versions précédentes (étape 0), à migrer. */
const LEGACY_GAME = "ustagammon.current-game";
/** Lit une clé en JSON ; undefined si elle est absente, illisible, ou si le
* stockage est interdit. */
function read(storage: KeyValueStorage, key: string): unknown {
try {
const text = storage.getItem(key);
return text === null ? undefined : JSON.parse(text);
} catch {
return undefined;
}
}
export function createKeyValueBackend(storage: KeyValueStorage): LibraryBackend {
const summaries = (): SavedGameSummary[] => {
const value = read(storage, SUMMARIES);
return Array.isArray(value) ? value : [];
};
const writeSummaries = (list: readonly SavedGameSummary[]) =>
storage.setItem(SUMMARIES, JSON.stringify(list));
return {
summaries: async () => summaries(),
summary: async (id) => summaries().find((s) => s.id === id),
journal: async (id) => read(storage, journalKey(id)),
write: async (summary, journal) => {
// Le journal d'abord : un résumé ne désigne jamais un journal
// absent.
storage.setItem(journalKey(summary.id), JSON.stringify(journal));
writeSummaries([...summaries().filter((s) => s.id !== summary.id), summary]);
},
remove: async (id) => {
writeSummaries(summaries().filter((s) => s.id !== id));
storage.removeItem(journalKey(id));
},
currentId: async () => {
const id = read(storage, CURRENT);
return typeof id === "string" ? id : undefined;
},
setCurrentId: async (id) => storage.setItem(CURRENT, JSON.stringify(id)),
};
}
/**
* Reprend dans library la partie enregistrée par les versions précédentes
* (une seule partie, sous une clé de localStorage), qui devient la partie
* en cours ; puis efface l'ancienne clé. Sans effet s'il n'y en a pas. Un
* journal illisible est effacé. Renvoie l'identifiant de la partie migrée.
*/
export async function migrateLegacyGame(
storage: KeyValueStorage,
library: GameLibrary,
now: number = Date.now(),
): Promise<string | null> {
let present: boolean;
try {
present = storage.getItem(LEGACY_GAME) !== null;
} catch {
return null;
}
if (!present) {
return null;
}
const journal: unknown = read(storage, LEGACY_GAME);
let id: string | null = null;
if (isJournal(journal) && journal.events.length > 0) {
id = newGameId(now);
// Les parties de l'étape 0 se jouaient toutes au niveau standard.
await library.save(id, journal as GameJournal, { level: "2ply" });
}
try {
storage.removeItem(LEGACY_GAME);
} catch {
// Stockage interdit : la migration sera refaite, sans dommage.
}
return id;
}
+96
View File
@@ -0,0 +1,96 @@
// Ouverture des parties enregistrées au démarrage : IndexedDB si possible,
// sinon localStorage, puis migration de la partie des versions précédentes.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { createGameLibrary, type GameLibrary, type LibraryOptions } from "./game-library.js";
import { openIndexedDbBackend } from "./indexeddb-backend.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "./key-value-backend.js";
/** Les stockages du navigateur ; chacun peut manquer ou être refusé. */
export interface BrowserStorage {
readonly indexedDB?: IDBFactory;
readonly localStorage?: KeyValueStorage;
}
export interface OpenedLibrary {
readonly library: GameLibrary;
/** Le stockage retenu : IndexedDB, son repli localStorage, ou la seule
* mémoire de la page (rien n'est gardé après sa fermeture). */
readonly kind: "indexeddb" | "localStorage" | "memory";
}
/** Au-delà, IndexedDB est tenu pour indisponible (une ouverture bloquée ne
* doit pas empêcher de jouer). */
const OPEN_TIMEOUT_MS = 5000;
export async function openGameLibrary(
storage: BrowserStorage,
options: LibraryOptions = {},
): Promise<OpenedLibrary> {
const opened = await open(storage, options);
if (storage.localStorage !== undefined) {
try {
await migrateLegacyGame(storage.localStorage, opened.library);
} catch {
// Migration impossible (stockage plein ou refusé) : l'ancienne
// partie reste où elle est, on la retentera au prochain démarrage.
}
}
return opened;
}
async function open(storage: BrowserStorage, options: LibraryOptions): Promise<OpenedLibrary> {
if (storage.indexedDB !== undefined) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const backend = await Promise.race([
openIndexedDbBackend(storage.indexedDB),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("IndexedDB muet")), OPEN_TIMEOUT_MS);
}),
]);
return { library: createGameLibrary(backend, options), kind: "indexeddb" };
} catch {
// Indisponible (navigation privée de certains navigateurs) : repli.
} finally {
clearTimeout(timer);
}
}
if (storage.localStorage !== undefined && usable(storage.localStorage)) {
return {
library: createGameLibrary(createKeyValueBackend(storage.localStorage), options),
kind: "localStorage",
};
}
return {
library: createGameLibrary(createKeyValueBackend(memoryStorage()), options),
kind: "memory",
};
}
/** localStorage peut exister et refuser toute écriture. */
function usable(storage: KeyValueStorage): boolean {
try {
storage.setItem("ustagammon.probe", "1");
storage.removeItem("ustagammon.probe");
return true;
} catch {
return false;
}
}
/** Un stockage clé-valeur en mémoire. */
export function memoryStorage(): KeyValueStorage {
const items = new Map<string, string>();
return {
getItem: (key) => items.get(key) ?? null,
setItem: (key, value) => void items.set(key, value),
removeItem: (key) => void items.delete(key),
};
}
+83
View File
@@ -0,0 +1,83 @@
// Ce que l'application garde en plus de la partie, dans le même stockage
// clé-valeur (localStorage) : les réglages du menu, en JSON, et le dernier
// plateau affiché, pour le redessiner dès l'ouverture de la page, avant que
// le moteur soit chargé (il est déjà décodé : aucune règle n'est rejouée).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Cube } from "../domain/game/cube.js";
import type { SideBoards } from "../domain/game/game.js";
import type { KeyValueStorage } from "./key-value-backend.js";
const SETTINGS_KEY = "ustagammon.settings";
const BOARD_KEY = "ustagammon.last-board";
/** Le dernier plateau affiché, tel que le moteur l'a décodé. */
export interface CachedBoard {
readonly sides: SideBoards;
readonly cube: Cube;
}
export interface SettingsStore {
/** Le texte JSON des réglages (null : jamais enregistrés). */
loadSettings(): string | null;
saveSettings(json: string): void;
loadBoard(): CachedBoard | null;
saveBoard(board: CachedBoard): void;
}
function isSide(value: unknown): boolean {
const side = value as Record<string, unknown> | null;
return (
side !== null &&
typeof side === "object" &&
Array.isArray(side.points) &&
side.points.length === 24 &&
side.points.every((n) => Number.isInteger(n) && n >= 0) &&
Number.isInteger(side.bar) &&
Number.isInteger(side.off) &&
Number.isInteger(side.pipCount)
);
}
export function createSettingsStore(
storage: Pick<KeyValueStorage, "getItem" | "setItem">,
): SettingsStore {
const write = (key: string, value: string) => {
try {
storage.setItem(key, value);
} catch {
// Stockage plein ou interdit : l'application marche sans.
}
};
return {
loadSettings() {
try {
return storage.getItem(SETTINGS_KEY);
} catch {
return null;
}
},
saveSettings(json) {
write(SETTINGS_KEY, json);
},
loadBoard() {
try {
const board = JSON.parse(storage.getItem(BOARD_KEY) ?? "null");
const valid =
board !== null &&
isSide(board.sides?.player) &&
isSide(board.sides?.computer) &&
Number.isInteger(board.cube?.value) &&
[null, "player", "computer"].includes(board.cube?.owner);
return valid ? board : null;
} catch {
return null;
}
},
saveBoard(board) {
write(BOARD_KEY, JSON.stringify(board));
},
};
}
+55
View File
@@ -0,0 +1,55 @@
// Les textes de l'interface du conseil du maître (ligne, feuille
// d'explication, menu). Les mots et les phrases du maître eux-mêmes sont
// dans docs/maitre/phrases.md (une seule source, ADR 0008).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { CubeAction } from "../domain/game/engine.js";
export const ADVICE_TEXTS = {
/** Interrupteur du menu, comme sur l'écran « Nouvelle partie ». */
menuSwitch: "Conseil du maître",
lineLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
/** Paysage : la pastille du mot, au lecteur d'écran. */
chipLabel: (line: string) => `Conseil du maître : ${line} Toucher pour l'explication.`,
sheetLabel: "Explication du maître",
yourMove: "Votre coup",
masterMove: "Le coup du maître",
choiceLabel: "Coup montré sur le plateau",
legendMine: (move: string) =>
`Votre coup, ${move}, joué pion par pion sur la position d'avant.`,
legendBest: (move: string) =>
`Le coup du maître, ${move}, joué pion par pion sur la position d'avant votre coup.`,
legendSame: (move: string) => `${move}, joué pion par pion sur la position d'avant.`,
legendCube: "La position au moment de la décision.",
replay: "Rejouer le coup",
numbers: "Voir les chiffres du moteur",
you: "(vous)",
moveNote: (ply: number) =>
`Écart d'équité à ${ply} plis, par rapport au meilleur coup. Un écart de 0,03 ou moins reste un bon coup.`,
cubeNote: (ply: number) =>
`Équités de celui qui double, pour un videau de 1, à ${ply} plis. Écart de votre décision : `,
cubeRows: {
noDouble: "Pas de double",
doubleTake: "Double, prend",
doublePass: "Double, passe",
},
glossary: (term: string) => `En savoir plus : « ${term} »`,
close: "Fermer",
missing: "Le maître n'a pas d'explication pour ce coup.",
} as const;
/** La fiche du glossaire d'une décision de videau, et son titre. */
export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: string }>> = {
double: { file: "doubler", term: "Doubler" },
noDouble: { file: "doubler", term: "Doubler" },
take: { file: "prendre", term: "Prendre" },
pass: { file: "passer", term: "Passer" },
};
/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */
export function formatEquity(value: number): string {
const text = Math.abs(value).toFixed(3).replace(".", ",");
return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text;
}
+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;
}
+409
View File
@@ -0,0 +1,409 @@
// La feuille d'explication du maître (#/explication/<événement>), qui monte
// du bas au-dessus du plateau voilé (charte, 1d « La feuille
// d'explication ») : le mot et le contexte, le coup du maître en titre, sa
// phrase, « Votre coup » et « Le coup du maître » joués pion par pion sur la
// position d'avant, puis les chiffres du moteur, repliés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js";
import {
type Advice,
type CubeAdvice,
type MoveAdvice,
masterCubeAction,
type Verdict,
} from "../domain/game/advice.js";
import type { Game, MoveSteps } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { boardModel, moveTrace, placeOf } from "./board-model.js";
import { checkerSpot, NO_MARKS, renderBoard, slideChecker } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type IconName, icon } from "./icons.js";
import { routeHash } from "./router.js";
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Le pictogramme d'une appréciation (interaction.md, pictogrammes). */
export const VERDICT_ICONS: Readonly<Record<Verdict, IconName>> = {
good: "verdictGood",
doubtful: "verdictDoubtful",
bad: "verdictBad",
veryBad: "verdictBad",
};
export interface AdviceViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
readonly veil: HTMLElement;
readonly game: () => Game | null;
readonly phrases: () => Promise<MasterPhrases>;
readonly reducedMotion: MediaQueryList;
/** Fermer : quitter l'adresse de la feuille. */
readonly close: () => void;
}
export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
}
type Shown = "mine" | "best";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
export function createAdviceView(options: AdviceViewOptions): AdviceView {
const { veil } = options;
const sheet = element(veil, "div", { id: "explain-sheet", className: "sheet explain-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "explain-title");
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// Chaque ouverture a son numéro : une ouverture plus récente, ou la
// fermeture, arrête l'animation en cours.
let opened = 0;
// Chaque coup joué sur le plateau de la feuille a aussi le sien.
let playing = 0;
async function show(eventId: string): Promise<void> {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
const verdictLine = element(head, "p", {
id: "explain-verdict",
className: "explain-verdict",
});
const close = element(head, "button", { id: "explain-close", className: "icon-button" });
close.type = "button";
close.append(icon("close"));
close.setAttribute("aria-label", ADVICE_TEXTS.close);
close.onclick = () => options.close();
const title = element(sheet, "h2", { id: "explain-title", className: "explain-title" });
if (game === null || advice === null) {
title.textContent = ADVICE_TEXTS.sheetLabel;
element(sheet, "p", { className: "explain-phrase", text: ADVICE_TEXTS.missing });
closeButton();
close.focus();
return;
}
const words = masterWords(await options.phrases(), advice);
if (id !== opened) {
return;
}
verdictLine.dataset.verdict = words.verdict;
verdictLine.append(icon(VERDICT_ICONS[words.verdict]));
element(verdictLine, "span", { text: `${words.word} · ${words.context}` });
title.textContent = words.title;
// Le titre dit déjà le coup du maître : la phrase ne le répète pas
// (et disparaît si elle ne disait que lui).
const rest = words.phrase.startsWith(`${words.title}.`)
? words.phrase.slice(words.title.length + 1).trim()
: words.phrase;
if (rest !== "") {
element(sheet, "p", { id: "explain-phrase", className: "explain-phrase", text: rest });
}
const body = element(sheet, "div", { className: "explain-body" });
const boardBox = element(body, "div", { className: "explain-board-box" });
if (advice.kind === "move") {
await showMoves(id, game, advice, body, boardBox);
} else {
await showCube(id, game, advice, boardBox);
}
if (id !== opened) {
return;
}
numbers(advice);
if (advice.kind === "cube") {
const entry = CUBE_GLOSSARY[masterCubeAction(advice)];
const link = element(sheet, "a", {
id: "explain-glossary",
className: "link-button explain-glossary",
text: ADVICE_TEXTS.glossary(entry.term),
});
link.href = routeHash({ screen: "glossary", term: entry.file });
}
closeButton();
close.focus();
}
function closeButton(): void {
const actions = element(sheet, "div", { className: "explain-actions" });
const done = element(actions, "button", {
id: "explain-done",
className: "secondary",
text: ADVICE_TEXTS.close,
});
done.type = "button";
done.onclick = () => options.close();
}
/** Le plateau de la feuille, dans son cadre. */
function boardSvg(box: HTMLElement): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "explain-board";
svg.setAttribute("class", "board explain-board");
svg.setAttribute("role", "img");
box.append(svg);
return svg;
}
function draw(svg: SVGSVGElement, steps: MoveSteps, index: number, traced: boolean): void {
const boards = steps.boards[index];
if (boards === undefined) {
return;
}
const width = svg.getBoundingClientRect().width || 358;
renderBoard(
svg,
boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
boardModel(boards),
{
...NO_MARKS,
dice: [],
cube: null,
band: null,
controls: null,
trace: traced ? moveTrace(steps.side, steps.halfMoves, steps.hits) : null,
},
);
// Les flèches des traces renvoient à leur pointe par identifiant :
// celui du plateau de jeu serait pris à sa place.
for (const marker of svg.querySelectorAll("marker[id]")) {
marker.id = `explain-${marker.id}`;
}
for (const path of svg.querySelectorAll("[marker-end]")) {
const ref = path.getAttribute("marker-end") ?? "";
path.setAttribute("marker-end", ref.replace("url(#", "url(#explain-"));
}
}
/** Joue un coup pion par pion (sans mouvement si le système le
* demande), puis laisse ses traces. */
async function animate(svg: SVGSVGElement, steps: MoveSteps): Promise<void> {
const id = ++playing;
if (!options.reducedMotion.matches) {
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== playing) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOf(steps.side, halfMove.from);
const to = placeOf(steps.side, halfMove.to);
draw(svg, steps, i, false);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(svg, steps, i + 1, false);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
if (id === playing) {
draw(svg, steps, steps.halfMoves.length, true);
}
}
async function showMoves(
id: number,
game: Game,
advice: MoveAdvice,
body: HTMLElement,
boardBox: HTMLElement,
): Promise<void> {
const same = advice.rank === 1;
const [mine, best] = await Promise.all([
game.stepsBefore(advice.eventId, "player", advice.played.halfMoves),
same ? null : game.stepsBefore(advice.eventId, "player", advice.best.halfMoves),
]);
if (id !== opened) {
return;
}
const legend = element(body, "p", { id: "explain-legend", className: "explain-legend" });
const svg = boardSvg(boardBox);
if (best === null) {
svg.dataset.move = "mine";
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
draw(svg, mine, 0, false);
void animate(svg, mine);
return;
}
const choice = element(body, "div", { id: "explain-choice", className: "segmented" });
choice.setAttribute("role", "radiogroup");
choice.setAttribute("aria-label", ADVICE_TEXTS.choiceLabel);
body.prepend(choice);
const buttons: Record<Shown, HTMLButtonElement> = {
mine: element(choice, "button", {
id: "explain-mine",
className: "segment",
text: ADVICE_TEXTS.yourMove,
}),
best: element(choice, "button", {
id: "explain-best",
className: "segment",
text: ADVICE_TEXTS.masterMove,
}),
};
const select = (shown: Shown) => {
for (const key of ["mine", "best"] as const) {
buttons[key].setAttribute("aria-checked", String(key === shown));
}
const notation = shown === "mine" ? advice.played.notation : advice.best.notation;
const text =
shown === "mine"
? ADVICE_TEXTS.legendMine(notation)
: ADVICE_TEXTS.legendBest(notation);
legend.textContent = text;
svg.setAttribute("aria-label", text);
svg.dataset.move = shown;
const steps = shown === "mine" ? mine : best;
draw(svg, steps, 0, false);
void animate(svg, steps);
};
for (const key of ["mine", "best"] as const) {
const button = buttons[key];
button.type = "button";
button.setAttribute("role", "radio");
button.onclick = () => select(key);
}
// Flèches gauche et droite entre les deux segments.
choice.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = buttons.mine.getAttribute("aria-checked") === "true" ? "best" : "mine";
select(next);
buttons[next].focus();
}
});
// Par défaut, le coup du maître (maquette).
select("best");
}
async function showCube(
id: number,
game: Game,
advice: CubeAdvice,
boardBox: HTMLElement,
): Promise<void> {
const steps = await game.stepsBefore(advice.eventId, "player", []);
if (id !== opened) {
return;
}
const svg = boardSvg(boardBox);
svg.setAttribute("aria-label", ADVICE_TEXTS.legendCube);
draw(svg, steps, 0, false);
element(boardBox, "p", { className: "explain-legend", text: ADVICE_TEXTS.legendCube });
}
/** « Voir les chiffres du moteur », replié par défaut. */
function numbers(advice: Advice): void {
const details = element(sheet, "details", {
id: "explain-numbers",
className: "explain-numbers",
});
element(details, "summary", { text: ADVICE_TEXTS.numbers });
const table = element(details, "div", { className: "explain-table" });
const row = (cells: string[], strong: boolean) => {
for (const text of cells) {
element(table, "span", { className: strong ? "strong" : "", text });
}
};
if (advice.kind === "move") {
// Les écarts du classement se lisent par rapport à son premier
// coup ; votre coup, s'il n'y figure pas, avec l'écart de
// l'analyse du coup joué.
const first = advice.ranking[0]?.equity ?? advice.best.equity;
const listed = advice.ranking.map((move, i) => ({
move,
rank: i + 1,
loss: move.equity - first,
}));
if (!advice.ranking.some((m) => m.notation === advice.played.notation)) {
listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss });
}
for (const { move, rank, loss } of listed) {
const mine = move.notation === advice.played.notation;
row(
[
String(rank),
mine ? `${move.notation} ${ADVICE_TEXTS.you}` : move.notation,
formatEquity(loss),
],
mine,
);
}
element(details, "p", {
className: "explain-note",
text: ADVICE_TEXTS.moveNote(advice.ply),
});
} else {
// Les lignes de votre décision, en gras : doubler mène à l'une des
// deux lignes « Double », selon la réponse de l'ordinateur.
const mine = {
noDouble: ["noDouble"],
double: ["doubleTake", "doublePass"],
take: ["doubleTake"],
pass: ["doublePass"],
}[advice.action];
for (const key of ["noDouble", "doubleTake", "doublePass"] as const) {
const bold = mine.includes(key);
const name = ADVICE_TEXTS.cubeRows[key];
row(
[
"",
bold && mine.length === 1 ? `${name} ${ADVICE_TEXTS.you}` : name,
formatEquity(advice.equities[key]),
],
bold,
);
}
element(details, "p", {
className: "explain-note",
text: `${ADVICE_TEXTS.cubeNote(advice.ply)}${formatEquity(advice.equityLoss)}.`,
});
}
}
return {
show,
hide() {
opened++;
playing++;
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
}
},
};
}
+1953 -197
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -155,3 +155,26 @@ export function pointSlot(number: number): { top: boolean; column: number } {
}
return { top: true, column: number - 13 };
}
/** 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,
* comme dans le dessin de référence de la charte (board.js), pas une règle. */
export const OPENING_BOARDS: SideBoards = (() => {
const points = Array.from({ length: 24 }, (_, i) => ({ 5: 5, 7: 3, 12: 5, 23: 2 })[i] ?? 0);
const side = { points, bar: 0, off: 0, pipCount: 167 };
return { player: side, computer: side };
})();
/** Qui mène la course, et de combien : le plus petit compte de points
* (leader null : égalité). */
export function pipLead(pipCount: Readonly<Record<Side, number>>): {
leader: Side | null;
by: number;
} {
const by = Math.abs(pipCount.player - pipCount.computer);
if (by === 0) {
return { leader: null, by };
}
return { leader: pipCount.player < pipCount.computer ? "player" : "computer", by };
}
+582 -175
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
// 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",
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)",
};
+278
View File
@@ -0,0 +1,278 @@
// « 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),
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;
}
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);
}
+191
View File
@@ -0,0 +1,191 @@
// La géométrie du plateau, calculée d'après la forme de l'écran (fonction
// pure, testée sous Node).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le dessin est en unités du viewBox, de hauteur fixe (520). En portrait, le
// plateau garde ses proportions habituelles (640 × 520) et suit la largeur de
// l'écran. En paysage, « le plateau est l'écran » (ADR 0012, amendements du
// 2026-11-03) : sa largeur suit le rapport largeur/hauteur de l'écran, et les
// flèches s'élargissent d'autant. Formules du composant Board des maquettes
// « Révision UX » : largeur d'une flèche = ce qui reste une fois ôtés le
// cadre, les colonnes et la barre, divisé par 12 ; rayon d'un pion = le plus
// petit de la demi-largeur d'une flèche (moins 2) et du dixième de la
// hauteur d'une demi-table (hors bande des numéros et bande centrale).
/** Portrait : barre haute, bandeaux et barre d'actions autour du plateau ;
* paysage : le plateau seul, commandes comprises. */
export type Layout = "portrait" | "landscape";
export interface Geometry {
readonly layout: Layout;
/** Taille du dessin, en unités du viewBox. */
readonly width: number;
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. */
readonly edge: number;
/** Colonne de commandes à gauche (0 en portrait). */
readonly left: number;
/** Colonne des sorties, à droite. */
readonly tray: number;
readonly bar: number;
/** Bande centrale, entre les pointes des flèches (dés, consigne). */
readonly band: number;
readonly pointWidth: number;
readonly radius: number;
/** D'un pion au suivant dans une pile. */
readonly step: number;
/** Bord gauche de la première colonne de flèches. */
readonly x0: number;
readonly barLeft: number;
/** Bord gauche de la moitié droite. */
readonly rightHalf: number;
readonly trayLeft: number;
/** Côté minimal d'une zone à toucher : 44 px à l'écran. */
readonly target: number;
/** Tailles de texte, en unités : numéros des flèches (12 px au moins à
* l'écran), consigne de la bande et pastilles des comptes (13 px). */
readonly numberSize: number;
readonly bandFont: number;
readonly chipFont: number;
/** Videau : côté et taille du chiffre ; nombre posé sur une pile trop
* haute (13 px au moins à l'écran). */
readonly cube: number;
readonly cubeFont: number;
readonly countFont: number;
/** Épaisseur des flèches du coup de l'ordinateur : 2,5 px à l'écran. */
readonly arrowWidth: number;
readonly die: number;
/** Nombre de pions dessinés au plus sur la barre, par camp (au-delà, un
* nombre sur le dernier) : en paysage, ils s'arrêtent avant les
* pastilles des comptes. */
readonly barStack: number;
/** Hauteur d'une pastille de compte, au bout de la barre (paysage). */
readonly chipHeight: number;
}
export const BOARD_HEIGHT = 520;
/** Rapport largeur / hauteur du plateau en portrait. */
export const PORTRAIT_ASPECT = 640 / 520;
/** Pions dessinés au plus sur une flèche ; au-delà, un nombre sur le dernier. */
export const MAX_STACK = 5;
/** Une taille en unités qui fait au moins px pixels à l'écran. */
function atLeast(px: number, pxPerUnit: number, min: number): number {
return Math.max(min, Math.ceil(px / pxPerUnit));
}
/**
* La géométrie du plateau pour une mise en page. aspect : rapport largeur /
* hauteur de la place offerte (ignoré en portrait) ; pxPerUnit : pixels à
* l'écran par unité du dessin, pour les tailles minimales (textes, cibles).
*/
export function boardGeometry(layout: Layout, aspect: number, pxPerUnit: number): Geometry {
const height = BOARD_HEIGHT;
const target = atLeast(46, pxPerUnit, 40);
const numberSize = atLeast(12.5, pxPerUnit, 13);
const text = atLeast(13.5, pxPerUnit, 17);
const sizes = {
cubeFont: text,
cube: Math.max(36, text + 20),
countFont: Math.max(18, text),
arrowWidth: 2.5 / pxPerUnit,
};
if (layout === "portrait") {
// Les mesures du plateau d'origine : flèches de 44, pions de 20.
const frame = 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 x0 = frame;
const barLeft = x0 + 6 * pointWidth;
return {
layout,
width,
height,
frame,
edge: frame,
left: 0,
tray,
bar,
band,
pointWidth,
radius,
step: 2 * radius + 2,
x0,
barLeft,
rightHalf: barLeft + bar,
trayLeft: barLeft + bar + 6 * pointWidth + frame,
target,
numberSize,
bandFont: atLeast(13.5, pxPerUnit, 17),
chipFont: atLeast(13.5, pxPerUnit, 17),
...sizes,
die: 36,
barStack: MAX_STACK,
chipHeight: 0,
};
}
const width = Math.round(height * aspect);
const frame = 6;
const edge = Math.max(24, numberSize + 8);
const left = target + 4;
const tray = 56;
const bar = 64;
const band = 80;
const pointWidth = (width - 2 * frame - left - tray - bar) / 12;
const radius = Math.min(pointWidth / 2 - 2, (height / 2 - edge - band / 2 - 4) / 10);
const step = 2 * radius + 1;
const chipFont = atLeast(13.5, pxPerUnit, 18);
const chipHeight = chipFont + 12;
// Pions de la barre, depuis le milieu (voir board-view) : le dernier
// s'arrête à 2 unités de la pastille.
const room = height / 2 - edge - 4 - chipHeight - 2 - (radius + 4) - radius;
const barStack = Math.max(1, Math.min(MAX_STACK, Math.floor(room / step) + 1));
const x0 = frame + left;
const barLeft = x0 + 6 * pointWidth;
return {
layout,
width,
height,
frame,
edge,
left,
tray,
bar,
band,
pointWidth,
radius,
step,
x0,
barLeft,
rightHalf: barLeft + bar,
trayLeft: barLeft + bar + 6 * pointWidth,
target,
numberSize,
bandFont: atLeast(13.5, pxPerUnit, 22),
chipFont,
...sizes,
die: Math.min(40, band - 12),
barStack,
chipHeight,
};
}
/** Abscisse du bord gauche d'une colonne de flèches (0 à 11). */
export function columnLeft(g: Geometry, column: number): number {
return column < 6 ? g.x0 + column * g.pointWidth : g.rightHalf + (column - 6) * g.pointWidth;
}
/** Ordonnée du centre du pion numéro index (0 : contre le bord) d'une pile
* de la rangée du haut ou du bas. */
export function stackY(g: Geometry, top: boolean, index: number): number {
const offset = g.radius + 1 + Math.min(index, MAX_STACK) * g.step;
return top ? g.edge + offset : g.height - g.edge - offset;
}
+47
View File
@@ -0,0 +1,47 @@
// Les vibrations (docs/identite/charte/interaction.md) : elles doublent un
// événement visible, jamais seules, et se coupent dans le menu. Rien ne vibre
// pour un toucher de bouton ni pour le lancer des dés.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export type HapticEvent =
| "checkerPlaced"
| "checkerHit"
| "computerDoubles"
| "gameWon"
| "gameLost";
/** Motifs en millisecondes (vibration, pause, vibration…). */
export const HAPTIC_PATTERNS: Readonly<Record<HapticEvent, readonly number[]>> = {
checkerPlaced: [12],
checkerHit: [25, 40, 25],
computerDoubles: [40, 60, 40],
gameWon: [30, 50, 30, 50, 60],
gameLost: [60],
};
/** navigator.vibrate, s'il existe. */
export type Vibrate = (pattern: number[]) => boolean;
/**
* La fonction qui fait vibrer pour un événement : rien si le navigateur ne
* sait pas vibrer ou si le joueur a coupé les vibrations. Une seule vibration
* à la fois : chaque appel remplace le précédent (comportement de
* navigator.vibrate).
*/
export function createHaptics(
vibrate: Vibrate | undefined,
enabled: () => boolean,
): (event: HapticEvent) => void {
return (event) => {
if (vibrate !== undefined && enabled()) {
try {
vibrate([...HAPTIC_PATTERNS[event]]);
} catch {
// Navigateur qui refuse (pas encore de geste du joueur) : la
// marque visible suffit.
}
}
};
}
+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));
}
+290 -51
View File
@@ -1,21 +1,43 @@
// La page d'aide : le scénario « Jouer une partie contre l'ordinateur » et le
// glossaire, lus à l'exécution dans les copies de docs/ faites par make site
// (ADR 0008). Rien n'est recopié dans le code.
// L'aide : les scénarios (onglet « Jouer », une question par scénario), le
// glossaire (« Les mots du jeu ») et « À propos », lus à l'exécution dans
// les copies de docs/ faites par make site (ADR 0008). Rien n'est recopié
// dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque onglet, chaque question et chaque terme a son adresse (ADR 0013,
// amendement) : on les ouvre par de vrais liens, et l'adresse dit ce qui
// est ouvert (show). Le titre d'un scénario est la question, sa
// description la réponse ; ses étapes, la preuve que l'application fait ce
// que dit la réponse, ne s'affichent pas
// (docs/identite/charte/accueil-tutoriel-aide.md, « Aide »).
import {
type GlossaryEntry,
type HelpFeature,
type HelpScenario,
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "./help-text.js";
import { HOME_TEXTS } from "./home-texts.js";
import { type HelpRoute, routeHash, slug } from "./router.js";
import { TEXTS } from "./texts.js";
/** Les scénarios montrés dans l'aide, dans l'ordre. */
const FEATURES = ["docs/scenarios/jouer-une-partie.feature"];
export const FEATURES = [
"docs/scenarios/jouer-une-partie.feature",
"docs/scenarios/jouer-un-match.feature",
"docs/scenarios/comprendre-mes-coups.feature",
"docs/scenarios/apprendre-les-regles.feature",
"docs/scenarios/mes-parties.feature",
"docs/scenarios/partager-une-position.feature",
"docs/scenarios/regler-l-application.feature",
"docs/scenarios/installer-l-application.feature",
"docs/scenarios/decouvrir-l-application.feature",
];
const GLOSSARY = "docs/glossaire/";
async function fetchText(url: string): Promise<string> {
@@ -26,10 +48,37 @@ 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
* copie du site : la bulle du compte de points en affiche le texte. */
export async function loadGlossaryEntry(file: string): Promise<GlossaryEntry | null> {
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>(
@@ -45,56 +94,246 @@ function element<K extends keyof HTMLElementTagNameMap>(
return child;
}
function renderFeature(parent: HTMLElement, feature: HelpFeature): void {
element(parent, "h2", feature.title);
for (const paragraph of feature.description) {
element(parent, "p", paragraph);
}
for (const scenario of feature.scenarios) {
element(parent, "h3", scenario.title);
/** Une question de l'onglet « Jouer » : son lien et sa réponse. */
interface Question {
readonly id: string;
readonly section: HTMLElement;
readonly link: HTMLAnchorElement;
readonly answer: HTMLElement;
}
export interface HelpView {
/** Montre l'onglet, la question ou le terme que désigne l'adresse
* (l'aide est lue au premier appel). */
show(route: HelpRoute): Promise<void>;
}
/**
* L'aide dans container : un onglet par panneau (Jouer, Les mots du jeu, À
* propos). Les liens des onglets sont dans tabs, déjà dans la page.
*/
export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpView {
const play = document.createElement("div");
play.id = "help-play";
const glossary = document.createElement("div");
glossary.id = "help-glossary";
const about = document.createElement("div");
about.id = "help-about";
const notice = document.createElement("p");
notice.className = "help-notice";
notice.setAttribute("role", "status");
const questions: Question[] = [];
const terms = new Map<string, { dt: HTMLElement; card: HTMLElement; entry: GlossaryCard }>();
let loading: Promise<boolean> | null = null;
// La question ouverte au dernier affichage : refermée, elle reste en vue.
let lastOpen: Question | null = null;
function renderScenario(parent: HTMLElement, scenario: HelpScenario): void {
let id = slug(scenario.title);
for (let n = 2; questions.some((q) => q.id === id); n++) {
id = `${slug(scenario.title)}-${n}`;
}
const section = element(parent, "section");
section.className = "question";
section.id = `aide-${id}`;
const heading = element(section, "h3");
const link = element(heading, "a", scenario.title);
link.href = routeHash({ screen: "help", question: id });
link.setAttribute("aria-controls", `aide-${id}-reponse`);
link.setAttribute("aria-expanded", "false");
const answer = element(section, "div");
answer.id = `aide-${id}-reponse`;
answer.className = "answer";
answer.hidden = true;
for (const paragraph of scenario.description) {
element(parent, "p", paragraph);
element(answer, "p", paragraph);
}
const steps = element(parent, "ul");
steps.className = "steps";
for (const step of scenario.steps) {
element(steps, "li", step);
if (scenario.position !== null) {
const board = element(answer, "a", HOME_TEXTS.seeOnBoard);
board.className = "see-board";
board.href = routeHash({
screen: "position",
position: scenario.position,
match: null,
});
}
questions.push({ id, section, link, answer });
}
}
function renderGlossary(parent: HTMLElement, entries: readonly GlossaryEntry[]): void {
element(parent, "h2", TEXTS.glossaryTitle);
const list = element(parent, "dl");
list.className = "glossary";
for (const entry of entries) {
element(list, "dt", entry.term);
element(list, "dd", entry.brief);
}
}
/** Remplit container avec l'aide (une seule fois ; les appels suivants ne
* font rien). */
export async function showHelp(container: HTMLElement): Promise<void> {
if (container.dataset.loaded === "true") {
return;
}
container.replaceChildren();
const status = element(container, "p", TEXTS.helpLoading);
try {
const [features, glossary] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
]);
status.remove();
for (const feature of features) {
renderFeature(container, feature);
function renderFeature(feature: HelpFeature): void {
element(play, "h2", feature.title);
for (const paragraph of feature.description) {
element(play, "p", paragraph).className = "feature-intro";
}
for (const scenario of feature.scenarios) {
renderScenario(play, scenario);
}
renderGlossary(container, glossary);
const about = element(container, "p", TEXTS.about);
about.className = "about";
container.dataset.loaded = "true";
} catch {
status.textContent = TEXTS.helpFailed;
}
function renderGlossary(entries: readonly GlossaryCard[]): void {
element(glossary, "h2", TEXTS.glossaryTitle);
const label = element(glossary, "label");
label.className = "glossary-search";
element(label, "span", HOME_TEXTS.glossarySearch).className = "search-label";
const search = element(label, "input");
search.id = "glossary-search";
search.type = "search";
search.autocomplete = "off";
search.spellcheck = false;
const none = element(glossary, "p", HOME_TEXTS.glossaryNone);
none.className = "glossary-none muted";
none.hidden = true;
const list = element(glossary, "dl");
list.className = "glossary";
const sorted = [...entries].sort((a, b) => a.term.localeCompare(b.term, "fr"));
for (const entry of sorted) {
const card = element(list, "div");
card.className = "term-card";
const dt = element(card, "dt");
dt.id = `glossaire-${entry.name}`;
const link = element(dt, "a", entry.term);
link.href = routeHash({ screen: "glossary", term: entry.name });
if (entry.english !== "") {
const english = element(dt, "span", entry.english);
english.className = "english";
english.lang = "en";
}
const dd = element(card, "dd");
element(dd, "p", entry.brief);
if (entry.seeAlso.length > 0) {
const see = element(dd, "p");
see.className = "see-also";
see.append(`${HOME_TEXTS.glossarySeeAlso} `);
entry.seeAlso.forEach((other, i) => {
if (i > 0) {
see.append(", ");
}
const a = element(see, "a", other.term);
a.href = routeHash({ screen: "glossary", term: other.name });
});
}
terms.set(entry.name, { dt, card, entry });
}
search.addEventListener("input", () => {
let shown = 0;
for (const { card, entry } of terms.values()) {
const match = matchesSearch(entry, search.value);
card.hidden = !match;
shown += match ? 1 : 0;
}
none.hidden = shown > 0;
});
}
function renderAbout(version: string | null): void {
element(about, "h2", TEXTS.aboutTitle);
for (const paragraph of HOME_TEXTS.aboutParagraphs) {
element(about, "p", paragraph).className = "about";
}
const line = element(
about,
"p",
version === null ? HOME_TEXTS.aboutVersionUnknown : HOME_TEXTS.aboutVersion(version),
);
line.id = "about-version";
line.className = "about";
}
function renderTutorialCard(): void {
const card = element(container, "a");
card.id = "help-tutorial";
card.className = "card help-tutorial";
card.href = "#/tutoriel/1";
const text = element(card, "span");
text.className = "help-tutorial-text";
element(text, "strong", HOME_TEXTS.helpTutorialTitle);
element(text, "span", HOME_TEXTS.helpTutorialText).className = "muted";
element(card, "span", HOME_TEXTS.helpTutorialLink).className = "pill";
}
async function load(): Promise<boolean> {
container.replaceChildren(notice);
notice.textContent = TEXTS.helpLoading;
try {
const [features, entries, version] = await Promise.all([
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
loadGlossary(),
publishedVersion(),
]);
const intro = element(play, "p", HOME_TEXTS.helpIntro);
intro.className = "help-intro muted";
for (const feature of features) {
renderFeature(feature);
}
renderGlossary(entries);
renderAbout(version);
notice.textContent = "";
renderTutorialCard();
container.append(play, glossary, about);
container.dataset.loaded = "true";
return true;
} catch {
notice.textContent = TEXTS.helpFailed;
loading = null; // on réessaiera à la prochaine ouverture
return false;
}
}
return {
async show(route) {
for (const tab of tabs.querySelectorAll<HTMLAnchorElement>("a[data-screen]")) {
if (tab.dataset.screen === route.screen) {
tab.setAttribute("aria-current", "page");
} else {
tab.removeAttribute("aria-current");
}
}
loading ??= load();
if (!(await loading)) {
return;
}
play.hidden = route.screen !== "help";
glossary.hidden = route.screen !== "glossary";
about.hidden = route.screen !== "about";
const question = route.screen === "help" ? route.question : null;
for (const q of questions) {
const open = q.id === question;
q.answer.hidden = !open;
q.link.setAttribute("aria-expanded", String(open));
// Le lien d'une question ouverte la referme.
q.link.href = routeHash({ screen: "help", question: open ? null : q.id });
q.section.classList.toggle("open", open);
}
const term = route.screen === "glossary" ? route.term : null;
for (const [name, { dt, card }] of terms) {
dt.classList.toggle("current", name === term);
card.classList.toggle("current", name === term);
if (name === term) {
dt.setAttribute("aria-current", "true");
card.hidden = false;
} else {
dt.removeAttribute("aria-current");
}
}
const missing =
(question !== null && !questions.some((q) => q.id === question)) ||
(term !== null && !terms.has(term));
notice.textContent = missing ? TEXTS.helpNotFound : "";
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
// La barre haute de l'aide reste en vue (app.css) : ce qu'on
// amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
} else if (closed !== null) {
closed.section.scrollIntoView({ block: "nearest" });
} else {
window.scrollTo(0, 0);
}
},
};
}
+64
View File
@@ -0,0 +1,64 @@
// Ce que montre l'accueil, et où s'ouvre l'application : fonctions pures,
// testées sous Node (docs/identite/charte/accueil-tutoriel-aide.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { installCardShown, isFirstLaunch, type Settings } from "./settings.js";
/** Les deux choix du premier lancement : « Je découvre » mène au
* tutoriel, « Je connais les règles » à la partie. */
export const WELCOME_LINKS = { discover: "#/tutoriel/1", play: "#/" } as const;
/** Les réglages après l'un des deux choix du premier lancement. */
export function welcomed(): Partial<Settings> {
return { welcomed: true };
}
/** L'adresse où ouvrir l'application lancée sans adresse d'écran (icône de
* l'écran d'accueil, adresse du site) : l'accueil. Une adresse donnée est
* gardée (null). */
export function launchHash(hash: string): string | null {
return hash === "" || hash === "#" ? "#/accueil" : null;
}
/** L'accueil, au premier lancement ou ensuite. */
export interface HomeModel {
/** Premier lancement : le choix « Je découvre » / « Je connais les
* règles », et rien d'autre. */
readonly welcome: boolean;
/** La carte de la partie : « Reprendre la partie » si une partie est en
* cours, sinon « Nouvelle partie » en action principale. */
readonly resume: boolean;
/** L'invitation à installer. */
readonly installCard: boolean;
}
/** Ce que montre l'accueil. playing : une partie est en cours (null : pas
* encore connu, le moteur se charge). */
export function homeModel(
settings: Settings,
playing: boolean | null,
installed: boolean,
): HomeModel {
const welcome = isFirstLaunch(settings);
return {
welcome,
resume: playing !== false,
installCard: !welcome && installCardShown(settings, installed),
};
}
/** La ligne « Installer l'application » du menu : tant que l'application
* n'est pas installée. */
export function installMenuShown(settings: Settings, installed: boolean): boolean {
return !installed && !settings.installed;
}
/** Les réglages après « Plus tard » sur l'invitation à installer. */
export function dismissInstall(settings: Settings): Partial<Settings> {
return {
installDismissals: settings.installDismissals + 1,
installDismissedAt: settings.gamesFinished,
};
}
+125
View File
@@ -0,0 +1,125 @@
// Les textes de l'accueil, du tutoriel, de l'installation et de la mise à
// jour (ADR 0007, point 5 ; docs/identite/charte/accueil-tutoriel-aide.md).
// Première version, à valider par Pierre. Le texte des étapes du tutoriel
// n'est pas ici : il vit dans docs/tutoriel/regles.md.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { ResultKind } from "../domain/game/engine.js";
import type { InstallPlatform } from "./install.js";
function plural(n: number, one: string, many: string): string {
return `${n} ${n > 1 ? many : one}`;
}
export const HOME_TEXTS = {
// Premier lancement
welcomeTitle: "Bienvenue à la table",
welcomeText:
"Ustagammon vous apprend le backgammon pas à pas, et vous fait jouer contre l'ordinateur, même sans réseau.",
discover: "Je découvre",
discoverDetail: "Les règles en huit petites étapes, plateau à l'appui",
knowRules: "Je connais les règles",
knowRulesDetail: "Une partie tout de suite",
tutorialInMenu: "Le tutoriel reste dans le menu, à tout moment.",
// Accueil
homeLink: "Accueil",
homeTitle: "À vous de jouer",
homeLead: "Reprenez votre partie contre l'ordinateur, ou revoyez les règles.",
gameOverline: "Partie en cours",
noGameOverline: "Pas de partie en cours",
againstComputer: "Contre l'ordinateur",
homePips: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
homeLoading: "Chargement…",
resumeGame: "Reprendre la partie",
rulesTitle: "Les règles du jeu",
rulesDetail: "Le tutoriel, en huit étapes, plateau à l'appui",
rulesStart: "Commencer",
rulesContinue: (step: number) => `Reprendre à l'étape ${step}`,
rulesAgain: "Revoir",
progress: (played: number, won: number) =>
`${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
// Installation
installTitle: "Installer Ustagammon",
installCardText: "Sur l'écran d'accueil, en plein écran, sans réseau.",
install: "Installer",
installLater: "Plus tard",
installMenu: "Installer l'application",
installLead:
"Une icône sur votre écran d'accueil, le jeu en plein écran, et tout marche sans réseau.",
installSteps: {
firefox: [
"Touchez le menu du navigateur ⋮, en bas ou en haut de l'écran.",
"Choisissez « Installer » ou « Ajouter à l'écran d'accueil ».",
"Confirmez. Ensuite, ouvrez Ustagammon depuis son icône.",
],
ios: [
"Touchez le bouton de partage, le carré d'où sort une flèche, en bas de Safari.",
"Choisissez « Sur l'écran d'accueil ».",
"Touchez « Ajouter ». Ensuite, ouvrez Ustagammon depuis son icône.",
],
} satisfies Record<InstallPlatform, readonly string[]>,
installUnderstood: "J'ai compris",
installed: "Ustagammon est installé : ouvrez-le depuis son icône.",
// Mise à jour
updateAvailable: "Nouvelle version prête.",
updateApply: "Mettre à jour",
updateCheck: "Rechercher une mise à jour",
updateChecking: "Recherche d'une mise à jour…",
updateLatest: "Vous avez la dernière version.",
updateUnavailable:
"Impossible de chercher une mise à jour : pas de réseau, ou navigateur qui ne le permet pas.",
// Tutoriel
tutorialMenu: "Les règles (tutoriel)",
tutorialProgress: "Étapes du tutoriel",
tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`,
tutorialSkip: "Quitter",
tutorialSkipLabel: "Quitter le tutoriel",
tutorialOverline: (n: number, total: number, practice: boolean) =>
`Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`,
tutorialEndOverline: "Les règles · fin",
tutorialEndTitle: "Vous connaissez les règles",
back: "Retour",
next: "Continuer",
replay: "Revoir",
replayLabel: "Revoir le coup",
take: "Prendre",
drop: "Passer",
tutorialLoading: "Chargement du tutoriel…",
tutorialFailed: "Le tutoriel n'a pas pu se charger.",
pathLabel: "Le sens du jeu de vos pions, en pointillés",
/** Le résultat d'une partie finie par le coup illustré (étape 7). */
illustratedEnd: (kind: ResultKind) =>
kind === "single"
? "Partie finie : l'ordinateur avait sorti des pions, c'est une partie simple."
: kind === "gammon"
? "Partie finie : l'ordinateur n'a sorti aucun pion, c'est un gammon."
: "Partie finie : il reste un pion de l'ordinateur dans votre camp, c'est un backgammon.",
computerDoubles: "L'ordinateur double.",
// Aide
helpTutorialTitle: "Nouveau au backgammon ?",
helpTutorialText: "Le tutoriel des règles, en huit étapes, plateau à l'appui.",
helpTutorialLink: "Apprendre les règles",
helpIntro:
"Une question en partie ? Elle a sûrement sa réponse ici. Touchez une question pour lire la réponse.",
seeOnBoard: "Voir sur le plateau",
glossarySearch: "Chercher un mot",
glossaryNone: "Aucun mot ne correspond.",
glossarySeeAlso: "Voir aussi :",
aboutVersion: (commit: string) => `Version publiée : ${commit}.`,
aboutVersionUnknown: "Version publiée : inconnue (lancée depuis les sources).",
aboutParagraphs: [
"Ustagammon est un logiciel libre, sous licence GNU GPL version 3 ou ultérieure : vous pouvez l'utiliser, l'étudier, le modifier et le partager.",
"Les textes du tutoriel, de l'aide et du glossaire sont sous licence Creative Commons BY-SA 4.0.",
"L'ordinateur joue et juge les coups avec GNU Backgammon (GPLv3), compilé pour le navigateur. Police Alegreya Sans, sous licence SIL Open Font License.",
"Né à l'Atelier du Huit, café associatif à Cugnaux. Sans publicité, sans compte, sans mise : tout reste sur votre téléphone.",
],
} as const;
+242
View File
@@ -0,0 +1,242 @@
// L'accueil (#/accueil) : au premier lancement, le choix « Je découvre » /
// « Je connais les règles » ; ensuite, la partie en cours, l'invitation à
// installer, les règles et la progression résumée
// (docs/identite/charte/accueil-tutoriel-aide.md, « Accueil »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Les leçons (1g) et la progression détaillée (1h) n'existent pas encore :
// l'accueil n'affiche que ce qui existe.
import type { SideBoards } from "../domain/game/game.js";
import { boardModel, OPENING_BOARDS } from "./board-model.js";
import { NO_MARKS, renderBoard } from "./board-view.js";
import { boardGeometry, PORTRAIT_ASPECT } from "./geometry.js";
import { type HomeModel, WELCOME_LINKS } from "./home-model.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { TUTORIAL_DONE } from "./settings.js";
import { TEXTS } from "./texts.js";
export interface HomeViewOptions {
readonly section: HTMLElement;
/** « Nouvelle partie » (bouton : une action, pas un écran). */
readonly newGame: () => void;
readonly install: () => void;
readonly dismissInstall: () => void;
/** Un des deux choix du premier lancement a été suivi. */
readonly welcomed: () => void;
}
export interface HomeState {
readonly model: HomeModel;
/** Le plateau de la partie en cours ou de la dernière partie (null :
* jamais joué). */
readonly sides: SideBoards | null;
/** La dernière étape du tutoriel vue. */
readonly tutorialSeen: number;
/** Parties jouées et gagnées (null : pas encore lues). */
readonly progress: { readonly played: number; readonly won: number } | null;
/** Le moteur se charge, ou l'écran est occupé. */
readonly busy: boolean;
}
export interface HomeView {
render(state: HomeState): void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
function boardSvg(parent: HTMLElement, id: string, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = id;
svg.setAttribute("class", `board ${className}`);
svg.setAttribute("aria-hidden", "true");
parent.append(svg);
return svg;
}
function logo(parent: HTMLElement): void {
for (const [variant, file] of [
["logo-light", "logo-horizontal-couleur.svg"],
["logo-dark", "logo-horizontal-sombre.svg"],
] as const) {
const img = element(parent, "img", { className: variant });
img.src = `icons/${file}`;
img.alt = "Ustagammon";
}
}
function drawBoard(svg: SVGSVGElement, sides: SideBoards, width: number): void {
renderBoard(svg, boardGeometry("portrait", PORTRAIT_ASPECT, width / 640), boardModel(sides), {
...NO_MARKS,
dice: [],
cube: { value: 1, owner: null },
band: null,
controls: null,
});
}
/** Une grande carte-lien du premier lancement : le choix et son détail. */
function choice(parent: HTMLElement, id: string, href: string, label: string, detail: string) {
const link = element(parent, "a", { id, className: "choice" });
link.href = href;
element(link, "strong", { text: label });
element(link, "span", { text: detail });
return link;
}
export function createHomeView(options: HomeViewOptions): HomeView {
const { section } = options;
section.replaceChildren();
// Premier lancement.
const welcome = element(section, "div", { id: "home-welcome", className: "welcome" });
const welcomeLogo = element(welcome, "h1", { className: "welcome-logo" });
logo(welcomeLogo);
const welcomeBoard = boardSvg(welcome, "welcome-board", "welcome-board");
const welcomeText = element(welcome, "div", { className: "welcome-text" });
element(welcomeText, "h2", { text: HOME_TEXTS.welcomeTitle });
element(welcomeText, "p", { text: HOME_TEXTS.welcomeText });
const choices = element(welcome, "div", { className: "welcome-choices" });
const discover = choice(
choices,
"welcome-discover",
WELCOME_LINKS.discover,
HOME_TEXTS.discover,
HOME_TEXTS.discoverDetail,
);
discover.classList.add("primary");
const play = choice(
choices,
"welcome-play",
WELCOME_LINKS.play,
HOME_TEXTS.knowRules,
HOME_TEXTS.knowRulesDetail,
);
element(choices, "p", { className: "caption", text: HOME_TEXTS.tutorialInMenu });
for (const link of [discover, play]) {
link.addEventListener("click", () => options.welcomed());
}
// Ensuite.
const main = element(section, "div", { id: "home-main", className: "home-main" });
const intro = element(main, "div", { className: "home-intro" });
element(intro, "h2", { text: HOME_TEXTS.homeTitle });
element(intro, "p", { className: "muted", text: HOME_TEXTS.homeLead });
const card = element(main, "article", { id: "home-game", className: "card home-game" });
const cardTop = element(card, "div", { className: "home-game-top" });
const miniBox = element(cardTop, "div", { className: "home-mini" });
const mini = boardSvg(miniBox, "home-board", "home-board");
const facts = element(cardTop, "div", { className: "home-facts" });
const gameOverline = element(facts, "span", { className: "overline" });
element(facts, "strong", { text: HOME_TEXTS.againstComputer });
const pips = element(facts, "span", { id: "home-pips", className: "home-pips" });
const actions = element(card, "div", { className: "home-actions" });
const resume = element(actions, "a", {
id: "home-resume",
className: "button",
text: HOME_TEXTS.resumeGame,
});
resume.href = "#/";
const fresh = element(actions, "button", { id: "home-new", text: TEXTS.newGame });
fresh.type = "button";
fresh.addEventListener("click", () => options.newGame());
const install = element(main, "aside", { id: "install-card", className: "install-card" });
install.setAttribute("aria-label", HOME_TEXTS.installTitle);
const symbol = element(install, "img", { className: "install-symbol" });
symbol.src = "icons/symbole-couleur.svg";
symbol.alt = "";
const installText = element(install, "span", { className: "install-text" });
element(installText, "strong", { text: HOME_TEXTS.installTitle });
element(installText, "span", { className: "muted", text: HOME_TEXTS.installCardText });
const installButton = element(install, "button", {
id: "install-card-button",
className: "secondary",
text: HOME_TEXTS.install,
});
installButton.type = "button";
installButton.addEventListener("click", () => options.install());
const later = element(install, "button", { id: "install-later", className: "icon-button" });
later.type = "button";
later.append(icon("close"));
later.setAttribute("aria-label", HOME_TEXTS.installLater);
later.title = HOME_TEXTS.installLater;
later.addEventListener("click", () => options.dismissInstall());
const rules = element(main, "a", { id: "home-rules", className: "card home-rules" });
const rulesText = element(rules, "span", { className: "home-rules-text" });
element(rulesText, "strong", { text: HOME_TEXTS.rulesTitle });
element(rulesText, "span", { className: "muted", text: HOME_TEXTS.rulesDetail });
const rulesAction = element(rules, "span", { className: "pill" });
const progress = element(main, "p", {
id: "home-progress",
className: "caption home-progress",
});
const footer = element(main, "footer", { className: "home-footer" });
const footerSymbol = element(footer, "img");
footerSymbol.src = "icons/symbole-couleur.svg";
footerSymbol.alt = "";
element(footer, "span", { text: HOME_TEXTS.footer });
return {
render(state) {
const { model } = state;
welcome.hidden = !model.welcome;
main.hidden = model.welcome;
if (model.welcome) {
drawBoard(welcomeBoard, OPENING_BOARDS, welcomeBoard.clientWidth || 360);
return;
}
const sides = state.sides ?? OPENING_BOARDS;
drawBoard(mini, sides, miniBox.clientWidth || 132);
gameOverline.textContent = model.resume
? HOME_TEXTS.gameOverline
: HOME_TEXTS.noGameOverline;
pips.textContent = HOME_TEXTS.homePips(sides.player.pipCount, sides.computer.pipCount);
// Une seule action principale : « Reprendre la partie », ou
// « Nouvelle partie » quand il n'y a rien à reprendre.
resume.hidden = !model.resume;
fresh.textContent = TEXTS.newGame;
fresh.classList.toggle("secondary", model.resume);
fresh.disabled = state.busy;
install.hidden = !model.installCard;
const seen = state.tutorialSeen;
rules.href = seen > 0 && seen < TUTORIAL_DONE ? `#/tutoriel/${seen}` : "#/tutoriel/1";
rulesAction.textContent =
seen === 0
? HOME_TEXTS.rulesStart
: seen >= TUTORIAL_DONE
? HOME_TEXTS.rulesAgain
: HOME_TEXTS.rulesContinue(seen);
progress.textContent =
state.progress === null || state.progress.played === 0
? ""
: HOME_TEXTS.progress(state.progress.played, state.progress.won);
},
};
}
+48
View File
@@ -0,0 +1,48 @@
// Les pictogrammes de l'interface (composant Icons de la charte,
// docs/identite/charte/interaction.md) : traits de 2 px à bouts ronds, en
// currentColor, dessinés en SVG. Les tracés viennent des maquettes
// « Révision UX » (repère 24 × 24). Couleur et épaisseur passent par la
// classe « icon » (app.css) : aucun style dans le SVG (CSP).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
export const ICON_PATHS = {
menu: "M4 7h16M4 12h16M4 17h16",
help: "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",
fullscreen: "M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5",
exitFullscreen: "M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5",
undo: "M9 14L4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3",
replay: "M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5",
close: "M6 6l12 12M18 6L6 18",
chevron: "M9 6l6 6-6 6",
copy: "M9 9h11v11H9zM5 15H4V4h11v1",
newGame: "M12 5v14M5 12h14",
games: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01",
vibrate: "M9 5h6v14H9zM5 8v8M19 8v8M2 10v4M22 10v4",
theme: "M12 21a9 9 0 1 0 0-18zM12 3a9 9 0 0 1 0 18",
screenOn: "M7 3h10v18H7zM11 18h2",
home: "M4 11l8-7 8 7M6 9.5V20h12V9.5M10 20v-6h4v6",
rules: "M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z",
install: "M12 4v11M7 10l5 5 5-5M5 20h14",
verdictGood: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM8 12.5l3 3 5-6",
verdictDoubtful:
"M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9.5 9.3a2.6 2.6 0 1 1 3.6 2.4c-.7.3-1.1.9-1.1 1.6v.6M12 17.2v.1",
verdictBad: "M12 21a9 9 0 1 0 0-18a9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6",
} as const;
export type IconName = keyof typeof ICON_PATHS;
const SVG_NS = "http://www.w3.org/2000/svg";
/** Un pictogramme, décoratif (le nom est porté par son bouton). */
export function icon(name: IconName): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", `icon icon-${name}`);
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", ICON_PATHS[name]);
svg.append(path);
return svg;
}
+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);
}
},
};
}
+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: (n: number) => `Commencer le match en ${n} points`,
kept: "Vos choix sont gardés pour la prochaine fois.",
// Score du match sur l'écran de jeu
/** « Match en 5 points », et « · partie Crawford ». */
matchLine: (length: number, crawford: boolean) =>
`Match en ${length} points${crawford ? " · partie Crawford" : ""}`,
/** Paysage : « Partie Crawford · À vous : lancez les dés. ». */
crawfordPrompt: (prompt: string) => `Partie Crawford · ${prompt}`,
/** Ce que lit le lecteur d'écran. */
scoreSpoken: (length: number, computer: number, player: number) =>
`Match en ${length} points : l'ordinateur ${computer}, vous ${player}`,
/** Paysage : « sur 5 ». */
outOf: (length: number) => `sur ${length}`,
playerScore: (n: number) => `Votre score : ${n}`,
computerScore: (n: number) => `Score de l'ordinateur : ${n}`,
// Fin d'une partie
/** Une nouvelle partie aux mêmes réglages (un match de même longueur
* après un match). */
replay: "Rejouer",
changeSettings: "Changer les réglages",
nextGame: "Partie suivante",
nextGameStarted: "Partie suivante du match.",
matchWonTitle: (winner: "player" | "computer") =>
winner === "player" ? "Vous gagnez le match" : "L'ordinateur gagne le match",
/** « +2 points pour vous. Match en 3 points : vous 2, l'ordinateur 0. » */
matchDetail: (
winner: "player" | "computer",
n: number,
length: number,
player: number,
computer: number,
over: boolean,
) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}. ${
over ? "Score final" : `Match en ${length} points`
} : vous ${player}, l'ordinateur ${computer}.`,
crawfordNext: "La partie suivante est la partie Crawford : personne ne pourra doubler.",
};
+166
View File
@@ -0,0 +1,166 @@
// La navigation entre les écrans : l'adresse de la page est la source de
// vérité (ADR 0013, amendement du 2026-11-03). On change d'écran par de
// vrais liens (<a href="#/aide">) ; l'application écoute hashchange et
// dessine l'écran que désigne l'adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Pour que « Retour », Échap ou la croix d'une feuille reviennent à l'écran
// d'avant sans empiler d'adresse (le bouton retour du téléphone ne doit pas
// rouvrir ce qu'on vient de fermer), chaque entrée de l'historique de
// l'onglet garde, dans history.state, la suite des adresses visitées
// jusqu'à elle. Elle survit au rechargement de la page.
import { parseRoute, type Route, stepsBack } from "./router.js";
/** Au plus autant d'adresses gardées par entrée de l'historique. */
const TRAIL_MAX = 50;
export interface Navigation {
/** L'écran que désigne l'adresse actuelle. */
readonly route: Route;
/** Va à une adresse, comme un lien (empile une entrée). */
go(hash: string): void;
/** Remplace l'adresse actuelle, sans empiler. */
replace(hash: string): void;
/** Quitte l'écran courant : revient à la dernière adresse de l'onglet
* qui ne vérifie pas stay, ou, s'il n'y en a pas (lien ouvert dans un
* nouvel onglet), remplace l'adresse par celle de la partie. */
leave(stay: (route: Route) => boolean): void;
}
interface TrailState {
readonly ustagammonTrail: readonly string[];
}
function trailOf(state: unknown): readonly string[] | null {
const trail = (state as Partial<TrailState> | null)?.ustagammonTrail;
return Array.isArray(trail) ? trail : null;
}
/** Le fragment actuel, « #/ » s'il est vide. */
function currentHash(): string {
return location.hash === "" || location.hash === "#" ? "#/" : location.hash;
}
/**
* Branche la navigation sur la page : onChange est appelé à chaque
* changement d'adresse (pas au démarrage : l'appelant lit route).
*/
export function startNavigation(onChange: (route: Route) => void): Navigation {
let trail: readonly string[] = [];
// Un retour dans l'historique est en cours (history.go est asynchrone) :
// une adresse demandée entre-temps attend son arrivée.
let leaving: ReturnType<typeof setTimeout> | null = null;
let queued: string | null = null;
const remember = (next: readonly string[]) => {
trail = next.slice(-TRAIL_MAX);
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: trail }, "");
} catch {
// Historique indisponible (page dans un cadre) : la navigation
// marche quand même, « Retour » ramène alors à la partie.
}
};
// L'entrée actuelle : déjà connue (rechargement), ou la première.
const known = trailOf(history.state);
remember(known !== null && known.at(-1) === currentHash() ? known : [currentHash()]);
window.addEventListener("hashchange", () => {
const hash = currentHash();
const saved = trailOf(history.state);
if (saved !== null && saved.at(-1) === hash) {
// Retour ou avance dans l'historique : une entrée déjà connue.
trail = saved;
} else {
// Un lien suivi : une nouvelle entrée.
remember([...trail, hash]);
}
onChange(parseRoute(hash));
if (leaving !== null) {
clearTimeout(leaving);
leaving = null;
const next = queued;
queued = null;
if (next !== null) {
go(next);
}
}
});
function go(hash: string): void {
if (leaving !== null) {
queued = hash;
} else if (hash !== currentHash()) {
location.hash = hash;
}
}
function replace(hash: string): void {
if (hash === currentHash()) {
return;
}
const url = new URL(location.href);
url.hash = hash;
const next = [...trail.slice(0, -1), hash];
try {
history.replaceState({ ...(history.state ?? {}), ustagammonTrail: next }, "", url);
} catch {
location.replace(url);
return;
}
trail = next;
// replaceState ne déclenche pas hashchange.
onChange(parseRoute(hash));
}
return {
get route() {
return parseRoute(currentHash());
},
go,
replace,
leave(stay) {
if (leaving !== null) {
return; // déjà en route
}
const steps = stepsBack(trail, stay);
if (steps === null) {
replace("#/");
return;
}
// Au cas où le navigateur ne reviendrait pas (historique
// indisponible), on n'attend pas plus d'une seconde.
leaving = setTimeout(() => {
leaving = null;
queued = null;
}, 1000);
history.go(steps);
},
};
}
/**
* Un lien qui remplace l'adresse au lieu d'en empiler une (les liens d'une
* feuille, comme le menu : le retour du téléphone ne rouvre pas la feuille
* fermée en suivant le lien). Ouvert dans un nouvel onglet, avec le bouton
* du milieu ou une touche, il reste un lien ordinaire.
*/
export function replacingLink(link: HTMLAnchorElement, navigation: Navigation): void {
link.addEventListener("click", (event) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
navigation.replace(new URL(link.href).hash);
});
}
+212
View File
@@ -0,0 +1,212 @@
// L'écran « Nouvelle partie » (#/nouvelle-partie) : type de partie (partie
// libre ou match en N points), niveau de l'ordinateur, conseil du maître
// (docs/identite/charte/ecrans-etape-1.md, chapitre 1c).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Chaque choix est gardé tout de suite dans les réglages : « Vos choix sont
// gardés pour la prochaine fois ». Fermer l'écran ne touche pas à la partie
// en cours ; seul « Commencer » en lance une.
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { MATCH_LENGTHS } from "../domain/game/match.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { routeHash } from "./router.js";
import type { Settings } from "./settings.js";
export interface NewGameViewOptions {
readonly section: HTMLElement;
readonly settings: () => Settings;
/** Un choix a changé : le garder. */
readonly change: (changes: Partial<Settings>) => void;
/** « Commencer la partie » ou « Commencer le match ». */
readonly start: () => void;
}
export interface NewGameView {
/** La croix : un lien « Retour » (l'écran de jeu le branche). */
readonly closeLink: HTMLAnchorElement;
/** Redessine d'après les réglages ; busy : le moteur n'est pas prêt. */
render(busy: boolean): void;
/** Le premier élément à recevoir le focus à l'ouverture. */
focus(): void;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** Un bouton qui fait partie d'un groupe de choix exclusifs. */
function radio(parent: HTMLElement, id: string, className: string, text?: string) {
const button = element(parent, "button", {
id,
className,
...(text === undefined ? {} : { text }),
});
button.type = "button";
button.setAttribute("role", "radio");
return button;
}
/** Une phrase sur une règle, terminée par le lien vers sa fiche. */
function rule(parent: HTMLElement, id: string, text: string, link: string, term: string) {
const p = element(parent, "p", { id, className: "new-game-rule" });
p.append(`${text} `);
const a = element(p, "a", { text: link });
a.href = routeHash({ screen: "glossary", term });
p.append(".");
return p;
}
export function createNewGameView(options: NewGameViewOptions): NewGameView {
const { section } = options;
section.replaceChildren();
section.setAttribute("aria-labelledby", "new-game-title");
const head = element(section, "div", { className: "new-game-head" });
const closeLink = element(head, "a", { id: "new-game-close", className: "icon-button" });
closeLink.href = "#/";
closeLink.setAttribute("aria-label", MATCH_TEXTS.close);
closeLink.append(icon("close"));
element(head, "h2", { id: "new-game-title", text: MATCH_TEXTS.title });
// Deux colonnes, l'une sous l'autre en portrait et côte à côte en
// paysage : le type de partie à gauche, le niveau et le conseil à droite.
const body = element(section, "div", { className: "new-game-body" });
const kindColumn = element(body, "div", { className: "new-game-column" });
const levelColumn = element(body, "div", { className: "new-game-column" });
// 1. Type de partie.
const kinds = element(kindColumn, "div", { className: "segmented" });
kinds.setAttribute("role", "radiogroup");
kinds.setAttribute("aria-label", MATCH_TEXTS.kindLabel);
const free = radio(kinds, "new-game-free", "segment", MATCH_TEXTS.free);
const match = radio(kinds, "new-game-match", "segment", MATCH_TEXTS.match);
free.addEventListener("click", () => options.change({ gameKind: "free" }));
match.addEventListener("click", () => options.change({ gameKind: "match" }));
const matchOptions = element(kindColumn, "div", {
id: "new-game-match-options",
className: "new-game-options",
});
const lengths = element(matchOptions, "div", { className: "lengths" });
lengths.setAttribute("role", "radiogroup");
element(lengths, "span", { text: MATCH_TEXTS.lengthBefore });
const lengthButtons = MATCH_LENGTHS.map((n) => {
const button = radio(lengths, `new-game-length-${n}`, "length", String(n));
button.setAttribute("aria-label", MATCH_TEXTS.lengthLabel(n));
button.addEventListener("click", () => options.change({ matchLength: n }));
return button;
});
element(lengths, "span", { text: MATCH_TEXTS.lengthAfter });
rule(
matchOptions,
"new-game-crawford",
MATCH_TEXTS.crawfordRule,
MATCH_TEXTS.crawfordLink,
"regle-de-crawford",
);
const freeOptions = element(kindColumn, "div", {
id: "new-game-free-options",
className: "new-game-options",
});
rule(
freeOptions,
"new-game-jacoby",
MATCH_TEXTS.jacobyRule,
MATCH_TEXTS.jacobyLink,
"regle-de-jacoby",
);
// 2. Niveau de l'ordinateur : six barres de hauteur croissante.
element(levelColumn, "h3", { className: "overline", text: MATCH_TEXTS.levelTitle });
const bars = element(levelColumn, "div", { className: "level-bars" });
bars.setAttribute("role", "radiogroup");
bars.setAttribute("aria-label", MATCH_TEXTS.levelTitle);
const levelButtons = COMPUTER_LEVELS.map((level, i) => {
const button = radio(bars, `new-game-level-${level}`, `level level-${i + 1}`);
button.setAttribute("aria-label", MATCH_TEXTS.levels[level].name);
button.dataset.level = level;
element(button, "span", { className: "level-bar" });
button.addEventListener("click", () => options.change({ level }));
return button;
});
const levelName = element(levelColumn, "p", {
id: "new-game-level-name",
className: "level-name",
});
const levelText = element(levelColumn, "p", {
id: "new-game-level-text",
className: "level-text",
});
// 3. Pendant la partie.
element(levelColumn, "h3", {
className: "overline during-game",
text: MATCH_TEXTS.duringGame,
});
const advice = element(levelColumn, "button", { id: "new-game-advice", className: "item" });
advice.type = "button";
advice.setAttribute("role", "switch");
const adviceLabel = element(advice, "span", { className: "item-label" });
element(adviceLabel, "span", { text: MATCH_TEXTS.advice });
element(adviceLabel, "small", { text: MATCH_TEXTS.adviceDetail });
element(advice, "span", { className: "switch" }).setAttribute("aria-hidden", "true");
advice.addEventListener("click", () =>
options.change({ masterAdvice: !options.settings().masterAdvice }),
);
// 4. Barre d'action collée en bas.
const actions = element(section, "div", { className: "new-game-actions" });
const start = element(actions, "button", { id: "new-game-start" });
start.type = "button";
start.addEventListener("click", () => options.start());
element(actions, "p", { className: "new-game-kept", text: MATCH_TEXTS.kept });
return {
closeLink,
render(busy) {
const s = options.settings();
const isMatch = s.gameKind === "match";
free.setAttribute("aria-checked", String(!isMatch));
match.setAttribute("aria-checked", String(isMatch));
matchOptions.hidden = !isMatch;
freeOptions.hidden = isMatch;
for (const [i, button] of lengthButtons.entries()) {
button.setAttribute("aria-checked", String(MATCH_LENGTHS[i] === s.matchLength));
}
const chosen = COMPUTER_LEVELS.indexOf(s.level);
for (const [i, button] of levelButtons.entries()) {
button.setAttribute("aria-checked", String(i === chosen));
button.classList.toggle("reached", i <= chosen);
}
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
advice.setAttribute("aria-checked", String(s.masterAdvice));
start.textContent = isMatch
? MATCH_TEXTS.startMatch(s.matchLength)
: MATCH_TEXTS.startGame;
start.disabled = busy;
},
focus() {
(section.querySelector<HTMLElement>("[aria-checked='true']") ?? start).focus();
},
};
}
+231
View File
@@ -0,0 +1,231 @@
// Les adresses des écrans (ADR 0013, amendement du 2026-11-03) : chaque
// écran a la sienne, dans le fragment de l'adresse (après #). Ce module
// traduit un fragment en écran et un écran en fragment ; fonctions pures,
// testées sous Node (app/tests/router.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un écran de l'application, tel que le désigne son adresse. */
export type Route =
/** #/ : la partie en cours. */
| { readonly screen: "game" }
/** #/accueil. */
| { readonly screen: "home" }
/** #/aide, et une question ouverte : #/aide/<identifiant>. */
| { readonly screen: "help"; readonly question: string | null }
/** #/glossaire, et un terme : #/glossaire/<fiche sans .md>. */
| { readonly screen: "glossary"; readonly term: string | null }
/** #/a-propos. */
| { readonly screen: "about" }
/** #/tutoriel, une étape : #/tutoriel/<n>, et son écran « à vous » :
* #/tutoriel/<n>/a-vous. */
| { readonly screen: "tutorial"; readonly step: number | null; readonly practice: boolean }
/** #/tutoriel/fin : la fin du tutoriel. */
| { readonly screen: "tutorialEnd" }
/** #/installer : la marche à suivre pour installer l'application. */
| { readonly screen: "install" }
/** #/parties, et une partie : #/parties/<identifiant>. */
| { readonly screen: "games"; readonly game: string | null }
/** #/menu. */
| { readonly screen: "menu" }
/** #/nouvelle-partie : le choix de la partie (type, niveau, conseil). */
| { readonly screen: "newGame" }
/** #/explication/<événement> : la feuille d'explication du maître sur
* une décision de la partie en cours (étape 1d). */
| { readonly screen: "explanation"; readonly event: string }
/** #/position/<Position ID>/<Match ID> (Match ID facultatif). */
| { readonly screen: "position"; readonly position: string; readonly match: string | null }
/** Une adresse que l'application ne connaît pas. */
| { readonly screen: "unknown"; readonly hash: string };
export type Screen = Route["screen"];
export const GAME_ROUTE: Route = { screen: "game" };
/** Longueur d'un Position ID de gnubg. */
const POSITION_ID_LENGTH = 14;
/** Un identifiant d'écran (question, terme, partie) : lettres minuscules,
* chiffres et tirets. */
const NAME = /^[a-z0-9][a-z0-9-]*$/;
/** L'écran que désigne un fragment (avec ou sans # au début). */
export function parseRoute(hash: string): Route {
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
if (fragment === "" || fragment === "/") {
return GAME_ROUTE;
}
const unknown: Route = { screen: "unknown", hash: `#${fragment}` };
if (!fragment.startsWith("/")) {
return unknown;
}
const [head = "", ...rest] = fragment.slice(1).split("/");
// Une barre finale (#/aide/) ne change rien.
if (rest.at(-1) === "") {
rest.pop();
}
if (head === "position") {
return positionRoute(rest.join("/")) ?? unknown;
}
if (head === "tutoriel") {
return tutorialRoute(rest) ?? unknown;
}
if (rest.length > 1) {
return unknown;
}
const name = rest[0] ?? null;
if (name !== null && !NAME.test(name)) {
return unknown;
}
switch (head) {
case "":
return name === null ? GAME_ROUTE : unknown;
case "accueil":
return name === null ? { screen: "home" } : unknown;
case "aide":
return { screen: "help", question: name };
case "glossaire":
return { screen: "glossary", term: name };
case "a-propos":
return name === null ? { screen: "about" } : unknown;
case "menu":
return name === null ? { screen: "menu" } : unknown;
case "nouvelle-partie":
return name === null ? { screen: "newGame" } : unknown;
case "explication":
return name === null ? unknown : { screen: "explanation", event: name };
case "parties":
return { screen: "games", game: name };
case "installer":
return name === null ? { screen: "install" } : unknown;
default:
return unknown;
}
}
/** #/tutoriel, #/tutoriel/<n>, #/tutoriel/<n>/a-vous, #/tutoriel/fin. */
function tutorialRoute(rest: readonly string[]): Route | null {
const [name, part, ...more] = rest;
if (name === undefined) {
return { screen: "tutorial", step: null, practice: false };
}
if (more.length > 0) {
return null;
}
if (name === "fin") {
return part === undefined ? { screen: "tutorialEnd" } : null;
}
if (!/^[1-9][0-9]?$/.test(name) || (part !== undefined && part !== "a-vous")) {
return null;
}
return { screen: "tutorial", step: Number(name), practice: part === "a-vous" };
}
/**
* #/position/... : le Position ID (14 caractères), puis, après une barre, le
* Match ID. Chaque identifiant est encodé avec encodeURIComponent, mais une
* adresse collée telle quelle (le base64 de gnubg contient + et /) est
* comprise aussi : le texte est décodé en entier, puis découpé à la
* longueur du Position ID. Leur validité est l'affaire du moteur.
*/
function positionRoute(text: string): Route | null {
let decoded: string;
try {
decoded = decodeURIComponent(text);
} catch {
return null;
}
if (decoded === "") {
return null;
}
if (decoded.length > POSITION_ID_LENGTH && decoded[POSITION_ID_LENGTH] === "/") {
const match = decoded.slice(POSITION_ID_LENGTH + 1);
return {
screen: "position",
position: decoded.slice(0, POSITION_ID_LENGTH),
match: match === "" ? null : match,
};
}
return { screen: "position", position: decoded, match: null };
}
/** Le fragment d'un écran, # compris. */
export function routeHash(route: Route): string {
switch (route.screen) {
case "game":
return "#/";
case "home":
return "#/accueil";
case "help":
return route.question === null ? "#/aide" : `#/aide/${route.question}`;
case "glossary":
return route.term === null ? "#/glossaire" : `#/glossaire/${route.term}`;
case "about":
return "#/a-propos";
case "tutorial":
return route.step === null
? "#/tutoriel"
: `#/tutoriel/${route.step}${route.practice ? "/a-vous" : ""}`;
case "tutorialEnd":
return "#/tutoriel/fin";
case "install":
return "#/installer";
case "games":
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
case "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;
}
+182
View File
@@ -0,0 +1,182 @@
// Les réglages de l'application (menu) et ce qui en dépend, sans navigateur
// (testé sous Node). Ils sont enregistrés à part de la partie, en JSON.
//
// 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";
export interface Settings {
/** Vibrations aux moments forts (activées par défaut). */
readonly vibrations: boolean;
/** Écran maintenu allumé pendant une partie (activé par défaut). */
readonly keepScreenOn: boolean;
readonly theme: Theme;
/** 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 {
let raw: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(text ?? "{}");
if (parsed !== null && typeof parsed === "object") {
raw = parsed as Record<string, unknown>;
}
} catch {
// Réglages illisibles : valeurs par défaut.
}
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";
return {
vibrations: bool("vibrations"),
keepScreenOn: bool("keepScreenOn"),
theme,
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";
}
/** La consigne doit-elle encore être longue ? (La partie en cours compte.) */
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
);
}
+141 -30
View File
@@ -22,9 +22,11 @@ function points(n: number): string {
return n > 1 ? `${n} points` : `${n} point`;
}
/** Une durée en secondes, avec une décimale : « 0,3 s ». */
function seconds(ms: number): string {
return `${(ms / 1000).toFixed(1).replace(".", ",")} s`;
/** Une consigne : sa forme longue (les trois premières parties) et sa forme
* courte (ensuite, et toujours en paysage, dans la bande du plateau). */
export interface PromptText {
readonly long: string;
readonly short: string;
}
export const TEXTS = {
@@ -36,62 +38,171 @@ export const TEXTS = {
double: "Doubler",
take: "Prendre",
drop: "Passer",
confirm: "Valider",
confirm: "Jouer ce coup",
cancel: "Annuler",
skip: "Continuer",
replay: "Revoir",
replayTitle: "Revoir le dernier coup de l'ordinateur",
undoTitle: "Annuler le demi-coup",
newGame: "Nouvelle partie",
reviewGame: "Revoir la partie",
help: "Aide",
menu: "Menu",
fullscreen: "Plein écran",
exitFullscreen: "Quitter le plein écran",
back: "Retour à la partie",
close: "Fermer",
/** Ce que fait un toucher sur les dés (nom accessible de la zone). */
diceActions: {
roll: "Lancer les dés",
confirm: "Jouer ce coup",
skip: "Passer votre tour",
},
moveHint: "Le coup fini, touchez les dés pour le jouer.",
// Menu
helpAndGlossary: "Aide et glossaire",
theme: "Thème",
themes: { auto: "Automatique", light: "Clair", dark: "Sombre" },
vibrations: "Vibrations",
keepScreenOn: "Écran allumé pendant la partie",
newGameStarted: "Nouvelle partie commencée.",
resumeOld: "Reprendre l'ancienne",
// Bandeaux des joueurs et compte de points
you: "Vous",
computer: "Ordinateur",
yourTurn: "à vous de jouer",
computerTurn: "joue",
lead: (n: number) => `mène de ${n}`,
pipCountButton: (who: string, n: number) => `Compte de points de ${who} : ${n}`,
pipCountFile: "compte-de-points.md",
seeGlossary: "Voir le glossaire",
// Chargement
loading: "Chargement du moteur…",
engineFailed: (message: string) => `Le moteur n'a pas pu se charger : ${message}`,
actionFailed: (message: string) => `Erreur : ${message}`,
confirmNewGame: "Abandonner la partie en cours et en commencer une nouvelle ?",
// Déroulement
// Déroulement (ligne d'information)
opening: (player: number, computer: number) =>
`Jet d'ouverture : vous ${player}, l'ordinateur ${computer}. ${
player > computer ? "Vous commencez." : "L'ordinateur commence."
}`,
yourRoll: "À vous : doublez ou lancez les dés.",
yourRollNoDouble: "À vous : lancez les dés.",
yourMove: (dice: readonly number[]) =>
`Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
pickTarget: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
confirmOrUndo:
"Touchez vos dés ou « Valider » pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
youCannotMove: (dice: readonly number[]) => `Aucun coup possible avec ${roll(dice)}.`,
pressContinue: "Touchez « Continuer » : le tour passe à l'ordinateur.",
thinking: "L'ordinateur réfléchit…",
computerMoved: (dice: readonly number[], move: string, ms: number) =>
`L'ordinateur a joué ${roll(dice)} : ${move} (${seconds(ms)}).`,
computerMoved: (dice: readonly number[], move: string) =>
`L'ordinateur a joué ${roll(dice)} : ${move}.`,
computerCannotMove: (dice: readonly number[]) =>
`L'ordinateur ne peut pas jouer ${roll(dice)}.`,
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
computerDoubles: "L'ordinateur vous propose de doubler : prenez ou passez.",
computerTakes: (value: number) =>
`L'ordinateur prend : la partie vaut maintenant ${points(value)}.`,
youTook: (value: number) =>
`Vous prenez : la partie vaut maintenant ${points(value)}, et le videau est chez vous.`,
confirmDouble: (value: number) => `Doubler à ${value} ? L'ordinateur pourra prendre ou passer.`,
// Consignes
prompts: {
loading: { long: "", short: "Chargement…" },
yourRoll: {
long: "À vous : touchez les dés pour les lancer, ou le videau pour doubler.",
short: "À vous : lancez les dés.",
},
yourRollNoDouble: {
long: "À vous : touchez les dés pour les lancer.",
short: "À vous : lancez les dés.",
},
yourMove: (dice: readonly number[]): PromptText => ({
long: `Jouez ${roll(dice)} : touchez un pion, puis sa case d'arrivée.`,
short: `Jouez ${roll(dice)}.`,
}),
pickTarget: {
long: "Touchez une case d'arrivée en surbrillance, ou un autre pion.",
short: "Choisissez la case.",
},
confirmOrUndo: {
long: "Touchez vos dés pour jouer ce coup, ou « Annuler » pour reprendre un pion.",
short: "Touchez les dés pour jouer.",
},
stuck: {
long: "Aucun coup possible : touchez vos dés pour passer votre tour.",
short: "Aucun coup : touchez les dés.",
},
thinking: { long: "L'ordinateur réfléchit", short: "L'ordinateur réfléchit" },
computerDoubles: {
long: "L'ordinateur vous propose de doubler : prenez ou passez.",
short: "L'ordinateur double.",
},
} satisfies Record<string, PromptText | ((dice: readonly number[]) => PromptText)>,
// Fin de partie
youWin: (kind: ResultKind, n: number) => `Vous gagnez ${RESULT_KIND[kind]} : ${points(n)}.`,
youLose: (kind: ResultKind, n: number) =>
`L'ordinateur gagne ${RESULT_KIND[kind]} : ${points(n)} pour lui.`,
computerDropped: (n: number) => `L'ordinateur passe : vous gagnez ${points(n)}.`,
youDropped: (n: number) => `Vous passez : l'ordinateur gagne ${points(n)}.`,
// Sous le plateau
pipCounts: (player: number, computer: number) =>
`Compte de points : vous ${player}, l'ordinateur ${computer}`,
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"
? `Vous gagnez ${RESULT_KIND[kind]}`
: `L'ordinateur gagne ${RESULT_KIND[kind]}`;
},
/** « 8 points pour lui, videau à 4. » */
endDetail: (winner: "player" | "computer", n: number, cube: number) =>
`${points(n)} pour ${winner === "player" ? "vous" : "lui"}${
cube > 1 ? `, videau à ${cube}` : ""
}.`,
// Aide
helpTitle: "Aide",
glossaryTitle: "Glossaire",
helpTabs: "Rubriques de l'aide",
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
glossaryTitle: "Les mots du jeu",
aboutTitle: "À propos",
helpLoading: "Chargement de l'aide…",
helpFailed: "L'aide n'a pas pu se charger.",
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
// Adresses (ADR 0013)
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
// Lien vers une position (ADR 0013)
copyPositionLink: "Copier le lien de la position",
linkCopied: "Lien de la position copié.",
linkNotCopied: (url: string) => `Copie impossible ; le lien : ${url}`,
positionTitle: "Position partagée",
unreadablePositionTitle: "Lien illisible",
unreadablePosition:
"Ce lien ne décrit pas une position que GNU Backgammon sait lire. Il a peut-être été coupé en le copiant.",
backToGame: "Retour à ma partie",
playFromHere: "Jouer à partir d'ici",
startedFromPosition: "Nouvelle partie à partir de la position du lien.",
/** Ce que montre la vue d'une position, sous le plateau. */
positionDetail: (
pips: Readonly<Record<"player" | "computer", number>>,
cube: { value: number; owner: "player" | "computer" | null },
match: { length: number; score: Readonly<Record<"player" | "computer", number>> } | null,
winner: "player" | "computer" | null,
) =>
[
winner === null
? "Vous avez la main : vos pions ivoire, en bas."
: `La partie est finie dans cette position : ${
winner === "player" ? "vous gagnez" : "l'ordinateur gagne"
}.`,
`Compte de points : vous ${pips.player}, l'ordinateur ${pips.computer}.`,
cube.value > 1 || cube.owner !== null
? `Videau à ${cube.value}, ${
cube.owner === null
? "au centre"
: cube.owner === "player"
? "chez vous"
: "chez l'ordinateur"
}.`
: "",
match === null
? ""
: `Match en ${points(match.length)}, ${match.score.player} à ${match.score.computer} : vous le jouerez en partie libre.`,
]
.filter((part) => part !== "")
.join(" "),
} as const;
+96
View File
@@ -0,0 +1,96 @@
// Le parcours du tutoriel : l'écran d'avant, l'écran d'après, et la barre
// de progression (charte : « les segments sont des liens vers les étapes
// déjà vues, pas vers les suivantes »). Fonctions pures, testées sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Route } from "./router.js";
import { TUTORIAL_DONE } from "./settings.js";
import type { Tutorial } from "./tutorial-text.js";
/** Un écran du tutoriel : une étape (explication ou « à vous »), ou la fin. */
export type TutorialPlace = { readonly step: number; readonly practice: boolean } | "end";
/** L'écran que désigne une adresse, ramené à un écran qui existe : une
* étape hors du tutoriel mène à la première, un « à vous » absent à
* l'explication de son étape. */
export function placeOf(tutorial: Tutorial, route: Route): TutorialPlace {
if (route.screen === "tutorialEnd") {
return "end";
}
if (route.screen !== "tutorial" || route.step === null) {
return { step: 1, practice: false };
}
const step = tutorial.steps[route.step - 1];
if (step === undefined) {
return { step: 1, practice: false };
}
return { step: step.number, practice: route.practice && step.practice !== null };
}
/** L'adresse d'un écran du tutoriel. */
export function placeRoute(place: TutorialPlace): Route {
return place === "end"
? { screen: "tutorialEnd" }
: { screen: "tutorial", step: place.step, practice: place.practice };
}
/** L'écran suivant (la fin après la dernière étape). */
export function nextPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace {
if (place === "end") {
return "end";
}
const step = tutorial.steps[place.step - 1];
if (!place.practice && step?.practice != null) {
return { step: place.step, practice: true };
}
return place.step >= tutorial.steps.length ? "end" : { step: place.step + 1, practice: false };
}
/** L'écran précédent (null avant la première étape). */
export function previousPlace(tutorial: Tutorial, place: TutorialPlace): TutorialPlace | null {
if (place === "end") {
const last = tutorial.steps.at(-1);
return last === undefined ? null : { step: last.number, practice: last.practice !== null };
}
if (place.practice) {
return { step: place.step, practice: false };
}
const before = tutorial.steps[place.step - 2];
return before === undefined
? null
: { step: before.number, practice: before.practice !== null };
}
/** Ce que retient la progression quand on arrive sur un écran : la
* dernière étape vue (TUTORIAL_DONE à la fin). */
export function seenAfter(seen: number, place: TutorialPlace): number {
return place === "end" ? TUTORIAL_DONE : Math.max(seen, place.step);
}
/** Un segment de la barre de progression. */
export interface ProgressSegment {
readonly step: number;
/** Étape faite ou en cours : le segment est en brique. */
readonly done: boolean;
/** Étape déjà vue : le segment est un lien vers elle. */
readonly link: boolean;
readonly current: boolean;
}
/** La barre de progression, sur l'écran place, quand les étapes jusqu'à
* seen ont déjà été vues. */
export function progress(total: number, place: TutorialPlace, seen: number): ProgressSegment[] {
const current = place === "end" ? total + 1 : place.step;
const reached = Math.max(seen, current);
return Array.from({ length: total }, (_, i) => {
const step = i + 1;
return {
step,
done: step <= current,
link: step <= reached && step !== current,
current: step === current,
};
});
}
+204
View File
@@ -0,0 +1,204 @@
// Lecture du texte du tutoriel (docs/tutoriel/regles.md, format décrit dans
// docs/tutoriel/README.md), copié tel quel dans le site par make site
// (ADR 0008 : une seule source). Fonction pure, testée sous Node.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
/** Un morceau de texte : simple, en gras, ou lien vers une fiche du
* glossaire (nom de fichier sans .md). */
export interface Inline {
readonly text: string;
readonly strong?: boolean;
readonly term?: string;
}
export type Paragraph = readonly Inline[];
/** Un écran du tutoriel : explication, ou « à vous ». */
export interface TutorialScreen {
/** Identifiant de position de gnubg, vu du camp au trait. */
readonly position: string;
readonly onRoll: "player" | "computer";
/** Le jet du coup illustré (explication) ou à jouer (« à vous »). */
readonly roll: readonly [number, number] | null;
/** Explication : le coup illustré, en notation de gnubg. */
readonly move: string | null;
/** Explication : le sens du jeu dessiné en pointillés. */
readonly path: boolean;
/** « À vous » : les coups justes, en notation de gnubg. */
readonly expected: readonly string[];
/** « À vous » de videau : la bonne réponse au double. */
readonly answer: "take" | "pass" | null;
readonly right: Paragraph;
readonly wrong: Paragraph;
readonly paragraphs: readonly Paragraph[];
}
export interface TutorialStep {
readonly number: number;
readonly title: string;
readonly explain: TutorialScreen;
readonly practice: TutorialScreen | null;
}
export interface Tutorial {
readonly steps: readonly TutorialStep[];
readonly end: { readonly action: string; readonly paragraphs: readonly Paragraph[] };
}
/** Lit les liens [mot](fiche.md) et le **gras** d'une ligne de texte. */
export function parseInline(text: string): Paragraph {
const parts: Inline[] = [];
const pattern = /\[([^\]]+)\]\(([a-z0-9-]+)\.md\)|\*\*([^*]+)\*\*/g;
let last = 0;
for (const match of text.matchAll(pattern)) {
const index = match.index ?? 0;
if (index > last) {
parts.push({ text: text.slice(last, index) });
}
if (match[1] !== undefined && match[2] !== undefined) {
parts.push({ text: match[1], term: match[2] });
} else {
parts.push({ text: match[3] ?? "", strong: true });
}
last = index + match[0].length;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
interface RawScreen {
keys: Map<string, string[]>;
paragraphs: string[];
}
function emptyScreen(): RawScreen {
return { keys: new Map(), paragraphs: [] };
}
function rollOf(text: string | undefined, where: string): readonly [number, number] | null {
if (text === undefined) {
return null;
}
const match = /^([1-6])-([1-6])$/.exec(text);
if (match === null) {
throw new Error(`${where} : jet illisible « ${text} »`);
}
return [Number(match[1]), Number(match[2])];
}
function screenOf(raw: RawScreen, where: string): TutorialScreen {
const one = (key: string) => raw.keys.get(key)?.[0];
const position = one("position");
if (position === undefined) {
throw new Error(`${where} : position absente`);
}
const trait = one("trait");
if (trait !== undefined && trait !== "ordinateur" && trait !== "joueur") {
throw new Error(`${where} : trait inconnu « ${trait} »`);
}
const answer = one("réponse");
if (answer !== undefined && answer !== "prendre" && answer !== "passer") {
throw new Error(`${where} : réponse inconnue « ${answer} »`);
}
return {
position,
onRoll: trait === "ordinateur" ? "computer" : "player",
roll: rollOf(one("jet"), where),
move: one("coup") ?? null,
path: one("chemin") === "oui",
expected: raw.keys.get("attendu") ?? [],
answer: answer === undefined ? null : answer === "prendre" ? "take" : "pass",
right: parseInline(one("juste") ?? ""),
wrong: parseInline(one("refus") ?? ""),
paragraphs: raw.paragraphs.map(parseInline),
};
}
/** Lit le texte du tutoriel. Erreur si un écran est incomplet ou si les
* étapes ne se suivent pas. */
export function parseTutorial(text: string): Tutorial {
const steps: {
number: number;
title: string;
explain: RawScreen;
practice: RawScreen | null;
}[] = [];
let end: RawScreen | null = null;
let current: RawScreen | null = null;
let paragraph: string[] = [];
const endParagraph = () => {
if (paragraph.length > 0 && current !== null) {
current.paragraphs.push(paragraph.join(" "));
}
paragraph = [];
};
for (const raw of text.split("\n")) {
const line = raw.trim();
const step = /^## (\d+)\. (.+)$/.exec(line);
const key = /^- ([a-zéè]+): (.+)$/.exec(line);
if (line === "") {
endParagraph();
} else if (step !== null) {
endParagraph();
current = emptyScreen();
steps.push({
number: Number(step[1]),
title: step[2] ?? "",
explain: current,
practice: null,
});
} else if (line === "## Fin") {
endParagraph();
current = emptyScreen();
end = current;
} else if (line === "### À vous") {
endParagraph();
const last = steps.at(-1);
if (last === undefined) {
throw new Error("« À vous » hors d'une étape");
}
current = emptyScreen();
last.practice = current;
} else if (current === null) {
// Notice avant la première étape : non affichée.
} else if (key !== null && paragraph.length === 0 && current.paragraphs.length === 0) {
const values = current.keys.get(key[1] ?? "") ?? [];
values.push((key[2] ?? "").trim());
current.keys.set(key[1] ?? "", values);
} else {
paragraph.push(line);
}
}
endParagraph();
steps.forEach((step, i) => {
if (step.number !== i + 1) {
throw new Error(`étape ${step.number} à la place de l'étape ${i + 1}`);
}
});
if (end === null) {
throw new Error("écran de fin absent");
}
const finish: RawScreen = end;
return {
steps: steps.map((step) => ({
number: step.number,
title: step.title,
explain: screenOf(step.explain, `étape ${step.number}`),
practice:
step.practice === null
? null
: screenOf(step.practice, `étape ${step.number}, à vous`),
})),
end: {
action: finish.keys.get("action")?.[0] ?? "",
paragraphs: finish.paragraphs.map(parseInline),
},
};
}
+734
View File
@@ -0,0 +1,734 @@
// L'écran du tutoriel (#/tutoriel/<n>, #/tutoriel/<n>/a-vous,
// #/tutoriel/fin) : la barre de progression, le titre, le plateau, le texte,
// et les écrans « à vous » où le joueur fait le geste sur le vrai plateau
// (docs/identite/charte/accueil-tutoriel-aide.md, « Tutoriel des règles »).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Le texte vient de docs/tutoriel/regles.md (copie du site) ; les plateaux,
// les coups illustrés et le jugement des gestes viennent du domaine, branché
// sur le moteur (TutorialAccess, assemblé dans main.ts). Aucune règle n'est
// écrite ici.
import type { MoveSteps } from "../domain/game/game.js";
import type { HalfMove } from "../domain/game/position.js";
import type { Roll } from "../domain/game/roll.js";
import type { Side } from "../domain/game/side.js";
import type { PracticeVerdict, TutorialBoard } from "../domain/learning/tutorial.js";
import {
boardModel,
moveTargets,
moveTrace,
onlyHalfMove,
type Place,
placeOf as placeOfChecker,
} from "./board-model.js";
import {
type BoardDecor,
checkerSpot,
NO_MARKS,
renderBoard,
type ShownDie,
slideChecker,
} from "./board-view.js";
import { boardGeometry, columnLeft, type Geometry, PORTRAIT_ASPECT } from "./geometry.js";
import { HOME_TEXTS } from "./home-texts.js";
import { icon } from "./icons.js";
import { type Route, routeHash } from "./router.js";
import {
nextPlace,
placeOf,
placeRoute,
previousPlace,
progress,
type TutorialPlace,
} from "./tutorial-nav.js";
import {
type Paragraph,
parseTutorial,
type Tutorial,
type TutorialScreen,
} from "./tutorial-text.js";
/** Le coup illustré se joue tout seul après une seconde. */
const AUTOPLAY_MS = 1000;
const SLIDE_MS = 160;
const PAUSE_MS = 120;
/** Un pion refusé reste un instant à sa place avant de revenir. */
const REFUSED_MS = 450;
const SOURCE = "docs/tutoriel/regles.md";
/** Un écran « à vous » où l'on joue un coup (MovePractice du domaine). */
export interface PracticeSession {
board(): Promise<{
player: TutorialBoard["sides"]["player"];
computer: TutorialBoard["sides"]["computer"];
}>;
nextHalfMoves(): Promise<HalfMove[]>;
play(halfMove: HalfMove): Promise<PracticeVerdict>;
reset(): void;
readonly diceToPlay: readonly { value: number; used: boolean }[];
}
/** Ce que le tutoriel demande au domaine et au moteur. */
export interface TutorialAccess {
board(position: string, onRoll: Side): Promise<TutorialBoard>;
illustrate(
position: string,
roll: Roll,
move: string,
): Promise<{ steps: MoveSteps; after: TutorialBoard }>;
practice(position: string, roll: Roll, expected: readonly string[]): Promise<PracticeSession>;
shouldTake(position: string): Promise<boolean>;
}
export interface TutorialViewOptions {
readonly section: HTMLElement;
readonly access: TutorialAccess;
readonly engineReady: Promise<unknown>;
/** La dernière étape vue, et son enregistrement à l'arrivée sur un
* écran. */
readonly seen: () => number;
readonly visit: (place: TutorialPlace) => void;
/** Un geste juste : la vibration « pion posé ». */
readonly placed: () => void;
readonly reducedMotion: MediaQueryList;
/** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */
readonly landscape: () => boolean;
/** L'adresse a désigné un écran qui n'existe pas : la remplacer. */
readonly replace: (hash: string) => void;
}
export interface TutorialView {
show(route: Route): Promise<void>;
/** La taille de l'écran a changé : redessiner le plateau. */
relayout(): void;
/** L'écran est quitté : arrêter le coup illustré. */
hide(): void;
}
function element<K extends keyof HTMLElementTagNameMap>(
parent: HTMLElement,
tag: K,
options: { id?: string; className?: string; text?: string } = {},
): HTMLElementTagNameMap[K] {
const child = document.createElement(tag);
if (options.id !== undefined) {
child.id = options.id;
}
if (options.className !== undefined) {
child.className = options.className;
}
if (options.text !== undefined) {
child.textContent = options.text;
}
parent.append(child);
return child;
}
/** Un paragraphe du tutoriel : texte, gras, liens vers le glossaire. */
export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void {
target.replaceChildren();
for (const part of paragraph) {
if (part.term !== undefined) {
const link = document.createElement("a");
link.href = routeHash({ screen: "glossary", term: part.term });
link.textContent = part.text;
target.append(link);
} else if (part.strong === true) {
const strong = document.createElement("strong");
strong.textContent = part.text;
target.append(strong);
} else {
target.append(part.text);
}
}
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** Le sens du jeu des pions du joueur, en pointillés : du haut à droite
* (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie.
* Tracé sous les pions et sous les numéros des flèches, qu'il ne cache
* jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré
* ivoire fait 8 px, les points 4 px, quelle que soit la taille). */
function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void {
const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2;
const top = g.height * 0.3;
const bottom = g.height * 0.7;
const right = center(11);
const left = center(0) - g.pointWidth * 0.25;
const turn = g.pointWidth * 0.6;
const end = g.trayLeft + g.tray * 0.25;
const d =
`M ${right} ${top} L ${left + turn} ${top} Q ${left} ${top} ${left} ${top + turn} ` +
`L ${left} ${bottom - turn} Q ${left} ${bottom} ${left + turn} ${bottom} L ${end} ${bottom}`;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "tutorial-path");
group.setAttribute("role", "img");
group.setAttribute("aria-label", HOME_TEXTS.pathLabel);
const px = (n: number) => n / pxPerUnit;
const width = px(4);
for (const [className, stroke] of [
["path-edge", px(8)],
["path-dots", width],
] as const) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", className);
path.setAttribute("d", d);
path.setAttribute("stroke-width", String(stroke));
if (className === "path-dots") {
path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`);
}
group.append(path);
}
const head = document.createElementNS(SVG_NS, "path");
head.setAttribute("class", "path-head");
const size = px(7);
head.setAttribute(
"d",
`M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`,
);
head.setAttribute("stroke-width", String(px(3)));
group.append(head);
// Sous les pions, et sous les numéros des flèches, remontés au-dessus
// du tracé ; les zones à toucher restent devant tout.
const pieces = svg.querySelector(":scope > g.pieces");
svg.insertBefore(group, pieces);
for (const number of svg.querySelectorAll(":scope > .point-number")) {
svg.insertBefore(number, pieces);
}
}
/** Crée l'écran du tutoriel dans section (vide dans index.html). */
export function createTutorialView(options: TutorialViewOptions): TutorialView {
const { section, access } = options;
section.replaceChildren();
const head = element(section, "header", { className: "tutorial-head" });
const bar = element(head, "nav", { id: "tutorial-progress", className: "tutorial-progress" });
bar.setAttribute("aria-label", HOME_TEXTS.tutorialProgress);
const skip = element(head, "a", { id: "tutorial-skip", className: "tutorial-skip" });
skip.href = "#/";
skip.textContent = HOME_TEXTS.tutorialSkip;
skip.setAttribute("aria-label", HOME_TEXTS.tutorialSkipLabel);
const overline = element(section, "p", { id: "tutorial-overline", className: "overline" });
const title = element(section, "h2", { id: "tutorial-title", className: "tutorial-title" });
const boardBox = element(section, "div", { className: "tutorial-board-box" });
const svg = document.createElementNS(SVG_NS, "svg");
svg.id = "tutorial-board";
svg.setAttribute("class", "board tutorial-board");
svg.setAttribute("role", "img");
boardBox.append(svg);
const replay = element(boardBox, "button", { id: "tutorial-replay", className: "icon-button" });
replay.type = "button";
replay.append(icon("replay"));
replay.setAttribute("aria-label", HOME_TEXTS.replayLabel);
replay.title = HOME_TEXTS.replayLabel;
// Le texte, la consigne « à vous » et la réponse : en paysage, la seule
// partie qui défile si l'écran est trop bas (app.css).
const body = element(section, "div", { className: "tutorial-body" });
const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" });
const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" });
const feedback = element(body, "p", {
id: "tutorial-feedback",
className: "tutorial-feedback",
});
feedback.setAttribute("role", "status");
const cube = element(section, "div", { id: "tutorial-cube", className: "tutorial-cube" });
const drop = element(cube, "button", {
id: "tutorial-drop",
className: "secondary",
text: HOME_TEXTS.drop,
});
drop.type = "button";
const take = element(cube, "button", {
id: "tutorial-take",
className: "secondary",
text: HOME_TEXTS.take,
});
take.type = "button";
const nav = element(section, "div", { className: "tutorial-nav" });
const back = element(nav, "a", {
id: "tutorial-back",
className: "button secondary",
text: HOME_TEXTS.back,
});
const next = element(nav, "a", {
id: "tutorial-next",
className: "button",
text: HOME_TEXTS.next,
});
let tutorial: Promise<Tutorial> | null = null;
// Chaque affichage a son numéro : un affichage dépassé ne touche plus
// à l'écran (le joueur est déjà ailleurs).
let shown = 0;
let autoplay: ReturnType<typeof setTimeout> | null = null;
// Un coup illustré se joue, ou un geste est jugé : les touchers
// attendent (data-busy, pour les tests dans un navigateur).
let busy = false;
const setBusy = (value: boolean) => {
busy = value;
section.toggleAttribute("data-busy", value);
};
// L'écran affiché.
let screen: TutorialScreen | null = null;
let place: TutorialPlace | null = null;
let boards: TutorialBoard | null = null;
let illustration: { steps: MoveSteps; after: TutorialBoard } | null = null;
let illustrated = false;
let practice: PracticeSession | null = null;
let targets = new Map<Place, Map<Place, HalfMove>>();
let selected: Place | null = null;
let solved = false;
const load = () => {
tutorial ??= fetch(SOURCE)
.then((response) => {
if (!response.ok) {
throw new Error(`${SOURCE} : ${response.status}`);
}
return response.text();
})
.then(parseTutorial)
.catch((error: unknown) => {
tutorial = null;
throw error;
});
return tutorial;
};
/** En paysage, le plateau occupe toute la hauteur, à gauche de la
* colonne du texte (app.css), avec la géométrie du jeu en paysage. */
function geometry(): Geometry {
const box = svg.getBoundingClientRect();
if (options.landscape()) {
const width = box.width || window.innerWidth;
const height = box.height || window.innerHeight;
return boardGeometry("landscape", width / height, height / 520);
}
const width = box.width || Math.min(window.innerWidth, 640);
return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640);
}
function shownDice(): ShownDie[] {
if (practice !== null) {
return practice.diceToPlay.map((die) => ({
value: die.value,
side: "player",
blocked: false,
used: die.used,
}));
}
const roll = screen?.move != null ? screen.roll : null;
if (roll === null || roll === undefined) {
return [];
}
const [a, b] = roll;
return [a, b].map((value) => ({
value,
side: "player" as const,
blocked: false,
used: false,
}));
}
/** Redessine le plateau dans son état courant. */
function draw(sides: TutorialBoard["sides"] | null = null): void {
const current = sides ?? (illustrated ? illustration?.after.sides : null) ?? boards?.sides;
if (current === undefined || current === null) {
return;
}
const g = geometry();
const decor: BoardDecor = {
...NO_MARKS,
dice: shownDice(),
cube: { value: 1, owner: null },
band: null,
controls: null,
movable: practice !== null && !solved && !busy ? new Set(targets.keys()) : new Set(),
selected,
targets: new Set(selected === null ? [] : (targets.get(selected)?.keys() ?? [])),
trace:
illustrated && illustration !== null
? moveTrace("player", illustration.steps.halfMoves, illustration.steps.hits)
: null,
};
renderBoard(svg, g, boardModel(current), decor);
if (screen?.path === true) {
drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width);
}
}
function clearTimer(): void {
if (autoplay !== null) {
clearTimeout(autoplay);
autoplay = null;
}
}
/** Le coup illustré, pion par pion (160 ms par pion, 120 ms entre deux),
* puis ses traces ; sans mouvement si le système le demande. */
async function playIllustration(id: number): Promise<void> {
const shownMove = illustration;
if (shownMove === null || id !== shown) {
return;
}
setBusy(true);
replay.disabled = true;
illustrated = false;
try {
if (!options.reducedMotion.matches) {
const { steps } = shownMove;
for (const [i, halfMove] of steps.halfMoves.entries()) {
if (id !== shown) {
return;
}
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const from = placeOfChecker("player", halfMove.from);
const to = placeOfChecker("player", halfMove.to);
draw(steps.boards[i] ?? null);
const start = checkerSpot(svg, from);
const victim = steps.hits[i] === true ? checkerSpot(svg, to) : null;
draw(steps.boards[i + 1] ?? null);
await Promise.all([
start && slideChecker(svg, to, start, SLIDE_MS),
victim && slideChecker(svg, "bar-computer", victim, SLIDE_MS),
]);
}
}
} finally {
if (id === shown) {
setBusy(false);
illustrated = true;
replay.disabled = false;
draw();
const result = shownMove.after.result;
feedback.textContent =
result === null ? "" : HOME_TEXTS.illustratedEnd(result.kind);
feedback.dataset.verdict = result === null ? "" : "info";
}
}
}
async function refreshTargets(): Promise<void> {
targets =
practice === null || solved ? new Map() : moveTargets(await practice.nextHalfMoves());
if (selected !== null && !targets.has(selected)) {
selected = null;
}
}
function setVerdict(verdict: "right" | "wrong", words: Paragraph): void {
feedback.dataset.verdict = verdict;
fillParagraph(feedback, words);
if (verdict === "right") {
feedback.prepend(icon("verdictGood"));
}
}
function markSolved(): void {
solved = true;
section.toggleAttribute("data-solved", true);
next.classList.remove("secondary");
take.disabled = true;
drop.disabled = true;
options.placed();
}
async function playHalfMove(halfMove: HalfMove): Promise<void> {
const session = practice;
const current = screen;
if (session === null || current === null || busy || solved) {
return;
}
const id = shown;
setBusy(true);
selected = null;
try {
const verdict = await session.play(halfMove);
if (id !== shown) {
return;
}
draw(await session.board());
if (verdict === "right") {
markSolved();
setVerdict("right", current.right);
} else if (verdict === "wrong") {
// Le pion revient, et une phrase dit pourquoi.
await new Promise((resolve) => setTimeout(resolve, REFUSED_MS));
if (id !== shown) {
return;
}
session.reset();
setVerdict("wrong", current.wrong);
} else {
feedback.textContent = "";
feedback.dataset.verdict = "";
}
await refreshTargets();
} finally {
if (id === shown) {
setBusy(false);
draw(practice === null ? null : await practice.board());
}
}
}
svg.addEventListener("click", (event) => {
if (practice === null || busy || solved) {
return;
}
const target = event.target as Element | null;
const placeName = target
?.closest("[data-place]")
?.getAttribute("data-place") as Place | null;
if (placeName == null) {
return;
}
const halfMove = selected === null ? undefined : targets.get(selected)?.get(placeName);
if (halfMove !== undefined) {
void playHalfMove(halfMove);
return;
}
const only = placeName === selected ? null : onlyHalfMove(targets, placeName);
if (only !== null) {
void playHalfMove(only);
return;
}
selected = targets.has(placeName) && placeName !== selected ? placeName : null;
void practice.board().then((sides) => draw(sides));
});
replay.addEventListener("click", () => {
clearTimer();
void playIllustration(shown);
});
async function answer(takes: boolean): Promise<void> {
const current = screen;
if (current === null || solved || busy) {
return;
}
const id = shown;
setBusy(true);
try {
const right = (await access.shouldTake(current.position)) === takes;
if (id !== shown) {
return;
}
if (right) {
markSolved();
}
setVerdict(right ? "right" : "wrong", right ? current.right : current.wrong);
} finally {
setBusy(false);
}
}
take.addEventListener("click", () => void answer(true));
drop.addEventListener("click", () => void answer(false));
function renderProgress(total: number, at: TutorialPlace, steps: Tutorial["steps"]): void {
bar.replaceChildren();
for (const segment of progress(total, at, options.seen())) {
const stepTitle = steps[segment.step - 1]?.title ?? "";
const label = HOME_TEXTS.tutorialStepLink(segment.step, stepTitle);
const item = segment.link
? document.createElement("a")
: document.createElement("span");
item.className = `segment${segment.done ? " done" : ""}`;
if (item instanceof HTMLAnchorElement) {
item.href = routeHash({ screen: "tutorial", step: segment.step, practice: false });
item.setAttribute("aria-label", label);
} else {
item.setAttribute("aria-hidden", "true");
}
if (segment.current) {
item.setAttribute("aria-current", "step");
}
bar.append(item);
}
bar.setAttribute(
"aria-description",
at === "end"
? HOME_TEXTS.tutorialEndOverline
: HOME_TEXTS.tutorialOverline(at.step, total, at.practice),
);
}
return {
async show(route) {
const id = ++shown;
// Prêt (pour les tests dans un navigateur) une fois ce plateau dessiné.
delete section.dataset.ready;
clearTimer();
setBusy(false);
practice = null;
illustration = null;
illustrated = false;
screen = null;
boards = null;
selected = null;
targets = new Map();
solved = false;
section.toggleAttribute("data-solved", false);
feedback.textContent = "";
feedback.dataset.verdict = "";
task.hidden = true;
cube.hidden = true;
replay.hidden = true;
take.disabled = false;
drop.disabled = false;
let loaded: Tutorial;
try {
if (tutorial === null) {
title.textContent = HOME_TEXTS.tutorialLoading;
text.replaceChildren();
svg.replaceChildren();
}
loaded = await load();
} catch {
if (id === shown) {
title.textContent = HOME_TEXTS.tutorialFailed;
}
return;
}
if (id !== shown) {
return;
}
const at = placeOf(loaded, route);
const wanted = routeHash(placeRoute(at));
if (routeHash(route) !== wanted) {
options.replace(wanted);
return;
}
place = at;
options.visit(at);
const total = loaded.steps.length;
renderProgress(total, at, loaded.steps);
const before = previousPlace(loaded, at);
back.href = before === null ? "#/accueil" : routeHash(placeRoute(before));
if (at === "end") {
overline.textContent = HOME_TEXTS.tutorialEndOverline;
title.textContent = HOME_TEXTS.tutorialEndTitle;
text.replaceChildren();
for (const paragraph of loaded.end.paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
next.textContent = loaded.end.action;
next.href = "#/";
next.classList.remove("secondary");
screen = loaded.steps[0]?.explain ?? null;
} else {
const step = loaded.steps[at.step - 1];
const current = at.practice ? step?.practice : step?.explain;
if (step === undefined || current == null) {
return;
}
screen = { ...current, path: current.path && !at.practice };
overline.textContent = HOME_TEXTS.tutorialOverline(at.step, total, at.practice);
title.textContent = step.title;
text.replaceChildren();
const paragraphs = at.practice ? [] : current.paragraphs;
for (const paragraph of paragraphs) {
fillParagraph(element(text, "p"), paragraph);
}
if (at.practice) {
task.hidden = false;
task.replaceChildren();
for (const [i, paragraph] of current.paragraphs.entries()) {
if (i > 0) {
task.append(" ");
}
const span = document.createElement("span");
fillParagraph(span, paragraph);
task.append(span);
}
}
const after = nextPlace(loaded, at);
next.textContent = HOME_TEXTS.next;
next.href = routeHash(placeRoute(after));
next.classList.toggle("secondary", at.practice);
}
const currentScreen = screen;
if (currentScreen === null) {
return;
}
// Le plateau : la position, puis le coup illustré ou le geste à
// faire, tous lus par le moteur.
try {
await options.engineReady;
const shownBoard = await access.board(currentScreen.position, currentScreen.onRoll);
if (id !== shown) {
return;
}
boards = shownBoard;
if (
at !== "end" &&
at.practice &&
currentScreen.roll !== null &&
currentScreen.expected.length > 0
) {
practice = await access.practice(
currentScreen.position,
currentScreen.roll,
currentScreen.expected,
);
if (id !== shown) {
return;
}
await refreshTargets();
} else if (at !== "end" && at.practice && currentScreen.answer !== null) {
cube.hidden = false;
feedback.textContent = HOME_TEXTS.computerDoubles;
feedback.dataset.verdict = "info";
} else if (
at !== "end" &&
currentScreen.move !== null &&
currentScreen.roll !== null
) {
illustration = await access.illustrate(
currentScreen.position,
currentScreen.roll,
currentScreen.move,
);
if (id !== shown) {
return;
}
replay.hidden = false;
replay.disabled = true;
autoplay = setTimeout(() => void playIllustration(id), AUTOPLAY_MS);
}
if (id !== shown) {
return;
}
draw(practice === null ? null : await practice.board());
section.dataset.ready = String(id);
} catch (error) {
if (id === shown) {
feedback.textContent = `${HOME_TEXTS.tutorialFailed} ${error instanceof Error ? error.message : ""}`;
}
}
},
relayout() {
if (place === null || busy) {
return;
}
if (practice !== null) {
void practice.board().then((sides) => draw(sides));
} else {
draw();
}
},
hide() {
shown++;
clearTimer();
setBusy(false);
},
};
}
+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/);
});
+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/);
});
+126
View File
@@ -0,0 +1,126 @@
// L'adaptateur du moteur WebAssembly pour les niveaux, l'analyse et la
// chance (étape 1a), sur le vrai moteur. La concordance des nombres avec
// gnubg de bureau est vérifiée par tests/test_wasm_reference.sh ; ici, la
// traduction des réponses en types du domaine.
//
// 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 { fileURLToPath } from "node:url";
import { type EngineCube, LUCK_THRESHOLDS, SKILL_THRESHOLDS } from "../src/domain/game/engine.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { loadNodeEngine } from "./support/node-engine.js";
const centered: EngineCube = { value: 1, owner: "centered" };
const middlegame = "mM/gATCw5+ABMA";
test("ouverture 3-1 : 24/23 13/10 est une erreur très mauvaise, 8/5 6/5 le meilleur coup", async () => {
const engine = await loadNodeEngine();
const analysis = await engine.analyseMove(
START_POSITION,
centered,
[3, 1],
"4HPwATDgc+IBKA",
2,
);
assert.ok(analysis !== null);
assert.equal(analysis.moveCount, 16);
assert.equal(analysis.rank, 2);
assert.equal(analysis.played.notation, "24/23 13/10");
assert.equal(analysis.best.notation, "8/5 6/5");
assert.equal(analysis.skill, "veryBad");
assert.ok(
Math.abs(analysis.equityLoss - (analysis.played.equity - analysis.best.equity)) < 1e-6,
);
assert.ok(analysis.equityLoss < -SKILL_THRESHOLDS.veryBad);
});
test("sans coup possible, il n'y a rien à analyser", async () => {
const engine = await loadNodeEngine();
const position = "27YBBwAcz8EDQA";
assert.equal(await engine.analyseMove(position, centered, [5, 5], position, 0), null);
});
test("double/prend : passer est une erreur de l'adversaire, prendre n'en est pas une", async () => {
const engine = await loadNodeEngine();
const position = "6O5uAAC4uzsAAA";
const pass = await engine.analyseCube(position, centered, "pass", 2);
assert.equal(pass.analysed, true);
assert.equal(pass.skill, "bad");
assert.ok(pass.equities !== null && pass.equities.doubleTake < pass.equities.doublePass);
const take = await engine.analyseCube(position, centered, "take", 2);
assert.deepEqual([take.skill, take.equityLoss], ["none", 0]);
const opponentOwns = await engine.analyseCube(
position,
{ value: 2, owner: "opponent" },
"noDouble",
2,
);
assert.deepEqual(opponentOwns, {
analysed: false,
equities: null,
equityLoss: 0,
skill: "none",
});
});
test("la chance d'un jet et sa catégorie", async () => {
const engine = await loadNodeEngine();
const opening = await engine.luck(START_POSITION, centered, [3, 1]);
assert.ok(Math.abs(opening.value - 0.2126) < 2e-3);
assert.equal(opening.rating, "none");
const doubleSix = await engine.luck(START_POSITION, centered, [6, 6]);
assert.ok(doubleSix.value > LUCK_THRESHOLDS.lucky);
assert.equal(doubleSix.rating, "lucky");
});
// Régression : sans cache vidé à chaque appel (engine.c, start_evaluation),
// une analyse à 2 plis changeait la chance calculée ensuite (évaluations à
// 0 pli des réseaux d'élagage restées dans le cache de gnubg).
test("un résultat ne dépend pas des calculs faits avant", async () => {
const engine = await loadNodeEngine();
const before = await engine.luck(middlegame, centered, [6, 4]);
await engine.analyseCube(middlegame, centered, "noDouble", 2);
await engine.analyseMove(middlegame, centered, [6, 4], "mM/gATDGzuABMA", 2);
assert.deepEqual(await engine.luck(middlegame, centered, [6, 4]), before);
});
test("le niveau débutant joue toujours le même coup, différent du meilleur", async () => {
const engine = await loadNodeEngine();
for (let i = 0; i < 3; i++) {
const move = await engine.levelMove(middlegame, centered, [6, 4], "beginner");
assert.equal(move?.notation, "24/20 24/18");
}
assert.equal((await engine.levelMove(middlegame, centered, [6, 4], "2ply"))?.notation, "24/14");
const cube = await engine.levelCubeDecision("6O5uAAC4uzsAAA", centered, "beginner");
assert.equal(cube.double, false);
});
test("les seuils du domaine sont ceux du moteur", async () => {
const root = new URL("../../../", import.meta.url);
const module = await import(new URL("build/wasm/engine.mjs", root).href);
const wrapper = await import(new URL("web/engine-wasm.mjs", root).href);
const raw = await wrapper.loadEngine(
module.default,
fileURLToPath(new URL("build/wasm/", root)),
);
const { result } = raw.run(["levels"]) as {
result: {
levels: { name: string }[];
skill_thresholds: { doubtful: number; bad: number; very_bad: number };
luck_thresholds: { lucky: number; very_lucky: number };
};
};
const close = (a: number, b: number) => Math.abs(a - b) < 1e-6;
assert.ok(close(result.skill_thresholds.doubtful, SKILL_THRESHOLDS.doubtful));
assert.ok(close(result.skill_thresholds.bad, SKILL_THRESHOLDS.bad));
assert.ok(close(result.skill_thresholds.very_bad, SKILL_THRESHOLDS.veryBad));
assert.ok(close(result.luck_thresholds.lucky, LUCK_THRESHOLDS.lucky));
assert.ok(close(result.luck_thresholds.very_lucky, LUCK_THRESHOLDS.veryLucky));
assert.deepEqual(
result.levels.map((l) => l.name),
["beginner", "casual", "intermediate", "advanced", "0ply", "1ply", "2ply", "3ply", "4ply"],
);
});
+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;
+230 -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. */
@@ -49,6 +59,38 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
return { valid: false, complete: false, position: null };
},
board: async () => notOver,
levelMove: async () => move,
levelCubeDecision: async () => noDouble,
levelDouble: async () => false,
levelTake: async () => true,
matchAfterGame: async (match, winner, points) => {
const score = { ...match.score };
score[winner] += points;
return {
score,
over: score[winner] >= match.length,
crawford: score[winner] === match.length - 1,
};
},
analyseMove: async () => null,
rankMoves: async () => [],
analyseCube: async () => ({
analysed: false,
equities: null,
equityLoss: 0,
skill: "none",
}),
luck: async () => ({ value: 0, rating: "none" }),
encodeMatch: async () => "MATCH",
decodeMatch: async () => ({
matchLength: 0,
score: [0, 0],
cubeValue: 1,
cubeOwner: -1,
playerOnRoll: 0,
crawford: false,
jacoby: true,
}),
...overrides,
};
}
@@ -217,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");
@@ -231,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(
@@ -263,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);
});
@@ -319,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(() => {
@@ -393,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));
});
+133
View File
@@ -0,0 +1,133 @@
// Tests de l'accueil et de l'invitation à installer, sans navigateur :
// premier lancement, règles d'apparition de la carte d'installation,
// réglages enregistrés par une version précédente.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { installPlatform } from "../src/ui/install.js";
import {
DEFAULT_SETTINGS,
INSTALL_AGAIN_AFTER,
installCardShown,
isFirstLaunch,
parseSettings,
type Settings,
TUTORIAL_DONE,
} from "../src/ui/settings.js";
import { nextPlace, placeOf, previousPlace, progress, seenAfter } from "../src/ui/tutorial-nav.js";
import { parseTutorial } from "../src/ui/tutorial-text.js";
const root = new URL("../../../", import.meta.url);
test("réglages d'une version précédente : les nouveaux champs prennent leur valeur par défaut", () => {
const old = parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}');
assert.equal(old.welcomed, false);
assert.equal(old.gamesFinished, 0);
assert.equal(old.tutorialSeen, 0);
assert.equal(old.installed, false);
const saved = parseSettings(
'{"welcomed": true, "gamesFinished": 2, "tutorialSeen": 5, "installDismissals": 1, ' +
'"installDismissedAt": 2, "installed": true}',
);
assert.deepEqual(saved, {
...DEFAULT_SETTINGS,
welcomed: true,
gamesFinished: 2,
tutorialSeen: 5,
installDismissals: 1,
installDismissedAt: 2,
installed: true,
});
assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0);
});
test("premier lancement : tant que rien n'a été fait, une seule fois", () => {
assert.ok(isFirstLaunch(DEFAULT_SETTINGS));
// La partie commencée d'office au chargement ne compte pas.
assert.ok(isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, welcomed: true }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesStarted: 2 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, gamesFinished: 1 }));
assert.ok(!isFirstLaunch({ ...DEFAULT_SETTINGS, tutorialSeen: 1 }));
});
test("invitation à installer : après la première partie terminée, une seule relance", () => {
const at = (changes: Partial<Settings>, installed = false) =>
installCardShown({ ...DEFAULT_SETTINGS, ...changes }, installed);
assert.ok(!at({}), "jamais avant la première partie terminée");
assert.ok(at({ gamesFinished: 1 }));
assert.ok(!at({ gamesFinished: 1 }, true), "jamais une fois installée");
assert.ok(!at({ gamesFinished: 1, installed: true }));
// « Plus tard » : masquée, puis de retour après trois parties de plus.
const later = { installDismissals: 1, installDismissedAt: 1 };
assert.equal(INSTALL_AGAIN_AFTER, 3);
assert.ok(!at({ ...later, gamesFinished: 1 }));
assert.ok(!at({ ...later, gamesFinished: 3 }));
assert.ok(at({ ...later, gamesFinished: 4 }));
// Une seconde fois « Plus tard » : plus jamais sur l'accueil.
assert.ok(!at({ installDismissals: 2, installDismissedAt: 4, gamesFinished: 40 }));
});
test("tutoriel : écran suivant, précédent, adresse ramenée à un écran qui existe", () => {
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
const explain = (step: number) => ({ step, practice: false });
const practice = (step: number) => ({ step, practice: true });
assert.deepEqual(nextPlace(tutorial, explain(2)), explain(3));
assert.deepEqual(nextPlace(tutorial, explain(3)), practice(3));
assert.deepEqual(nextPlace(tutorial, practice(3)), explain(4));
assert.equal(nextPlace(tutorial, practice(8)), "end");
assert.equal(previousPlace(tutorial, explain(1)), null);
assert.deepEqual(previousPlace(tutorial, explain(4)), practice(3));
assert.deepEqual(previousPlace(tutorial, explain(3)), explain(2));
assert.deepEqual(previousPlace(tutorial, "end"), practice(8));
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: null, practice: false }),
explain(1),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 2, practice: true }),
explain(2),
);
assert.deepEqual(
placeOf(tutorial, { screen: "tutorial", step: 42, practice: false }),
explain(1),
);
assert.equal(placeOf(tutorial, { screen: "tutorialEnd" }), "end");
assert.equal(seenAfter(5, explain(3)), 5);
assert.equal(seenAfter(0, "end"), TUTORIAL_DONE);
// La barre : des liens vers les étapes vues seulement, pas l'actuelle.
const bar = progress(8, explain(3), 5);
assert.deepEqual(
bar.filter((s) => s.link).map((s) => s.step),
[1, 2, 4, 5],
);
assert.deepEqual(
bar.filter((s) => s.done).map((s) => s.step),
[1, 2, 3],
);
assert.deepEqual(
bar.filter((s) => s.current).map((s) => s.step),
[3],
);
});
test("la marche à suivre pour installer dépend du navigateur", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const ipad = "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
const firefox = "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0";
const chrome =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/130.0 Mobile Safari/537.36";
assert.equal(installPlatform(iphone), "ios");
assert.equal(installPlatform(ipad), "ios");
assert.equal(installPlatform(firefox), "firefox");
// Sans invite du système, Chrome suit la marche à suivre de Firefox
// (menu ⋮, « Installer »), la plus proche de la sienne.
assert.equal(installPlatform(chrome), "firefox");
});
+275
View File
@@ -0,0 +1,275 @@
// Tests de la révision UX qui se vérifient sans navigateur : géométrie du
// plateau selon l'écran, vibrations, réglages, compte de points, plateau
// gardé pour le chargement, et règles de la charte vérifiables dans le code.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
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 { 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";
import {
DEFAULT_SETTINGS,
LONG_PROMPT_GAMES,
longPrompts,
nextTheme,
parseSettings,
} from "../src/ui/settings.js";
const root = new URL("../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
// --------------------------------------------------------------- Géométrie
/** Les écrans en paysage de la recette : largeur et hauteur en pixels,
* dont 844 × 320 (Chrome garde sa barre d'adresse) et un rapport plus large
* encore. */
const LANDSCAPES: [number, number][] = [
[915, 412],
[844, 390],
[844, 320],
[740, 360],
[800, 308],
];
test("géométrie en portrait : les mesures du plateau d'origine", () => {
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);
assert.equal(g.trayLeft + g.tray + g.frame, 640);
// Numéros d'au moins 12 px à l'écran, à 412 px de large.
assert.ok(g.numberSize * (396 / 640) >= 12, `numéros de ${g.numberSize} unités`);
});
test("géométrie en paysage : le plateau remplit l'écran, flèches de 44 px et pions de 22 px au moins", () => {
for (const [width, height] of LANDSCAPES) {
const px = height / BOARD_HEIGHT;
const g = boardGeometry("landscape", width / height, px);
const where = `${width}x${height}`;
assert.ok(Math.abs(g.width / g.height - width / height) < 0.01, `${where} : proportions`);
// Les colonnes, la barre et les flèches occupent toute la largeur.
assert.ok(Math.abs(g.trayLeft + g.tray + g.frame - g.width) < 1e-9, `${where} : largeur`);
assert.ok(g.pointWidth * px >= 44, `${where} : flèches de ${g.pointWidth * px} px`);
assert.ok(2 * g.radius * px >= 22, `${where} : pions de ${2 * g.radius * px} px`);
assert.ok(g.numberSize * px >= 12, `${where} : numéros de ${g.numberSize * px} px`);
assert.ok(g.bandFont * px >= 13, `${where} : consigne de ${g.bandFont * px} px`);
assert.ok(g.chipFont * px >= 13, `${where} : comptes de ${g.chipFont * px} px`);
assert.ok(g.target * px >= 44, `${where} : cibles de ${g.target * px} px`);
assert.ok(g.cubeFont * px >= 13, `${where} : videau en ${g.cubeFont * px} px`);
assert.ok(g.countFont * px >= 13, `${where} : nombres des piles en ${g.countFont * px} px`);
assert.ok(g.cube <= g.left - 8, `${where} : videau plus large que sa colonne`);
assert.ok(g.left - 4 >= g.target - 4, `${where} : colonne de commandes trop étroite`);
// Les numéros tiennent dans le cadre.
assert.ok(g.numberSize < g.edge, `${where} : numéros hors du cadre`);
}
});
test("une pile de cinq pions ne déborde pas dans la bande centrale (dés, consigne)", () => {
const cases = [
boardGeometry("portrait", 640 / 520, 0.62),
...[1.9, 2.16, 2.6].map((aspect) => boardGeometry("landscape", aspect, 0.615)),
];
for (const g of cases) {
const band = g.height / 2 - g.die / 2;
const last = stackY(g, true, MAX_STACK - 1) + g.radius;
assert.ok(last <= band, `${g.layout} ${g.width} : pion jusqu'à ${last}, dés dès ${band}`);
}
});
test("en paysage, les pions de la barre s'arrêtent avant les pastilles des comptes", () => {
for (const [width, height] of LANDSCAPES) {
const g = boardGeometry("landscape", width / height, height / BOARD_HEIGHT);
const lastOffset = g.radius + 4 + (g.barStack - 1) * g.step + g.radius;
const chipEdge = g.height / 2 - (g.edge + 4 + g.chipHeight);
assert.ok(g.barStack >= 1);
assert.ok(lastOffset <= chipEdge, `${width}x${height} : ${lastOffset} > ${chipEdge}`);
}
});
// -------------------------------------------------------------- Vibrations
test("vibrations : les motifs de la charte, et rien quand elles sont coupées", () => {
const calls: number[][] = [];
let on = true;
const haptic = createHaptics(
(pattern) => {
calls.push(pattern);
return true;
},
() => on,
);
haptic("checkerPlaced");
haptic("checkerHit");
haptic("computerDoubles");
haptic("gameWon");
haptic("gameLost");
assert.deepEqual(calls, [[12], [25, 40, 25], [40, 60, 40], [30, 50, 30, 50, 60], [60]]);
on = false;
haptic("checkerHit");
assert.equal(calls.length, 5);
// Sans navigator.vibrate, ou si le navigateur refuse : aucune erreur.
assert.doesNotThrow(() => createHaptics(undefined, () => true)("gameWon"));
const refusing = createHaptics(
() => {
throw new Error("refusé");
},
() => true,
);
assert.doesNotThrow(() => refusing("gameWon"));
// Aucun motif ne dépasse 60 ms par impulsion (interaction.md).
for (const pattern of Object.values(HAPTIC_PATTERNS)) {
assert.ok(pattern.every((ms) => ms > 0 && ms <= 60));
}
});
// ---------------------------------------------------------------- Réglages
test("réglages : valeurs par défaut, lecture tolérante, thème en boucle", () => {
assert.deepEqual(parseSettings(null), DEFAULT_SETTINGS);
assert.equal(DEFAULT_SETTINGS.vibrations, true);
assert.equal(DEFAULT_SETTINGS.keepScreenOn, true);
assert.deepEqual(parseSettings("pas du JSON"), DEFAULT_SETTINGS);
assert.deepEqual(parseSettings('{"vibrations": false, "theme": "dark", "gamesStarted": 4}'), {
...DEFAULT_SETTINGS,
vibrations: false,
theme: "dark",
gamesStarted: 4,
});
assert.deepEqual(parseSettings('{"vibrations": "non", "theme": "rose", "gamesStarted": -1}'), {
...DEFAULT_SETTINGS,
});
assert.equal(nextTheme("auto"), "light");
assert.equal(nextTheme("light"), "dark");
assert.equal(nextTheme("dark"), "auto");
});
test("consigne longue pendant les trois premières parties, courte ensuite", () => {
assert.equal(LONG_PROMPT_GAMES, 3);
for (const games of [0, 1, 2, 3]) {
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: games }), true);
}
assert.equal(longPrompts({ ...DEFAULT_SETTINGS, gamesStarted: 4 }), false);
});
test("stockage des réglages et du dernier plateau affiché", () => {
const data = new Map<string, string>();
const store = createSettingsStore({
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => void data.set(key, value),
});
assert.equal(store.loadSettings(), null);
store.saveSettings('{"vibrations":false}');
assert.equal(store.loadSettings(), '{"vibrations":false}');
assert.equal(store.loadBoard(), null);
const board = { sides: OPENING_BOARDS, cube: { value: 2, owner: "player" as const } };
store.saveBoard(board);
assert.deepEqual(store.loadBoard(), board);
// Un plateau abîmé est ignoré : la position de départ sera dessinée.
data.set("ustagammon.last-board", '{"sides": {"player": {"points": [1]}}}');
assert.equal(store.loadBoard(), null);
const full = createSettingsStore({
getItem: () => {
throw new Error("interdit");
},
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.saveBoard(board));
assert.equal(full.loadBoard(), null);
assert.equal(full.loadSettings(), null);
});
// ------------------------------------------------------- Compte de points
test("compte de points : le plus petit mène, de l'écart", () => {
assert.deepEqual(pipLead({ player: 136, computer: 128 }), { leader: "computer", by: 8 });
assert.deepEqual(pipLead({ player: 100, computer: 128 }), { leader: "player", by: 28 });
assert.deepEqual(pipLead({ player: 167, computer: 167 }), { leader: null, by: 0 });
// La position de départ : 15 pions, 167 points chacun.
for (const side of [OPENING_BOARDS.player, OPENING_BOARDS.computer]) {
assert.equal(
side.points.reduce((a, b) => a + b, 0),
15,
);
assert.equal(
side.points.reduce((sum, n, i) => sum + n * (i + 1), 0),
side.pipCount,
);
}
});
// ------------------------------------------------------------------ Charte
/** Les fichiers .ts d'un dossier, récursivement. */
function sources(dir: string): string[] {
const url = new URL(dir, root);
return readdirSync(url).flatMap((name) => {
const path = `${dir}${name}`;
return statSync(new URL(path, root)).isDirectory() ? sources(`${path}/`) : [path];
});
}
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,
/(^|[^.\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", () => {
const css = read("web/app.css");
const block = (start: string) => {
const from = css.indexOf(start);
assert.ok(from >= 0, `bloc ${start} absent`);
const body = css.slice(css.indexOf("{", from + start.length - 1) + 1);
return body
.slice(0, body.indexOf("}"))
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
};
assert.deepEqual(
block(':root:not([data-theme="light"]) {'),
block(':root[data-theme="dark"] {'),
);
});
test("aucun style en ligne ni attribut style dans la page et le code (CSP)", () => {
assert.doesNotMatch(read("web/index.html"), /\sstyle=|<style/);
for (const path of sources("app/src/")) {
assert.doesNotMatch(read(path), /setAttribute\(\s*"style"|\.style\./, path);
}
});
test("pictogrammes : le jeu de la charte, en tracés SVG", () => {
for (const name of [
"menu",
"help",
"fullscreen",
"exitFullscreen",
"undo",
"replay",
"close",
]) {
assert.ok(name in ICON_PATHS, `pictogramme ${name} absent`);
}
for (const d of Object.values(ICON_PATHS)) {
assert.match(d, /^M[\d.\s,a-zA-Z-]+$/);
}
});
+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 });
});
+147
View File
@@ -0,0 +1,147 @@
// Tests du routeur (app/src/ui/router.ts) : chaque adresse du tableau de
// l'ADR 0013 (amendement du 2026-11-03) mène au bon écran, et chaque écran
// redonne son adresse.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import {
isHelpScreen,
parseRoute,
type Route,
routeHash,
slug,
stepsBack,
} from "../src/ui/router.js";
const START = "4HPwATDgc/ABMA";
const MATCH = "cAkAAAAAAAAA";
/** Les adresses du tableau de l'ADR, et l'écran de chacune. */
const TABLE: [string, Route][] = [
["#/", { screen: "game" }],
["#/accueil", { screen: "home" }],
["#/aide", { screen: "help", question: null }],
["#/aide/comment-jouer", { screen: "help", question: "comment-jouer" }],
["#/glossaire", { screen: "glossary", term: null }],
["#/glossaire/videau", { screen: "glossary", term: "videau" }],
["#/a-propos", { screen: "about" }],
["#/tutoriel", { screen: "tutorial", step: null, practice: false }],
["#/tutoriel/3", { screen: "tutorial", step: 3, practice: false }],
["#/tutoriel/3/a-vous", { screen: "tutorial", step: 3, practice: true }],
["#/tutoriel/fin", { screen: "tutorialEnd" }],
["#/installer", { screen: "install" }],
["#/parties", { screen: "games", game: null }],
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
["#/menu", { screen: "menu" }],
["#/nouvelle-partie", { screen: "newGame" }],
["#/explication/12", { screen: "explanation", event: "12" }],
[
"#/position/4HPwATDgc%2FABMA/cAkAAAAAAAAA",
{ screen: "position", position: START, match: MATCH },
],
["#/position/4HPwATDgc%2FABMA", { screen: "position", position: START, match: null }],
];
test("chaque adresse du tableau de l'ADR 0013 mène à son écran, et inversement", () => {
for (const [hash, route] of TABLE) {
assert.deepEqual(parseRoute(hash), route, hash);
assert.equal(routeHash(route), hash, hash);
}
});
test("une adresse vide ou « # » est la partie en cours ; une barre finale ne change rien", () => {
assert.deepEqual(parseRoute(""), { screen: "game" });
assert.deepEqual(parseRoute("#"), { screen: "game" });
assert.deepEqual(parseRoute("#/aide/"), { screen: "help", question: null });
assert.deepEqual(parseRoute("#/glossaire/blot/"), { screen: "glossary", term: "blot" });
});
test("une adresse inconnue est reconnue comme telle", () => {
for (const hash of [
"#/inconnu",
"#aide",
"#/aide/a/b",
"#/aide/Majuscules",
"#/menu/plus",
"#/tutoriel/0",
"#/tutoriel/x",
"#/tutoriel/3/b",
"#/tutoriel/3/a-vous/x",
"#/tutoriel/fin/a-vous",
"#/installer/x",
"#/a-propos/x",
"#/position",
"#/position/%E0%A4%A",
]) {
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());
});
+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);
});
+25 -3
View File
@@ -19,6 +19,7 @@ import {
} from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import type { GameState } from "../../src/domain/game/state.js";
import { boardModel, pipLead } from "../../src/ui/board-model.js";
import { loadNodeEngine } from "../support/node-engine.js";
// Le chargement du moteur prend une à deux secondes sur le PC de 2011.
@@ -283,7 +284,7 @@ Then("c'est toujours à vous", () => {
assert.equal(lastEvent().type, "DiceRolled");
});
When("vous continuez", async () => {
When("vous laissez passer votre tour", async () => {
await game.skipTurn();
const event = lastEvent();
assert.ok(event.type === "MovePlayed" && event.side === "player");
@@ -344,6 +345,27 @@ Then("vous pouvez revoir son coup", async () => {
assert.deepEqual(await game.lastMoveSteps(), computerMove);
});
// ------------------------------------------------------- Compte de points
// Les bandeaux lisent le compte de points du plateau donné par le moteur ;
// qui mène se calcule dans l'interface (board-model.ts).
async function pips() {
return boardModel(await game.displayedBoard()).pipCount;
}
Then("chaque camp a un compte de points de {int}", async (n: number) => {
assert.deepEqual(await pips(), { player: n, computer: n });
});
Then("personne ne mène la course", async () => {
assert.equal(pipLead(await pips()).leader, null);
});
Then("vous menez la course de {int}", async (n: number) => {
assert.deepEqual(pipLead(await pips()), { leader: "player", by: n });
});
// ------------------------------------------------------------------ Videau
// La confirmation elle-même est une question de l'interface (une fenêtre
@@ -404,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", () => {
+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}`);
});
@@ -0,0 +1,206 @@
// Étapes du scénario docs/scenarios/regler-l-application.feature (ADR 0008) :
// la partie reprise passe par le domaine et le vrai moteur, et par le même
// enregistrement que l'application ; les réglages et les vibrations par les
// modules de l'interface qui les portent (settings.ts, haptics.ts).
//
// 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 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 { createGameLibrary, type GameLibrary } from "../../src/storage/game-library.js";
import { createGameSession, type GameSession } from "../../src/storage/game-session.js";
import { createKeyValueBackend } from "../../src/storage/key-value-backend.js";
import { memoryStorage } from "../../src/storage/open-library.js";
import { BANNER_MS } from "../../src/ui/app.js";
import { createHaptics } from "../../src/ui/haptics.js";
import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js";
import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(30_000);
let engine: Engine;
let game: Game;
let oldJournal: GameJournal;
let oldBoard: unknown;
let settings: Settings;
let vibrations: number[][] = [];
BeforeAll(async () => {
engine = await loadNodeEngine();
});
// -------------------------------------------------- Nouvelle partie annulable
// Comme dans l'application : chaque partie est enregistrée par la session
// (ici sur un stockage en mémoire), et l'ancienne est relue dans le
// stockage par son identifiant.
let library: GameLibrary;
let session: GameSession;
let oldId: string | null;
Given("une partie est en cours depuis quelques coups", async () => {
library = createGameLibrary(createKeyValueBackend(memoryStorage()));
let dice = fixedDice([3, 1, 6, 4, 5, 2]);
session = createGameSession(library, {
start: () => Game.start(engine, { dice }),
resume: (journal) => Game.resume(engine, journal, { dice }),
});
game = session.newGame();
dice = () => 1 + Math.floor(Math.random() * 6);
for (const halfMove of parseHalfMoves("8/5 6/5")) {
assert.ok(await game.playHalfMove(halfMove));
}
await game.confirmMove();
await game.playComputerTurn();
session.save(game.journal);
oldId = session.shownId;
assert.equal(game.state.turn, "player");
});
When("vous commencez une nouvelle partie depuis le menu", async () => {
oldJournal = game.journal;
oldBoard = await game.displayedBoard();
game = session.newGame();
session.save(game.journal);
});
Then("une nouvelle partie commence aussitôt", () => {
assert.equal(game.state.position, START_POSITION);
assert.ok(game.events.every((e) => e.type === "GameStarted" || e.type === "DiceRolled"));
});
Then("l'ancienne partie peut être reprise pendant {int} secondes", (seconds: number) => {
assert.equal(BANNER_MS, seconds * 1000);
});
When("vous reprenez l'ancienne partie", async () => {
const old = await session.resumePrevious();
assert.ok(old !== null);
game = old;
session.save(game.journal);
});
Then("vous retrouvez exactement la partie quittée", async () => {
assert.equal(session.shownId, oldId);
assert.deepEqual((await library.current())?.journal, oldJournal);
// La nouvelle partie, abandonnée, est supprimée.
assert.deepEqual(
(await library.list()).map((s) => s.id),
[oldId],
);
assert.deepEqual(game.journal, oldJournal);
assert.deepEqual(await game.displayedBoard(), oldBoard);
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "rolling");
});
// --------------------------------------------------------------- Réglages
Given("vous ouvrez l'application pour la première fois", () => {
settings = parseSettings(null);
vibrations = [];
});
const haptic = createHaptics(
(pattern) => {
vibrations.push(pattern);
return true;
},
() => settings.vibrations,
);
When("un pion est frappé", () => {
vibrations = [];
haptic("checkerHit");
});
Then("le téléphone vibre deux fois, brièvement", () => {
assert.deepEqual(vibrations, [[25, 40, 25]]);
});
When("vous coupez les vibrations dans le menu", () => {
settings = { ...settings, vibrations: false };
});
Then("le téléphone ne vibre pas", () => {
assert.deepEqual(vibrations, []);
});
Then("l'écran reste allumé pendant une partie", () => {
assert.equal(settings.keepScreenOn, true);
});
When("vous coupez « Écran allumé pendant la partie » dans le menu", () => {
settings = parseSettings(JSON.stringify({ ...settings, keepScreenOn: false }));
});
Then("l'écran peut se mettre en veille comme d'habitude", () => {
assert.equal(settings.keepScreenOn, false);
});
Then("le thème suit celui du téléphone", () => {
assert.equal(settings.theme, "auto");
});
When("vous changez de thème dans le menu", () => {
// Rien : l'étape suivante fait le tour des thèmes.
});
Then("il passe à clair, puis à sombre, puis revient à automatique", () => {
const seen = [settings.theme];
for (let i = 0; i < 3; i++) {
seen.push(nextTheme(seen.at(-1) ?? "auto"));
}
assert.deepEqual(seen, ["auto", "light", "dark", "auto"]);
});
// ------------------------------------------------------------ Mise à jour
// Le bandeau dépend de deux choses : une version attend (le service worker
// l'a installée), et la partie est entre deux tours. La partie est celle du
// domaine, sur le vrai moteur.
let updateAvailable = false;
Given("une nouvelle version d'Ustagammon est disponible", () => {
updateAvailable = true;
});
const shownNow = (otherBanner = false) =>
updateBannerShown(
updateAvailable,
betweenTurns(game.state.phase, game.pendingHalfMoves.length),
otherBanner,
);
Given("vous avez des dés à jouer", () => {
// Jet d'ouverture 3-1 : vous commencez.
game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), level: "0ply" });
assert.equal(game.state.turn, "player");
assert.equal(game.state.phase, "moving");
});
When("vous déplacez un pion de votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 8, to: 5 }));
});
Then("l'application ne vous propose pas encore de mettre à jour", () => {
assert.ok(!shownNow());
});
When("vous avez joué votre coup", async () => {
assert.ok(await game.playHalfMove({ from: 6, to: 5 }));
await game.confirmMove();
});
Then("l'application vous propose de mettre à jour", () => {
assert.ok(shownNow());
// Jamais par-dessus le bandeau « Reprendre l'ancienne ».
assert.ok(!shownNow(true));
});
+263
View File
@@ -0,0 +1,263 @@
// Tests du stockage des parties (src/storage/), sans navigateur : la
// bibliothèque de parties sur un stockage clé-valeur en mémoire (le repli
// de localStorage), la migration de la partie des versions précédentes et
// le choix du stockage. IndexedDB, absent de Node, est essayé dans un vrai
// navigateur (tests/browser/play.mjs).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { test } from "node:test";
import { branches } from "../src/domain/game/branches.js";
import type { Engine } from "../src/domain/game/engine.js";
import {
appendEvent,
EMPTY_JOURNAL,
type GameJournal,
goBackTo,
} from "../src/domain/game/events.js";
import { Game } from "../src/domain/game/game.js";
import { START_POSITION } from "../src/domain/game/position.js";
import { fixedDice } from "../src/domain/game/roll.js";
import { createGameLibrary, isJournal, newGameId } from "../src/storage/game-library.js";
import { createGameSession } from "../src/storage/game-session.js";
import {
createKeyValueBackend,
type KeyValueStorage,
migrateLegacyGame,
} from "../src/storage/key-value-backend.js";
import { memoryStorage, openGameLibrary } from "../src/storage/open-library.js";
/** Une partie commencée, et éventuellement quelques événements de plus. */
function journal(extraRolls = 0): GameJournal {
let j = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
for (let i = 0; i < extraRolls; i++) {
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [3, 3] });
}
return j;
}
/** Une horloge qui avance d'une seconde à chaque lecture. */
function clock(start = 1_000_000): () => number {
let t = start;
return () => {
t += 1000;
return t;
};
}
function library(storage: KeyValueStorage = memoryStorage()) {
return createGameLibrary(createKeyValueBackend(storage), { now: clock() });
}
test("plusieurs parties : liste, chargement, dernière jouée d'abord", async () => {
const games = library();
assert.deepEqual(await games.list(), []);
assert.equal(await games.current(), null);
const a = await games.save("a", journal(), { level: "2ply" });
await games.save("b", journal(1), { level: "beginner" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b", "a"],
);
assert.equal(a.status, "playing");
assert.equal(a.result, null);
assert.equal(a.moveCount, 0);
assert.equal(a.branchCount, 1);
// Rejouer « a » la remet en tête ; sa date de début ne change pas.
const again = await games.save("a", journal(2), { level: "2ply" });
assert.equal(again.startedAt, a.startedAt);
assert.ok(again.updatedAt > a.updatedAt);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["a", "b"],
);
assert.deepEqual((await games.load("a"))?.journal, journal(2));
assert.equal((await games.load("b"))?.summary.level, "beginner");
assert.equal(await games.load("inconnue"), null);
});
test("partie en cours : la dernière enregistrée, ou celle qu'on reprend", async () => {
const games = library();
await games.save("ancienne", journal(), { level: "2ply" });
await games.save("nouvelle", journal(1), { level: "2ply" });
assert.equal((await games.current())?.summary.id, "nouvelle");
// « Reprendre l'ancienne » : elle redevient la partie en cours, sans
// être modifiée ; la nouvelle peut être supprimée.
const before = await games.load("ancienne");
await games.setCurrent("ancienne");
await games.remove("nouvelle");
assert.deepEqual(await games.current(), before);
assert.deepEqual(
(await games.list()).map((s) => s.id),
["ancienne"],
);
await assert.rejects(games.setCurrent("nouvelle"), /introuvable/);
// La partie en cours supprimée : la dernière jouée la remplace.
await games.save("autre", journal(), { level: "2ply" });
await games.setCurrent("ancienne");
await games.remove("ancienne");
assert.equal((await games.current())?.summary.id, "autre");
});
test("les opérations sont faites dans l'ordre des appels", async () => {
const games = library();
// Lancées sans attendre, comme l'enregistrement après chaque action.
void games.save("a", journal(), { level: "2ply" });
void games.save("a", journal(3), { level: "2ply" });
void games.remove("a");
void games.save("b", journal(), { level: "2ply" });
assert.deepEqual(
(await games.list()).map((s) => s.id),
["b"],
);
});
test("les branches et le résultat sont résumés", async () => {
const games = library();
let j = journal(2);
j = goBackTo(j, "2");
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [5, 2] });
j = appendEvent(j, {
type: "MovePlayed",
side: "player",
halfMoves: [],
position: START_POSITION,
});
j = appendEvent(j, { type: "GameEnded", winner: "player", kind: "gammon", points: 2 });
const summary = await games.save("x", j, { level: "2ply" });
assert.equal(summary.branchCount, 2);
assert.equal(summary.moveCount, 1);
assert.equal(summary.status, "over");
assert.deepEqual(summary.result, { winner: "player", kind: "gammon", points: 2 });
});
test("un journal illisible n'est pas chargé ; un stockage plein ne casse rien", async () => {
const storage = memoryStorage();
const games = library(storage);
await games.save("a", journal(), { level: "2ply" });
storage.setItem("ustagammon.game.a", "{pas du JSON");
assert.equal(await games.load("a"), null);
assert.equal(isJournal({ events: [], head: 3 }), false);
assert.equal(isJournal(null), false);
const full = library({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
removeItem: () => {},
});
await assert.rejects(full.save("a", journal(), { level: "2ply" }), /plein/);
// La bibliothèque reste utilisable après un échec.
assert.deepEqual(await full.list(), []);
});
test("la partie des versions précédentes est migrée, une seule fois", async () => {
const storage = memoryStorage();
const old = journal(1);
storage.setItem("ustagammon.current-game", JSON.stringify(old));
const games = library(storage);
const id = await migrateLegacyGame(storage, games, 42);
assert.ok(id !== null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
const current = await games.current();
assert.equal(current?.summary.id, id);
assert.deepEqual(current?.journal, old);
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal((await games.list()).length, 1);
// Un ancien enregistrement illisible est seulement effacé.
storage.setItem("ustagammon.current-game", "{pas du JSON");
assert.equal(await migrateLegacyGame(storage, games), null);
assert.equal(storage.getItem("ustagammon.current-game"), null);
assert.equal((await games.list()).length, 1);
});
test("sans IndexedDB : localStorage, ou à défaut la mémoire", async () => {
const storage = memoryStorage();
storage.setItem("ustagammon.current-game", JSON.stringify(journal()));
const opened = await openGameLibrary({ localStorage: storage });
assert.equal(opened.kind, "localStorage");
assert.equal((await opened.library.list()).length, 1);
const refused: KeyValueStorage = {
getItem: () => {
throw new Error("interdit");
},
setItem: () => {
throw new Error("interdit");
},
removeItem: () => {
throw new Error("interdit");
},
};
const memory = await openGameLibrary({ localStorage: refused });
assert.equal(memory.kind, "memory");
await memory.library.save("a", journal(), { level: "2ply" });
assert.equal((await memory.library.current())?.summary.id, "a");
// IndexedDB qui échoue à l'ouverture : repli.
const broken = {
open: () => {
throw new Error("indisponible");
},
} as unknown as IDBFactory;
assert.equal((await openGameLibrary({ indexedDB: broken })).kind, "memory");
});
test("les identifiants de partie sont distincts", () => {
const ids = new Set(Array.from({ length: 100 }, () => newGameId(1)));
assert.ok(ids.size > 95);
assert.match(newGameId(), /^[0-9a-z]+-[0-9a-z]{6}$/);
});
test("session : « Reprendre l'ancienne » par l'identifiant, et reprise d'une branche", async () => {
const games = library();
// Game.start et Game.resume n'interrogent pas le moteur.
const engine = {} as Engine;
const session = createGameSession(games, {
start: () => Game.start(engine, { dice: fixedDice([5, 2]) }),
resume: (j) => Game.resume(engine, j),
});
assert.equal(await session.restore(), null);
assert.equal(await session.resumePrevious(), null);
const first = session.newGame();
const firstId = session.shownId as string;
session.save(first.journal);
const second = session.newGame();
session.save(second.journal);
assert.notEqual(session.shownId, firstId);
// L'ancienne revient, relue dans le stockage ; la nouvelle disparaît.
const back = await session.resumePrevious();
assert.deepEqual(back?.journal, first.journal);
assert.equal(session.shownId, firstId);
assert.deepEqual(
(await games.list()).map((s) => s.id),
[firstId],
);
assert.equal(await session.resumePrevious(), null);
// Une branche reprise devient la ligne courante enregistrée.
let j = goBackTo(first.journal, "1");
j = appendEvent(j, { type: "DiceRolled", side: null, roll: [6, 1] });
await games.save(firstId, j, { level: "2ply" });
const original = branches(j).find((b) => b.forkedFrom === null);
assert.ok(original !== undefined);
const opened = await session.open(firstId, original.head);
assert.equal(opened.journal.head, original.head);
assert.equal((await games.current())?.journal.head, original.head);
await assert.rejects(session.open("inconnue"), /introuvable/);
});
+108
View File
@@ -0,0 +1,108 @@
// Des décisions du joueur dans des positions connues, jouées par le domaine
// sur le vrai moteur, puis analysées : de quoi tester le conseil du maître
// (tests unitaires et scénarios).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { readFileSync } from "node:fs";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import { type MasterPhrases, parsePhrases } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.js";
import type { Engine } from "../../src/domain/game/engine.js";
import { Game } from "../../src/domain/game/game.js";
import { parseHalfMoves, START_POSITION } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
const root = new URL("../../../../", import.meta.url);
/** Les phrases du maître, lues dans leur source. */
export function loadPhrases(): MasterPhrases {
return parsePhrases(readFileSync(new URL("docs/maitre/phrases.md", root), "utf8"));
}
/** Une position de double et prise (videau au centre), vue de celui qui a
* la main : doubler y est juste, et prendre aussi (tests du moteur, 1a). */
export const DOUBLE_TAKE_POSITION = "6O5uAAC4uzsAAA";
/** Le joueur, au trait à la position de départ, lance dice et joue move ;
* la partie et l'événement du coup. */
export async function playOpening(
engine: Engine,
dice: readonly [number, number],
move: string,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: START_POSITION, firstTurn: "player" },
// Le jet du joueur, puis celui de l'ordinateur s'il joue ensuite.
dice: fixedDice([...dice, 6, 5]),
});
await game.roll();
for (const halfMove of parseHalfMoves(move)) {
if (!(await game.playHalfMove(halfMove))) {
throw new Error(`demi-coup refusé : ${move}`);
}
}
await game.confirmMove();
const played = game.events.findLast((e) => e.type === "MovePlayed");
return { game, eventId: played?.id ?? "" };
}
/** Le joueur, au trait dans la position de double et prise, double ;
* l'ordinateur répond. */
export async function playerDoubles(engine: Engine): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([]),
});
await game.double();
const offered = game.events.find((e) => e.type === "CubeOffered");
return { game, eventId: offered?.id ?? "" };
}
/** Le joueur, au trait dans la position de double et prise, lance sans
* doubler. */
export async function playerRollsWithoutDoubling(
engine: Engine,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "player" },
dice: fixedDice([6, 5]),
});
await game.roll();
const rolled = game.events.findLast((e) => e.type === "DiceRolled");
return { game, eventId: rolled?.id ?? "" };
}
/** L'ordinateur, au trait dans la position de double et prise, double ; le
* joueur prend (take) ou passe. */
export async function playerAnswers(
engine: Engine,
take: boolean,
): Promise<{ game: Game; eventId: string }> {
const game = Game.start(engine, {
setup: { position: DOUBLE_TAKE_POSITION, firstTurn: "computer" },
level: "2ply",
dice: fixedDice([]),
});
await game.playComputerTurn();
if (take) {
game.take();
} else {
await game.drop();
}
const answer = game.events.find((e) => e.type === (take ? "CubeTaken" : "CubeDropped"));
return { game, eventId: answer?.id ?? "" };
}
/** Le conseil du moteur sur une décision ; erreur s'il n'y en a pas. */
export async function adviceOn(
engine: Engine,
played: { game: Game; eventId: string },
): Promise<Advice> {
const advice = await analyseDecision(engine, played.game.journal, played.eventId);
if (advice === null) {
throw new Error(`aucun conseil pour l'événement ${played.eventId}`);
}
return advice;
}
+86
View File
@@ -0,0 +1,86 @@
// Jouer une partie du domaine sans interface, pour les tests, les
// scénarios et les parties d'exemple : les deux camps, avec des dés
// reproductibles.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import type { Engine } from "../../src/domain/game/engine.js";
import type { Game } from "../../src/domain/game/game.js";
import type { DiceSource } from "../../src/domain/game/roll.js";
/** Des dés pseudo-aléatoires reproductibles (générateur congruentiel). */
export function seededDice(seed: number): DiceSource {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return 1 + (Math.floor(state / 65536) % 6);
};
}
/**
* Une action de la partie, quel que soit le camp qui a la main. Le joueur
* joue le choice-ième coup légal (le dernier s'il y en a moins), prend les
* doubles si take, et double quand il le peut si double.
*/
export async function step(
game: Game,
engine: Engine,
strategy: { choice?: number; take?: boolean; double?: boolean } = {},
): Promise<void> {
const state = game.state;
if (state.turn === "computer" && state.phase === "doubled") {
if (strategy.take ?? true) {
game.take();
} else {
await game.drop();
}
} else if (state.turn === "computer") {
await game.playComputerTurn();
} else if (state.phase === "rolling") {
if (strategy.double === true && (await game.canDouble())) {
await game.double();
} else {
await game.roll();
}
} else if (state.phase === "moving" && state.roll !== null) {
const moves = await engine.legalMoves(state.position, state.roll);
const move = moves[Math.min(strategy.choice ?? 0, moves.length - 1)];
if (move === undefined) {
await game.skipTurn();
return;
}
for (const halfMove of move.halfMoves) {
if (!(await game.playHalfMove(halfMove))) {
throw new Error("coup légal refusé");
}
}
await game.confirmMove();
} else {
throw new Error(`phase inattendue : ${state.phase}`);
}
}
/** Joue jusqu'à count coups (des deux camps), ou jusqu'à la fin. */
export async function playMoves(
game: Game,
engine: Engine,
count: number,
choice = 0,
): Promise<void> {
let played = 0;
while (played < count && game.state.phase !== "over") {
const before = game.events.length;
await step(game, engine, { choice });
played += game.events.slice(before).filter((event) => event.type === "MovePlayed").length;
}
}
export async function playToTheEnd(game: Game, engine: Engine, choice = 0): Promise<void> {
for (let i = 0; game.state.phase !== "over"; i++) {
if (i > 2000) {
throw new Error("la partie ne finit pas");
}
await step(game, engine, { choice });
}
}
+139
View File
@@ -0,0 +1,139 @@
// Parties d'exemple exportées en SGF, pour les faire relire par le GNU
// Backgammon de référence (tests/test_sgf_reference.sh).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node build/web/tests/tools/sgf-samples.js <dossier>
//
// Joue sur le vrai moteur WebAssembly, avec des dés reproductibles, des
// parties variées : complètes, avec branches, avec videau, depuis une
// position particulière. Pour chaque branche de chaque partie, écrit
// <nom>.sgf (la partie exportée, cette branche en premier : c'est celle que
// gnubg lit) et <nom>.json : les positions successives du journal sur cette
// branche, celles que gnubg doit retrouver (tests/sgf/check.py).
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Game } from "../../src/domain/game/game.js";
import { exportSgf } from "../../src/domain/game/sgf.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playMoves, playToTheEnd, seededDice, step } from "../support/play.js";
/** Le dernier événement « coup joué » de la ligne courante. */
function lastMoveId(game: Game, skip = 0): string {
const moves = game.events.filter((event) => event.type === "MovePlayed");
const move = moves.at(-1 - skip);
if (move === undefined) {
throw new Error("aucun coup joué");
}
return move.id;
}
async function main(): Promise<void> {
const out = process.argv[2];
if (out === undefined) {
throw new Error("usage : sgf-samples.js <dossier>");
}
mkdirSync(out, { recursive: true });
const engine = await loadNodeEngine();
const games: [string, Game][] = [];
// Une partie complète, puis une autre où le joueur joue autrement.
for (const [seed, choice] of [
[3, 0],
[11, 2],
] as const) {
const game = Game.start(engine, { dice: seededDice(seed), level: "0ply" });
await playToTheEnd(game, engine, choice);
games.push([`complete-${seed}`, game]);
}
// Des branches : rejouée d'un coup de l'ordinateur (nouveaux dés), d'un
// jet du joueur (même jet, autre coup), et une branche d'une branche.
{
const game = Game.start(engine, { dice: seededDice(5), level: "0ply" });
await playMoves(game, engine, 14);
const original = game.events.at(-1)?.id as string;
game.goBackTo(lastMoveId(game, 6));
await playMoves(game, engine, 8);
const rolled = game.events.findLast((e) => e.type === "DiceRolled" && e.side === "player");
if (rolled === undefined) {
throw new Error("aucun jet du joueur");
}
game.goBackTo(rolled.id);
await playMoves(game, engine, 5, 1);
game.switchBranch(original);
await playToTheEnd(game, engine);
games.push(["branches", game]);
}
// Le joueur double d'entrée ; l'ordinateur prend ou passe selon le moteur.
{
const game = Game.start(engine, { dice: seededDice(9), level: "0ply" });
for (let i = 0; i < 400 && game.state.phase !== "over"; i++) {
await step(game, engine, { double: true });
}
games.push(["player-doubles", game]);
}
// Depuis une position particulière : l'ordinateur, au trait, double
// (position de référence « cube-double-take ») ; le joueur prend dans
// une branche, passe dans une autre.
{
const game = Game.start(engine, {
dice: seededDice(13),
level: "0ply",
setup: { position: "6O5uAAC4uzsAAA", firstTurn: "computer" },
});
await step(game, engine);
const offered = game.events.at(-1)?.id as string;
await playMoves(game, engine, 6);
game.goBackTo(offered);
await step(game, engine, { take: false });
games.push(["setup-cube", game]);
}
// Depuis une position particulière, videau déjà doublé chez le joueur,
// qui a la main ; partie laissée en cours, dés lancés et pas joués.
{
const game = Game.start(engine, {
dice: seededDice(17),
level: "0ply",
setup: {
position: "t+0OAADg/wcAQA",
firstTurn: "player",
cube: { value: 2, owner: "player" },
},
});
await playMoves(game, engine, 4);
await game.roll();
games.push(["setup-unfinished", game]);
}
let files = 0;
for (const [name, game] of games) {
const original = game.journal;
for (const [i, branch] of game.branches.entries()) {
game.switchBranch(branch.head);
const sgf = await exportSgf(engine, game.journal, {
playerName: "Vous",
computerName: "Ordinateur",
date: "2026-11-03",
});
// Après chaque coup, la position vue de celui qui a alors la main,
// et le résultat.
const expected = {
after: game.events.flatMap((e) => (e.type === "MovePlayed" ? [e.position] : [])),
result: game.state.result,
};
writeFileSync(join(out, `${name}-${i}.sgf`), sgf);
writeFileSync(join(out, `${name}-${i}.json`), `${JSON.stringify(expected)}\n`);
files++;
}
game.goBackTo(original.head as string);
}
console.log(`${files} parties exportées dans ${out}`);
}
await main();
+212
View File
@@ -0,0 +1,212 @@
// Tests du tutoriel des règles : le texte (docs/tutoriel/regles.md) se lit,
// chaque position est valide pour le vrai moteur, chaque coup illustré et
// chaque coup attendu est légal, chaque écran « à vous » accepte ses coups
// justes et refuse les autres, et la réponse au double est celle du moteur.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import type { Move } from "../src/domain/game/engine.js";
import {
canStillLeadTo,
illustratedMove,
MovePractice,
shouldTake,
tutorialBoard,
} from "../src/domain/learning/tutorial.js";
import { parseInline, parseTutorial, type TutorialScreen } from "../src/ui/tutorial-text.js";
import { loadNodeEngine } from "./support/node-engine.js";
const root = new URL("../../../", import.meta.url);
const tutorial = parseTutorial(readFileSync(new URL("docs/tutoriel/regles.md", root), "utf8"));
test("le texte du tutoriel a huit étapes, une fin, et ses écrans « à vous »", () => {
assert.equal(tutorial.steps.length, 8);
assert.deepEqual(
tutorial.steps.filter((s) => s.practice !== null).map((s) => s.number),
[3, 4, 6, 8],
);
assert.ok(tutorial.end.action !== "");
assert.ok(tutorial.end.paragraphs.length > 0);
for (const step of tutorial.steps) {
assert.ok(step.explain.paragraphs.length > 0, `étape ${step.number} sans texte`);
const practice = step.practice;
if (practice !== null) {
assert.ok(practice.paragraphs.length > 0, `étape ${step.number} : consigne absente`);
assert.ok(
practice.right.length > 0 && practice.wrong.length > 0,
`étape ${step.number} : phrases juste et refus absentes`,
);
assert.ok(
practice.expected.length > 0 || practice.answer !== null,
`étape ${step.number} : ni coup attendu, ni réponse au double`,
);
}
}
});
test("chaque mot du glossaire cité par le tutoriel a sa fiche", () => {
const screens = tutorial.steps.flatMap((s) => [s.explain, ...(s.practice ? [s.practice] : [])]);
const inlines = [
...screens.flatMap((s) => [...s.paragraphs.flat(), ...s.right, ...s.wrong]),
...tutorial.end.paragraphs.flat(),
];
const terms = inlines.flatMap((i) => (i.term === undefined ? [] : [i.term]));
assert.ok(terms.length >= 10, `seulement ${terms.length} liens vers le glossaire`);
for (const term of terms) {
assert.ok(
existsSync(new URL(`docs/glossaire/${term}.md`, root)),
`fiche ${term}.md absente`,
);
}
});
test("les liens et le gras du texte sont lus", () => {
assert.deepEqual(parseInline("Un [blot](blot.md) **seul**, voilà."), [
{ text: "Un " },
{ text: "blot", term: "blot" },
{ text: " " },
{ text: "seul", strong: true },
{ text: ", voilà." },
]);
});
test("un texte mal formé est refusé", () => {
assert.throws(() =>
parseTutorial("## 2. Sans la première\n\n- position: x\n\nTexte.\n\n## Fin\n"),
);
assert.throws(() => parseTutorial("## 1. Sans position\n\nTexte.\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Jet faux\n\n- position: x\n- jet: 7-1\n\n## Fin\n"));
assert.throws(() => parseTutorial("## 1. Sans fin\n\n- position: x\n\nTexte.\n"));
});
/** Tous les écrans, avec leur nom pour les messages. */
function screens(): [string, TutorialScreen][] {
return tutorial.steps.flatMap((s) => [
[`étape ${s.number}`, s.explain] as [string, TutorialScreen],
...(s.practice === null
? []
: [[`étape ${s.number}, à vous`, s.practice] as [string, TutorialScreen]]),
]);
}
test("chaque position du tutoriel est valide pour le moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
const board = await tutorialBoard(engine, screen.position, screen.onRoll);
const count = (side: "player" | "computer") => {
const s = board.sides[side];
return s.points.reduce((a, b) => a + b, 0) + s.bar + s.off;
};
assert.equal(count("player"), 15, `${name} : pions ivoire`);
assert.equal(count("computer"), 15, `${name} : pions noyer`);
}
});
test("chaque coup illustré est légal, et la partie de l'étape 7 finit par un gammon", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.move === null) {
continue;
}
assert.ok(screen.roll !== null, `${name} : coup sans jet`);
const { steps, after } = await illustratedMove(
engine,
screen.position,
screen.roll,
screen.move,
);
assert.equal(steps.boards.length, steps.halfMoves.length + 1, name);
if (name === "étape 7") {
assert.deepEqual(after.result, { winner: "player", kind: "gammon" });
} else {
assert.equal(after.result, null, name);
}
if (name === "étape 4") {
assert.ok(steps.hits.some(Boolean), "étape 4 : le coup illustré ne frappe pas");
}
}
});
test("chaque écran « à vous » accepte ses coups attendus et refuse les autres", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.expected.length === 0) {
continue;
}
assert.ok(screen.roll !== null, `${name} : pas de jet`);
const legal = await engine.legalMoves(screen.position, screen.roll);
for (const move of legal) {
const practice = await MovePractice.start(
engine,
screen.position,
screen.roll,
screen.expected,
);
let verdict = "continue";
for (const halfMove of move.halfMoves) {
verdict = await practice.play(halfMove);
if (verdict !== "continue") {
break;
}
}
const right = screen.expected.includes(move.notation);
assert.equal(verdict, right ? "right" : "wrong", `${name} : ${move.notation}`);
}
// Un coup attendu au moins, et un coup refusé au moins.
assert.ok(
legal.some((m) => !screen.expected.includes(m.notation)),
`${name} : rien à refuser`,
);
}
});
test("le joueur peut revenir en arrière après un coup refusé", async () => {
const engine = await loadNodeEngine();
const practice = await MovePractice.start(engine, "4HPwATDgc/ABMA", [3, 1], ["8/5 6/5"]);
assert.equal(await practice.play({ from: 24, to: 21 }), "wrong");
practice.reset();
assert.deepEqual(practice.pendingHalfMoves, []);
assert.equal(await practice.play({ from: 8, to: 5 }), "continue");
assert.equal(await practice.play({ from: 6, to: 5 }), "right");
});
test("la réponse au double de l'étape 8 est celle du moteur", async () => {
const engine = await loadNodeEngine();
for (const [name, screen] of screens()) {
if (screen.answer === null) {
continue;
}
assert.equal(screen.onRoll, "computer", `${name} : l'ordinateur doit avoir la main`);
assert.equal(await shouldTake(engine, screen.position), screen.answer === "take", name);
}
});
test("des demi-coups peuvent encore mener à un coup attendu, ou plus", () => {
const move = (notation: string, halfMoves: [number, number][]): Move => ({
notation,
halfMoves: halfMoves.map(([from, to]) => ({ from, to })),
position: notation,
});
const expected = [
move("8/5 6/5", [
[8, 5],
[6, 5],
]),
];
assert.ok(canStillLeadTo([], expected));
assert.ok(canStillLeadTo([{ from: 6, to: 5 }], expected));
assert.ok(!canStillLeadTo([{ from: 8, to: 7 }], expected));
assert.ok(
!canStillLeadTo(
[
{ from: 8, to: 5 },
{ from: 8, to: 5 },
],
expected,
),
);
});
+84 -49
View File
@@ -1,6 +1,6 @@
// Tests de ce qui, dans l'interface et le stockage, se vérifie sans
// navigateur : le plateau à dessiner, les mises en évidence, la lecture de
// l'aide, l'enregistrement de la partie.
// l'aide. Le stockage des parties a ses tests (storage.test.ts).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -9,9 +9,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import type { BoardSide } from "../src/domain/game/engine.js";
import { appendEvent, EMPTY_JOURNAL } from "../src/domain/game/events.js";
import { parseHalfMoves, START_POSITION } from "../src/domain/game/position.js";
import { createGameStore } from "../src/storage/game-store.js";
import { parseHalfMoves } from "../src/domain/game/position.js";
import {
boardModel,
moveTargets,
@@ -21,7 +19,15 @@ import {
pointColor,
pointSlot,
} from "../src/ui/board-model.js";
import { parseFeature, parseGlossaryEntry, parseGlossaryIndex } from "../src/ui/help-text.js";
import { FEATURES } from "../src/ui/help.js";
import { HELP_QUESTIONS, helpHash } from "../src/ui/help-context.js";
import {
matchesSearch,
parseFeature,
parseGlossaryEntry,
parseGlossaryIndex,
} from "../src/ui/help-text.js";
import { slug } from "../src/ui/router.js";
import { TEXTS } from "../src/ui/texts.js";
// Compilé dans build/web/tests/ : la racine du dépôt est trois dossiers plus haut.
@@ -145,21 +151,77 @@ test("l'aide lit le scénario tel qu'il est écrit dans docs/scenarios/", () =>
const feature = parseFeature(read("docs/scenarios/jouer-une-partie.feature"));
assert.equal(feature.title, "Jouer une partie contre l'ordinateur");
assert.match(feature.description[0] ?? "", /^Vous jouez une partie complète/);
assert.equal(feature.scenarios[0]?.title, "Commencer une partie");
assert.equal(feature.scenarios[0]?.title, "Comment commence une partie ?");
assert.equal(feature.scenarios[0]?.position, "4HPwATDgc/ABMA");
assert.deepEqual(feature.scenarios[0]?.steps, [
"Quand vous commencez une nouvelle partie",
"Alors les pions sont en position de départ",
"Et le premier jet de dés désigne qui commence",
]);
const refused = feature.scenarios.find((s) => s.title === "Un coup interdit est refusé");
assert.match(refused?.description[0] ?? "", /^La case 12 est tenue/);
const refused = feature.scenarios.find(
(s) => s.title === "Pourquoi je ne peux pas poser mon pion sur cette flèche ?",
);
assert.match(
refused?.description[0] ?? "",
/^Une flèche où l'ordinateur a au moins deux pions/,
);
assert.equal(refused?.steps[0], "Étant donné que c'est à vous de jouer avec le jet 3-1");
assert.equal(feature.scenarios[2]?.position, null);
// Chaque scénario a des étapes : aucune n'a été prise pour du texte libre.
for (const scenario of feature.scenarios) {
assert.ok(scenario.steps.length >= 2, scenario.title);
}
});
test("aide : chaque scénario affiché est une question avec sa réponse", () => {
const listed = read("docs/scenarios/README.md");
const ids = new Set<string>();
for (const url of FEATURES) {
assert.ok(
listed.includes(`](${url.replace("docs/scenarios/", "")})`),
`${url} hors de l'index`,
);
const feature = parseFeature(read(url));
assert.ok(feature.description.length > 0, `${url} : pas d'introduction`);
for (const scenario of feature.scenarios) {
// La réponse se lit seule : pas de vocabulaire technique.
assert.ok(scenario.description.length > 0, `${scenario.title} : pas de réponse`);
for (const word of ["scénario", "événement", "moteur", "Étant donné"]) {
assert.ok(
!scenario.description.join(" ").includes(word),
`${scenario.title} : « ${word} » dans la réponse`,
);
}
const id = slug(scenario.title);
assert.ok(id !== "" && !ids.has(id), `${scenario.title} : identifiant ${id} en double`);
ids.add(id);
}
}
});
test("aide contextuelle : chaque question ouverte depuis la partie existe dans les scénarios", () => {
const titles = FEATURES.flatMap((url) => parseFeature(read(url)).scenarios.map((s) => s.title));
for (const title of Object.values(HELP_QUESTIONS)) {
assert.ok(titles.includes(title), `« ${title} » n'est plus le titre d'un scénario`);
}
assert.equal(helpHash("stuck"), "#/aide/je-ne-peux-rien-jouer-avec-mes-des");
assert.equal(helpHash(null), "#/aide");
});
test("glossaire : nom anglais, renvois, recherche sans accents", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/camp.md"));
assert.ok(entry !== null);
assert.equal(entry.english, "Home board");
assert.deepEqual(
entry.seeAlso.map((s) => s.name),
["sortie", "barre", "point"],
);
assert.ok(matchesSearch(entry, "camp"));
assert.ok(matchesSearch(entry, "HOME"));
assert.ok(matchesSearch(entry, "fleches sortir"));
assert.ok(!matchesSearch(entry, "videau"));
});
test("l'aide lit chaque fiche de l'index du glossaire", () => {
const files = parseGlossaryIndex(read("docs/glossaire/README.md"));
assert.ok(files.includes("videau.md"));
@@ -169,51 +231,24 @@ test("l'aide lit chaque fiche de l'index du glossaire", () => {
assert.ok(entry !== null, file);
assert.ok(entry.brief.length > 10, file);
}
assert.deepEqual(parseGlossaryEntry(read("docs/glossaire/videau.md")), {
term: "Videau",
brief: "le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
});
const videau = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.equal(videau?.term, "Videau");
assert.equal(videau?.english, "Cube");
assert.equal(
videau?.brief,
"le dé à doubler, marqué 2, 4, 8, 16, 32 et 64 : il indique la valeur actuelle de la partie.",
);
assert.equal(parseGlossaryEntry("pas une fiche"), null);
});
test("la partie enregistrée se relit ; un enregistrement illisible est ignoré", () => {
const data = new Map<string, string>();
const storage = {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
};
const store = createGameStore(storage);
assert.equal(store.load(), null);
const journal = appendEvent(EMPTY_JOURNAL, {
type: "GameStarted",
position: START_POSITION,
cube: { value: 1, owner: null },
firstTurn: null,
});
store.save(journal);
assert.deepEqual(store.load(), journal);
for (const key of data.keys()) {
data.set(key, "{pas du JSON");
}
assert.equal(store.load(), null);
// Un stockage plein ou interdit ne casse pas la partie.
const full = createGameStore({
getItem: () => null,
setItem: () => {
throw new Error("plein");
},
});
assert.doesNotThrow(() => full.save(journal));
});
test("les textes de fin de partie emploient les mots du glossaire", () => {
assert.equal(TEXTS.youWin("gammon", 2), "Vous gagnez un gammon : 2 points.");
assert.equal(TEXTS.endTitle("computer", "gammon", false), "L'ordinateur gagne un gammon");
assert.equal(TEXTS.endDetail("computer", 8, 4), "8 points pour lui, videau à 4.");
assert.equal(TEXTS.endTitle("player", "single", false), "Vous gagnez la partie simple");
assert.equal(TEXTS.endDetail("player", 1, 1), "1 point pour vous.");
assert.equal(TEXTS.endTitle("player", "single", true), "L'ordinateur passe");
assert.equal(
TEXTS.youLose("single", 1),
"L'ordinateur gagne la partie simple : 1 point pour lui.",
);
assert.equal(
TEXTS.computerMoved([6, 4], "24/18 13/9", 312),
"L'ordinateur a joué 6-4 : 24/18 13/9 (0,3 s).",
TEXTS.computerMoved([6, 4], "24/18 13/9"),
"L'ordinateur a joué 6-4 : 24/18 13/9.",
);
});
@@ -94,3 +94,59 @@ les justifie pas.
dépendances seront fixés avec le socle, à la fin de l'étape 0.
- Les sous-agents reçoivent ces règles dans leurs consignes ; `CLAUDE.md` y
renvoie.
## Amendements
### 2026-11-03 — Le stockage des parties est un format durable
Avec les parties enregistrées (étape 1b), le schéma IndexedDB (base
« ustagammon », version 1 : magasins `games`, `journals`, `settings`) et les
clés `localStorage` de l'application (`ustagammon.settings`,
`ustagammon.last-board`, et le repli des parties) rejoignent le journal
d'événements parmi les formats durables : toute modification passe par une
montée de version avec migration testée, jamais par une réécriture qui
perdrait les parties des joueurs.
### 2026-11-09 — Version 2 des événements : niveau et match
Avec le choix du niveau et les matchs (étape 1c), le format des événements
passe à la version 2 (`EVENT_VERSION`, `app/src/domain/game/events.ts`) :
- `GameStarted` porte le niveau de l'ordinateur (`level`, un niveau de gnubg)
et, pour une partie de match, le match à son début (`match` : longueur,
score, partie Crawford) ;
- `GameEnded` porte, dans un match, le match après la partie (score, fin du
match, partie Crawford suivante), tel que le moteur l'a calculé.
Chaque partie d'un match est une partie à part entière, avec son journal et
son enregistrement ; la suivante reprend le match que la précédente a laissé.
La migration (`migrateJournal`, testée dans `app/tests/game.test.ts`) passe un
journal de la version 1 à la version 2 au moment de le reprendre : le niveau,
implicite, devient explicite (2 plis, le seul qui existait) ; rien d'autre ne
change, une partie de la version 1 étant une partie libre. Le schéma IndexedDB
ne change pas (version 1) : le résumé d'une partie gagne un champ facultatif
`match`, recalculé à chaque enregistrement, et les réglages
(`ustagammon.settings`) quatre champs (`gameKind`, `matchLength`, `level`,
`masterAdvice`) qui ont une valeur par défaut quand ils manquent.
### 2026-11-10 — Le conseil du maître rattaché au journal
Avec le conseil du maître (étape 1d), le journal d'une partie
(`GameJournal`, `app/src/domain/game/events.ts`) gagne un champ facultatif
`advice` : pour chaque décision du joueur analysée (coup, jet sans doubler,
double, prise ou refus d'un double), l'analyse du moteur à 2 plis, rangée
sous l'identifiant de l'événement de la décision
(`app/src/domain/game/advice.ts`). Ce n'est pas un événement : l'analyse
arrive après coup, d'un second moteur, et ne change pas la partie ; les
événements gardent leur version (2). Le bilan de fin de partie (1e) la
relira sans recalcul.
- Chaque conseil porte sa version (`ADVICE_VERSION`, 1). À la reprise
(`migrateJournal`), un conseil d'une autre version, ou rattaché à un
événement absent, est oublié : ce n'est qu'une analyse, qui peut être
refaite. Un journal enregistré avant l'étape 1d n'a pas de champ `advice`
et se reprend tel quel. Testé dans `app/tests/advice.test.ts`.
- Ajouter un événement, revenir en arrière ou changer de branche garde les
conseils ; un conseil reste attaché à son événement, même hors de la
branche courante.
- Le schéma IndexedDB ne change pas : le journal est enregistré tel quel.
+132
View File
@@ -0,0 +1,132 @@
# 0013. Une position du plateau dans l'adresse de la page
- **Statut** : accepté
- **Date** : 2026-11-03
- **Décideurs** : Pierre (besoin), Claude Code (format)
## Contexte
Pierre a demandé, au premier test sur le téléphone (2026-11-03), qu'avec la
seule adresse on arrive à une position du plateau : chaque position devient un
lien direct, à partager, à mettre dans une leçon ou dans un prompt pour une IA.
GNU Backgammon décrit déjà toute position par deux identifiants publics et
documentés : le Position ID (14 caractères, les pions) et le Match ID
(12 caractères : videau, score, longueur du match, joueur au trait, dés). Le
moteur sait les lire et les écrire (`engine.h`).
## Décision
- L'adresse porte la position dans le fragment (après `#`) :
`https://ustagammon.sans.pub/#position=<Position ID>&match=<Match ID>`,
chaque identifiant encodé avec `encodeURIComponent` (le base64 de gnubg
contient `+` et `/`). `match` est facultatif : sans lui, partie libre, videau
au centre, joueur du bas au trait, sans dés.
- Ouvrir un tel lien affiche la position, non jouable, avec deux actions :
« Jouer à partir d'ici » (nouvelle partie contre l'ordinateur depuis cette
position, sans toucher à la partie en cours) et « Retour à ma partie ».
- Toute position affichée (partie, coup de l'ordinateur, analyse, leçon) peut
être copiée en lien (« Copier le lien de la position »).
- Un identifiant illisible pour le moteur donne un message clair et le retour
à la partie, jamais une page cassée.
## Alternatives écartées
- **Paramètres de requête (`?position=`)** : ils changent l'adresse demandée
au serveur et au service worker ; hors ligne, la page en cache ne
correspondrait plus. Le fragment ne quitte jamais le navigateur.
- **XGID (format d'eXtreme Gammon)** : répandu, mais un seul identifiant de
plus à décoder ; gnubg l'importe, son import pourra s'ajouter plus tard
(outil « import d'un identifiant GNU ou XGID » du cadrage).
- **Format maison plus lisible** : il faudrait le décoder sans le moteur, donc
réimplémenter une règle de représentation (ADR 0007).
## Conséquences
- Le fragment est lu au chargement et à chaque changement (`hashchange`) ; le
domaine reçoit une position et un Match ID, le moteur les valide.
- Les liens restent valables tant que GNU Backgammon garde ces formats, stables
depuis des années.
- Les liens contiennent la position, rien d'autre : pas de donnée personnelle.
## Amendements
### 2026-11-03 — Une adresse pour chaque écran, des liens plutôt que des états
Demandé par Pierre : « il faudra vraiment que chaque page (l'aide, etc.) ait
une URL. Utilise des liens accessibles, pas des états. » Aucun lien de
position n'a encore circulé : le format ci-dessus est remplacé par un schéma
unique pour tous les écrans, avant sa première mise en œuvre.
- Chaque écran a son adresse, dans le fragment (pour les mêmes raisons
qu'au-dessus : hors ligne, service worker, aucune configuration serveur) :
| Écran | Adresse |
| --- | --- |
| Partie en cours | `#/` |
| Accueil | `#/accueil` |
| Aide (onglet Jouer) | `#/aide`, une question : `#/aide/<identifiant>` |
| Les mots du jeu | `#/glossaire`, un terme : `#/glossaire/<fichier sans .md>` |
| À propos | `#/a-propos` |
| Tutoriel | `#/tutoriel`, une étape : `#/tutoriel/<n>` |
| Mes parties | `#/parties`, une partie : `#/parties/<id>` |
| Réglages / menu | `#/menu` |
| Une position | `#/position/<Position ID>/<Match ID>` (Match ID facultatif) |
Les identifiants de gnubg sont encodés avec `encodeURIComponent`. Les
écrans à venir (analyse, leçons, progression) suivront le même schéma.
- On change d'écran par de vrais liens (`<a href="#/aide">`), pas par des
boutons qui modifient un état caché : le lien se partage, s'ouvre dans un
nouvel onglet, se lit aux lecteurs d'écran comme un lien, et le bouton
« retour » du téléphone ramène à l'écran précédent. Les actions de jeu
(lancer, jouer, doubler) restent des boutons : elles ne sont pas des écrans.
- Les feuilles (menu, explication) et panneaux qui sont des écrans à part
entière ont aussi leur adresse ; une confirmation ou un bandeau éphémère
n'en a pas.
- Une adresse inconnue mène à la partie en cours avec un message clair.
- L'adresse de l'écran courant est la source de vérité de la navigation :
l'application l'écoute (`hashchange`) et dessine l'écran correspondant.
### 2026-11-09 — Adresses du tutoriel et de l'installation
Ajoutées avec l'accueil et le tutoriel (étape 0e.5), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Écran « à vous » d'une étape du tutoriel | `#/tutoriel/<n>/a-vous` |
| Fin du tutoriel | `#/tutoriel/fin` |
| Marche à suivre pour installer l'application | `#/installer` |
Lancée sans adresse, l'application ouvre l'accueil (`#/accueil`) ; `#/` reste
la partie en cours, pour que les liens existants ne changent pas.
### 2026-11-09 — Adresse de l'écran « Nouvelle partie »
Ajoutée avec le choix de la partie et le match (étape 1c), dans le même
schéma :
| Écran | Adresse |
| --- | --- |
| Nouvelle partie : type de partie, niveau, conseil du maître | `#/nouvelle-partie` |
Le menu, l'accueil et « Nouvelle partie » en fin de partie y mènent. « Commencer »
remplace cette adresse par celle de la partie (`#/`) : le retour du téléphone
ne rouvre pas l'écran. Le lien d'une position d'une partie de match porte le
Match ID du match (longueur, score, partie Crawford), écrit par le moteur.
### 2026-11-10 — Adresse de la feuille d'explication du maître
Ajoutée avec le conseil du maître (étape 1d), dans le même schéma :
| Écran | Adresse |
| --- | --- |
| Feuille d'explication du maître sur une décision de la partie en cours | `#/explication/<événement>` |
`<événement>` est l'identifiant, dans le journal de la partie en cours, de
l'événement de la décision commentée (le coup, le double, la prise…). La
ligne du maître est un lien vers cette adresse ; la feuille monte au-dessus
de la partie, comme le menu, et « Fermer » (ou le retour du téléphone)
revient à la partie. Rechargée, l'adresse rouvre la même explication : le
conseil est enregistré dans le journal (ADR 0007, amendement du
2026-11-10). Un événement sans conseil donne un message, jamais une page
cassée. L'adresse n'a de sens que sur l'appareil qui a joué la partie : ce
n'est pas un lien à partager (le lien de la position, lui, se partage).
+1
View File
@@ -19,3 +19,4 @@ l'ancien passe au statut « remplacé par NNNN ». Voir l'ADR 0001.
| [0010](0010-identite-visuelle.md) | Identité visuelle : symbole, logotype, palette | accepté |
| [0011](0011-lecons-conseil-et-bilan.md) | Leçons numérotées, conseil du maître après le coup, bilan de fin de partie | accepté |
| [0012](0012-mode-immersif.md) | Mode immersif : le plateau en paysage, sur demande | accepté |
| [0013](0013-position-dans-l-adresse.md) | Une position du plateau dans l'adresse de la page | accepté |
+25 -15
View File
@@ -17,10 +17,10 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| Sous-étape | Contenu | Dépend de |
| --- | --- | --- |
| 1a. Moteur pour l'analyse | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages (fait) | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
| 1d. Conseil du maître (fait) | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
@@ -36,20 +36,30 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
ton des phrases du maître est encourageant ; l'espace qu'il occupe est
réservé dans la mise en page (revue UX 0e.2), pas en fenêtre surgissante.
## Questions pour Pierre (ne bloquent pas le démarrage)
## Décisions de Pierre (2026-11-09)
- **Indice** : le cadrage le liste dans le module Jeu, l'ADR 0011 l'a laissé
« à concevoir séparément ». Il n'est pas dans l'étape 1 sans décision.
- **Questionnaire de premier lancement** (choix du parcours) : le parcours 1
seul existe en V1 ; proposé : un simple choix « Je découvre » / « Je connais
les règles » qui oriente vers le tutoriel ou la partie.
- **Seuils des appréciations** : proposés d'après ceux de GNU Backgammon
(douteux 0,04, mauvais 0,08, très mauvais 0,16 d'équité), à calibrer.
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
- **Indice avant le coup** : pas en V1 (« l'idée est bonne »), à reprendre en
V1.5 ou V2. Le conseil du maître après le coup reste le seul mécanisme de
l'étape 1.
- **Premier lancement** : un choix « Je découvre » / « Je connais les règles »
(fait en 0e.5).
- **Seuils des appréciations** : ceux de GNU Backgammon (douteux 0,03,
mauvais 0,06, très mauvais 0,12, `arSkillLevel`), exposés par le moteur
depuis 1a ; à recalibrer après les retours de Pierre sur de vraies parties.
Le niveau le plus grave se dit « Grosse erreur », pas « gaffe ».
- **Leçons** : pas de relecture préalable par un joueur fort ; elles sont
livrées telles quelles et améliorées sur retours d'experts. Garde-fous
maintenus : chaque réponse attendue est le meilleur coup du moteur, vérifié
par un test, et le texte n'affirme rien que le moteur ne confirme.
- **Plein écran par défaut dans l'app installée** : non (pas de
`display_override` fullscreen) ; la barre d'état reste visible en portrait.
- **Validés** : le texte du tutoriel et toutes les maquettes du canevas
(accueil, installation, tutoriel, aide, écrans 1c à 1i), ainsi que le cadre
des phrases du maître (section « Les phrases du maître » de
`docs/identite/charte/ecrans-etape-1.md`).
## Ordre de réalisation
1a et 1b d'abord (fondations, sans conflit avec l'interface), en parallèle des
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g après la validation des
maquettes d'accueil et de tutoriel par Pierre ; 1h et 1i ; 1j en dernier. Chaque
sous-étapes 0e. Puis 1c, 1d, 1e, 1f dans cet ordre ; 1g (maquettes validées par Pierre) ; 1h et 1i ; 1j en dernier. Chaque
sous-étape a son plan dans `docs/plans/` et sa merge request.
+1 -4
View File
@@ -23,16 +23,13 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
| Sous-étape | Contenu | État | Plan |
| --- | --- | --- | --- |
| 0e.3 | Révision UX arbitrée (session « identité », sur délégation de Pierre) : portrait stable, bandeaux de compte de points, menu en feuille et nouvelle partie annulable, vibrations, écran allumé, paysage « plateau = écran » dès la rotation (ADR 0012 amendé) | ⏳ | `plans/2026-11-03-revision-ux.md` |
| 0e.4 | Position dans l'adresse : chaque position du plateau a un lien direct (nouvel ADR pour le format) | ⏳ | |
| 0e.5 | Page d'accueil, installation en un toucher (« Ajouter à l'écran d'accueil », proposée par l'app), tutoriel des règles très didactique avec plateaux illustrés, aide réécrite sur un ton plus humain ; textes à valider par Pierre | ⏳ | |
## Étapes suivantes
| Étape | Contenu | État |
| --- | --- | --- |
| 1. V1 Jouer et comprendre | Détail et ordre dans `docs/etape-1.md` : moteur pour l'analyse, parties enregistrées et branches, match et réglages, conseil du maître, fin de partie et analyse, prompts, accueil, tutoriel et leçons, progression, plateau soigné, plan de test | 🔄 |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression | ⏳ |
| 2. V1.5 Apprendre | Parcours 2, exercices, progression ; idée retenue par Pierre : un indice avant le coup (sans la réponse) | ⏳ |
| 3. V2 Perfectionnement et IA | Parcours 3 et 4, rollouts, révision espacée | ⏳ |
Chaque étape reçoit son fichier `docs/etape-N.md` avant de démarrer.
+8
View File
@@ -35,11 +35,15 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| Terme | Anglais (code) |
| --- | --- |
| [Appréciation](appreciation.md) | Skill |
| [Backgammon](backgammon.md) | Backgammon |
| [Barre](barre.md) | Bar |
| [Blot](blot.md) | Blot |
| [Branche](branche.md) | Branch |
| [Camp](camp.md) | Home board |
| [Chance](chance.md) | Luck |
| [Compte de points](compte-de-points.md) | Pip count |
| [Conseil du maître](conseil-du-maitre.md) | Advice |
| [Coup](coup.md) | Move |
| [Demi-coup](demi-coup.md) | Half-move |
| [Course](course.md) | Race |
@@ -50,9 +54,12 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Identifiant de match](identifiant-de-match.md) | Match ID |
| [Identifiant de position](identifiant-de-position.md) | Position ID |
| [Jet](jet.md) | Roll |
| [Lien de position](lien-de-position.md) | Position link |
| [Match](match.md) | Match |
| [Moteur](moteur.md) | Engine |
| [Niveau de jeu](niveau-de-jeu.md) | Level |
| [Partie](partie.md) | Game |
| [Partie enregistrée](partie-enregistree.md) | Saved game |
| [Passer](passer.md) | Drop |
| [Pion](pion.md) | Checker |
| [Plateau](plateau.md) | Board |
@@ -61,5 +68,6 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
| [Prendre](prendre.md) | Take |
| [Règle de Crawford](regle-de-crawford.md) | Crawford rule |
| [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule |
| [SGF](sgf.md) | SGF |
| [Sortie](sortie.md) | Bear-off |
| [Videau](videau.md) | Cube |
+14
View File
@@ -0,0 +1,14 @@
---
term: Appréciation
english: Skill
code: Skill
---
# Appréciation
**En bref** : le jugement du moteur sur un coup ou une décision de videau, d'après l'équité perdue par rapport au meilleur choix.
GNU Backgammon distingue trois degrés, selon l'équité perdue : douteux (plus
de 0,03), mauvais (plus de 0,06) et très mauvais (plus de 0,12). En dessous,
il n'y a rien à signaler.
Voir aussi : [Équité](equite.md), [Coup](coup.md), [Videau](videau.md)
+6 -1
View File
@@ -1,9 +1,14 @@
---
term: Branche
english: Branch
code: Branch
---
# Branche
**En bref** : suite de coups rejouée à partir d'un moment d'une partie (« Rejouer d'ici ») ; la partie d'origine reste intacte.
Voir aussi : [Événement de partie](evenement-de-partie.md)
Une partie garde toutes ses branches : vous pouvez revenir à l'une ou à
l'autre depuis « Mes parties », et l'export SGF les contient toutes, en
variantes.
Voir aussi : [Événement de partie](evenement-de-partie.md), [Partie enregistrée](partie-enregistree.md)
+12
View File
@@ -0,0 +1,12 @@
---
term: Camp
english: Home board
---
# Camp
**En bref** : les six dernières flèches d'un joueur, avant sa sortie : il y rassemble ses pions pour les sortir, et un pion adverse frappé doit y rentrer.
Votre camp est en bas à droite (vos flèches 1 à 6) ; celui de l'ordinateur,
en haut à droite (vos flèches 19 à 24), où rentrent vos pions frappés.
Voir aussi : [Sortie](sortie.md), [Barre](barre.md), [Point](point.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Chance
english: Luck
code: Luck
---
# Chance
**En bref** : ce qu'un jet de dés fait gagner ou perdre, en équité, par rapport à la moyenne de tous les jets possibles.
Un jet est dit chanceux s'il fait gagner plus de 0,3 d'équité par rapport à la
moyenne, très chanceux au-delà de 0,6 ; malchanceux ou très malchanceux dans
l'autre sens. Comparer la chance d'une partie à ses erreurs aide à savoir si
on a perdu à cause des dés ou de son jeu.
Voir aussi : [Équité](equite.md), [Jet](jet.md)
+18
View File
@@ -0,0 +1,18 @@
---
term: Conseil du maître
english: Advice
code: Advice
---
# Conseil du maître
**En bref** : après chacun de vos coups et chacune de vos décisions de videau, une ligne qui donne l'appréciation du moteur et ce que le maître aurait joué.
Le maître ne parle jamais avant votre coup : il commente ce que vous venez de
jouer, avec un mot (bon coup, imprécision, erreur, grosse erreur) choisi
d'après l'[équité](equite.md) perdue par rapport au meilleur choix (voir
[Appréciation](appreciation.md)). Toucher la ligne ouvre l'explication : votre
coup et celui du maître, joués sur le plateau, et les chiffres du
[moteur](moteur.md) si vous les demandez. Le conseil se coupe dans le menu ou
sur l'écran « Nouvelle partie ».
Voir aussi : [Appréciation](appreciation.md), [Coup](coup.md), [Videau](videau.md)
+2 -1
View File
@@ -1,9 +1,10 @@
---
term: Identifiant de match
english: Match ID
code: MatchId
---
# Identifiant de match
**En bref** : code de 12 caractères qui décrit le reste de l'état du jeu : score, videau, joueur au trait, dés ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de position](identifiant-de-position.md)
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Lien de position](lien-de-position.md)
+1 -1
View File
@@ -7,4 +7,4 @@ code: PositionId
**En bref** : code de 14 caractères, par exemple 4HPwATDgc/ABMA, qui décrit la place de tous les pions ; c'est le format de GNU Backgammon.
Voir aussi : [Identifiant de match](identifiant-de-match.md)
Voir aussi : [Identifiant de match](identifiant-de-match.md), [Lien de position](lien-de-position.md)
+15
View File
@@ -0,0 +1,15 @@
---
term: Lien de position
english: Position link
code: PositionLink
---
# Lien de position
**En bref** : une adresse qui ouvre directement une position du plateau, à partager, à garder ou à coller dans une question ; elle contient l'identifiant de position et l'identifiant de match.
Le lien montre la position du point de vue du joueur qui a la main : c'est
vous, en bas. « Jouer à partir d'ici » commence une nouvelle partie depuis
cette position, sans toucher à votre partie en cours. Le lien ne contient que
la position : aucune donnée personnelle.
Voir aussi : [Identifiant de position](identifiant-de-position.md), [Identifiant de match](identifiant-de-match.md)
+8 -1
View File
@@ -1,9 +1,16 @@
---
term: Match
english: Match
code: Match
---
# Match
**En bref** : suite de parties jouée jusqu'à ce qu'un joueur atteigne un nombre de points fixé à l'avance, par exemple 5 points.
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md)
Chaque partie rapporte ses points à son gagnant : 1 pour une partie simple,
2 pour un gammon, 3 pour un backgammon, multipliés par la valeur du videau.
Le premier qui atteint la longueur du match le gagne. Dans Ustagammon, un
match se joue en 3, 5, 7 ou 11 points ; le score, la fin du match et la
partie Crawford sont calculés par GNU Backgammon.
Voir aussi : [Partie](partie.md), [Règle de Crawford](regle-de-crawford.md), [Identifiant de match](identifiant-de-match.md)
+33
View File
@@ -0,0 +1,33 @@
---
term: Niveau de jeu
english: Level
code: Level
---
# Niveau de jeu
**En bref** : la force de l'ordinateur, du débutant, qui commet volontairement des erreurs, au plus fort, qui réfléchit plusieurs plis à l'avance.
Les niveaux sont ceux de GNU Backgammon. Les quatre premiers (débutant,
amateur, intermédiaire, avancé) jugent la position sans regarder les jets
suivants et ajoutent un « bruit » à leur jugement, de plus en plus faible :
c'est ce qui leur fait choisir parfois un moins bon coup. Ce bruit dépend de
la position seule : dans la même position, avec le même jet, l'ordinateur
joue toujours le même coup. Les niveaux suivants, sans bruit, réfléchissent
de 0 à 4 plis.
L'écran « Nouvelle partie » en propose six :
| Nom montré | Niveau de GNU Backgammon |
| --- | --- |
| Débutant | beginner (0 pli, bruit 0,060) |
| Amateur | casual play (0 pli, bruit 0,050) |
| Intermédiaire | intermediate (0 pli, bruit 0,040) |
| Avancé | advanced (0 pli, bruit 0,015) |
| Expert | 0 pli sans bruit, l'ancien « expert » de gnubg |
| Champion | 2 plis, l'ancien « world class » de gnubg |
Champion est le plus fort qui joue sans attente sur le téléphone ; 3 et
4 plis ne sont pas proposés. L'ordinateur double, prend et passe comme celui
de GNU Backgammon, au même niveau.
Voir aussi : [Pli](pli.md), [Moteur](moteur.md)
+14
View File
@@ -0,0 +1,14 @@
---
term: Partie enregistrée
english: Saved game
code: SavedGameSummary
---
# Partie enregistrée
**En bref** : une partie gardée sur votre téléphone, finie ou en cours, avec toutes ses branches ; vous la retrouvez dans « Mes parties ».
Chaque partie est enregistrée après chacune de vos actions : fermer
l'application ne perd rien. Rien ne quitte le téléphone, sauf si vous
exportez une partie.
Voir aussi : [Partie](partie.md), [Branche](branche.md), [SGF](sgf.md)
+8
View File
@@ -6,4 +6,12 @@ english: Crawford rule
**En bref** : en match, dans la partie qui suit le moment où un joueur arrive à un point de la victoire, personne ne peut doubler.
Cette partie s'appelle la partie Crawford. Elle protège celui qui arrive
au bord de la victoire : sans elle, son adversaire doublerait aussitôt,
puisqu'il n'a plus rien à perdre. Après la partie Crawford, le videau
revient, et celui qui est mené peut de nouveau doubler. La règle ne joue
qu'une fois par match ; si les deux joueurs arrivent à un point de la
victoire, il n'y a pas de partie Crawford. Dans Ustagammon, elle s'applique
toujours aux matchs, comme dans GNU Backgammon.
Voir aussi : [Match](match.md), [Doubler](doubler.md)
+6 -1
View File
@@ -6,4 +6,9 @@ english: Jacoby rule
**En bref** : en partie libre, hors match, un gammon ou un backgammon ne compte que si le videau a été utilisé.
Voir aussi : [Gammon](gammon.md), [Videau](videau.md)
Tant que personne n'a doublé, un gammon ou un backgammon ne rapporte qu'un
point. La règle encourage à doubler une position forte plutôt qu'à jouer le
gammon sans risque. Elle s'applique toujours aux parties libres d'Ustagammon,
jamais aux matchs.
Voir aussi : [Gammon](gammon.md), [Videau](videau.md), [Match](match.md)
+13
View File
@@ -0,0 +1,13 @@
---
term: SGF
english: SGF
---
# SGF
**En bref** : format de fichier des parties de GNU Backgammon ; une partie exportée en SGF s'ouvre dans GNU Backgammon pour l'analyser.
SGF (Smart Game Format) est un format commun à plusieurs jeux. Les branches
d'une partie y deviennent des variantes ; GNU Backgammon ouvre la branche
courante.
Voir aussi : [Partie enregistrée](partie-enregistree.md), [Moteur](moteur.md)
+2
View File
@@ -14,6 +14,8 @@ Décisions : ADR 0010. Fichiers du logo et des icônes : `../logo/`.
| Fichier | Contenu |
| --- | --- |
| `guide-de-marque.md` | Règles d'usage : ton, couleurs, typographie, formes, mouvement, icônes, composants |
| `ecrans-etape-1.md` | Écrans de l'étape 1, lot par lot : nouvelle partie et match (1c), conseil du maître (1d), bilan et analyse (1e), IA (1f), leçons (1g), progression (1h), gestes (1i) |
| `accueil-tutoriel-aide.md` | Accueil, installation sans intrusion, tutoriel des règles (forme et première version du texte), aide en questions |
| `recette-ux.md` | Critères de recette design de l'écran de jeu, à cocher sur les captures et sur le téléphone |
| `interaction.md` | Comportement : réponse immédiate, annuler plutôt que confirmer, mouvement, vibrations, clavier, mises en page de l'écran de jeu (portrait, paysage, ordinateur), pictogrammes |
| `tokens.json` | Toutes les valeurs, thèmes clair et sombre, avec une note d'usage par couleur |
@@ -0,0 +1,176 @@
# Accueil, installation, tutoriel et aide
Règles de l'étape 0e.5 (et 1g pour le tutoriel). Maquettes : rangée « Étape
0e.5 » du canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe et
l'artboard « Accueil ». Les textes sont une **première version, à valider par
Pierre**. Comportement général : `interaction.md`.
## Accueil
**Premier lancement** (aucune partie ni leçon enregistrée) : le logo, le
plateau en position de départ, « Bienvenue à la table », une phrase, puis deux
grands choix :
- **« Je découvre »** (action principale) → le tutoriel, étape 1 ;
- **« Je connais les règles »** → une partie tout de suite.
Le choix ne revient plus. Le tutoriel reste dans le menu.
**Ensuite**, de haut en bas :
1. **La partie en cours** : miniature du plateau, comptes de points,
« Reprendre la partie » (seule action principale de l'écran) et
« Nouvelle partie ». Sans partie en cours : « Nouvelle partie » devient
l'action principale.
2. **L'invitation à installer**, si elle doit apparaître (voir plus bas).
3. **La prochaine leçon** : une seule carte (titre, état, progression,
« Continuer » en secondaire), avec le lien « Toutes les leçons ».
4. **La progression résumée**, en une ligne `caption` : « 1 leçon terminée
sur 8 · 12 parties jouées · 4 gagnées ».
5. En pied de page, la phrase de l'Atelier du Huit.
Pas de chiffres d'erreur ou d'équité sur l'accueil : ils viennent avec
l'analyse.
## Installation
Le but : proposer l'installation au bon moment, une fois, sans jamais
interrompre.
- **Jamais** au premier lancement, jamais pendant une partie, jamais en
fenêtre qui s'impose.
- **Quand** : à partir de la fin de la **première partie terminée**, quand le
joueur a vu ce que vaut l'app.
- **Où** : une carte compacte sur l'accueil (symbole, « Installer
Ustagammon », « Sur l'écran d'accueil, en plein écran, sans réseau. »,
bouton « Installer » et croix « Plus tard »), et une ligne « Installer
l'application » dans le menu, toujours présente tant que l'app n'est pas
installée.
- **« Plus tard »** masque la carte ; elle revient une seule fois, après
trois parties de plus. Ensuite, seul le menu la propose.
- **Chrome Android** : le bouton déclenche l'invite du système, gardée depuis
l'événement `beforeinstallprompt`. Sans cet événement (navigateur qui ne
propose pas l'installation), le même bouton ouvre la marche à suivre.
- **Firefox Android** : une feuille en trois étapes numérotées (menu ⋮,
« Installer » ou « Ajouter à l'écran d'accueil », confirmer), qui se ferme
par « J'ai compris ».
- **iPhone (Safari)** : même feuille, avec ces étapes : bouton de partage,
« Sur l'écran d'accueil », « Ajouter ».
- **Une fois installée** (`display-mode: standalone`, ou événement
`appinstalled`) : plus aucune proposition, la ligne du menu disparaît.
## Tutoriel des règles
### Forme
- Huit étapes, chacune en **écrans courts** : un titre, un plateau illustré,
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
- En haut : une barre de progression en huit segments (`accent` pour les
étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
« Continuer ».
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
plateau est le composant de l'app, non jouable sauf sur les écrans
« à vous ». Aucune règle n'est codée hors du moteur : un écran « à vous »
demande au moteur si le coup du joueur fait partie des coups permis attendus.
- **Les pions bougent** : sur un écran d'explication, le coup illustré se joue
tout seul après une seconde, pion par pion (`duration-fast`), puis reste
affiché ; « Revoir » le rejoue. Sur les écrans à flèches (sens du jeu), un
tracé en pointillés `text` sur un liseré ivoire, jamais en brique (la brique
veut dire « à faire »).
- Un geste juste : coche `verdict-good`, vibration « pion posé », phrase
courte, « Continuer » devient l'action principale. Un geste faux : le pion
revient, et une phrase dit pourquoi, sans le mot « erreur ».
- Chaque terme nouveau est un lien vers sa fiche du glossaire, à sa première
apparition.
### Texte, première version
1. **Le but du jeu.** « Vous avez quinze pions ivoire, l'ordinateur quinze
pions noyer. Chacun fait faire le tour du plateau à ses pions, puis les
sort. Le premier qui a sorti tous les siens gagne la partie. »
2. **Dans quel sens avancer.** « Vos pions ivoire font le tour du plateau dans
le sens des pointillés : ils partent d'en haut à droite, passent à gauche,
puis reviennent en bas jusqu'aux six dernières flèches, en bas à droite.
C'est là que vous les rassemblerez avant de les sortir. Les pions noyer de
l'ordinateur font le même chemin, en sens inverse. »
3. **Les dés et les doubles.** « Chaque dé déplace un pion d'autant de
flèches. Avec 5 et 2, vous jouez un pion de 5 et un pion de 2, ou le même
pion de 5 puis de 2. Un double se joue quatre fois : avec 4-4, vous avancez
quatre fois de 4. » *À vous* : jouer 3-1 en faisant le point 5 (8/5 6/5).
4. **Frapper un pion seul.** « Un pion seul sur une flèche s'appelle un blot.
Si vous vous posez dessus, vous le frappez : il part sur la barre et devra
refaire tout le chemin. Une flèche où l'adversaire a deux pions ou plus est
fermée : vous ne pouvez pas vous y poser. » *À vous* : frapper le blot du
point 7 avec 6-1.
5. **Rentrer depuis la barre.** « Votre pion frappé attend sur la barre, au
milieu. Avant de jouer quoi que ce soit d'autre, il doit rentrer dans le
camp adverse, en haut à droite. Chaque dé indique une flèche d'entrée :
avec 3 et 4, la 22 ou la 21. Si les deux flèches sont fermées, votre tour
passe. »
6. **Sortir ses pions.** « Quand vos quinze pions sont dans les six dernières
flèches, vous pouvez commencer à les sortir : un 6 sort un pion de la
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
reste en plus un pion sur la barre ou dans votre camp, c'est un
backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
passe, et vous gagnez tout de suite. Celui qui a pris garde le cube : lui
seul pourra redoubler. » *À vous* : décider de prendre ou passer, avec une
phrase du maître ensuite.
Fin du tutoriel : « Vous connaissez les règles. Le reste s'apprend en jouant,
et le maître vous accompagne. » → « Jouer ma première partie ».
## Aide
L'aide n'est plus une copie brute des fichiers : c'est une page organisée en
questions, avec un ton. **La source reste unique** (ADR 0008) : les mêmes
fichiers servent d'aide et de tests.
- **Trois onglets** : « Jouer » (les scénarios), « Les mots du jeu » (le
glossaire), « À propos ». Le tutoriel est proposé en tête de page.
- **« Jouer »** : un scénario = une question repliable (`details`), fermée par
défaut sauf celle qui correspond à l'écran d'où l'on vient.
- Le **titre du scénario** est la question, formulée comme le joueur se la
pose : « Comment jouer un coup ? », « Je me suis trompé de pion », « Je ne
peux rien jouer avec mes dés ». C'est un changement de titre dans les
`.feature` : les tests n'en dépendent pas.
- La **description du scénario** (le paragraphe sous le titre) est la
réponse affichée. Elle se lit seule, au présent, au « vous ».
- Les **étapes Gherkin** ne s'affichent pas dans l'aide : elles restent la
preuve que l'app fait ce que dit la réponse. « Voir sur le plateau » ouvre
la position de l'exemple quand le scénario en donne une (lien ADR 0013).
- La **description de la fonctionnalité** sert d'introduction à l'onglet.
- **« Les mots du jeu »** : une fiche du glossaire = une carte (le mot, son nom
anglais en `caption` italique, « En bref », les renvois), triées par ordre
alphabétique, avec un champ de recherche en tête.
- **Ton** : celui du maître de café, patient et concret. Pas de « Étant
donné que », pas de vocabulaire technique (moteur, scénario, événement).
## Liens (ADR 0013, amendement : une adresse par écran)
Chaque élément qui mène ailleurs est un vrai lien (`<a href>`), qu'on peut
ouvrir, partager et retrouver avec le bouton « retour » du téléphone.
| Élément | Adresse |
| --- | --- |
| « Je découvre », « Toutes les leçons » | `#/tutoriel/1` |
| Étape *n* du tutoriel (« Continuer », « Retour », segments de la barre de progression) | `#/tutoriel/<n>` |
| « Je connais les règles », « Reprendre la partie » | l'écran de jeu |
| Un terme du jeu dans un texte (blot, barre, camp…) | `#/glossaire/<terme>` |
| Une position illustrée, « Voir sur le plateau » | `#/position/<Position ID>/<Match ID>` |
| Aide, onglet « Jouer » | `#/aide` ; une question ouverte : `#/aide/<scénario>` |
| Menu | `#/menu` |
- Les segments de la barre de progression du tutoriel sont des liens vers les
étapes déjà vues, pas vers les suivantes.
- Le bouton « retour » du téléphone ferme d'abord la feuille ou la question
ouverte, puis revient à l'écran précédent ; il ne quitte jamais une partie
sans retour possible.
+253
View File
@@ -0,0 +1,253 @@
# Écrans de l'étape 1
Règles de design des écrans de l'étape 1 (`docs/etape-1.md`), lot par lot.
Maquettes : canevas https://claude.ai/artifact/CQw6vbDpi6XQqVWQa4sANe, rangées
« Étape 1 ». Comportement général : `interaction.md` ; liens : ADR 0013.
## 1c. Nouvelle partie et match
### Écran « Nouvelle partie » (`#/nouvelle-partie`)
Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
étapes, de haut en bas :
1. **Type de partie** : un sélecteur à deux segments, « Partie libre » et
« Match » (fond `surface-sunken`, segment choisi en `surface` avec
`shadow-raised`).
- **Match** : « Le premier à [3] [5] [7] [11] points », quatre boutons
carrés de 44 px, le choisi en brique. Dessous, une phrase sur la règle de
Crawford, avec le lien vers sa fiche du glossaire.
- **Partie libre** : une phrase sur la règle de Jacoby, avec le lien vers
sa fiche. Pas d'interrupteur pour Crawford ni Jacoby : ce sont les règles
usuelles, et le joueur débutant n'a pas à les choisir.
2. **Niveau de l'ordinateur** : six barres de hauteur croissante, à toucher
(Débutant, Amateur, Intermédiaire, Avancé, Expert, Champion) ; les barres
jusqu'au niveau choisi sont en brique, le niveau choisi porte l'anneau de
focus. Dessous, le nom en `heading` et une phrase qui le décrit en mots de
joueur, jamais en plis ni en bruit :
- Débutant : « Joue simplement et laisse passer des occasions. Idéal pour
vos premières parties. »
- Amateur : « Un joueur qui connaît les règles et commet encore des
erreurs. »
- Intermédiaire : « Un habitué de la table : solide, sans être
implacable. »
- Avancé : « Un bon joueur de club. Peu d'erreurs de pions, un videau
sérieux. »
- Expert : « Très peu d'erreurs. Chaque point se gagne. »
- Champion : « GNU Backgammon à son meilleur niveau sur ce téléphone. »
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
« Une ligne après chacun de vos coups ».
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
Commencer une nouvelle partie alors qu'une partie est en cours suit la règle
« annuler plutôt que confirmer » (bandeau « Reprendre l'ancienne »).
### Score de match sur l'écran de jeu
- **Portrait** : une ligne `overline` centrée sous la barre haute, « Match en
5 points », complétée par « · partie Crawford » pendant la partie Crawford ;
dans chaque bandeau, une pastille carrée (contour `border-strong`, chiffre
gras) avec le score de ce joueur, juste avant son compte de points.
- **Paysage** : dans la colonne de commandes, sous le menu, une case qui
empile le score de l'ordinateur, « sur 5 », puis le vôtre (même ordre que
les bandeaux : l'ordinateur en haut, vous en bas).
- Le score se lit aussi au lecteur d'écran : « Match en 5 points :
l'ordinateur 4, vous 2 ».
- Pendant la partie Crawford, le videau n'est pas affiché : il n'existe pas
pour cette partie.
## 1d. Conseil du maître
### La ligne du maître
Maquette « Portrait révisé : conseil du maître après le coup ». Une ligne de
44 px, réservée sous le bandeau du joueur (elle ne pousse rien) :
- icône d'appréciation, puis le **mot en couleur** suivi d'un point
(« Bon coup. »), puis la **phrase du maître en italique**, sur une ligne,
tronquée par des points de suspension si elle est trop longue, et un
chevron ;
- elle apparaît **après** la validation du coup du joueur (ou sa décision de
videau), dès que l'analyse est prête, en fondu de `duration-fast` ; elle
s'efface à l'action suivante du joueur (lancer les dés, toucher un pion) ;
- **pendant que le joueur réfléchit, rien n'apparaît** ; si l'analyse arrive
trop tard (le joueur a déjà relancé), la ligne n'apparaît pas, et le coup
reste commenté dans le bilan ;
- conseil désactivé : la ligne reste réservée et vide (la mise en page ne
bouge pas).
### Les mots
| Seuil gnubg (écart d'équité) | Mot | Couleur | Icône |
| --- | --- | --- | --- |
| meilleur coup, ou moins de 0,03 | Bon coup | `verdict-good` | coche |
| 0,03 à 0,06 (doubtful) | Imprécision | `verdict-doubtful` | point d'interrogation |
| 0,06 à 0,12 (bad) | Erreur | `verdict-bad` | croix |
| plus de 0,12 (very bad) | Grosse erreur | `verdict-bad` | croix |
Pour le videau : « Bon choix » et « Bonne décision » remplacent « Bon coup ».
« Grosse erreur » est la recommandation de design ; `docs/etape-1.md` écrit
« gaffe », et le choix final revient à Pierre (ton).
### La feuille d'explication
Maquette « Feuille d'explication du maître ». Toucher la ligne ouvre une
feuille qui monte du bas (`duration-moderate`), au-dessus du plateau voilé :
1. le mot d'appréciation et le contexte (« Erreur · votre 3-1 d'ouverture ») ;
2. le titre : le coup du maître, à la première personne (« J'aurais joué
8/5 6/5 ») ; pour un bon coup : « C'est aussi le coup que j'aurais joué » ;
3. la phrase du maître, en italique ;
4. un sélecteur à deux segments, « Votre coup » et « Le coup du maître », au-
dessus d'un plateau qui montre la position d'avant le coup ; le coup choisi
se joue pion par pion (`duration-fast`), avec un tracé du départ à
l'arrivée : `text` pour votre coup, `verdict-good` pour celui du maître ;
5. « Voir les chiffres du moteur » (repliée par défaut) : le classement des
coups avec leur écart d'équité et le niveau d'évaluation ; votre coup est en
gras ;
6. « Demander à une IA » (1f) et « Fermer ».
Le lien vers la fiche d'un concept du glossaire n'apparaît que si le moteur
permet de le déterminer, sinon rien.
### Les phrases du maître
- **Le maître parle à la première personne et vouvoie** : « J'aurais joué… »,
jamais « Vous auriez dû… », jamais « Il fallait… ».
- **Il ne cite que des faits** : le coup recommandé, le rang de votre coup,
les pions frappés, les points faits ou laissés (lisibles dans le coup
lui-même), l'écart d'équité sur demande. Aucune explication stratégique
inventée (« la case la plus précieuse », « trop risqué ») : celles-là
viendront des leçons, relues par un joueur fort.
- **Trois parties au plus** : ce que vous avez fait, ce que le maître préfère,
un fait qui les distingue. Moins de 140 caractères pour la ligne ; la
feuille peut aller jusqu'à deux phrases.
- **Ton** : encourageant et sobre. Pas de point d'exclamation, pas d'émoji, pas
de superlatif. Les bons coups ont plusieurs formules qui tournent
(« C'est le coup du maître. », « Exactement ce que j'aurais joué. »,
« Bien vu. »), pour ne pas lasser.
- **Exemples** (gabarits à remplir avec les données du moteur) :
- Bon coup : « Bien vu. 8/5 6/5 fait votre point 5. »
- Imprécision : « J'aurais joué {meilleur}. Votre coup était le
{rang}e choix, tout près. »
- Erreur : « J'aurais joué {meilleur}. Votre {joué} était le {rang}e
choix. »
- Videau, prise juste : « Bonne décision : prendre était le bon choix. »
- Videau, double manqué : « Ici, j'aurais doublé. »
## 1e. Bilan et analyse
### Bilan (`#/parties/<id>/bilan`)
Maquette « Bilan de fin de partie ». Il remplace le panneau de fin de partie
quand l'analyse est prête ; avant, le panneau actuel s'affiche et le bilan le
remplace en place, sans saut.
1. `overline` « Fin de partie », titre `display` (« Vous gagnez », « L'ordinateur
gagne un gammon »), puis une phrase : le résultat en points et une
lecture simple du jeu et des dés (« Votre jeu a fait la différence, avec
des dés un peu contre vous. »), construite à partir du taux d'erreur et de
la chance donnés par le moteur, jamais inventée.
2. Trois cases côte à côte : le nombre de bons coups, d'imprécisions et
d'erreurs (grosses erreurs comprises), chiffre en 28 px, mot et icône en
couleur d'appréciation.
3. Deux jauges sur une ligne chacune :
- « Votre jeu » : le taux d'erreur traduit en niveau (correspondance avec
les classements de gnubg documentée, mêmes noms que les niveaux de
l'ordinateur), jauge en `verdict-good` ;
- « Les dés » : la chance, jauge centrée (à droite si elle vous a aidé, à
gauche sinon), en `border-strong`, avec trois mots possibles : « pour
vous », « équilibrés », « contre vous » (« un peu » ou « nettement »
selon l'écart).
Lien « Comment lire ces mesures » vers les fiches du glossaire.
4. « Le coup à revoir » : la plus grosse erreur, mini-plateau, mot
d'appréciation, coup joué et coup du maître ; « Revoir ce coup » (ouvre
l'analyse sur ce coup) et « Demander à une IA ». Sans erreur ni
imprécision : la carte dit « Aucun coup à revoir : vous avez joué comme le
maître. »
5. Barre d'actions : « Voir l'analyse » et « Nouvelle partie » (principale).
### Analyse (`#/parties/<id>/analyse/<coup>`)
Maquette interactive « Analyse ». Une page qui défile, de haut en bas :
1. **Courbe de vos chances de gagner**, coup après coup, en pourcentage (pas
en équité : un débutant lit « 41 % », pas « −0,18 »). Tracé `text` de 2 px,
aire `accent-soft`, ligne pointillée à 50 %. Les coups commentés sont des
points de couleur d'appréciation, les autres de petits points `text`.
**On parcourt la courbe du doigt** : chaque coup possède une bande verticale
sur toute la hauteur du graphique, qui sert de cible tactile ; le coup
choisi porte un trait vertical brique et un point cerclé.
2. **Visualiseur** : « ‹ Coup 7 sur 24 · 6-1 › », le plateau de la position
d'avant le coup, la phrase du maître, le sélecteur « Votre coup » / « Le
coup du maître » de la feuille 1d, puis « Rejouer d'ici » (crée une
branche, ADR 0011 et 1b) et « Demander à une IA ».
3. **Les coups à revoir** : filtres en pastilles de 44 px (« Tout »,
« Erreurs », « Imprécisions », avec leurs nombres), puis une ligne par
coup : numéro, dés, coup joué, mot d'appréciation. Toucher une ligne place
le visualiseur et la courbe sur ce coup.
Sur ordinateur, `←` et `→` passent d'un coup à l'autre.
## 1f. Demander à une IA
Le bouton est secondaire partout, jamais l'action principale, avec l'icône
de copie : dans la feuille du maître (1d), dans la carte « Le coup à
revoir » du bilan, dans le visualiseur de l'analyse, et dans une fiche du
glossaire. Après la copie, le bandeau « Prompt copié » (`verdict-good`, 2 s),
puis le texte du prompt reste lisible dans une zone `surface-sunken`
repliable, pour qui veut le relire avant de le coller (composant `AskAI`).
## 1g. Leçons (`#/lecons`, `#/lecons/<n>/<écran>`)
Maquette « Leçons ». En tête, un rappel discret « Les règles du jeu ·
revoir » (le tutoriel de 0e.5). Puis les cinq leçons numérotées, dans
l'ordre, chacune avec une pastille ronde à son numéro :
- terminée : pastille `verdict-good`, « Terminée » ;
- en cours : carte bordée de brique, pastille brique, « 2 exercices sur 4 »,
barre de progression, bouton « Continuer » (seule action principale de
l'écran) ;
- à commencer : pastille au contour `border-strong`.
Toutes les leçons restent ouvertes : l'ordre est conseillé, jamais imposé.
Les écrans d'une leçon suivent la forme du tutoriel (barre de progression,
« Passer », « Retour », « Continuer », écrans « à vous » contrôlés par le
moteur).
## 1h. Progression (`#/progression`)
Maquette « Progression ».
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
tendance monte, la phrase reste encourageante et sans reproche (« Votre
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
pour reprendre un point. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
vers la fiche « taux d'erreur ».
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
en chiffre gras aligné à droite), chacune ouvrant son bilan.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
## 1i. Gestes sur le plateau
- **Glisser-déposer** : le pion suit le doigt, agrandi à 115 % et décalé de
12 px vers le haut pour rester visible sous le doigt ; à son départ, un pion
fantôme en pointillés ; les cases d'arrivée permises gardent leur voile et
leur anneau (révision UX). Lâché sur une case permise : il s'y pose
(`duration-fast`, vibration « pion posé ») ; lâché ailleurs : il revient à
sa place en `duration-fast`, sans vibration ni message.
- **Double toucher** : joue le coup évident quand il existe (un seul coup légal
complet commence par ce pion, ou le moteur n'en propose qu'un) ; sinon, il
vaut un simple toucher, sans message d'erreur.
- **Toucher simple** : inchangé (pion puis case, ou avance directe s'il n'a
qu'une case d'arrivée).
- Les trois gestes coexistent ; aucun n'est expliqué par une fenêtre : le
tutoriel et l'aide en parlent.

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