Nouvelle partie directe après un bilan, bouton « Commencer », fiche « Notation des coups » #57
@@ -10,7 +10,7 @@
|
||||
// stockage, dans le journal (events.ts, champ advice). Le format est
|
||||
// durable (ADR 0007) : chaque conseil porte sa version (ADVICE_VERSION).
|
||||
|
||||
import type { CubeAction, CubeEquities, Ply, Skill } from "./engine.js";
|
||||
import type { CubeAction, CubeEquities, MoveNotation, Ply, Skill } from "./engine.js";
|
||||
import type { HalfMove, PositionId } from "./position.js";
|
||||
import type { Roll } from "./roll.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ export type Advice = MoveAdvice | CubeAdvice;
|
||||
/** Un coup, tel que le conseil le retient. */
|
||||
export interface AdvisedMove {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** Équité (avec videau) du point de vue du joueur, et le niveau auquel
|
||||
* le moteur l'a évaluée. */
|
||||
|
||||
@@ -281,6 +281,15 @@ export interface EngineMatchAfter {
|
||||
readonly crawford: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Un coup écrit dans la notation de gnubg : « 8/5 6/5 », « 24/22* »,
|
||||
* « 6/off », « bar/22 », « 8/4(2) ». Le moteur l'écrit ; l'application ne
|
||||
* fait que l'afficher.
|
||||
*
|
||||
* Glossaire : docs/glossaire/notation-des-coups.md
|
||||
*/
|
||||
export type MoveNotation = string;
|
||||
|
||||
/**
|
||||
* Un coup légal complet.
|
||||
*
|
||||
@@ -288,7 +297,7 @@ export interface EngineMatchAfter {
|
||||
*/
|
||||
export interface Move {
|
||||
/** Notation de gnubg, par exemple « 8/5 6/5 ». */
|
||||
readonly notation: string;
|
||||
readonly notation: MoveNotation;
|
||||
/** Ses demi-coups, dans un ordre jouable. */
|
||||
readonly halfMoves: readonly HalfMove[];
|
||||
/** La position après le coup, vue du joueur qui vient de jouer. */
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
import type { Advice } from "./advice.js";
|
||||
import { type Branch, branches, switchToBranch } from "./branches.js";
|
||||
import { CENTERED_CUBE, type Cube } from "./cube.js";
|
||||
import type { BoardSide, Engine, EngineCube, Level, Ply, ResultKind } from "./engine.js";
|
||||
import type {
|
||||
BoardSide,
|
||||
Engine,
|
||||
EngineCube,
|
||||
Level,
|
||||
MoveNotation,
|
||||
Ply,
|
||||
ResultKind,
|
||||
} from "./engine.js";
|
||||
import {
|
||||
appendEvent,
|
||||
currentLine,
|
||||
@@ -290,7 +298,7 @@ export class Game {
|
||||
* À défaut (aucun coup légal ne correspond), les demi-coups ; chaîne
|
||||
* vide si l'événement n'est pas un coup ou si aucun pion n'a bougé.
|
||||
*/
|
||||
async moveNotation(eventId: string): Promise<string> {
|
||||
async moveNotation(eventId: string): Promise<MoveNotation> {
|
||||
const line = currentLine({ ...this.#journal, head: eventId });
|
||||
const event = line.at(-1);
|
||||
if (event?.type !== "MovePlayed" || event.halfMoves.length === 0) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
|
||||
import { askAiLink, clearSheet } from "./ask-ai.js";
|
||||
import { type IconName, icon } from "./icons.js";
|
||||
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** Préfixe des identifiants du plateau de la feuille. */
|
||||
@@ -203,8 +204,9 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
const svg = sheetBoard(boardBox);
|
||||
if (best === null) {
|
||||
svg.dataset.move = "mine";
|
||||
svg.setAttribute("aria-label", ADVICE_TEXTS.legendSame(advice.played.notation));
|
||||
legend.textContent = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
const text = ADVICE_TEXTS.legendSame(advice.played.notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
showWithNotation(legend, text, advice.played.notation);
|
||||
void player?.play(mine);
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +235,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
|
||||
shown === "mine"
|
||||
? ADVICE_TEXTS.legendMine(notation)
|
||||
: ADVICE_TEXTS.legendBest(notation);
|
||||
legend.textContent = text;
|
||||
showWithNotation(legend, text, notation);
|
||||
svg.setAttribute("aria-label", text);
|
||||
svg.dataset.move = shown;
|
||||
void player?.play(shown === "mine" ? mine : best);
|
||||
|
||||
+47
-14
@@ -36,6 +36,7 @@ import {
|
||||
} from "../domain/analysis/phrases.js";
|
||||
import type { Advice } from "../domain/game/advice.js";
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, NewGame, SideBoards } from "../domain/game/game.js";
|
||||
import type { HalfMove } from "../domain/game/position.js";
|
||||
@@ -84,6 +85,7 @@ import { installPlatform, startInstaller } from "./install.js";
|
||||
import { MATCH_TEXTS } from "./match-texts.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { createNewGameView } from "./new-game-view.js";
|
||||
import { showWithNotation } from "./notation.js";
|
||||
import { createPromptSource, type PromptsAccess } from "./prompt-source.js";
|
||||
import { createReviewRunner, type RunAnalysis } from "./review-runner.js";
|
||||
import {
|
||||
@@ -443,6 +445,10 @@ export function startApp(options: AppOptions): void {
|
||||
let stuck = false;
|
||||
let busy = true;
|
||||
let info: string = TEXTS.loading;
|
||||
// La dernière ligne « L'ordinateur a joué … » et le coup qu'elle cite :
|
||||
// tant qu'elle est la ligne d'information, ce coup mène à la fiche
|
||||
// « Notation des coups ».
|
||||
let computerLine: ComputerTurnLine | null = null;
|
||||
let engineFailed = false;
|
||||
// Le dernier coup de l'ordinateur, montré (traces, « Revoir ») jusqu'à
|
||||
// la prochaine action du joueur ; null : rien à montrer.
|
||||
@@ -646,7 +652,11 @@ export function startApp(options: AppOptions): void {
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
} else {
|
||||
ui.info.textContent = info;
|
||||
showWithNotation(
|
||||
ui.info,
|
||||
info,
|
||||
computerLine?.text === info ? computerLine.notation : null,
|
||||
);
|
||||
}
|
||||
ui.prompt.textContent = panelShown
|
||||
? ""
|
||||
@@ -1037,7 +1047,8 @@ export function startApp(options: AppOptions): void {
|
||||
while (game !== null && game.state.turn === "computer" && isPlaying(game)) {
|
||||
await refresh();
|
||||
await game.playComputerTurn();
|
||||
info = await describeComputerTurn(game);
|
||||
computerLine = await describeComputerTurn(game);
|
||||
info = computerLine.text;
|
||||
save();
|
||||
played = true;
|
||||
}
|
||||
@@ -1529,25 +1540,37 @@ export function startApp(options: AppOptions): void {
|
||||
* La partie à l'écran vient de finir : son analyse part en
|
||||
* arrière-plan, et le bilan remplace le panneau de fin de partie dès
|
||||
* qu'il est prêt (charte 1e), si le panneau est toujours là.
|
||||
*
|
||||
* Le bilan s'ouvre un peu plus tard (microtâche) : seulement si cette
|
||||
* partie finie est encore celle de l'écran. Sinon, « Commencer » de
|
||||
* l'écran « Nouvelle partie » ouvrait le bilan de l'ancienne partie
|
||||
* par-dessus la nouvelle, qui venait de commencer entre-temps (défaut
|
||||
* signalé le 2026-11-11).
|
||||
*/
|
||||
function reviewFinishedGame(): void {
|
||||
const id = options.games.shownId();
|
||||
if (game === null || id === null || busy || game.state.phase !== "over") {
|
||||
const finished = game;
|
||||
if (finished === null || id === null || busy || finished.state.phase !== "over") {
|
||||
return;
|
||||
}
|
||||
// L'analyse d'une autre partie, regardée, passe d'abord.
|
||||
const running = reviewRunner.running;
|
||||
if (running === null || running === id) {
|
||||
review(id, game.journal);
|
||||
review(id, finished.journal);
|
||||
}
|
||||
if (
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed &&
|
||||
phrases !== null &&
|
||||
readyForSummary(game.journal)
|
||||
readyForSummary(finished.journal)
|
||||
) {
|
||||
queueMicrotask(() => {
|
||||
if (navigation.route.screen === "game" && !panelDismissed) {
|
||||
if (
|
||||
game === finished &&
|
||||
options.games.shownId() === id &&
|
||||
navigation.route.screen === "game" &&
|
||||
!panelDismissed
|
||||
) {
|
||||
navigation.go(routeHash({ screen: "summary", game: id }));
|
||||
}
|
||||
});
|
||||
@@ -2015,8 +2038,10 @@ export function startApp(options: AppOptions): void {
|
||||
welcomed: () => saveSettings(welcomed()),
|
||||
});
|
||||
|
||||
// L'écran « Nouvelle partie » : « Commencer » remplace son adresse par
|
||||
// celle de la partie (le retour du téléphone ne le rouvre pas).
|
||||
// L'écran « Nouvelle partie » : « Commencer » lance la partie, puis
|
||||
// remplace son adresse par celle de la partie (le retour du téléphone
|
||||
// ne le rouvre pas). Dans cet ordre : l'écran de jeu ne se redessine
|
||||
// jamais avec l'ancienne partie, finie, et son panneau de fin.
|
||||
const newGameView = createNewGameView({
|
||||
section: ui.newGameSection,
|
||||
settings: () => settings,
|
||||
@@ -2025,8 +2050,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
}
|
||||
navigation.replace("#/");
|
||||
newGame(chosenGame(settings));
|
||||
navigation.replace("#/");
|
||||
},
|
||||
});
|
||||
leavingLink(newGameView.closeLink, (route) => route.screen === "newGame");
|
||||
@@ -2661,22 +2686,30 @@ async function computerMoveToShow(game: Game): Promise<MoveSteps | null> {
|
||||
return game.lastMoveSteps();
|
||||
}
|
||||
|
||||
/** La ligne d'information après le tour de l'ordinateur, et le coup
|
||||
* qu'elle cite (null s'il n'a pas joué de coup). */
|
||||
interface ComputerTurnLine {
|
||||
readonly text: string;
|
||||
readonly notation: MoveNotation | null;
|
||||
}
|
||||
|
||||
/** Ce qu'a fait l'ordinateur pendant son tour (sans durée de calcul). Son
|
||||
* coup s'écrit dans la notation du moteur, comme dans l'analyse. */
|
||||
async function describeComputerTurn(game: Game): Promise<string> {
|
||||
async function describeComputerTurn(game: Game): Promise<ComputerTurnLine> {
|
||||
const last = game.events.at(-1);
|
||||
if (last?.type === "CubeOffered") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const played = game.events.findLast((e) => e.type === "MovePlayed");
|
||||
if (played?.type !== "MovePlayed") {
|
||||
return "";
|
||||
return { text: "", notation: null };
|
||||
}
|
||||
const dice = rollBefore(game);
|
||||
if (played.halfMoves.length === 0) {
|
||||
return TEXTS.computerCannotMove(dice);
|
||||
return { text: TEXTS.computerCannotMove(dice), notation: null };
|
||||
}
|
||||
return TEXTS.computerMoved(dice, await game.moveNotation(played.id));
|
||||
const notation = await game.moveNotation(played.id);
|
||||
return { text: TEXTS.computerMoved(dice, notation), notation };
|
||||
}
|
||||
|
||||
/** Le résultat d'une partie finie, en deux parties : le titre du panneau
|
||||
|
||||
@@ -57,7 +57,7 @@ export const MATCH_TEXTS = {
|
||||
advice: "Conseil du maître",
|
||||
adviceDetail: "Une ligne après chacun de vos coups",
|
||||
startGame: "Commencer la partie",
|
||||
startMatch: (n: number) => `Commencer le match en ${n} points`,
|
||||
startMatch: "Commencer le match",
|
||||
kept: "Vos choix sont gardés pour la prochaine fois.",
|
||||
|
||||
// Score du match sur l'écran de jeu
|
||||
|
||||
@@ -200,9 +200,7 @@ export function createNewGameView(options: NewGameViewOptions): NewGameView {
|
||||
levelName.textContent = MATCH_TEXTS.levels[s.level].name;
|
||||
levelText.textContent = MATCH_TEXTS.levels[s.level].text;
|
||||
advice.setAttribute("aria-checked", String(s.masterAdvice));
|
||||
start.textContent = isMatch
|
||||
? MATCH_TEXTS.startMatch(s.matchLength)
|
||||
: MATCH_TEXTS.startGame;
|
||||
start.textContent = isMatch ? MATCH_TEXTS.startMatch : MATCH_TEXTS.startGame;
|
||||
start.disabled = busy;
|
||||
},
|
||||
focus() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Un coup écrit dans la notation du moteur (« 24/22* 6/1 », « 8/4(2) »),
|
||||
// touchable vers sa fiche du glossaire : un débutant y apprend ce que
|
||||
// veulent dire l'étoile, « off », « bar » et « (2) ».
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import type { MoveNotation } from "../domain/game/engine.js";
|
||||
import { routeHash } from "./router.js";
|
||||
|
||||
/** La fiche du glossaire de la notation (docs/glossaire/, sans .md). */
|
||||
export const NOTATION_TERM = "notation-des-coups";
|
||||
|
||||
/**
|
||||
* Remplace le contenu de parent par text, où la première occurrence de
|
||||
* notation devient un lien vers la fiche « Notation des coups ». Sans
|
||||
* notation dans le texte, le texte seul.
|
||||
*/
|
||||
export function showWithNotation(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
notation: MoveNotation | null,
|
||||
): void {
|
||||
const at = notation === null || notation === "" ? -1 : text.indexOf(notation);
|
||||
if (notation === null || at < 0) {
|
||||
parent.textContent = text;
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.className = "notation";
|
||||
link.href = routeHash({ screen: "glossary", term: NOTATION_TERM });
|
||||
link.textContent = notation;
|
||||
parent.replaceChildren(text.slice(0, at), link, text.slice(at + notation.length));
|
||||
}
|
||||
@@ -59,6 +59,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec
|
||||
| [Match](match.md) | Match |
|
||||
| [Moteur](moteur.md) | Engine |
|
||||
| [Niveau de jeu](niveau-de-jeu.md) | Level |
|
||||
| [Notation des coups](notation-des-coups.md) | Move notation |
|
||||
| [Partie](partie.md) | Game |
|
||||
| [Partie enregistrée](partie-enregistree.md) | Saved game |
|
||||
| [Passer](passer.md) | Drop |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
term: Notation des coups
|
||||
english: Move notation
|
||||
code: MoveNotation
|
||||
---
|
||||
# Notation des coups
|
||||
|
||||
**En bref** : un coup s'écrit case de départ/case d'arrivée pour chaque pion, comme 13/7 ; une étoile signale un pion adverse frappé (24/22*), « off » un pion sorti (6/off), « bar » un pion qui rentre depuis la barre (bar/22), et (2) un déplacement fait deux fois avec un double (8/4(2)).
|
||||
|
||||
Ustagammon écrit les coups comme GNU Backgammon, le moteur qui les calcule.
|
||||
Chaque joueur numérote les cases de son propre point de vue, de 1 (la case la
|
||||
plus proche de sa sortie) à 24 :
|
||||
|
||||
- `13/7` : un pion part de la case 13 et arrive sur la case 7. Un coup de deux
|
||||
dés compte en général deux déplacements, séparés par une espace :
|
||||
`13/7 8/7`.
|
||||
- `24/22*` : l'étoile dit que le pion arrivé sur la 22 y a frappé un pion
|
||||
seul de l'adversaire, qui part sur la barre.
|
||||
- `6/off` : le pion de la case 6 sort du plateau.
|
||||
- `bar/22` : un pion qui était sur la barre rentre sur la case 22.
|
||||
- `8/4(2)` : le même déplacement, fait deux fois. Avec un double, on joue
|
||||
quatre fois le dé : `8/4(2) 6/2(2)` déplace deux pions de 8 à 4 et deux de
|
||||
6 à 2.
|
||||
- `13/1*` : un seul pion qui avance avec ses deux dés s'écrit d'un trait,
|
||||
de sa case de départ à sa case d'arrivée.
|
||||
|
||||
Voir aussi : [Coup](coup.md), [Demi-coup](demi-coup.md), [Barre](barre.md), [Sortie](sortie.md), [Blot](blot.md)
|
||||
@@ -38,8 +38,8 @@ Ouvert depuis le menu, l'accueil et la fin de partie. Une seule page, sans
|
||||
3. **Pendant la partie** : interrupteur « Conseil du maître », sous-titre
|
||||
« Une ligne après chacun de vos coups ».
|
||||
4. **Barre d'action collée en bas** : un seul bouton principal, qui dit ce
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match en 5
|
||||
points »), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
qu'il lance (« Commencer la partie » ou « Commencer le match » ; choix
|
||||
de Pierre du 2026-11-11, la longueur se lit déjà sur le bouton choisi), et la mention « Vos choix sont gardés pour la prochaine fois. ».
|
||||
|
||||
Par défaut : partie libre, Intermédiaire, conseil activé. Fermer (croix en
|
||||
haut à gauche, ou retour du téléphone) ne change rien à la partie en cours.
|
||||
|
||||
@@ -4,7 +4,9 @@ Fonctionnalité: Choisir sa partie et jouer un match
|
||||
L'écran « Nouvelle partie » s'ouvre depuis le menu, l'accueil et, à la
|
||||
fin d'une partie, par « Changer les réglages ». Vous y choisissez une partie libre ou un match en 3, 5, 7
|
||||
ou 11 points, le niveau de l'ordinateur, et si le maître commente vos
|
||||
coups. Vos choix sont gardés pour la prochaine fois.
|
||||
coups. Vos choix sont gardés pour la prochaine fois. « Commencer la
|
||||
partie » (ou « Commencer le match ») lance aussitôt la partie, sur le
|
||||
plateau, même juste après le bilan d'une partie finie.
|
||||
|
||||
Scénario: Comment choisir le niveau de l'ordinateur ?
|
||||
Six niveaux, du Débutant, qui laisse passer des occasions, au Champion,
|
||||
|
||||
+16
-4
@@ -12,10 +12,13 @@
|
||||
# build/browser-shots/maitre/. Le bilan de fin de partie et l'écran
|
||||
# d'analyse (tests/browser/bilan.mjs), captures dans
|
||||
# build/browser-shots/bilan/. « Demander à une IA » (tests/browser/prompts.mjs),
|
||||
# captures dans build/browser-shots/prompts/.
|
||||
# captures dans build/browser-shots/prompts/. « Nouvelle partie » après une
|
||||
# partie finie et son bilan, par chaque entrée
|
||||
# (tests/browser/nouvelle-partie.mjs), captures dans
|
||||
# build/browser-shots/nouvelle-partie/.
|
||||
#
|
||||
# BROWSERS=chromium et SCRIPTS=accueil (ou play, match, maitre, bilan,
|
||||
# prompts) restreignent le test.
|
||||
# prompts, nouvelle-partie) restreignent le test.
|
||||
#
|
||||
# Outils : l'image de Playwright (navigateurs) et le paquet playwright,
|
||||
# installé une fois par npm dans build/browser-tools/ (réseau nécessaire la
|
||||
@@ -28,7 +31,7 @@ cd "$(dirname "$0")/.."
|
||||
image="${PLAYWRIGHT_IMAGE:?lancer via make test-browser}"
|
||||
browsers="${BROWSERS:-chromium firefox}"
|
||||
# Les scripts à lancer (tests/browser/<nom>.mjs), tous par défaut.
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts}"
|
||||
scripts="${SCRIPTS:-play accueil match maitre bilan prompts nouvelle-partie}"
|
||||
|
||||
unset MAKEFLAGS MFLAGS MAKELEVEL
|
||||
make -s package >/dev/null
|
||||
@@ -66,7 +69,8 @@ update_port="$(docker port "$update_container" 80/tcp | head -n1 | sed 's/.*://'
|
||||
rm -rf build/browser-shots
|
||||
mkdir -p build/browser-shots
|
||||
cp tests/browser/play.mjs tests/browser/accueil.mjs tests/browser/match.mjs tests/browser/recette.mjs \
|
||||
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs "$tools/"
|
||||
tests/browser/maitre.mjs tests/browser/bilan.mjs tests/browser/prompts.mjs tests/browser/nouvelle-partie.mjs \
|
||||
"$tools/"
|
||||
status=0
|
||||
for browser in $browsers; do
|
||||
# Réseau de l'hôte : le site est sur 127.0.0.1. Le service worker
|
||||
@@ -113,5 +117,13 @@ for browser in $browsers; do
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node prompts.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
if [[ " $scripts " == *" nouvelle-partie "* ]]; then
|
||||
# « Nouvelle partie » après une partie finie et son bilan : la
|
||||
# nouvelle partie tout de suite ; captures dans
|
||||
# build/browser-shots/nouvelle-partie/.
|
||||
docker run --rm --init --ipc=host --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
-v "$PWD/$tools:/tools" -v "$PWD/build/browser-shots:/shots" -w /tools "$image" \
|
||||
node nouvelle-partie.mjs "http://localhost:$port/" "$browser" /shots || status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
@@ -184,6 +184,15 @@ await until("feuille du maître", () => document.getElementById("explain-mine")
|
||||
await page.click("#explain-mine");
|
||||
const sheetLegend = await page.textContent("#explain-legend");
|
||||
check(sheetLegend.includes(" 6/off,"), `feuille du maître : ${sheetLegend}`);
|
||||
// Le coup y est un lien vers la fiche « Notation des coups ».
|
||||
const notationLink = await page.evaluate(() => {
|
||||
const a = document.querySelector("#explain-legend a.notation");
|
||||
return a === null ? null : { text: a.textContent, href: a.getAttribute("href") };
|
||||
});
|
||||
check(
|
||||
notationLink?.text === "6/off" && notationLink.href === "#/glossaire/notation-des-coups",
|
||||
`feuille du maître, lien de la notation : ${JSON.stringify(notationLink)}`,
|
||||
);
|
||||
await page.click("#explain-done");
|
||||
await until("feuille fermée", () => document.getElementById("explain-veil").hidden);
|
||||
// Le joueur passe.
|
||||
|
||||
@@ -194,7 +194,7 @@ await page.click("#new-game-match");
|
||||
await page.click("#new-game-length-3");
|
||||
await page.click("#new-game-level-beginner");
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match en 3 points",
|
||||
check(s.match === "true" && s.lengthsShown && !s.jacobyShown && s.start === "Commencer le match",
|
||||
`match en 3 points : ${JSON.stringify(s)}`);
|
||||
check(s.level === "beginner" && s.reached === 1 && s.name === "Débutant", `Débutant : ${JSON.stringify(s)}`);
|
||||
check((await page.getAttribute("#new-game-crawford a", "href")) === "#/glossaire/regle-de-crawford",
|
||||
@@ -254,7 +254,7 @@ await page.click("#menu-new-game");
|
||||
await until("écran « Nouvelle partie »", () => location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden);
|
||||
s = await screen();
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match en 3 points",
|
||||
check(s.match === "true" && s.level === "beginner" && s.start === "Commencer le match",
|
||||
`choix retrouvés : ${JSON.stringify(s)}`);
|
||||
await page.click("#new-game-start");
|
||||
await until("match commencé", () => location.hash === "#/" && !document.body.classList.contains("busy"));
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// nouvelle-partie.mjs -- après une partie finie et son bilan, « Nouvelle
|
||||
// partie » mène tout droit à la nouvelle partie, sur le plateau, avec les
|
||||
// réglages choisis, par chaque entrée vers #/nouvelle-partie : l'accueil
|
||||
// (l'application rouverte), le menu, « Changer les réglages » du bilan.
|
||||
// Dans un vrai navigateur sans écran (Playwright), sur le site servi par
|
||||
// l'image de production.
|
||||
//
|
||||
// Copyright (C) 2026 les auteurs d'Ustagammon.
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
//
|
||||
// Usage : node nouvelle-partie.mjs <adresse du site> <chromium|firefox> <dossier des captures>
|
||||
// Lancé par tests/browser.sh. Défaut signalé par un joueur : « tu fais
|
||||
// nouvelle partie […] tu te retrouves sur le résultat du match précédent et
|
||||
// tu dois cliquer à nouveau sur nouvelle partie ». Chaque partie finie est
|
||||
// une fin de course courte, partie d'un lien de position avec des dés
|
||||
// imposés, comme dans bilan.mjs. Vérifie aussi le libellé du bouton
|
||||
// (« Commencer la partie », « Commencer le match ») et que le coup de
|
||||
// l'ordinateur, sur la ligne d'information, mène à la fiche « Notation des
|
||||
// coups ». Captures (Chromium) dans <dossier des captures>/nouvelle-partie/,
|
||||
// en 412×915 et 915×412.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
import { hiddenUnderActionBar } from "./recette.mjs";
|
||||
|
||||
const [base, browserName, shots] = process.argv.slice(2);
|
||||
const browserType = { chromium, firefox }[browserName];
|
||||
if (!base || !browserType || !shots) {
|
||||
console.error("usage : node nouvelle-partie.mjs <adresse> <chromium|firefox> <dossier des captures>");
|
||||
process.exit(2);
|
||||
}
|
||||
const full = browserName === "chromium";
|
||||
const dir = `${shots}/nouvelle-partie`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const PORTRAIT = { width: 412, height: 915 };
|
||||
const LANDSCAPE = { width: 915, height: 412 };
|
||||
// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1,
|
||||
// un sur 2. Le joueur joue 6/5/off, l'ordinateur double, le joueur passe.
|
||||
const BEAROFF = "BQAACAIAAAAAAA";
|
||||
|
||||
const problems = [];
|
||||
const log = (text) => console.log(`[${browserName}] ${text}`);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let captures = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await browserType.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: PORTRAIT,
|
||||
deviceScaleFactor: 1,
|
||||
locale: "fr-FR",
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// Les dés imposés d'abord (un dé par valeur), puis une graine fixe.
|
||||
window.__dice = [];
|
||||
let state = 7;
|
||||
Math.random = () => {
|
||||
const die = window.__dice.shift();
|
||||
if (die !== undefined) {
|
||||
return (die - 0.5) / 6;
|
||||
}
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
window.__csp = [];
|
||||
document.addEventListener("securitypolicyviolation", (e) =>
|
||||
window.__csp.push(`${e.effectiveDirective} ${e.blockedURI}`),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => problems.push(`erreur de la page : ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Attend une condition dans la page (sans page.waitForFunction, qui passe
|
||||
* par eval, interdit par la politique de sécurité du site). */
|
||||
async function until(what, fn, arg, ms = 120_000) {
|
||||
for (let waited = 0; waited < ms; waited += 100) {
|
||||
if (await page.evaluate(fn, arg)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await page.screenshot({ path: `${dir}/${browserName}-bloque.png` }).catch(() => {});
|
||||
throw new Error(`attente trop longue : ${what} (adresse ${await page.evaluate(() => location.hash)})`);
|
||||
}
|
||||
|
||||
const idle = () => until("écran libre", () => !document.body.classList.contains("busy"));
|
||||
|
||||
/** Une capture en portrait et en paysage (Chromium seulement), après la
|
||||
* règle de la recette : rien d'interactif sous une barre d'action. */
|
||||
async function capture(name) {
|
||||
if (!full) {
|
||||
return;
|
||||
}
|
||||
for (const size of [PORTRAIT, LANDSCAPE]) {
|
||||
await page.setViewportSize(size);
|
||||
await sleep(300);
|
||||
const hidden = await hiddenUnderActionBar(page);
|
||||
check(
|
||||
hidden.length === 0,
|
||||
`${name} ${size.width}x${size.height} : caché sous la barre d'action : ${hidden.join(", ")}`,
|
||||
);
|
||||
await page.screenshot({ path: `${dir}/${name}-${size.width}x${size.height}.png` });
|
||||
captures++;
|
||||
}
|
||||
await page.setViewportSize(PORTRAIT);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
/** Une partie finie et son bilan à l'écran : une fin de course depuis un
|
||||
* lien de position, 6/5/off, puis le double de l'ordinateur passé. Renvoie
|
||||
* l'identifiant de la partie. */
|
||||
async function finishedGameWithSummary() {
|
||||
await page.goto(`${base}#/position/${encodeURIComponent(BEAROFF)}`);
|
||||
await until("position lue", () => {
|
||||
const play = document.getElementById("position-play");
|
||||
return !play.hidden && !play.disabled;
|
||||
});
|
||||
await page.click("#position-play");
|
||||
await until(
|
||||
"partie commencée",
|
||||
() =>
|
||||
location.hash === "#/" &&
|
||||
!document.body.classList.contains("busy") &&
|
||||
!document.getElementById("roll").hidden,
|
||||
);
|
||||
await page.evaluate(() => window.__dice.push(6, 1));
|
||||
await page.click("#roll");
|
||||
await idle();
|
||||
for (const [from, to] of [
|
||||
["p6", "p5"],
|
||||
["p5", "off-player"],
|
||||
]) {
|
||||
await page.click(`#board .hit[data-place="${from}"]`);
|
||||
await idle();
|
||||
const land = page.locator(`#board .hit.can-land[data-place="${to}"]`);
|
||||
if ((await land.count()) > 0) {
|
||||
await land.click();
|
||||
await idle();
|
||||
}
|
||||
}
|
||||
await page.click("#confirm");
|
||||
await until("double de l'ordinateur", () => !document.getElementById("drop").hidden);
|
||||
await page.click("#drop");
|
||||
await until(
|
||||
"bilan de la partie finie",
|
||||
() =>
|
||||
/^#\/parties\/[a-z0-9-]+\/bilan$/.test(location.hash) &&
|
||||
document.getElementById("summary")?.dataset.ready === "true",
|
||||
);
|
||||
return page.evaluate(() => location.hash.split("/")[2]);
|
||||
}
|
||||
|
||||
/** Choisit les réglages sur l'écran « Nouvelle partie », vérifie le libellé
|
||||
* du bouton, puis commence. */
|
||||
async function chooseAndStart(choice, shot) {
|
||||
await until(
|
||||
"écran « Nouvelle partie »",
|
||||
() =>
|
||||
location.hash === "#/nouvelle-partie" &&
|
||||
!document.getElementById("new-game").hidden &&
|
||||
!document.getElementById("new-game-start").disabled,
|
||||
);
|
||||
await page.click(choice.match ? "#new-game-match" : "#new-game-free");
|
||||
if (choice.match) {
|
||||
await page.click(`#new-game-length-${choice.length}`);
|
||||
}
|
||||
await page.click(`#new-game-level-${choice.level}`);
|
||||
const label = await page.textContent("#new-game-start");
|
||||
const expected = choice.match ? "Commencer le match" : "Commencer la partie";
|
||||
check(label === expected, `bouton : « ${label} », attendu « ${expected} »`);
|
||||
if (shot) {
|
||||
await capture("1-nouvelle-partie");
|
||||
}
|
||||
await page.click("#new-game-start");
|
||||
}
|
||||
|
||||
/** La nouvelle partie est à l'écran, sur le plateau, avec les réglages
|
||||
* choisis ; ni le bilan ni le panneau de fin de l'ancienne partie, même un
|
||||
* peu plus tard. */
|
||||
async function checkNewGame(entry, oldId, choice) {
|
||||
await until(
|
||||
`${entry} : partie commencée`,
|
||||
() => location.hash === "#/" && !document.body.classList.contains("busy"),
|
||||
);
|
||||
// Le défaut apparaissait juste après : l'adresse passait au bilan de
|
||||
// l'ancienne partie. On regarde pendant deux secondes.
|
||||
for (let waited = 0; waited < 2000; waited += 100) {
|
||||
const s = await page.evaluate(() => ({
|
||||
hash: location.hash,
|
||||
summary: !document.getElementById("summary-veil").hidden,
|
||||
panel: !document.getElementById("panel-veil").hidden,
|
||||
board: !document.getElementById("game").hidden,
|
||||
newGame: !document.getElementById("new-game").hidden,
|
||||
}));
|
||||
check(
|
||||
s.hash === "#/" && !s.summary && !s.panel && s.board && !s.newGame,
|
||||
`${entry} : la nouvelle partie n'est pas à l'écran (ancienne partie ${oldId}) : ${JSON.stringify(s)}`,
|
||||
);
|
||||
await sleep(100);
|
||||
}
|
||||
await idle();
|
||||
const shown = await page.evaluate(() => ({
|
||||
line: document.getElementById("match-line").textContent,
|
||||
lineShown: !document.getElementById("match-line").hidden,
|
||||
level: JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").level,
|
||||
over: document.getElementById("again").hidden === false,
|
||||
}));
|
||||
check(!shown.over, `${entry} : la partie à l'écran est finie`);
|
||||
check(shown.level === choice.level, `${entry} : niveau ${shown.level}`);
|
||||
if (choice.match) {
|
||||
check(
|
||||
shown.lineShown && shown.line === `Match en ${choice.length} points`,
|
||||
`${entry} : ligne du match « ${shown.line} »`,
|
||||
);
|
||||
} else {
|
||||
check(!shown.lineShown, `${entry} : un match au lieu d'une partie libre`);
|
||||
}
|
||||
log(`${entry} : nouvelle partie directe (${choice.match ? `match en ${choice.length}` : "partie libre"}, ${choice.level})`);
|
||||
}
|
||||
|
||||
// --------------------------- 1. L'application rouverte, puis l'accueil
|
||||
|
||||
let oldId = await finishedGameWithSummary();
|
||||
// L'application rouverte : sans adresse, elle ouvre l'accueil.
|
||||
await page.goto(base);
|
||||
await until(
|
||||
"accueil",
|
||||
() =>
|
||||
location.hash === "#/accueil" &&
|
||||
!document.getElementById("home-new").hidden &&
|
||||
!document.getElementById("home-new").disabled,
|
||||
);
|
||||
await page.click("#home-new");
|
||||
let choice = { match: true, length: 3, level: "beginner" };
|
||||
await chooseAndStart(choice, true);
|
||||
await checkNewGame("accueil", oldId, choice);
|
||||
await capture("2-partie-demarree");
|
||||
|
||||
// Le coup de l'ordinateur, sur la ligne d'information, mène à la fiche
|
||||
// « Notation des coups ». On joue jusqu'à ce qu'il ait joué un coup.
|
||||
for (let actions = 0; ; actions++) {
|
||||
check(actions < 200, "l'ordinateur ne joue pas de coup");
|
||||
const link = await page.evaluate(() => document.querySelector("#info a.notation")?.href ?? null);
|
||||
if (link !== null) {
|
||||
check(link.endsWith("#/glossaire/notation-des-coups"), `lien de la notation : ${link}`);
|
||||
break;
|
||||
}
|
||||
const what = await page.evaluate(() => {
|
||||
const usable = (id) => {
|
||||
const b = document.getElementById(id);
|
||||
return !b.hidden && !b.disabled;
|
||||
};
|
||||
for (const id of ["take", "skip", "confirm", "roll"]) {
|
||||
if (usable(id)) {
|
||||
return `#${id}`;
|
||||
}
|
||||
}
|
||||
if (document.querySelector("#board .hit.can-land")) {
|
||||
return "#board .hit.can-land";
|
||||
}
|
||||
return document.querySelector("#board .hit.can-move") ? "#board .hit.can-move" : null;
|
||||
});
|
||||
if (what === null) {
|
||||
await sleep(100);
|
||||
continue;
|
||||
}
|
||||
await page.locator(what).first().click();
|
||||
await idle();
|
||||
}
|
||||
await page.click("#info a.notation");
|
||||
await until(
|
||||
"fiche « Notation des coups »",
|
||||
() =>
|
||||
location.hash === "#/glossaire/notation-des-coups" &&
|
||||
document.getElementById("glossaire-notation-des-coups") !== null,
|
||||
);
|
||||
await page.goBack();
|
||||
await until("retour à la partie", () => location.hash === "#/");
|
||||
log("ligne de l'ordinateur : la notation mène à sa fiche");
|
||||
|
||||
// --------------------------------- 2. L'application rouverte sur le menu
|
||||
|
||||
oldId = await finishedGameWithSummary();
|
||||
await page.goto(`${base}#/menu`);
|
||||
await until("menu", () => !document.getElementById("menu-veil").hidden);
|
||||
await page.click("#menu-new-game");
|
||||
choice = { match: false, level: "amateur" };
|
||||
await chooseAndStart(choice, false);
|
||||
await checkNewGame("menu", oldId, choice);
|
||||
|
||||
// ------------------------------- 3. « Changer les réglages » du bilan
|
||||
|
||||
oldId = await finishedGameWithSummary();
|
||||
await page.click("#summary-settings");
|
||||
choice = { match: true, length: 5, level: "intermediate" };
|
||||
await chooseAndStart(choice, false);
|
||||
await checkNewGame("bilan, « Changer les réglages »", oldId, choice);
|
||||
|
||||
const csp = await page.evaluate(() => window.__csp);
|
||||
for (const violation of csp) {
|
||||
problems.push(`politique de sécurité : ${violation}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) {
|
||||
console.error(`[${browserName}] ${problem}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
log(`nouvelle partie : tout est conforme (${captures} captures)`);
|
||||
+13
@@ -586,6 +586,19 @@ button:disabled {
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Un coup en notation du moteur, lien vers sa fiche du glossaire (ligne de
|
||||
* l'ordinateur, feuille du maître) : couleur du texte, soulignement en
|
||||
* pointillé discret. Le remplissage vertical d'un élément en ligne agrandit
|
||||
* la zone d'appui sans changer la hauteur des lignes. */
|
||||
a.notation {
|
||||
padding-block: 8px;
|
||||
color: inherit;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: var(--muted);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.infoline .replay {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user