@@ -0,0 +1,297 @@
// maitre.mjs -- le conseil du maître (étape 1d) joué par l'interface, dans
// un vrai navigateur sans écran (Playwright), sur le site servi par l'image
// de production (docs/plans/2026-11-03-conseil-du-maitre.md).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
//
// Usage : node maitre.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// Lancé par tests/browser.sh. Chaque cas part d'une position connue
// (#/position/..., « Jouer à partir d'ici ») avec des dés imposés : les dés
// de l'application viennent de Math.random, remplacé ici par une file de
// valeurs choisies (window.__dice), puis par un générateur à graine.
// Vérifie la ligne du maître (bon coup, imprécision, erreur, grosse
// erreur), la feuille d'explication et son adresse, une décision de videau
// commentée, le conseil coupé dans le menu, l'absence d'erreur et de
// violation de la politique de sécurité. Captures de la recette design
// (Chromium) dans <dossier des captures>/maitre/, en 412× 915 et 915× 412.
import { mkdirSync } from "node:fs" ;
import { chromium , firefox } from "playwright" ;
const [ base , browserName , shots ] = process . argv . slice ( 2 ) ;
const browserType = { chromium , firefox } [ browserName ] ;
if ( ! base || ! browserType || ! shots ) {
console . error ( "usage : node maitre.mjs <adresse> <chromium|firefox> <dossier des captures>" ) ;
process . exit ( 2 ) ;
}
const full = browserName === "chromium" ;
const dir = ` ${ shots } /maitre ` ;
mkdirSync ( dir , { recursive : true } ) ;
const PORTRAIT = { width : 412 , height : 915 } ;
const LANDSCAPE = { width : 915 , height : 412 } ;
const START = "4HPwATDgc/ABMA" ;
// Une position de double et prise (videau au centre) : doubler y est juste.
const DOUBLE _TAKE = "6O5uAAC4uzsAAA" ;
const problems = [ ] ;
const log = ( text ) => console . log ( ` [ ${ browserName } ] ${ text } ` ) ;
const sleep = ( ms ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
let captures = 0 ;
function check ( condition , message ) {
if ( ! condition ) {
throw new Error ( message ) ;
}
}
const browser = await browserType . launch ( ) ;
const context = await browser . newContext ( {
viewport : PORTRAIT ,
deviceScaleFactor : 1 ,
locale : "fr-FR" ,
// Sans animation : les captures montrent les coups posés, avec leurs
// traces.
reducedMotion : "reduce" ,
} ) ;
await context . addInitScript ( ( ) => {
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
window . _ _dice = [ ] ;
let state = 11 ;
Math . random = ( ) => {
const die = window . _ _dice . shift ( ) ;
if ( die !== undefined ) {
return ( die - 0.5 ) / 6 ;
}
state = ( state * 1103515245 + 12345 ) % 2147483648 ;
return state / 2147483648 ;
} ;
window . _ _csp = [ ] ;
document . addEventListener ( "securitypolicyviolation" , ( e ) =>
window . _ _csp . push ( ` ${ e . effectiveDirective } ${ e . blockedURI } ` ) ,
) ;
} ) ;
const page = await context . newPage ( ) ;
page . on ( "pageerror" , ( error ) => problems . push ( ` erreur de la page : ${ error . message } ` ) ) ;
page . on ( "console" , ( message ) => {
if ( message . type ( ) === "error" ) {
problems . push ( ` console : ${ message . text ( ) } ` ) ;
}
} ) ;
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
* par eval, interdit par la politique de sécurité du site). */
async function until ( what , fn , arg , ms = 90_000 ) {
for ( let waited = 0 ; waited < ms ; waited += 100 ) {
if ( await page . evaluate ( fn , arg ) ) {
return ;
}
await sleep ( 100 ) ;
}
await page . screenshot ( { path : ` ${ dir } / ${ browserName } -bloque.png ` } ) . catch ( ( ) => { } ) ;
throw new Error ( ` attente trop longue : ${ what } ` ) ;
}
const idle = ( ) => until ( "écran libre" , ( ) => ! document . body . classList . contains ( "busy" ) ) ;
/** Une capture de la recette, dans les deux tailles (Chromium seulement). */
async function capture ( name ) {
if ( ! full ) {
return ;
}
// Le bandeau « Reprendre l'ancienne » (10 s) ne gêne pas la lecture.
await until ( "bandeau parti" , ( ) => document . getElementById ( "banner" ) . hidden , null , 15_000 ) ;
for ( const size of [ PORTRAIT , LANDSCAPE ] ) {
await page . setViewportSize ( size ) ;
await sleep ( 300 ) ;
await page . screenshot ( { path : ` ${ dir } / ${ name } - ${ size . width } x ${ size . height } .png ` } ) ;
captures ++ ;
}
await page . setViewportSize ( PORTRAIT ) ;
await sleep ( 300 ) ;
}
/** Une nouvelle partie à partir d'une position, joueur au trait. */
async function startFrom ( position ) {
await page . goto ( ` ${ base } #/position/ ${ encodeURIComponent ( position ) } ` ) ;
await until ( "position lue" , ( ) => {
const play = document . getElementById ( "position-play" ) ;
return ! play . hidden && ! play . disabled ;
} ) ;
await page . click ( "#position-play" ) ;
await until ( "partie commencée" , ( ) =>
location . hash === "#/" && ! document . body . classList . contains ( "busy" ) &&
! document . getElementById ( "roll" ) . hidden ) ;
}
/** Lance les dés imposés et joue un coup, demi-coup par demi-coup : le pion
* de départ, puis sa case d'arrivée si le pion ne s'y est pas déjà posé. */
async function play ( dice , halfMoves ) {
await page . evaluate ( ( d ) => {
window . _ _dice . push ( ... d ) ;
} , dice ) ;
await page . click ( "#roll" ) ;
await idle ( ) ;
for ( const [ from , to ] of halfMoves ) {
await page . click ( ` #board .hit[data-place="p ${ from } "] ` ) ;
await idle ( ) ;
const land = page . locator ( ` #board .hit.can-land[data-place="p ${ to } "] ` ) ;
if ( ( await land . count ( ) ) > 0 ) {
await land . click ( ) ;
await idle ( ) ;
}
}
check ( ! ( await page . locator ( "#confirm" ) . isHidden ( ) ) , ` coup incomplet : ${ JSON . stringify ( halfMoves ) } ` ) ;
await page . click ( "#confirm" ) ;
}
/** La ligne du maître telle qu'elle s'affiche. */
async function adviceLine ( ) {
return page . evaluate ( ( ) => {
const line = document . getElementById ( "advice-line" ) ;
return {
shown : ! line . hidden ,
word : line . querySelector ( ".advice-word" ) ? . textContent ? ? "" ,
phrase : line . querySelector ( ".advice-phrase" ) ? . textContent ? ? "" ,
href : line . getAttribute ( "href" ) ,
infoShown : ! document . getElementById ( "info" ) . hidden ,
height : line . getBoundingClientRect ( ) . height ,
chip : document . getElementById ( "advice-chip" ) !== null ,
} ;
} ) ;
}
async function waitAdvice ( ) {
await until ( "ligne du maître" , ( ) =>
! document . getElementById ( "advice-line" ) . hidden &&
! document . body . classList . contains ( "busy" ) ) ;
return adviceLine ( ) ;
}
// ------------------------------------------------- Bon coup, et ses voisins
const cases = [
{ name : "1-bon-coup" , dice : [ 3 , 1 ] , moves : [ [ 8 , 5 ] , [ 6 , 5 ] ] , word : "Bon coup." } ,
{ name : "2-imprecision" , dice : [ 2 , 1 ] , moves : [ [ 13 , 11 ] , [ 11 , 10 ] ] , word : "Imprécision." } ,
{ name : "3-erreur" , dice : [ 2 , 1 ] , moves : [ [ 24 , 22 ] , [ 6 , 5 ] ] , word : "Erreur." } ,
{ name : "4-grosse-erreur" , dice : [ 3 , 1 ] , moves : [ [ 24 , 23 ] , [ 13 , 10 ] ] , word : "Grosse erreur." } ,
] ;
for ( const c of cases ) {
await startFrom ( START ) ;
// Rien pendant que le joueur réfléchit.
check ( ! ( await adviceLine ( ) ) . shown , ` ${ c . name } : ligne avant le coup ` ) ;
await play ( c . dice , c . moves ) ;
const line = await waitAdvice ( ) ;
check ( line . word === c . word , ` ${ c . name } : mot « ${ line . word } », attendu « ${ c . word } » ` ) ;
check ( line . phrase . length > 0 && ! line . phrase . includes ( "!" ) , ` ${ c . name } : phrase « ${ line . phrase } » ` ) ;
check ( ! line . infoShown , ` ${ c . name } : la ligne du dernier coup reste visible ` ) ;
check ( line . height <= 44 , ` ${ c . name } : ligne de ${ line . height } px ` ) ;
check ( /^#\/explication\/\d+$/ . test ( line . href ) , ` ${ c . name } : lien ${ line . href } ` ) ;
if ( c . word !== "Bon coup." ) {
check ( line . phrase . startsWith ( "J'aurais joué" ) || line . phrase . startsWith ( "Tout près" ) ,
` ${ c . name } : le coup du maître n'est pas nommé : « ${ line . phrase } » ` ) ;
}
await capture ( c . name ) ;
if ( full ) {
// En paysage, la pastille du mot mène à la même feuille.
await page . setViewportSize ( LANDSCAPE ) ;
await sleep ( 200 ) ;
check ( ( await adviceLine ( ) ) . chip , ` ${ c . name } : pas de pastille en paysage ` ) ;
await page . setViewportSize ( PORTRAIT ) ;
await sleep ( 200 ) ;
}
log ( ` ${ c . name } : « ${ line . word } ${ line . phrase } » ` ) ;
}
// La ligne s'efface à l'action suivante du joueur.
await page . click ( "#roll" ) ;
await idle ( ) ;
check ( ! ( await adviceLine ( ) ) . shown , "la ligne reste après le lancer suivant" ) ;
// ------------------------------------------------- Feuille d'explication
await startFrom ( START ) ;
await play ( [ 3 , 1 ] , [ [ 24 , 23 ] , [ 13 , 10 ] ] ) ;
await waitAdvice ( ) ;
await page . click ( "#advice-line" ) ;
await until ( "feuille ouverte" , ( ) =>
/ ^ # \ / explication \ / \ d + $ / . test ( location . hash ) &&
! document . getElementById ( "explain-veil" ) . hidden &&
document . getElementById ( "explain-board" ) !== null &&
document . querySelector ( "#explain-board .board-frame" ) !== null ) ;
const sheet = await page . evaluate ( ( ) => ( {
verdict : document . getElementById ( "explain-verdict" ) . textContent ,
title : document . getElementById ( "explain-title" ) . textContent ,
mine : document . getElementById ( "explain-mine" ) ? . getAttribute ( "aria-checked" ) ,
best : document . getElementById ( "explain-best" ) ? . getAttribute ( "aria-checked" ) ,
numbersOpen : document . getElementById ( "explain-numbers" ) . open ,
} ) ) ;
check ( sheet . verdict === "Grosse erreur · votre 3-1 d'ouverture" , ` feuille : ${ JSON . stringify ( sheet ) } ` ) ;
check ( sheet . title === "J'aurais joué 8/5 6/5" , ` titre : ${ sheet . title } ` ) ;
check ( sheet . best === "true" && sheet . mine === "false" , "le coup du maître n'est pas montré d'abord" ) ;
check ( ! sheet . numbersOpen , "les chiffres du moteur ne sont pas repliés" ) ;
await capture ( "5-feuille-explication" ) ;
await page . click ( "#explain-mine" ) ;
await sleep ( 300 ) ;
await page . click ( "#explain-numbers summary" ) ;
const rows = await page . evaluate ( ( ) => document . querySelector ( ".explain-table .strong" ) ? . textContent ) ;
check ( rows !== undefined , "votre coup n'est pas en gras dans les chiffres" ) ;
await capture ( "5b-feuille-votre-coup-chiffres" ) ;
// L'adresse se recharge : la feuille revient, le conseil vient du journal.
await page . reload ( ) ;
await until ( "feuille rouverte après rechargement" , ( ) =>
! document . getElementById ( "explain-veil" ) . hidden &&
document . getElementById ( "explain-title" ) ? . textContent === "J'aurais joué 8/5 6/5" ) ;
await page . click ( "#explain-done" ) ;
await until ( "feuille fermée" , ( ) =>
location . hash === "#/" && document . getElementById ( "explain-veil" ) . hidden ) ;
log ( "feuille d'explication : mot, coup du maître, votre coup, chiffres repliés, adresse rechargée" ) ;
// ------------------------------------------------- Décision de videau
await startFrom ( DOUBLE _TAKE ) ;
check ( ! ( await page . locator ( "#double" ) . isHidden ( ) ) , "doubler n'est pas proposé" ) ;
await page . click ( "#double" ) ;
await page . click ( "#double-confirm" ) ;
const cube = await waitAdvice ( ) ;
check ( cube . word === "Bonne décision." && cube . phrase === "Doubler était le bon choix." ,
` videau : « ${ cube . word } ${ cube . phrase } » ` ) ;
await capture ( "6-decision-de-videau" ) ;
log ( ` décision de videau : « ${ cube . word } ${ cube . phrase } » ` ) ;
// ------------------------------------------------- Conseil coupé
await page . click ( "#menu-button" ) ;
await page . waitForSelector ( "#menu" , { state : "visible" } ) ;
check ( ( await page . getAttribute ( "#menu-advice" , "aria-checked" ) ) === "true" , "conseil coupé par défaut" ) ;
await page . click ( "#menu-advice" ) ;
check ( ( await page . getAttribute ( "#menu-advice" , "aria-checked" ) ) === "false" , "interrupteur sans effet" ) ;
await page . click ( "#menu-close" ) ;
await startFrom ( START ) ;
await play ( [ 3 , 1 ] , [ [ 24 , 23 ] , [ 13 , 10 ] ] ) ;
await idle ( ) ;
// Laisse le temps à l'analyse : la ligne ne doit pas venir.
await sleep ( 3000 ) ;
const off = await adviceLine ( ) ;
check ( ! off . shown && off . infoShown , ` conseil coupé : ${ JSON . stringify ( off ) } ` ) ;
await capture ( "7-conseil-desactive" ) ;
// On le rallume : le conseil déjà prêt réapparaît.
await page . click ( "#menu-button" ) ;
await page . waitForSelector ( "#menu" , { state : "visible" } ) ;
await page . click ( "#menu-advice" ) ;
await page . click ( "#menu-close" ) ;
check ( ( await waitAdvice ( ) ) . word === "Grosse erreur." , "conseil rallumé : la ligne ne revient pas" ) ;
log ( "conseil coupé dans le menu : la ligne reste vide ; rallumé, elle revient" ) ;
const csp = await page . evaluate ( ( ) => window . _ _csp ) ;
for ( const violation of csp ) {
problems . push ( ` politique de sécurité : ${ violation } ` ) ;
}
await browser . close ( ) ;
if ( problems . length > 0 ) {
for ( const problem of problems ) {
console . error ( ` [ ${ browserName } ] ${ problem } ` ) ;
}
process . exit ( 1 ) ;
}
log ( ` conseil du maître : tout est conforme ( ${ captures } captures) ` ) ;