Compare commits
24
Commits
@@ -74,6 +74,36 @@ export interface Engine {
|
||||
|
||||
/** La chance d'un jet pour le joueur au trait. */
|
||||
luck(position: PositionId, cube: EngineCube, roll: Roll): Promise<Luck>;
|
||||
|
||||
/** Le Match ID d'une partie libre, joueur 0 au trait, avec ce videau vu
|
||||
* de lui, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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 type { GameSetup, 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 (partie libre, 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";
|
||||
const cube: EngineCube =
|
||||
state.cube.owner === null
|
||||
? { value: state.cube.value, owner: "centered" }
|
||||
: {
|
||||
value: state.cube.value,
|
||||
owner: state.cube.owner === onRoll ? "onRoll" : "opponent",
|
||||
};
|
||||
return { position: state.position, matchId: await engine.encodeMatch(cube) };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -22,12 +22,19 @@ import type {
|
||||
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
|
||||
@@ -208,6 +215,35 @@ export function createWasmEngine(run: RunCommand): Engine {
|
||||
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(
|
||||
position: PositionId,
|
||||
roll: Roll,
|
||||
|
||||
+10
-2
@@ -6,6 +6,7 @@
|
||||
// Licence : GPLv3 ou ultérieure (voir LICENSE).
|
||||
|
||||
import { Game } from "./domain/game/game.js";
|
||||
import { linkOf, readPosition } from "./domain/game/position-link.js";
|
||||
import { exportSgf } from "./domain/game/sgf.js";
|
||||
import { createWasmEngine } from "./engine/wasm-engine.js";
|
||||
import { startEngineWorker } from "./engine/worker-run.js";
|
||||
@@ -35,7 +36,10 @@ const opening = (async () => {
|
||||
// Le niveau de l'ordinateur : celui de Game.start (STANDARD_PLY).
|
||||
const session = createGameSession(
|
||||
library,
|
||||
{ start: () => Game.start(engine), resume: (journal) => Game.resume(engine, journal) },
|
||||
{
|
||||
start: (setup) => Game.start(engine, setup === undefined ? {} : { setup }),
|
||||
resume: (journal) => Game.resume(engine, journal),
|
||||
},
|
||||
"2ply",
|
||||
);
|
||||
const saved = await session.restore();
|
||||
@@ -57,7 +61,11 @@ startApp({
|
||||
stored = opened;
|
||||
}),
|
||||
]),
|
||||
newGame: () => storage().session.newGame(),
|
||||
newGame: (setup) => storage().session.newGame(setup),
|
||||
positions: {
|
||||
read: (link) => readPosition(engine, link),
|
||||
linkOf: (game) => linkOf(engine, game.state),
|
||||
},
|
||||
savedGame: () => storage().saved,
|
||||
resumePrevious: () => storage().session.resumePrevious(),
|
||||
saveGame: (journal) => storage().session.save(journal),
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
import { switchToBranch } from "../domain/game/branches.js";
|
||||
import type { Level } from "../domain/game/engine.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game } from "../domain/game/game.js";
|
||||
import type { Game, GameSetup } from "../domain/game/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: () => Game;
|
||||
readonly start: (setup?: GameSetup) => Game;
|
||||
readonly resume: (journal: GameJournal) => Game;
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ export interface GameSession {
|
||||
/** 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 ; celle qu'elle remplace reste reprenable par
|
||||
* resumePrevious, jusqu'au prochain changement de partie. */
|
||||
newGame(): Game;
|
||||
/** Une nouvelle partie (depuis la position de setup si elle est donnée) ;
|
||||
* celle qu'elle remplace reste reprenable par resumePrevious, jusqu'au
|
||||
* prochain changement de partie. */
|
||||
newGame(setup?: GameSetup): Game;
|
||||
/** Enregistre la partie à l'écran (sans attendre la fin de l'écriture ;
|
||||
* un échec est ignoré : la partie continue). */
|
||||
save(journal: GameJournal): void;
|
||||
@@ -79,10 +80,10 @@ export function createGameSession(
|
||||
return null; // partie illisible : on en commencera une nouvelle
|
||||
}
|
||||
},
|
||||
newGame() {
|
||||
newGame(setup) {
|
||||
previousId = shownId;
|
||||
shownId = newGameId();
|
||||
return games.start();
|
||||
return games.start(setup);
|
||||
},
|
||||
save(journal) {
|
||||
if (shownId !== null) {
|
||||
|
||||
+419
-65
@@ -14,11 +14,18 @@
|
||||
// d'information, consigne, barre d'actions) ; en paysage, le plateau est
|
||||
// l'écran et porte ses commandes (ADR 0012). Toutes les actions passent par
|
||||
// une même table (actions), qu'on les déclenche au toucher ou au clavier.
|
||||
//
|
||||
// Navigation (ADR 0013, amendement du 2026-11-03) : chaque écran a son
|
||||
// adresse, et l'adresse est la source de vérité. On change d'écran par de
|
||||
// vrais liens (aide, glossaire, menu, mes parties, position) ; applyRoute
|
||||
// dessine l'écran que désigne l'adresse, au démarrage et à chaque
|
||||
// changement. Les actions de jeu restent des boutons.
|
||||
|
||||
import type { Cube } from "../domain/game/cube.js";
|
||||
import type { GameJournal } from "../domain/game/events.js";
|
||||
import type { Game, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import type { Game, GameSetup, MoveSteps, SideBoards } from "../domain/game/game.js";
|
||||
import { formatHalfMoves, type HalfMove } from "../domain/game/position.js";
|
||||
import { type PositionLink, type SharedPosition, setupOf } from "../domain/game/position-link.js";
|
||||
import type { SavedGameSummary } from "../domain/game/saved-game.js";
|
||||
import { otherSide, type Side } from "../domain/game/side.js";
|
||||
import {
|
||||
@@ -44,8 +51,10 @@ import { GAMES_TEXTS } from "./games-texts.js";
|
||||
import { createGamesView, type GamesView } from "./games-view.js";
|
||||
import { boardGeometry, type Geometry, type Layout, PORTRAIT_ASPECT } from "./geometry.js";
|
||||
import { createHaptics, type Vibrate } from "./haptics.js";
|
||||
import { loadGlossaryEntry, showHelp } from "./help.js";
|
||||
import { createHelpView, loadGlossaryEntry } from "./help.js";
|
||||
import { icon } from "./icons.js";
|
||||
import { replacingLink, startNavigation } from "./navigation.js";
|
||||
import { isHelpScreen, type Route, routeHash } from "./router.js";
|
||||
import { longPrompts, nextTheme, parseSettings, type Settings } from "./settings.js";
|
||||
import { type PromptText, TEXTS } from "./texts.js";
|
||||
|
||||
@@ -67,8 +76,11 @@ export interface CachedBoard {
|
||||
export interface AppOptions {
|
||||
/** Résolue quand le moteur est chargé, rejetée s'il ne peut pas l'être. */
|
||||
readonly engineReady: Promise<unknown>;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré). */
|
||||
readonly newGame: () => Game;
|
||||
/** Une nouvelle partie (le jet d'ouverture est déjà tiré), ou depuis
|
||||
* une position donnée (setup). */
|
||||
readonly newGame: (setup?: GameSetup) => Game;
|
||||
/** Les liens vers une position (ADR 0013). */
|
||||
readonly positions: PositionsAccess;
|
||||
/** La partie enregistrée, ou null s'il n'y en a pas. */
|
||||
readonly savedGame: () => Game | null;
|
||||
/** « Reprendre l'ancienne » : la partie quittée par la dernière
|
||||
@@ -99,6 +111,14 @@ export interface SavedGamesAccess {
|
||||
readonly exportSgf: (id: string) => Promise<{ fileName: string; text: string }>;
|
||||
}
|
||||
|
||||
/** Ce que la vue d'une position demande au domaine et au moteur. */
|
||||
export interface PositionsAccess {
|
||||
/** La position d'un lien, lue par le moteur (erreur si illisible). */
|
||||
readonly read: (link: PositionLink) => Promise<SharedPosition>;
|
||||
/** Le lien de la position d'une partie. */
|
||||
readonly linkOf: (game: Game) => Promise<PositionLink>;
|
||||
}
|
||||
|
||||
function byId<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (element === null) {
|
||||
@@ -162,21 +182,32 @@ export function startApp(options: AppOptions): void {
|
||||
again: byId<HTMLButtonElement>("again"),
|
||||
moveHint: byId("move-hint"),
|
||||
replay: byId<HTMLButtonElement>("replay"),
|
||||
helpButton: byId<HTMLButtonElement>("help-button"),
|
||||
copyLink: byId<HTMLButtonElement>("copy-link"),
|
||||
helpButton: byId<HTMLAnchorElement>("help-button"),
|
||||
fullscreenButton: byId<HTMLButtonElement>("fullscreen-button"),
|
||||
menuButton: byId<HTMLButtonElement>("menu-button"),
|
||||
helpBack: byId<HTMLButtonElement>("help-back"),
|
||||
menuButton: byId<HTMLAnchorElement>("menu-button"),
|
||||
helpBack: byId<HTMLAnchorElement>("help-back"),
|
||||
helpTabs: byId("help-tabs"),
|
||||
game: byId("game"),
|
||||
help: byId("help"),
|
||||
helpContent: byId("help-content"),
|
||||
position: byId("position-view"),
|
||||
positionTitle: byId("position-title"),
|
||||
positionMessage: byId("position-message"),
|
||||
positionBoard: byId("position-board") as unknown as SVGSVGElement,
|
||||
positionDetail: byId("position-detail"),
|
||||
positionBack: byId<HTMLAnchorElement>("position-back"),
|
||||
positionPlay: byId<HTMLButtonElement>("position-play"),
|
||||
positionCopy: byId<HTMLButtonElement>("position-copy"),
|
||||
menuVeil: byId("menu-veil"),
|
||||
menu: byId("menu"),
|
||||
menuTitle: byId("menu-title"),
|
||||
menuClose: byId<HTMLButtonElement>("menu-close"),
|
||||
menuNewGame: byId<HTMLButtonElement>("menu-new-game"),
|
||||
menuGames: byId<HTMLButtonElement>("menu-games"),
|
||||
menuGames: byId<HTMLAnchorElement>("menu-games"),
|
||||
menuCopyLink: byId<HTMLButtonElement>("menu-copy-link"),
|
||||
menuFullscreen: byId<HTMLButtonElement>("menu-fullscreen"),
|
||||
menuHelp: byId<HTMLButtonElement>("menu-help"),
|
||||
menuHelp: byId<HTMLAnchorElement>("menu-help"),
|
||||
menuTheme: byId<HTMLButtonElement>("menu-theme"),
|
||||
menuVibrations: byId<HTMLButtonElement>("menu-vibrations"),
|
||||
menuScreenOn: byId<HTMLButtonElement>("menu-screen-on"),
|
||||
@@ -218,7 +249,8 @@ export function startApp(options: AppOptions): void {
|
||||
// ---------------------------------------------------- Textes fixes
|
||||
|
||||
ui.board.setAttribute("aria-label", TEXTS.boardLabel);
|
||||
const label = (button: HTMLButtonElement, text: string, name?: Parameters<typeof icon>[0]) => {
|
||||
ui.positionBoard.setAttribute("aria-label", TEXTS.boardLabel);
|
||||
const label = (button: HTMLElement, text: string, name?: Parameters<typeof icon>[0]) => {
|
||||
button.replaceChildren();
|
||||
if (name !== undefined) {
|
||||
button.append(icon(name));
|
||||
@@ -234,6 +266,9 @@ export function startApp(options: AppOptions): void {
|
||||
ui.replay.append(icon("replay"));
|
||||
ui.replay.setAttribute("aria-label", TEXTS.replayTitle);
|
||||
ui.replay.title = TEXTS.replayTitle;
|
||||
ui.copyLink.append(icon("copy"));
|
||||
ui.copyLink.setAttribute("aria-label", TEXTS.copyPositionLink);
|
||||
ui.copyLink.title = TEXTS.copyPositionLink;
|
||||
ui.roll.textContent = TEXTS.roll;
|
||||
ui.double.textContent = TEXTS.double;
|
||||
ui.take.textContent = TEXTS.take;
|
||||
@@ -246,11 +281,20 @@ export function startApp(options: AppOptions): void {
|
||||
ui.doubleConfirm.textContent = TEXTS.double;
|
||||
ui.doubleCancel.textContent = TEXTS.cancel;
|
||||
ui.helpBack.textContent = TEXTS.back;
|
||||
ui.helpTabs.setAttribute("aria-label", TEXTS.helpTabs);
|
||||
for (const tab of ui.helpTabs.querySelectorAll<HTMLElement>("a[data-screen]")) {
|
||||
const screen = tab.dataset.screen as "help" | "glossary" | "about";
|
||||
tab.textContent = TEXTS.helpTabNames[screen];
|
||||
}
|
||||
ui.positionBack.textContent = TEXTS.backToGame;
|
||||
ui.positionPlay.textContent = TEXTS.playFromHere;
|
||||
label(ui.positionCopy, TEXTS.copyPositionLink, "copy");
|
||||
ui.menuTitle.textContent = TEXTS.menu;
|
||||
ui.menuClose.append(icon("close"));
|
||||
ui.menuClose.setAttribute("aria-label", TEXTS.close);
|
||||
label(ui.menuNewGame, TEXTS.newGame, "newGame");
|
||||
label(ui.menuGames, GAMES_TEXTS.button, "games");
|
||||
label(ui.menuCopyLink, TEXTS.copyPositionLink, "copy");
|
||||
label(ui.menuHelp, TEXTS.helpAndGlossary, "help");
|
||||
ui.menuTheme.prepend(icon("theme"));
|
||||
child(ui.menuTheme, ".item-label").textContent = TEXTS.theme;
|
||||
@@ -260,7 +304,12 @@ export function startApp(options: AppOptions): void {
|
||||
child(ui.menuScreenOn, ".item-label").textContent = TEXTS.keepScreenOn;
|
||||
ui.bannerText.textContent = TEXTS.newGameStarted;
|
||||
ui.resumeOld.textContent = TEXTS.resumeOld;
|
||||
child(ui.bubble, ".link-button").textContent = TEXTS.seeGlossary;
|
||||
const glossaryLink = child<HTMLAnchorElement>(ui.bubble, ".link-button");
|
||||
glossaryLink.textContent = TEXTS.seeGlossary;
|
||||
glossaryLink.href = routeHash({
|
||||
screen: "glossary",
|
||||
term: TEXTS.pipCountFile.replace(/\.md$/, ""),
|
||||
});
|
||||
|
||||
// ------------------------------------------------ État de l'écran
|
||||
|
||||
@@ -294,6 +343,15 @@ export function startApp(options: AppOptions): void {
|
||||
let immersive = false;
|
||||
let rotated = false;
|
||||
let bubbleSide: Side | null = null;
|
||||
// Le lien de la position de la partie, recalculé à chaque mise à jour :
|
||||
// « Copier le lien » écrit alors tout de suite dans le presse-papiers.
|
||||
let currentLink: PositionLink | null = null;
|
||||
// Un message lié à l'adresse (adresse inconnue, lien copié…), montré
|
||||
// dans la ligne d'information jusqu'à la prochaine action.
|
||||
let notice: string | null = null;
|
||||
// La position partagée affichée (#/position/...), une fois lue.
|
||||
let shownPosition: SharedPosition | null = null;
|
||||
let positionRequest = 0;
|
||||
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape) and (max-height: 40rem)");
|
||||
const portraitQuery = window.matchMedia("(orientation: portrait)");
|
||||
@@ -349,6 +407,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (game.pendingHalfMoves.length === 0) {
|
||||
options.saveBoard({ sides, cube: game.state.cube });
|
||||
}
|
||||
currentLink = await options.positions.linkOf(game).catch(() => null);
|
||||
}
|
||||
|
||||
/** Recalcule ce qui demande le moteur, puis redessine. */
|
||||
@@ -403,7 +462,9 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// Ligne d'information : un jet sans coup possible reste annoncé
|
||||
// jusqu'à l'action suivante, même après un rechargement de la page.
|
||||
if (stuck && state.roll !== null) {
|
||||
if (notice !== null) {
|
||||
ui.info.textContent = notice;
|
||||
} else if (stuck && state.roll !== null) {
|
||||
ui.info.textContent = TEXTS.youCannotMove(state.roll);
|
||||
} else if (computerCouldNotMove(game)) {
|
||||
ui.info.textContent = TEXTS.computerCannotMove(rollBefore(game));
|
||||
@@ -434,6 +495,8 @@ export function startApp(options: AppOptions): void {
|
||||
show(ui.drop, doubled);
|
||||
show(ui.again, over && panelDismissed);
|
||||
show(ui.replay, shownMove !== null);
|
||||
// Le coup de l'ordinateur montré : sa position se copie en lien.
|
||||
show(ui.copyLink, shownMove !== null && currentLink !== null);
|
||||
for (const button of [
|
||||
ui.roll,
|
||||
ui.double,
|
||||
@@ -444,10 +507,11 @@ export function startApp(options: AppOptions): void {
|
||||
ui.replay,
|
||||
ui.again,
|
||||
ui.menuNewGame,
|
||||
ui.positionPlay,
|
||||
]) {
|
||||
button.disabled = busy;
|
||||
}
|
||||
ui.menuGames.disabled = false;
|
||||
ui.menuCopyLink.disabled = currentLink === null;
|
||||
ui.cancel.disabled = busy || game.pendingHalfMoves.length === 0;
|
||||
renderPanel(game, landscape, doubled);
|
||||
renderMenu();
|
||||
@@ -487,12 +551,17 @@ export function startApp(options: AppOptions): void {
|
||||
ui.skip,
|
||||
ui.again,
|
||||
ui.replay,
|
||||
ui.copyLink,
|
||||
]) {
|
||||
button.hidden = true;
|
||||
}
|
||||
ui.moveHint.hidden = true;
|
||||
ui.menuNewGame.disabled = true;
|
||||
ui.menuGames.disabled = true;
|
||||
ui.menuCopyLink.disabled = true;
|
||||
ui.positionPlay.disabled = true;
|
||||
if (notice !== null) {
|
||||
ui.info.textContent = notice;
|
||||
}
|
||||
renderMenu();
|
||||
}
|
||||
|
||||
@@ -577,7 +646,9 @@ export function startApp(options: AppOptions): void {
|
||||
/** Une seule vibration par fin de partie. */
|
||||
function announceEnd(g: Game): void {
|
||||
const head = g.journal.head;
|
||||
if (g.state.phase !== "over" || head === announcedEnd) {
|
||||
// Pendant l'animation du dernier coup, une prise vibrerait après
|
||||
// la fin et la remplacerait : on l'annonce au dessin qui suit.
|
||||
if (g.state.phase !== "over" || head === announcedEnd || animating) {
|
||||
return;
|
||||
}
|
||||
announcedEnd = head;
|
||||
@@ -727,6 +798,7 @@ export function startApp(options: AppOptions): void {
|
||||
targets = new Map();
|
||||
shownMove = null;
|
||||
bubbleSide = null;
|
||||
notice = null;
|
||||
ui.bubble.hidden = true;
|
||||
render();
|
||||
try {
|
||||
@@ -740,18 +812,24 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
function startNewGame(): void {
|
||||
game = options.newGame();
|
||||
function startNewGame(setup?: GameSetup): void {
|
||||
game = options.newGame(setup);
|
||||
panelDismissed = false;
|
||||
announcedEnd = null;
|
||||
saveSettings({ gamesStarted: settings.gamesStarted + 1 });
|
||||
const opening = game.events.findLast((e) => e.type === "DiceRolled");
|
||||
info =
|
||||
opening?.type === "DiceRolled" ? TEXTS.opening(opening.roll[0], opening.roll[1]) : "";
|
||||
setup !== undefined
|
||||
? TEXTS.startedFromPosition
|
||||
: opening?.type === "DiceRolled"
|
||||
? TEXTS.opening(opening.roll[0], opening.roll[1])
|
||||
: "";
|
||||
}
|
||||
|
||||
/** « Nouvelle partie » : tout de suite, sans confirmation ; une partie en
|
||||
* cours reste reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(): void {
|
||||
/** « Nouvelle partie » (ou « Jouer à partir d'ici », depuis une
|
||||
* position) : tout de suite, sans confirmation ; une partie en cours
|
||||
* reste reprenable pendant 10 s (interaction.md). */
|
||||
function newGame(setup?: GameSetup): void {
|
||||
closeOverlays();
|
||||
if (busy || game === null) {
|
||||
return;
|
||||
@@ -760,7 +838,7 @@ export function startApp(options: AppOptions): void {
|
||||
if (old !== null) {
|
||||
showBanner(old);
|
||||
}
|
||||
void act(() => startNewGame());
|
||||
void act(() => startNewGame(setup));
|
||||
}
|
||||
|
||||
function showBanner(journal: GameJournal): void {
|
||||
@@ -808,6 +886,10 @@ export function startApp(options: AppOptions): void {
|
||||
info = "";
|
||||
}
|
||||
|
||||
// L'adresse de la page dit quel écran montrer (applyRoute, plus bas).
|
||||
const navigation = startNavigation((route) => applyRoute(route));
|
||||
const isSheet = (route: Route) => route.screen === "menu" || route.screen === "games";
|
||||
|
||||
// « Mes parties » : reprendre une partie (ou une branche) en change à
|
||||
// l'écran, sans recharger la page.
|
||||
gamesView = createGamesView({
|
||||
@@ -820,12 +902,16 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
const other = await options.games.open(id, branchHead);
|
||||
hideBanner();
|
||||
leaveSheet();
|
||||
await act(() => showGame(other));
|
||||
},
|
||||
remove: options.games.remove,
|
||||
exportSgf: options.games.exportSgf,
|
||||
onClose: () => leaveSheet(),
|
||||
});
|
||||
|
||||
const helpView = createHelpView(ui.helpContent, ui.helpTabs);
|
||||
|
||||
/** Joue un demi-coup du joueur ; le coup attend ensuite sa suite ou sa
|
||||
* validation. Le pion qui se pose vibre (deux fois s'il frappe). */
|
||||
function playHalfMove(halfMove: HalfMove): void {
|
||||
@@ -920,32 +1006,37 @@ export function startApp(options: AppOptions): void {
|
||||
void settle(true);
|
||||
},
|
||||
again: () => newGame(),
|
||||
// Le menu a son adresse : l'ouvrir, c'est aller à #/menu (comme son
|
||||
// lien) ; le refermer, revenir à l'écran d'avant.
|
||||
menu: () => {
|
||||
if (ui.menuVeil.hidden) {
|
||||
if (!ui.menuVeil.hidden) {
|
||||
closeOverlays();
|
||||
renderMenu();
|
||||
openOverlay(ui.menuVeil, ui.menuNewGame);
|
||||
} else {
|
||||
closeOverlays();
|
||||
closeTransient();
|
||||
navigation.go("#/menu");
|
||||
}
|
||||
},
|
||||
help: () => {
|
||||
closeOverlays();
|
||||
ui.game.hidden = true;
|
||||
ui.help.hidden = false;
|
||||
ui.root.classList.add("help-open");
|
||||
window.scrollTo(0, 0);
|
||||
void showHelp(ui.helpContent);
|
||||
closeTransient();
|
||||
navigation.go("#/aide");
|
||||
},
|
||||
fullscreen: () => {
|
||||
closeOverlays();
|
||||
void (immersive ? exitImmersive() : enterImmersive());
|
||||
},
|
||||
// Échap : la confirmation ou la bulle ouverte, sinon l'écran courant
|
||||
// (feuille, aide, position), comme le retour du téléphone.
|
||||
close: () => {
|
||||
if (!ui.help.hidden) {
|
||||
closeHelp();
|
||||
} else {
|
||||
closeOverlays();
|
||||
const route = navigation.route;
|
||||
if (!ui.doubleVeil.hidden || !ui.bubble.hidden) {
|
||||
closeTransient();
|
||||
render();
|
||||
} else if (isSheet(route)) {
|
||||
leaveSheet();
|
||||
} else if (isHelpScreen(route)) {
|
||||
navigation.leave(isHelpScreen);
|
||||
} else if (route.screen === "position") {
|
||||
navigation.leave((r) => r.screen === "position");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -959,12 +1050,10 @@ export function startApp(options: AppOptions): void {
|
||||
focus.focus();
|
||||
}
|
||||
|
||||
/** Ferme le menu, la confirmation du double et la bulle ; ne touche
|
||||
* jamais à la partie. */
|
||||
function closeOverlays(): void {
|
||||
const open = !ui.menuVeil.hidden || !ui.doubleVeil.hidden;
|
||||
gamesView?.close();
|
||||
ui.menuVeil.hidden = true;
|
||||
/** Ferme la confirmation du double et la bulle, qui n'ont pas
|
||||
* d'adresse ; ne touche jamais à la partie. */
|
||||
function closeTransient(): void {
|
||||
const open = !ui.doubleVeil.hidden;
|
||||
ui.doubleVeil.hidden = true;
|
||||
bubbleSide = null;
|
||||
ui.bubble.hidden = true;
|
||||
@@ -973,24 +1062,40 @@ export function startApp(options: AppOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
function closeHelp(): void {
|
||||
ui.help.hidden = true;
|
||||
ui.game.hidden = false;
|
||||
ui.root.classList.remove("help-open");
|
||||
render();
|
||||
/** Ferme aussi le menu ou « Mes parties » : on quitte leur adresse. */
|
||||
function closeOverlays(): void {
|
||||
closeTransient();
|
||||
if (isSheet(navigation.route)) {
|
||||
leaveSheet();
|
||||
}
|
||||
}
|
||||
|
||||
for (const veil of [ui.menuVeil, ui.doubleVeil]) {
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
closeOverlays();
|
||||
}
|
||||
});
|
||||
/** Quitte le menu ou « Mes parties » : la feuille se ferme tout de
|
||||
* suite, l'adresse suit (le retour dans l'historique est asynchrone). */
|
||||
function leaveSheet(): void {
|
||||
const menuWasOpen = !ui.menuVeil.hidden;
|
||||
ui.menuVeil.hidden = true;
|
||||
gamesView?.hide();
|
||||
if (menuWasOpen) {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
navigation.leave(isSheet);
|
||||
}
|
||||
|
||||
ui.doubleCancel.onclick = () => closeOverlays();
|
||||
ui.menuVeil.addEventListener("click", (event) => {
|
||||
if (event.target === ui.menuVeil) {
|
||||
closeOverlays();
|
||||
}
|
||||
});
|
||||
ui.doubleVeil.addEventListener("click", (event) => {
|
||||
if (event.target === ui.doubleVeil) {
|
||||
closeTransient();
|
||||
}
|
||||
});
|
||||
|
||||
ui.doubleCancel.onclick = () => closeTransient();
|
||||
ui.doubleConfirm.onclick = () => {
|
||||
closeOverlays();
|
||||
closeTransient();
|
||||
void act(async (g) => {
|
||||
await g.double();
|
||||
info = g.state.phase === "over" ? "" : TEXTS.computerTakes(g.state.cube.value);
|
||||
@@ -1015,21 +1120,231 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// --------------------------------------------------------- Menu
|
||||
|
||||
ui.menuButton.onclick = actions.menu;
|
||||
// Le bouton Menu et l'aide sont des liens (#/menu, #/aide) : rien à
|
||||
// brancher. Les liens du menu remplacent son adresse : le retour du
|
||||
// téléphone ne rouvre pas le menu.
|
||||
replacingLink(ui.menuGames, navigation);
|
||||
replacingLink(ui.menuHelp, navigation);
|
||||
ui.menuClose.onclick = () => closeOverlays();
|
||||
ui.menuNewGame.onclick = () => newGame();
|
||||
ui.menuGames.onclick = () => {
|
||||
ui.menuCopyLink.onclick = () => {
|
||||
const link = currentLink;
|
||||
closeOverlays();
|
||||
void gamesView?.open();
|
||||
if (link !== null) {
|
||||
void copyLink(link);
|
||||
}
|
||||
};
|
||||
ui.menuFullscreen.onclick = actions.fullscreen;
|
||||
ui.menuHelp.onclick = actions.help;
|
||||
ui.menuTheme.onclick = () => saveSettings({ theme: nextTheme(settings.theme) });
|
||||
ui.menuVibrations.onclick = () => saveSettings({ vibrations: !settings.vibrations });
|
||||
ui.menuScreenOn.onclick = () => saveSettings({ keepScreenOn: !settings.keepScreenOn });
|
||||
ui.helpButton.onclick = actions.help;
|
||||
ui.helpBack.onclick = closeHelp;
|
||||
ui.fullscreenButton.onclick = actions.fullscreen;
|
||||
// « Retour » : à l'écran d'avant l'aide (la partie, si l'aide a été
|
||||
// ouverte par son adresse).
|
||||
leavingLink(ui.helpBack, isHelpScreen);
|
||||
leavingLink(ui.positionBack, (route) => route.screen === "position");
|
||||
|
||||
/** Un lien « Retour » : il revient dans l'historique plutôt que d'y
|
||||
* empiler la partie (sauf ouvert dans un nouvel onglet). */
|
||||
function leavingLink(link: HTMLAnchorElement, stay: (route: Route) => boolean): void {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigation.leave(stay);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Adresses
|
||||
|
||||
let menuReturnFocus: HTMLElement | null = null;
|
||||
|
||||
/** Dessine l'écran que désigne l'adresse (ADR 0013, amendement). */
|
||||
function applyRoute(route: Route): void {
|
||||
const help = isHelpScreen(route);
|
||||
const position = route.screen === "position";
|
||||
const wasPage = ui.game.hidden;
|
||||
ui.game.hidden = help || position;
|
||||
ui.help.hidden = !help;
|
||||
ui.position.hidden = !position;
|
||||
ui.root.classList.toggle("page-open", help || position);
|
||||
if (wasPage && !ui.game.hidden) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// Feuilles : le menu et « Mes parties ».
|
||||
const menuOpen = route.screen === "menu";
|
||||
if (menuOpen && ui.menuVeil.hidden) {
|
||||
closeTransient();
|
||||
menuReturnFocus =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
renderMenu();
|
||||
ui.menuVeil.hidden = false;
|
||||
ui.menuNewGame.focus();
|
||||
} else if (!menuOpen && !ui.menuVeil.hidden) {
|
||||
ui.menuVeil.hidden = true;
|
||||
if (route.screen === "game") {
|
||||
(menuReturnFocus ?? ui.menuButton).focus();
|
||||
}
|
||||
}
|
||||
if (route.screen === "games") {
|
||||
const expanded = route.game;
|
||||
// La liste vient du stockage, ouvert avec le moteur.
|
||||
void options.engineReady.then(
|
||||
() => {
|
||||
const now = navigation.route;
|
||||
if (now.screen === "games" && now.game === expanded) {
|
||||
void gamesView?.show(expanded);
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
} else {
|
||||
gamesView?.hide();
|
||||
}
|
||||
|
||||
if (help) {
|
||||
void helpView.show(route);
|
||||
}
|
||||
if (position) {
|
||||
void showPosition(route);
|
||||
} else {
|
||||
positionRequest++;
|
||||
shownPosition = null;
|
||||
}
|
||||
|
||||
// Écrans à venir (0e.5) et adresses inconnues : la partie, avec un
|
||||
// message, à l'adresse de la partie.
|
||||
if (route.screen === "home" || route.screen === "tutorial" || route.screen === "unknown") {
|
||||
notice = route.screen === "unknown" ? TEXTS.unknownAddress : TEXTS.screenNotYet;
|
||||
navigation.replace("#/");
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// -------------------------------------------- Vue d'une position
|
||||
|
||||
/** #/position/... : lit la position par le moteur, puis la montre. */
|
||||
async function showPosition(route: Extract<Route, { screen: "position" }>): Promise<void> {
|
||||
const request = ++positionRequest;
|
||||
shownPosition = null;
|
||||
renderPosition({ kind: "loading" });
|
||||
try {
|
||||
await options.engineReady;
|
||||
const shared = await options.positions.read({
|
||||
position: route.position,
|
||||
matchId: route.match,
|
||||
});
|
||||
if (request === positionRequest) {
|
||||
shownPosition = shared;
|
||||
renderPosition({ kind: "shown", shared });
|
||||
}
|
||||
} catch {
|
||||
if (request === positionRequest) {
|
||||
renderPosition({ kind: "unreadable" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PositionState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "unreadable" }
|
||||
| { kind: "shown"; shared: SharedPosition };
|
||||
|
||||
function renderPosition(state: PositionState): void {
|
||||
const shown = state.kind === "shown";
|
||||
ui.positionTitle.textContent =
|
||||
state.kind === "unreadable" ? TEXTS.unreadablePositionTitle : TEXTS.positionTitle;
|
||||
ui.positionMessage.textContent =
|
||||
state.kind === "loading"
|
||||
? TEXTS.prompts.loading.short
|
||||
: state.kind === "unreadable"
|
||||
? TEXTS.unreadablePosition
|
||||
: "";
|
||||
ui.positionBoard.toggleAttribute("hidden", !shown);
|
||||
ui.position.classList.toggle("with-board", shown);
|
||||
ui.positionDetail.hidden = !shown;
|
||||
ui.positionCopy.hidden = !shown;
|
||||
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
|
||||
if (state.kind !== "shown") {
|
||||
return;
|
||||
}
|
||||
const shared = state.shared;
|
||||
// En paysage, le plateau occupe toute la hauteur, à gauche de la
|
||||
// colonne du titre et des actions (app.css), avec la géométrie du
|
||||
// jeu : la colonne gauche du cadre garde le videau, sans menu ni
|
||||
// bouton annuler (il ne se joue pas).
|
||||
const landscape = layout() === "landscape";
|
||||
const box = ui.positionBoard.getBoundingClientRect();
|
||||
const width = box.width || Math.min(window.innerWidth, 640);
|
||||
const height = box.height || window.innerHeight;
|
||||
const model = boardModel(shared.sides);
|
||||
renderBoard(
|
||||
ui.positionBoard,
|
||||
landscape
|
||||
? boardGeometry("landscape", width / height, height / 520)
|
||||
: boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
|
||||
model,
|
||||
{
|
||||
dice: [],
|
||||
cube: shared.cube,
|
||||
...NO_MARKS,
|
||||
band: null,
|
||||
controls: null,
|
||||
},
|
||||
);
|
||||
ui.positionDetail.textContent = TEXTS.positionDetail(
|
||||
model.pipCount,
|
||||
shared.cube,
|
||||
shared.match,
|
||||
shared.result === null
|
||||
? null
|
||||
: shared.result.winner === "onRoll"
|
||||
? "player"
|
||||
: "computer",
|
||||
);
|
||||
}
|
||||
|
||||
ui.positionPlay.onclick = () => {
|
||||
const shared = shownPosition;
|
||||
if (shared === null || busy || game === null) {
|
||||
return;
|
||||
}
|
||||
// La nouvelle partie est l'écran de jeu, à la place de la position
|
||||
// (le retour du téléphone ne rouvre pas la position).
|
||||
navigation.replace("#/");
|
||||
newGame(setupOf(shared));
|
||||
};
|
||||
ui.positionCopy.onclick = () => {
|
||||
if (shownPosition !== null) {
|
||||
void copyLink(shownPosition.link, ui.positionMessage);
|
||||
}
|
||||
};
|
||||
ui.copyLink.onclick = () => {
|
||||
if (currentLink !== null) {
|
||||
void copyLink(currentLink);
|
||||
}
|
||||
};
|
||||
|
||||
/** Copie l'adresse complète d'une position ; le résultat s'affiche dans
|
||||
* target (par défaut, la ligne d'information de la partie). */
|
||||
async function copyLink(link: PositionLink, target?: HTMLElement): Promise<void> {
|
||||
const url = new URL(location.href);
|
||||
url.hash = routeHash({
|
||||
screen: "position",
|
||||
position: link.position,
|
||||
match: link.matchId,
|
||||
});
|
||||
const text = (await copyText(url.href)) ? TEXTS.linkCopied : TEXTS.linkNotCopied(url.href);
|
||||
if (target === undefined) {
|
||||
notice = text;
|
||||
render();
|
||||
} else {
|
||||
target.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Plein écran (ADR 0012)
|
||||
|
||||
@@ -1100,6 +1415,9 @@ export function startApp(options: AppOptions): void {
|
||||
if (!animating) {
|
||||
render();
|
||||
}
|
||||
if (shownPosition !== null) {
|
||||
renderPosition({ kind: "shown", shared: shownPosition });
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", relayout);
|
||||
@@ -1130,7 +1448,6 @@ export function startApp(options: AppOptions): void {
|
||||
for (const side of ["computer", "player"] as const) {
|
||||
ui.strips[side].count.onclick = () => void toggleBubble(side);
|
||||
}
|
||||
child<HTMLButtonElement>(ui.bubble, ".link-button").onclick = actions.help;
|
||||
|
||||
// ------------------------------------------------------ Touchers
|
||||
|
||||
@@ -1153,6 +1470,10 @@ export function startApp(options: AppOptions): void {
|
||||
closeOverlays();
|
||||
render();
|
||||
}
|
||||
// Un lien du plateau (le menu, en paysage) : le navigateur le suit.
|
||||
if (target?.closest("a[href]") != null) {
|
||||
return;
|
||||
}
|
||||
const action = target?.closest("[data-action]")?.getAttribute("data-action");
|
||||
if (action != null && action in actions) {
|
||||
actions[action as ActionName]();
|
||||
@@ -1184,7 +1505,8 @@ export function startApp(options: AppOptions): void {
|
||||
if (event.altKey || event.metaKey || (event.ctrlKey && event.key.toLowerCase() !== "z")) {
|
||||
return;
|
||||
}
|
||||
const onButton = event.target instanceof HTMLButtonElement;
|
||||
const onButton =
|
||||
event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement;
|
||||
let name: ActionName | null = null;
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
@@ -1224,6 +1546,11 @@ export function startApp(options: AppOptions): void {
|
||||
name = "close";
|
||||
break;
|
||||
}
|
||||
// Hors de l'écran de jeu (aide, position), seules les touches de
|
||||
// navigation servent.
|
||||
if (ui.game.hidden && name !== null && !["close", "help", "menu"].includes(name)) {
|
||||
name = null;
|
||||
}
|
||||
if (name !== null) {
|
||||
event.preventDefault();
|
||||
actions[name]();
|
||||
@@ -1232,8 +1559,9 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// ---------------------------------------------------------- Démarrage
|
||||
|
||||
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive.
|
||||
render();
|
||||
// Réponse immédiate : le plateau est dessiné avant que le moteur arrive,
|
||||
// ou l'écran que désigne l'adresse.
|
||||
applyRoute(navigation.route);
|
||||
options.engineReady.then(
|
||||
async () => {
|
||||
game = options.savedGame();
|
||||
@@ -1267,6 +1595,32 @@ export function startApp(options: AppOptions): void {
|
||||
|
||||
// ------------------------------------------------- Lecture de la partie
|
||||
|
||||
/** Écrit un texte dans le presse-papiers ; faux si le navigateur refuse.
|
||||
* Hors contexte sûr (http sur le réseau local), navigator.clipboard
|
||||
* n'existe pas : repli sur une zone de texte sélectionnée, hors de l'écran. */
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Repli ci-dessous.
|
||||
}
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.readOnly = true;
|
||||
area.className = "offscreen";
|
||||
document.body.append(area);
|
||||
area.select();
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
area.remove();
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** La partie attend-elle encore un coup ou un jet (ni double, ni fin) ? */
|
||||
function isPlaying(game: Game): boolean {
|
||||
const phase = game.state.phase;
|
||||
|
||||
@@ -532,7 +532,26 @@ function drawControls(
|
||||
transform: `translate(${x - size / 2},${y - size / 2}) scale(${scale})`,
|
||||
});
|
||||
add(icon, "path", { class: "control-icon", d: ICON_PATHS[name] });
|
||||
if (on) {
|
||||
if (!on) {
|
||||
return;
|
||||
}
|
||||
if (action === "menu") {
|
||||
// Le menu a son adresse (ADR 0013) : un vrai lien, comme dans
|
||||
// la barre haute du portrait.
|
||||
const link = add(hits, "a", {
|
||||
href: "#/menu",
|
||||
"data-action": action,
|
||||
role: "link",
|
||||
"aria-label": label,
|
||||
});
|
||||
add(link, "rect", {
|
||||
class: "hit action",
|
||||
x: Math.max(0, x - g.target / 2),
|
||||
y: Math.max(0, y - g.target / 2),
|
||||
width: g.target,
|
||||
height: g.target,
|
||||
});
|
||||
} else {
|
||||
addAction(g, hits, action, label, { x, y }, g.target);
|
||||
}
|
||||
};
|
||||
|
||||
+63
-34
@@ -8,12 +8,17 @@
|
||||
// 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. */
|
||||
@@ -23,17 +28,21 @@ export interface GamesViewOptions {
|
||||
/** 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). */
|
||||
* 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 liste, à jour. */
|
||||
open(): Promise<void>;
|
||||
close(): void;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -51,7 +60,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
const title = element("h2", "sheet-title");
|
||||
title.id = "games-title";
|
||||
title.textContent = GAMES_TEXTS.title;
|
||||
const close = button("", () => hide(), "icon-button");
|
||||
const close = button("", () => options.onClose(), "icon-button");
|
||||
close.id = "games-close";
|
||||
close.setAttribute("aria-label", GAMES_TEXTS.close);
|
||||
close.append(icon("close"));
|
||||
@@ -64,13 +73,12 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
document.body.append(veil);
|
||||
veil.addEventListener("click", (event) => {
|
||||
if (event.target === veil) {
|
||||
hide();
|
||||
options.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
function hide(): void {
|
||||
veil.hidden = true;
|
||||
}
|
||||
// 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 () => {
|
||||
@@ -110,24 +118,11 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
actions.append(
|
||||
button(
|
||||
GAMES_TEXTS.resume,
|
||||
run(async () => {
|
||||
await options.resume(game.id);
|
||||
hide();
|
||||
}),
|
||||
run(() => options.resume(game.id)),
|
||||
"",
|
||||
"resume",
|
||||
),
|
||||
button(
|
||||
GAMES_TEXTS.showBranches,
|
||||
run(async () => {
|
||||
if (branchList.hidden) {
|
||||
await renderBranches(game.id, branchList);
|
||||
}
|
||||
branchList.hidden = !branchList.hidden;
|
||||
}),
|
||||
"secondary",
|
||||
"branches",
|
||||
),
|
||||
branchesLink(game.id),
|
||||
button(
|
||||
GAMES_TEXTS.exportSgf,
|
||||
run(async () => download(await options.exportSgf(game.id))),
|
||||
@@ -149,6 +144,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
}
|
||||
await options.remove(game.id);
|
||||
await render();
|
||||
await expand();
|
||||
}),
|
||||
"secondary",
|
||||
"remove",
|
||||
@@ -164,6 +160,17 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
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);
|
||||
@@ -182,10 +189,7 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
text,
|
||||
button(
|
||||
GAMES_TEXTS.resumeBranch,
|
||||
run(async () => {
|
||||
await options.resume(id, branch.head);
|
||||
hide();
|
||||
}),
|
||||
run(() => options.resume(id, branch.head)),
|
||||
"secondary",
|
||||
"resume-branch",
|
||||
),
|
||||
@@ -195,14 +199,39 @@ export function createGamesView(options: GamesViewOptions): GamesView {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 open() {
|
||||
message.textContent = "";
|
||||
await render();
|
||||
veil.hidden = false;
|
||||
close.focus();
|
||||
async show(next) {
|
||||
expanded = next;
|
||||
if (veil.hidden) {
|
||||
message.textContent = "";
|
||||
veil.hidden = false;
|
||||
await run(render)();
|
||||
close.focus();
|
||||
}
|
||||
await expand();
|
||||
},
|
||||
hide() {
|
||||
veil.hidden = true;
|
||||
},
|
||||
close: hide,
|
||||
get isOpen() {
|
||||
return !veil.hidden;
|
||||
},
|
||||
|
||||
+184
-48
@@ -1,23 +1,31 @@
|
||||
// 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).
|
||||
|
||||
import {
|
||||
type GlossaryEntry,
|
||||
type HelpFeature,
|
||||
type HelpScenario,
|
||||
parseFeature,
|
||||
parseGlossaryEntry,
|
||||
parseGlossaryIndex,
|
||||
} from "./help-text.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",
|
||||
"docs/scenarios/mes-parties.feature",
|
||||
"docs/scenarios/partager-une-position.feature",
|
||||
"docs/scenarios/regler-l-application.feature",
|
||||
];
|
||||
const GLOSSARY = "docs/glossaire/";
|
||||
@@ -30,10 +38,19 @@ async function fetchText(url: string): Promise<string> {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryEntry[]> {
|
||||
/** Une fiche du glossaire et son nom de fichier, sans .md. */
|
||||
interface GlossaryCard extends GlossaryEntry {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
async function loadGlossary(): Promise<GlossaryCard[]> {
|
||||
const files = parseGlossaryIndex(await fetchText(`${GLOSSARY}README.md`));
|
||||
const texts = await Promise.all(files.map((file) => fetchText(GLOSSARY + file)));
|
||||
return texts.map(parseGlossaryEntry).filter((entry) => entry !== null);
|
||||
return texts.flatMap((text, i) => {
|
||||
const entry = parseGlossaryEntry(text);
|
||||
const name = (files[i] ?? "").replace(/\.md$/, "");
|
||||
return entry === null ? [] : [{ ...entry, name }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Une fiche du glossaire (nom de fichier dans docs/glossaire/), lue dans la
|
||||
@@ -55,56 +72,175 @@ 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);
|
||||
for (const paragraph of scenario.description) {
|
||||
element(parent, "p", paragraph);
|
||||
/** 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, HTMLElement>();
|
||||
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 steps = element(parent, "ul");
|
||||
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(answer, "p", paragraph);
|
||||
}
|
||||
const steps = element(answer, "ul");
|
||||
steps.className = "steps";
|
||||
for (const step of scenario.steps) {
|
||||
element(steps, "li", step);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 list = element(glossary, "dl");
|
||||
list.className = "glossary";
|
||||
for (const entry of entries) {
|
||||
const term = element(list, "dt");
|
||||
term.id = `glossaire-${entry.name}`;
|
||||
const link = element(term, "a", entry.term);
|
||||
link.href = routeHash({ screen: "glossary", term: entry.name });
|
||||
element(list, "dd", entry.brief);
|
||||
terms.set(entry.name, term);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAbout(): void {
|
||||
element(about, "h2", TEXTS.aboutTitle);
|
||||
const text = element(about, "p", TEXTS.about);
|
||||
text.className = "about";
|
||||
}
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
container.replaceChildren(notice);
|
||||
notice.textContent = TEXTS.helpLoading;
|
||||
try {
|
||||
const [features, entries] = await Promise.all([
|
||||
Promise.all(FEATURES.map(async (url) => parseFeature(await fetchText(url)))),
|
||||
loadGlossary(),
|
||||
]);
|
||||
for (const feature of features) {
|
||||
renderFeature(feature);
|
||||
}
|
||||
renderGlossary(entries);
|
||||
renderAbout();
|
||||
notice.textContent = "";
|
||||
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] of terms) {
|
||||
dt.classList.toggle("current", name === term);
|
||||
if (name === term) {
|
||||
dt.setAttribute("aria-current", "true");
|
||||
} 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));
|
||||
if (target !== undefined) {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
} else if (closed !== null) {
|
||||
closed.section.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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 (écran à venir, étape 0e.5). */
|
||||
| { 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, et une étape : #/tutoriel/<n> (écran à venir). */
|
||||
| { readonly screen: "tutorial"; readonly step: number | null }
|
||||
/** #/parties, et une partie : #/parties/<identifiant>. */
|
||||
| { readonly screen: "games"; readonly game: string | null }
|
||||
/** #/menu. */
|
||||
| { readonly screen: "menu" }
|
||||
/** #/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 (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 "parties":
|
||||
return { screen: "games", game: name };
|
||||
case "tutoriel": {
|
||||
if (name === null) {
|
||||
return { screen: "tutorial", step: null };
|
||||
}
|
||||
const step = Number(name);
|
||||
return /^[1-9][0-9]?$/.test(name) ? { screen: "tutorial", step } : unknown;
|
||||
}
|
||||
default:
|
||||
return unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #/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}`;
|
||||
case "games":
|
||||
return route.game === null ? "#/parties" : `#/parties/${route.game}`;
|
||||
case "menu":
|
||||
return "#/menu";
|
||||
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;
|
||||
}
|
||||
+50
-1
@@ -153,8 +153,57 @@ export const TEXTS = {
|
||||
|
||||
// Aide
|
||||
helpTitle: "Aide",
|
||||
glossaryTitle: "Glossaire",
|
||||
helpTabs: "Rubriques de l'aide",
|
||||
helpTabNames: { help: "Jouer", glossary: "Les mots du jeu", about: "À propos" },
|
||||
glossaryTitle: "Les mots du jeu",
|
||||
aboutTitle: "À propos",
|
||||
helpLoading: "Chargement de l'aide…",
|
||||
helpFailed: "L'aide n'a pas pu se charger.",
|
||||
helpNotFound: "Cette page de l'aide n'existe pas, ou plus : voici le sommaire.",
|
||||
about: "Ustagammon est un logiciel libre (GPLv3). L'ordinateur joue avec GNU Backgammon.",
|
||||
|
||||
// Adresses (ADR 0013)
|
||||
unknownAddress: "Cette adresse ne mène à aucun écran : voici votre partie.",
|
||||
screenNotYet: "Cet écran n'existe pas encore : 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;
|
||||
|
||||
@@ -59,6 +59,16 @@ function fakeEngine(overrides: Partial<Engine> = {}): Engine {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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 }],
|
||||
["#/tutoriel/3", { screen: "tutorial", step: 3 }],
|
||||
["#/parties", { screen: "games", game: null }],
|
||||
["#/parties/mh2k3x-a1b2c3", { screen: "games", game: "mh2k3x-a1b2c3" }],
|
||||
["#/menu", { screen: "menu" }],
|
||||
[
|
||||
"#/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",
|
||||
"#/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);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
// É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: (setup) =>
|
||||
Game.start(engine, { dice: seededDice(7), ply: 0, ...(setup ? { setup } : {}) }),
|
||||
resume: (journal) => Game.resume(engine, journal, { ply: 0 }),
|
||||
},
|
||||
"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(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}`);
|
||||
});
|
||||
+21
-12
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
|
||||
| 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 | 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 |
|
||||
| 1d. Conseil du maître | 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,21 +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.
|
||||
- **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 d'équité, `arSkillLevel`), exposés par le
|
||||
moteur depuis 1a ; à calibrer sur de vraies parties.
|
||||
- **Relecture des leçons** par un joueur fort de l'Atelier du Huit.
|
||||
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.
|
||||
|
||||
@@ -23,15 +23,14 @@ Demandes de Pierre du 2026-11-03, traitées avant d'ouvrir l'étape 1.
|
||||
|
||||
| Sous-étape | Contenu | État | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| 0e.4 | Une adresse par écran (aide, glossaire, menu, mes parties…) et par position, par de vrais liens (ADR 0013 amendé) | ⏳ | `plans/2026-11-03-position-dans-l-adresse.md` |
|
||||
| 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 | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
| 0e.5 | Page d'accueil, mise à jour de l'app en un toucher, 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 | ⏳ | `plans/2026-11-03-accueil-tutoriel-aide.md` |
|
||||
|
||||
## É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.
|
||||
|
||||
@@ -52,6 +52,7 @@ 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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -14,6 +14,7 @@ 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 |
|
||||
|
||||
@@ -67,7 +67,8 @@ interrompre.
|
||||
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 « Passer » à droite. En bas : « Retour » et
|
||||
é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
|
||||
@@ -113,9 +114,10 @@ interrompre.
|
||||
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 quand vous finissez, 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. »
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -163,3 +163,38 @@ Une entrée datée par session : ce qui a été fait, décidé, et la suite.
|
||||
Playwright (412×915, 915×412, 844×320, clair et sombre) avant l'essai de
|
||||
Pierre sur le téléphone.
|
||||
- Réglage en main des vibrations sur le Fairphone 4.
|
||||
|
||||
## 2026-11-03 → 2026-11-09 : retours 0e et début de l'étape 1
|
||||
|
||||
**Fait**
|
||||
- Retours du premier test sur téléphone, traités et publiés :
|
||||
- saisie du coup (MR !28) ;
|
||||
- révision UX arbitrée par la session « identité » (MR !35) ;
|
||||
- parties enregistrées avec branches et export SGF, sous-étape 1b (MR !37).
|
||||
- Fusionnés :
|
||||
- adresses par écran et liens de position, sous-étape 0e.4 (MR !43). L'ADR 0013 est amendé : une adresse par écran, de vrais liens ;
|
||||
- moteur pour l'analyse et les niveaux, sous-étape 1a (MR !32) : 9 niveaux de gnubg, analyse de coup et de videau, chance d'un jet, concordance avec gnubg de bureau à 1,64e-3 près.
|
||||
- Décisions techniques :
|
||||
- le cache d'évaluation de gnubg est vidé à chaque calcul, pour que les résultats ne dépendent que de leurs arguments ;
|
||||
- le schéma IndexedDB est un format durable (amendement de l'ADR 0007) ;
|
||||
- l'image Docker de test est nommée par worktree, ce qui corrige l'instabilité des tests concurrents.
|
||||
- Plans écrits pour toute l'étape 1, de 1c à 1i. La session « identité » a maquetté tous les écrans (MR !40 à !42).
|
||||
- Décisions et validations de Pierre (2026-11-09), consignées dans `docs/etape-1.md` :
|
||||
- « Grosse erreur » plutôt que « gaffe » ;
|
||||
- pas d'indice en V1 ;
|
||||
- pas de relecture préalable des leçons ;
|
||||
- pas de plein écran par défaut ;
|
||||
- seuils de gnubg ;
|
||||
- validés : le texte du tutoriel, les maquettes et le cadre des phrases du maître.
|
||||
- Nouvelles demandes de Pierre, intégrées aux plans :
|
||||
- une adresse pour chaque écran ;
|
||||
- la mise à jour forcée de l'app (0e.5) ;
|
||||
- un coach bienveillant, mis en avant mais discret.
|
||||
|
||||
**Reste à faire**
|
||||
- 0e.5 (MR !44) : appliquer les corrections de recette T1 à T5, puis fusionner et publier.
|
||||
- Publication bloquée : `RESTART.sh`, un fichier local non suivi par git, fait échouer `make lint`, et donc `make deploy-prod`.
|
||||
- Étape 1 :
|
||||
- 1c en cours ;
|
||||
- puis 1d, 1e, 1f, 1g, 1h et 1i ;
|
||||
- enfin le plan de test 1j.
|
||||
|
||||
@@ -41,9 +41,24 @@ Traiter les points 2 et 3 du message de Pierre du 2026-11-03 et sa demande d'ins
|
||||
- « Les mots du jeu » : les cartes du glossaire, avec une recherche ;
|
||||
- « À propos » : licence, crédits, version ;
|
||||
- le tutoriel est proposé en tête de l'aide.
|
||||
5. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
|
||||
6. **`make test-browser`** :
|
||||
5. **Mise à jour de l'application** (demande de Pierre du 2026-11-09). Quand
|
||||
le service worker trouve une nouvelle version, l'app affiche un bandeau
|
||||
discret « Nouvelle version disponible » avec « Mettre à jour », jamais
|
||||
pendant un coup en cours. Toucher le bouton active la nouvelle version
|
||||
(`skipWaiting`, puis rechargement au `controllerchange`), et la partie
|
||||
reprend où elle en était. Le menu propose aussi « Rechercher une mise à
|
||||
jour » (`registration.update()`), qui dit « Vous avez la dernière
|
||||
version » sinon. L'onglet « À propos » affiche la version publiée (le
|
||||
commit de `version.json`). Aujourd'hui, `web/sw.js` appelle
|
||||
`skipWaiting()` dès l'installation et efface l'ancien cache : une page
|
||||
ouverte garde son ancien code et peut ne plus trouver les anciens
|
||||
fichiers. Le nouveau service worker doit attendre l'accord du joueur, sauf
|
||||
à la toute première installation.
|
||||
6. **Scénarios Gherkin** : premier lancement, installation, suivre le tutoriel.
|
||||
7. **`make test-browser`** :
|
||||
- premier lancement, tutoriel complet avec ses écrans « à vous », aide ;
|
||||
- mise à jour : publier deux versions du site de test l'une après
|
||||
l'autre, voir le bandeau, mettre à jour, retrouver la partie ;
|
||||
- `beforeinstallprompt` simulé dans Chromium ;
|
||||
- captures dans `build/browser-shots/accueil-tutoriel/` pour la recette design.
|
||||
|
||||
|
||||
@@ -29,17 +29,21 @@ du moteur.
|
||||
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
|
||||
au bilan (1e) sans recalcul.
|
||||
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
|
||||
« Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
|
||||
« Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
|
||||
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
|
||||
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
|
||||
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
|
||||
sur demande, lien vers la fiche du glossaire du concept en jeu si le moteur
|
||||
permet de le déterminer, sinon rien.
|
||||
3. **Phrases du maître** : gabarits versionnés en français dans une source
|
||||
unique sous `docs/` (CC BY-SA 4.0), ton encourageant, jamais culpabilisant ;
|
||||
elles ne citent que des faits du moteur (coup recommandé, rang, pions
|
||||
frappés, écart) : aucune explication inventée. Première version à faire
|
||||
valider par Pierre (ton).
|
||||
unique sous `docs/` (CC BY-SA 4.0). Référence de ton, approuvée par Pierre
|
||||
le 2026-11-09 : la section « Les phrases du maître » de
|
||||
`docs/identite/charte/ecrans-etape-1.md` (1re personne et vouvoiement,
|
||||
faits du moteur uniquement, moins de 140 caractères, ton sobre sans
|
||||
exclamation, formules qui tournent pour les bons coups, exemples). Un test
|
||||
vérifie la longueur et l'absence de « ! » sur chaque gabarit rendu. Les
|
||||
phrases livrées passent par la session « identité » puis par Pierre pour
|
||||
la relecture finale.
|
||||
4. **Discret** : rien n'apparaît pendant que le joueur réfléchit ; la ligne
|
||||
s'efface à l'action suivante ; désactivable (réglage de 1c et menu).
|
||||
5. **Videau** : même traitement pour doubler, ne pas doubler, prendre, passer.
|
||||
|
||||
@@ -18,7 +18,7 @@ Mettre en place les cinq premières leçons numérotées de l'ADR 0011. Chaque l
|
||||
4. La course et le compte de points.
|
||||
5. Sortir ses pions sans se faire frapper.
|
||||
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Les contenus sont livrés en brouillon, marqués « à relire par un joueur fort » (cadrage, risque « contenus »), et Pierre les valide.
|
||||
Chaque leçon compte deux à quatre exercices « à vous ». Leurs positions sont données par identifiants gnubg. La réponse attendue est le meilleur coup du moteur à 2 plis, vérifié par un test. Le texte n'affirme rien que le moteur ne confirme. Décision de Pierre du 2026-11-09 : pas de relecture préalable par un joueur fort ; les leçons sont livrées telles quelles et améliorées sur retours d'experts. Les garde-fous restent : 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.
|
||||
2. **Progression.** Elle est enregistrée dans le stockage de 1b, dans le magasin `settings` ou dans un nouveau magasin avec montée de version (ADR 0007 amendé). Chaque leçon a son état : à commencer, « 2 exercices sur 4 », terminée.
|
||||
3. **Adresses** : `#/lecons` et `#/lecons/<n>/<écran>`, à ajouter au tableau de l'ADR 0013.
|
||||
4. **Accueil.** Il reçoit la carte « prochaine leçon » prévue par la charte.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Une adresse pour chaque écran, et pour chaque position (0e.4)
|
||||
|
||||
- **Date** : 2026-11-03
|
||||
- **Étape** : 0e.4 (feuille de route)
|
||||
- **Statut** : prévu
|
||||
- **Exécutant** : sous-agent general-purpose, dans un worktree
|
||||
|
||||
## Objectif
|
||||
|
||||
Appliquer l'ADR 0013 et son amendement du 2026-11-03 : chaque écran (aide,
|
||||
glossaire, menu, mes parties…) a son adresse et s'ouvre par un vrai lien ;
|
||||
chaque position du plateau a un lien direct.
|
||||
|
||||
## Consignes
|
||||
|
||||
ADR 0007, 0008 et 0013. Aucune nouvelle dépendance.
|
||||
|
||||
0. **Routeur** : un petit module pur (sans dépendance) qui traduit le
|
||||
fragment en écran et inversement, testé unitairement ; l'app dessine
|
||||
l'écran demandé par l'adresse, sur `hashchange`. Tous les écrans existants
|
||||
(aide et ses questions, glossaire et ses termes, à propos, menu, mes
|
||||
parties de 1b) passent par lui, avec de vrais liens `<a href>` ; le bouton
|
||||
retour du navigateur ramène à l'écran précédent. Les actions de jeu restent
|
||||
des boutons.
|
||||
1. Lecture de `#/position/<Position ID>/<Match ID>` ; validation par le
|
||||
moteur ; message clair si illisible.
|
||||
2. Vue de la position, non jouable, avec « Jouer à partir d'ici » (nouvelle
|
||||
partie depuis la position, la partie en cours restant reprenable) et
|
||||
« Retour à ma partie ».
|
||||
3. Domaine : démarrer une partie depuis une position et un Match ID donnés
|
||||
(événement GameStarted étendu de façon rétrocompatible, ou équivalent
|
||||
justifié), sans règle réimplémentée.
|
||||
4. « Copier le lien de la position » depuis le menu de la partie et depuis la
|
||||
vue du coup de l'ordinateur ; le lien copié rouvre exactement la même
|
||||
position (test aller-retour).
|
||||
5. Scénarios Gherkin (« Ouvrir un lien vers une position », « Partager la
|
||||
position »), fiches du glossaire (Position ID, Match ID si absentes).
|
||||
6. `make lint`, `make test`, `make test-browser` : chaque adresse du tableau
|
||||
de l'ADR ouverte directement dans un nouvel onglet montre le bon écran ;
|
||||
« retour » du navigateur ; liens reconnus comme liens (rôle `link`) ;
|
||||
position : ouvrir un lien, jouer à partir d'ici, revenir, lien invalide.
|
||||
|
||||
## Critères de réussite
|
||||
|
||||
Un lien copié depuis l'app, ouvert dans un nouvel onglet, montre la même
|
||||
position ; les trois commandes passent.
|
||||
|
||||
## Résultat
|
||||
|
||||
À remplir à la clôture.
|
||||
@@ -48,4 +48,5 @@ Règles d'écriture :
|
||||
| --- | --- | --- |
|
||||
| [jouer-une-partie.feature](jouer-une-partie.feature) | Jouer une partie contre l'ordinateur | Étape 0d |
|
||||
| [mes-parties.feature](mes-parties.feature) | Retrouver mes parties, leurs branches, les exporter en SGF | Étape 1b |
|
||||
| [partager-une-position.feature](partager-une-position.feature) | Ouvrir et partager le lien d'une position | Étape 0e.4 |
|
||||
| [regler-l-application.feature](regler-l-application.feature) | Régler l'application (menu) | Étape 0e |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Partager une position
|
||||
|
||||
Chaque position du plateau a son lien : une adresse qui la rouvre telle
|
||||
quelle, à envoyer à un ami, à garder pour plus tard ou à coller dans une
|
||||
question. Le lien contient les deux identifiants de GNU Backgammon, celui
|
||||
de la position et celui du match, et rien d'autre.
|
||||
|
||||
Scénario: Partager la position
|
||||
Pendant la partie, « Copier le lien de la position », dans le menu ou à
|
||||
côté du dernier coup de l'ordinateur, copie l'adresse de la position à
|
||||
l'écran. Ouvert ailleurs, le lien montre la même position.
|
||||
|
||||
Étant donné que votre partie en cours compte quelques coups
|
||||
Quand vous copiez le lien de la position
|
||||
Alors le lien contient l'identifiant de la position et celui du match
|
||||
Quand vous ouvrez ce lien
|
||||
Alors vous voyez exactement la même position, avec le même videau
|
||||
|
||||
Scénario: Ouvrir un lien vers une position
|
||||
Un lien vers une position montre le plateau, sans le rendre jouable :
|
||||
vous y êtes en bas, avec la main. « Jouer à partir d'ici » commence une
|
||||
nouvelle partie contre l'ordinateur depuis cette position, et votre
|
||||
partie en cours reste dans « Mes parties ». « Retour à ma partie » vous
|
||||
ramène à votre partie, intacte.
|
||||
|
||||
Étant donné que votre partie en cours compte quelques coups
|
||||
Quand vous ouvrez un lien vers une position où le videau est à 2, chez l'ordinateur
|
||||
Alors vous voyez cette position, avec la main
|
||||
Et votre partie n'a pas changé
|
||||
Quand vous choisissez « Jouer à partir d'ici »
|
||||
Alors une nouvelle partie commence à cette position, à vous de lancer les dés
|
||||
Et le videau est à 2, chez l'ordinateur
|
||||
Et votre partie précédente est toujours enregistrée
|
||||
|
||||
Scénario: Ouvrir un lien abîmé
|
||||
Un lien coupé ou mal recopié ne casse rien : un message le dit, et
|
||||
« Retour à ma partie » reste là.
|
||||
|
||||
Quand vous ouvrez un lien dont l'identifiant de position est abîmé
|
||||
Alors le lien est déclaré illisible
|
||||
Quand vous ouvrez un lien dont l'identifiant de match est abîmé
|
||||
Alors le lien est déclaré illisible
|
||||
+395
-8
@@ -76,6 +76,15 @@ await context.addInitScript(() => {
|
||||
return null;
|
||||
};
|
||||
}
|
||||
// Les liens copiés (« Copier le lien de la position ») : le
|
||||
// presse-papiers du navigateur sans écran n'est pas lisible partout.
|
||||
window.__copied = [];
|
||||
if (window.Clipboard) {
|
||||
Clipboard.prototype.writeText = function (text) {
|
||||
window.__copied.push(String(text));
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
window.__vibrations = [];
|
||||
Object.defineProperty(Navigator.prototype, "vibrate", {
|
||||
configurable: true,
|
||||
@@ -291,10 +300,49 @@ async function checkStable(when) {
|
||||
stableChecks++;
|
||||
}
|
||||
|
||||
/** Les boutons visibles d'une page dont le texte passe à la ligne : un
|
||||
* bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
|
||||
* haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
|
||||
* un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
|
||||
async function wrappedButtons(target) {
|
||||
return target.evaluate(() => {
|
||||
const wrapped = [];
|
||||
for (const el of document.querySelectorAll("button, a.button")) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (el.closest("[hidden]") || r.width === 0 || r.height === 0) {
|
||||
continue;
|
||||
}
|
||||
const name = el.id ? `#${el.id}` : el.textContent.trim();
|
||||
const tops = new Set();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
||||
if (node.textContent.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
for (const line of range.getClientRects()) {
|
||||
if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) {
|
||||
tops.add(line.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.height > 48.5 && tops.size > 1) {
|
||||
wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`);
|
||||
} else if (el.scrollWidth > el.clientWidth + 1) {
|
||||
wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`);
|
||||
}
|
||||
}
|
||||
return wrapped;
|
||||
});
|
||||
}
|
||||
|
||||
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
|
||||
* mesures pour le compte rendu. */
|
||||
async function checkView(width, height, when) {
|
||||
const where = `${width}x${height} (${when})`;
|
||||
const wrapped = await wrappedButtons(page);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
const seen = await page.evaluate(() => {
|
||||
const visible = (el) => {
|
||||
if (el.closest("[hidden]")) {
|
||||
@@ -312,7 +360,7 @@ async function checkView(width, height, when) {
|
||||
el.className?.baseVal ?? el.className;
|
||||
// Cibles tactiles : boutons et zones d'action du plateau.
|
||||
const small = [];
|
||||
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action]")) {
|
||||
for (const el of document.querySelectorAll("button, [role=button], [role=switch], [data-action], a.icon-button, a.item, a.button")) {
|
||||
if (!visible(el)) {
|
||||
continue;
|
||||
}
|
||||
@@ -528,7 +576,7 @@ async function touchTarget(action, label, count = 1, index = 0) {
|
||||
check((await zones.count()) === count,
|
||||
`zone ${action} : ${await zones.count()} sur le plateau, ${count} attendues`);
|
||||
for (const zone of await zones.all()) {
|
||||
check((await zone.getAttribute("role")) === "button" &&
|
||||
check((await zone.getAttribute("role")) === (action === "menu" ? "link" : "button") &&
|
||||
(await zone.getAttribute("aria-label")) === label, `zone ${action} sans rôle accessible`);
|
||||
const box = await zone.boundingBox();
|
||||
check(box.width >= 44 && box.height >= 44,
|
||||
@@ -588,10 +636,329 @@ async function checkReplay(traces, reducedMotion) {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Adresses
|
||||
|
||||
/** Un nouvel onglet ouvert directement sur une adresse (fragment ou URL
|
||||
* complète), ses erreurs comptées comme celles de la page principale. */
|
||||
async function openTab(address, viewport = PORTRAIT) {
|
||||
const tab = await context.newPage();
|
||||
await tab.setViewportSize(viewport);
|
||||
tab.on("pageerror", (error) => problems.push(`erreur de la page (${address}) : ${error.message}`));
|
||||
tab.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
problems.push(`console (${address}) : ${message.text()}`);
|
||||
}
|
||||
});
|
||||
tab.on("dialog", (dialog) => {
|
||||
problems.push(`boîte native ouverte (${address}) : ${dialog.type()}`);
|
||||
void dialog.dismiss();
|
||||
});
|
||||
await tab.goto(new URL(address, base).href);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/** Ce que montre un onglet : écran, question, terme, feuille, message. */
|
||||
async function screenOf(tab) {
|
||||
return tab.evaluate(() => {
|
||||
const shown = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
return el !== null && !el.hasAttribute("hidden") && el.getClientRects().length > 0;
|
||||
};
|
||||
const text = (id) => document.getElementById(id)?.textContent ?? "";
|
||||
return {
|
||||
hash: location.hash,
|
||||
busy: document.body.classList.contains("busy"),
|
||||
game: shown("game"),
|
||||
help: shown("help"),
|
||||
play: shown("help-play"),
|
||||
glossary: shown("help-glossary"),
|
||||
about: shown("help-about"),
|
||||
question: document.querySelector("#help-play .question.open")?.id ?? null,
|
||||
term: document.querySelector("#help-glossary dt.current")?.id ?? null,
|
||||
menu: shown("menu"),
|
||||
games: shown("games"),
|
||||
listed: document.querySelectorAll("#games .games-item").length,
|
||||
expanded:
|
||||
document
|
||||
.querySelector('#games [data-action="branches"][aria-expanded="true"]')
|
||||
?.closest(".games-item")?.dataset.id ?? null,
|
||||
position: shown("position-view"),
|
||||
positionBoard: shown("position-board"),
|
||||
positionTitle: text("position-title"),
|
||||
positionMessage: text("position-message"),
|
||||
positionPlay: shown("position-play"),
|
||||
info: text("info"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Attend que l'onglet montre l'écran attendu (expected : test sur
|
||||
* screenOf), au plus 30 s ; sinon, échoue avec ce qu'il montre. */
|
||||
async function expectScreen(tab, what, expected) {
|
||||
let state = null;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
state = await screenOf(tab);
|
||||
if (expected(state)) {
|
||||
return state;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
await tab.screenshot({ path: `${shots}/${browserName}-adresse-echec.png` }).catch(() => {});
|
||||
throw new Error(`${what} : écran inattendu ${JSON.stringify(state)}`);
|
||||
}
|
||||
|
||||
/** Le dernier lien copié par l'application dans cet onglet. */
|
||||
async function lastCopied(tab) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const copied = await tab.evaluate(() => window.__copied.at(-1) ?? null);
|
||||
if (copied !== null) {
|
||||
return copied;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error("aucun lien copié");
|
||||
}
|
||||
|
||||
/** L'élément est-il exposé comme un lien (rôle link) ? */
|
||||
async function isLink(tab, selector) {
|
||||
const all = tab.locator(selector);
|
||||
const links = all.and(tab.getByRole("link"));
|
||||
return (await all.count()) > 0 && (await links.count()) === (await all.count());
|
||||
}
|
||||
|
||||
async function checkAddresses() {
|
||||
const dir = `${shots}/adresses`;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const playing = (s) => s.hash === "#/" && s.game && !s.help && !s.position && !s.menu && !s.games;
|
||||
|
||||
// Le lien de la position en cours, copié depuis le menu de la partie.
|
||||
await openMenu();
|
||||
await page.evaluate(() => {
|
||||
window.__copied = [];
|
||||
});
|
||||
await page.click("#menu-copy-link");
|
||||
const positionUrl = await lastCopied(page);
|
||||
const positionHash = new URL(positionUrl).hash;
|
||||
check(/^#\/position\/[^/]{14,}\/[^/]+$/.test(positionHash), `lien de position copié : ${positionUrl}`);
|
||||
check(!(await page.isVisible("#menu")), "le menu reste ouvert après la copie du lien");
|
||||
|
||||
// Chaque adresse du tableau de l'ADR, dans un nouvel onglet. Les
|
||||
// identifiants d'une question et d'une partie sont lus en chemin.
|
||||
let question = null;
|
||||
let gameId = null;
|
||||
let gamesBefore = 0;
|
||||
const notYet = (s) => s.hash === "#/" && s.game && s.info.includes("n'existe pas encore");
|
||||
const addresses = [
|
||||
["#/", playing],
|
||||
["#/accueil", notYet],
|
||||
["#/aide", (s) => s.help && s.play && !s.glossary && !s.about && s.question === null],
|
||||
[() => `#/aide/${question}`, (s) => s.help && s.play && s.question === `aide-${question}`],
|
||||
["#/glossaire", (s) => s.help && s.glossary && !s.play && s.term === null],
|
||||
["#/glossaire/lien-de-position", (s) => s.help && s.glossary && s.term === "glossaire-lien-de-position"],
|
||||
["#/a-propos", (s) => s.help && s.about && !s.play && !s.glossary],
|
||||
["#/tutoriel", notYet],
|
||||
["#/tutoriel/2", notYet],
|
||||
["#/parties", (s) => s.games && s.listed >= 2 && !s.menu],
|
||||
[() => `#/parties/${gameId}`, (s) => s.games && s.expanded === gameId],
|
||||
["#/menu", (s) => s.menu && !s.games],
|
||||
[positionHash, (s) => s.position && s.positionBoard && !s.game && s.positionPlay],
|
||||
[`#/position/${positionHash.split("/")[2]}`, (s) => s.position && s.positionBoard],
|
||||
["#/position/pas-une-position", (s) =>
|
||||
s.position && !s.positionBoard && s.positionMessage.includes("ne décrit pas une position")],
|
||||
["#/nulle-part", (s) => s.hash === "#/" && s.game && s.info.includes("ne mène à aucun écran")],
|
||||
];
|
||||
for (const [address, expected] of addresses) {
|
||||
const hash = typeof address === "function" ? address() : address;
|
||||
const tab = await openTab(hash);
|
||||
await expectScreen(tab, `adresse ${hash}`, expected);
|
||||
if (hash === "#/aide") {
|
||||
question = await tab.evaluate(() =>
|
||||
document.querySelector("#help-play .question")?.id.replace(/^aide-/, ""));
|
||||
check(typeof question === "string" && question !== "", "aide : aucune question");
|
||||
}
|
||||
if (hash === "#/parties") {
|
||||
await tab.waitForSelector("#games .games-item");
|
||||
gameId = await tab.evaluate(() => document.querySelector("#games .games-item")?.dataset.id);
|
||||
gamesBefore = (await screenOf(tab)).listed;
|
||||
}
|
||||
await tab.close();
|
||||
}
|
||||
log(`adresses : ${addresses.length} adresses ouvertes directement, chacune sur son écran`);
|
||||
|
||||
// Captures : l'aide ouverte par son adresse, une question, un terme, la
|
||||
// vue d'une position, un lien illisible ; en portrait et en paysage.
|
||||
const captures = [
|
||||
["aide", "#/aide", (s) => s.help && s.play],
|
||||
["question", `#/aide/${question}`, (s) => s.question === `aide-${question}`],
|
||||
["terme", "#/glossaire/lien-de-position", (s) => s.term === "glossaire-lien-de-position"],
|
||||
["position", positionHash, (s) => s.position && s.positionBoard],
|
||||
["lien-invalide", "#/position/pas-une-position", (s) => s.position &&
|
||||
s.positionMessage.includes("ne décrit pas une position")],
|
||||
];
|
||||
for (const [width, height] of [[412, 915], [915, 412]]) {
|
||||
for (const [name, hash, expected] of captures) {
|
||||
const tab = await openTab(hash, { width, height });
|
||||
await expectScreen(tab, `capture ${name}`, expected);
|
||||
await sleep(300);
|
||||
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
|
||||
const where = `${name} ${width}x${height}`;
|
||||
const wrapped = await wrappedButtons(tab);
|
||||
check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
|
||||
if (name === "question" || name === "terme") {
|
||||
// La barre haute de l'aide reste en vue, et ce qu'on a
|
||||
// ouvert s'arrête juste sous elle.
|
||||
const seen = await tab.evaluate(() => {
|
||||
const bar = document.getElementById("help-bar").getBoundingClientRect();
|
||||
const target = document.querySelector(
|
||||
"#help-play .question.open, #help-glossary dt.current",
|
||||
).getBoundingClientRect();
|
||||
return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
|
||||
});
|
||||
check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1,
|
||||
`${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`);
|
||||
check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2,
|
||||
`${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`);
|
||||
}
|
||||
if (name === "position") {
|
||||
// Les trois actions, dans l'ordre, visibles sans défiler.
|
||||
const seen = await tab.evaluate(() => ({
|
||||
scrolled: window.scrollY,
|
||||
order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id),
|
||||
boxes: ["position-play", "position-copy", "position-back"].map((id) => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return [r.left, r.top, r.right, r.bottom];
|
||||
}),
|
||||
board: (() => {
|
||||
const r = document.getElementById("position-board").getBoundingClientRect();
|
||||
return [r.left, r.top, r.width, r.height];
|
||||
})(),
|
||||
cube: (() => {
|
||||
const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect();
|
||||
return r === undefined ? null : [r.left, r.top, r.right, r.bottom];
|
||||
})(),
|
||||
controls: document.querySelectorAll("#position-board [data-action]").length,
|
||||
}));
|
||||
check(seen.order.join() === "position-play,position-copy,position-back",
|
||||
`${where} : actions dans l'ordre ${seen.order}`);
|
||||
const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height);
|
||||
if (width > height) {
|
||||
check(seen.scrolled === 0 && inView,
|
||||
`${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`);
|
||||
check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1,
|
||||
`${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`);
|
||||
// Le videau, dans la colonne gauche du cadre, entier à
|
||||
// l'écran ; ni menu ni annuler : la position ne se joue pas.
|
||||
const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1];
|
||||
check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height,
|
||||
`${where} : videau coupé : ${JSON.stringify(seen.cube)}`);
|
||||
check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`);
|
||||
} else {
|
||||
// Empilées sur toute la largeur.
|
||||
const [first] = seen.boxes;
|
||||
check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 &&
|
||||
Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])),
|
||||
`${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`);
|
||||
}
|
||||
}
|
||||
await tab.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Retour du navigateur et rôle des liens, dans un même onglet.
|
||||
{
|
||||
const tab = await openTab("#/");
|
||||
await expectScreen(tab, "partie", playing);
|
||||
for (const selector of ["#help-button", "#menu-button"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
for (const selector of ["#roll, #confirm, #again, #double", "#fullscreen-button"]) {
|
||||
check((await tab.locator(selector).and(tab.getByRole("link")).count()) === 0,
|
||||
`${selector} : une action de jeu exposée comme un lien`);
|
||||
}
|
||||
await tab.click("#help-button");
|
||||
await expectScreen(tab, "aide par son lien", (s) => s.hash === "#/aide" && s.help && s.play);
|
||||
for (const selector of ["#help-back", "#help-tabs a", "#help-play .question h3 a"]) {
|
||||
check(await isLink(tab, selector), `${selector} : pas des liens`);
|
||||
}
|
||||
await tab.click(`#aide-${question} h3 a`);
|
||||
await expectScreen(tab, "question par son lien", (s) => s.hash === `#/aide/${question}` &&
|
||||
s.question === `aide-${question}`);
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : question refermée", (s) => s.hash === "#/aide" && s.help &&
|
||||
s.question === null);
|
||||
await tab.click('#help-tabs a[data-screen="glossary"]');
|
||||
await expectScreen(tab, "onglet glossaire", (s) => s.hash === "#/glossaire" && s.glossary);
|
||||
check(await isLink(tab, "#help-glossary dt a"), "les termes du glossaire ne sont pas des liens");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : onglet Jouer", (s) => s.hash === "#/aide" && s.play);
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : la partie", playing);
|
||||
// Le menu, puis « Mes parties » depuis le menu : le retour ferme la
|
||||
// feuille et ramène à la partie, sans rouvrir le menu.
|
||||
await tab.click("#menu-button");
|
||||
await expectScreen(tab, "menu par son lien", (s) => s.hash === "#/menu" && s.menu);
|
||||
for (const selector of ["#menu-games", "#menu-help"]) {
|
||||
check(await isLink(tab, selector), `${selector} n'est pas un lien`);
|
||||
}
|
||||
check((await tab.locator("#menu-new-game").and(tab.getByRole("button")).count()) === 1,
|
||||
"« Nouvelle partie » n'est plus un bouton");
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : menu fermé", playing);
|
||||
await tab.click("#menu-button");
|
||||
await expectScreen(tab, "menu", (s) => s.menu);
|
||||
await tab.click("#menu-games");
|
||||
await expectScreen(tab, "mes parties depuis le menu", (s) => s.hash === "#/parties" && s.games &&
|
||||
!s.menu);
|
||||
await tab.goBack();
|
||||
await expectScreen(tab, "retour : mes parties fermé", playing);
|
||||
// Échap quitte l'aide comme « Retour ».
|
||||
await tab.click("#help-button");
|
||||
await expectScreen(tab, "aide", (s) => s.help);
|
||||
await tab.keyboard.press("Escape");
|
||||
await expectScreen(tab, "Échap : la partie", playing);
|
||||
await tab.close();
|
||||
}
|
||||
log("adresses : retour du navigateur (question, onglet, menu, mes parties), Échap, rôle link des liens");
|
||||
|
||||
// Aller-retour d'un lien de position : le lien copié depuis la vue
|
||||
// ouverte par ce lien est le même ; « Retour à ma partie » ; « Jouer à
|
||||
// partir d'ici » commence une partie de plus, l'ancienne restant
|
||||
// reprenable.
|
||||
{
|
||||
const tab = await openTab(positionUrl);
|
||||
await expectScreen(tab, "lien de position", (s) => s.position && s.positionBoard && s.positionPlay);
|
||||
await tab.click("#position-copy");
|
||||
const again = await lastCopied(tab);
|
||||
check(again === positionUrl, `aller-retour du lien : ${again} au lieu de ${positionUrl}`);
|
||||
check(await isLink(tab, "#position-back"), "« Retour à ma partie » n'est pas un lien");
|
||||
await tab.click("#position-back");
|
||||
await expectScreen(tab, "retour à ma partie (nouvel onglet)", playing);
|
||||
// Depuis la partie : le lien suivi, puis « Retour à ma partie ».
|
||||
await tab.evaluate((hash) => {
|
||||
location.hash = hash;
|
||||
}, positionHash);
|
||||
await expectScreen(tab, "lien suivi depuis la partie", (s) => s.position && s.positionBoard);
|
||||
await tab.click("#position-back");
|
||||
await expectScreen(tab, "retour à ma partie", playing);
|
||||
await tab.goForward();
|
||||
await expectScreen(tab, "avance : la position", (s) => s.position && s.positionPlay);
|
||||
await tab.click("#position-play");
|
||||
await expectScreen(tab, "jouer à partir d'ici", (s) => playing(s) && !s.busy);
|
||||
await tab.goto(new URL("#/parties", base).href);
|
||||
const after = await expectScreen(tab, "mes parties", (s) => s.games && s.listed > 0);
|
||||
check(after.listed === gamesBefore + 1,
|
||||
`jouer à partir d'ici : ${after.listed} parties, ${gamesBefore + 1} attendues`);
|
||||
await tab.close();
|
||||
}
|
||||
log("adresses : lien de position copié, rouvert à l'identique, retour à la partie, partie jouée depuis la position");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Menu
|
||||
|
||||
async function openMenu() {
|
||||
await page.click("#menu-button");
|
||||
// Le menu a son adresse (#/menu) : il s'ouvre au changement d'adresse.
|
||||
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#menu"), "le menu ne s'ouvre pas");
|
||||
}
|
||||
|
||||
@@ -954,10 +1321,19 @@ for (let actions = 0; ; actions++) {
|
||||
} else {
|
||||
await (await touchTarget("confirm", "Jouer ce coup", 2, confirms % 2)).click();
|
||||
}
|
||||
await waitForPlayer();
|
||||
const played = await savedJournal();
|
||||
check(countMoves(played, "player") === countMoves(journal, "player") + 1,
|
||||
"le coup joué n'est pas dans le journal");
|
||||
const next = await waitForPlayer();
|
||||
// L'application enregistre sans attendre ; sur un disque lent,
|
||||
// l'écriture d'IndexedDB peut suivre de loin : on lui laisse 10 s.
|
||||
const expected = countMoves(journal, "player") + 1;
|
||||
let played = await savedJournal();
|
||||
for (let i = 0; i < 100 && countMoves(played, "player") < expected; i++) {
|
||||
await sleep(100);
|
||||
played = await savedJournal();
|
||||
}
|
||||
check(countMoves(played, "player") === expected,
|
||||
`le coup joué n'est pas dans le journal : ${countMoves(played, "player")} coups ` +
|
||||
`enregistrés, ${expected} attendus ; à l'écran : ${next}, ` +
|
||||
`« ${await page.textContent("#info")} »`);
|
||||
halfMoves = 0;
|
||||
touched = false;
|
||||
} else {
|
||||
@@ -1074,6 +1450,7 @@ for (let actions = 0; ; actions++) {
|
||||
// Le clavier ouvre aussi le menu (M) et Échap le ferme.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.keyboard.press("m");
|
||||
await page.waitForSelector("#menu", { state: "visible", timeout: 5000 }).catch(() => {});
|
||||
check(await page.isVisible("#menu"), "M n'ouvre pas le menu");
|
||||
await page.keyboard.press("Escape");
|
||||
const pips = await page.textContent("#strip-player .count");
|
||||
@@ -1214,7 +1591,7 @@ step("mes parties");
|
||||
step("aide");
|
||||
// Aide : scénarios et glossaire lus dans les copies de docs/.
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
await page.waitForSelector(".glossary dt", { state: "attached" });
|
||||
const terms = await page.locator(".glossary dt").count();
|
||||
const scenarios = await page.locator("#help-content h3").count();
|
||||
check(terms >= 20 && scenarios >= 9, `aide incomplète : ${terms} termes, ${scenarios} scénarios`);
|
||||
@@ -1281,7 +1658,7 @@ if (offline === "roll") {
|
||||
await waitForPlayer();
|
||||
}
|
||||
await page.click("#help-button");
|
||||
await page.waitForSelector(".glossary dt");
|
||||
await page.waitForSelector(".glossary dt", { state: "attached" });
|
||||
await page.click("#help-back");
|
||||
log(
|
||||
offlineMode
|
||||
@@ -1313,6 +1690,16 @@ check(
|
||||
);
|
||||
log("CSP : aucune violation pendant la partie, script en ligne bloqué ; aucune boîte native");
|
||||
|
||||
step("adresses");
|
||||
// Chaque écran a son adresse (ADR 0013, amendement du 2026-11-03) : ouverte
|
||||
// directement dans un nouvel onglet, elle montre le bon écran ; le retour du
|
||||
// navigateur referme d'abord la question ou la feuille ouverte ; les liens
|
||||
// sont des liens (rôle link) ; un lien de position fait l'aller-retour.
|
||||
if (offlineMode) {
|
||||
await context.setOffline(false);
|
||||
}
|
||||
await checkAddresses();
|
||||
|
||||
log(`recette : ${recette.views} vues vérifiées (${stableChecks} contrôles de stabilité du ` +
|
||||
`portrait, ${recette.rotations} rotations sans perte d'état), numéros des flèches à ` +
|
||||
`${recette.minNumber.toFixed(1)} px au moins, réponse au toucher en ${responseMs.toFixed(1)} ms ` +
|
||||
|
||||
+275
-5
@@ -363,6 +363,8 @@ button {
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
/* Un bouton tient sur une ligne (charte). */
|
||||
white-space: nowrap;
|
||||
/* Pas de délai ni de zoom sur un double toucher. */
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -1165,14 +1167,14 @@ html.landscape .banner {
|
||||
bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* L'aide reste une page qu'on fait défiler. */
|
||||
html.landscape.help-open,
|
||||
html.landscape.help-open body {
|
||||
/* L'aide et la vue d'une position restent des pages qu'on fait défiler. */
|
||||
html.landscape.page-open,
|
||||
html.landscape.page-open body {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
html.landscape.help-open body {
|
||||
html.landscape.page-open body {
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--background);
|
||||
}
|
||||
@@ -1306,7 +1308,7 @@ html.rotated .banner {
|
||||
}
|
||||
|
||||
.about {
|
||||
margin-top: var(--space-8);
|
||||
margin-top: var(--space-2);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
@@ -1316,3 +1318,271 @@ html.rotated .banner {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la
|
||||
* page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une
|
||||
* question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */
|
||||
.help {
|
||||
--help-bar-height: 113px;
|
||||
}
|
||||
|
||||
.help-bar {
|
||||
position: sticky;
|
||||
top: env(safe-area-inset-top);
|
||||
z-index: 3;
|
||||
height: var(--help-bar-height);
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.help .question,
|
||||
.glossary dt {
|
||||
scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ;
|
||||
* sur une ligne, qui défile si l'écran est trop étroit. */
|
||||
.help-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
margin-top: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.help-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-3);
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.help-tabs a[aria-current="page"] {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.help-notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Une question de l'aide : son titre est un lien qui l'ouvre (son adresse),
|
||||
* ou la referme quand elle est ouverte. */
|
||||
.question {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.help .question h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.question h3 a {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
padding: var(--space-2) 0;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.question h3 a::after {
|
||||
content: "";
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin: 0 6px 4px auto;
|
||||
border-right: 2px solid var(--muted);
|
||||
border-bottom: 2px solid var(--muted);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.question.open h3 a::after {
|
||||
margin-bottom: -4px;
|
||||
transform: rotate(-135deg);
|
||||
}
|
||||
|
||||
.answer {
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Le terme ouvert par son adresse (#/glossaire/<terme>). */
|
||||
.glossary dt a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.glossary dt.current,
|
||||
.glossary dt.current + dd {
|
||||
margin-inline: calc(-1 * var(--space-4));
|
||||
padding-inline: var(--space-4);
|
||||
background: var(--accent-soft);
|
||||
/* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */
|
||||
animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards;
|
||||
}
|
||||
|
||||
@keyframes term-fade {
|
||||
to { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- Liens et boutons */
|
||||
|
||||
/* Un lien qui mène à un écran, avec l'allure d'un bouton. */
|
||||
a.button {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--button-height);
|
||||
padding: 0 20px;
|
||||
font-weight: 700;
|
||||
color: var(--on-accent);
|
||||
text-decoration: none;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
white-space: nowrap;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
a.button.secondary {
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
a.button:active {
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
a.icon-button,
|
||||
a.item,
|
||||
a.link-button {
|
||||
text-decoration: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
a.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.infoline .copy-link {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Zone de texte de la copie de repli (presse-papiers), hors de l'écran. */
|
||||
.offscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ Vue d'une position */
|
||||
|
||||
.position-view {
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.position-title {
|
||||
margin: var(--space-6) 0 var(--space-2);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.position-message:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.position-board {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.position-detail {
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Les actions empilées sur toute la largeur, la principale en premier. */
|
||||
.position-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.position-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#position-copy {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#position-copy .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
|
||||
* hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
|
||||
* dans une colonne à droite. */
|
||||
html.landscape .position-view.with-board {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 288px;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board > * {
|
||||
grid-column: 2;
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-title {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-board {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
aspect-ratio: auto;
|
||||
background: var(--frame);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-actions {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
html.landscape .position-view.with-board .position-actions > * {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
+35
-7
@@ -29,9 +29,11 @@
|
||||
<img class="logo-light" src="icons/logo-horizontal-couleur.svg" alt="Ustagammon">
|
||||
<img class="logo-dark" src="icons/logo-horizontal-sombre.svg" alt="Ustagammon">
|
||||
</h1>
|
||||
<button id="help-button" type="button" class="icon-button labelled"></button>
|
||||
<!-- Ce qui mène à un autre écran est un lien vers son adresse (ADR 0013,
|
||||
amendement) ; les actions sont des boutons. -->
|
||||
<a id="help-button" href="#/aide" class="icon-button labelled"></a>
|
||||
<button id="fullscreen-button" type="button" class="icon-button labelled"></button>
|
||||
<button id="menu-button" type="button" class="icon-button labelled"></button>
|
||||
<a id="menu-button" href="#/menu" class="icon-button labelled"></a>
|
||||
</header>
|
||||
|
||||
<main id="game" class="game">
|
||||
@@ -46,7 +48,7 @@
|
||||
<div id="pip-bubble" class="bubble" role="dialog" aria-labelledby="pip-bubble-term" hidden>
|
||||
<p id="pip-bubble-term" class="bubble-term"></p>
|
||||
<p class="bubble-text"></p>
|
||||
<button type="button" class="link-button"></button>
|
||||
<a class="link-button" href="#/glossaire"></a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="strip-player" class="strip">
|
||||
@@ -58,6 +60,7 @@
|
||||
<div class="infoline">
|
||||
<p id="info" class="info" aria-live="polite"></p>
|
||||
<button id="replay" type="button" class="icon-button replay" hidden></button>
|
||||
<button id="copy-link" type="button" class="icon-button copy-link" hidden></button>
|
||||
</div>
|
||||
<p id="prompt" class="prompt" aria-live="polite"></p>
|
||||
<div class="spacer"></div>
|
||||
@@ -79,11 +82,35 @@
|
||||
<noscript><p>Ustagammon a besoin de JavaScript.</p></noscript>
|
||||
</main>
|
||||
|
||||
<section id="help" class="help" hidden>
|
||||
<button id="help-back" type="button" class="secondary"></button>
|
||||
<!-- L'aide : un onglet par adresse (#/aide, #/glossaire, #/a-propos). -->
|
||||
<section id="help" class="help page" hidden>
|
||||
<!-- La barre haute de l'aide reste en vue quand la page défile. -->
|
||||
<div id="help-bar" class="help-bar">
|
||||
<a id="help-back" href="#/" class="button secondary"></a>
|
||||
<nav id="help-tabs" class="help-tabs">
|
||||
<a href="#/aide" data-screen="help"></a>
|
||||
<a href="#/glossaire" data-screen="glossary"></a>
|
||||
<a href="#/a-propos" data-screen="about"></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div id="help-content"></div>
|
||||
</section>
|
||||
|
||||
<!-- Une position partagée (#/position/<Position ID>/<Match ID>), non
|
||||
jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
|
||||
sur toute la hauteur, le reste dans une colonne à droite. -->
|
||||
<section id="position-view" class="position-view page" aria-labelledby="position-title" hidden>
|
||||
<h2 id="position-title" class="position-title"></h2>
|
||||
<p id="position-message" class="position-message" role="status"></p>
|
||||
<svg id="position-board" class="board position-board" role="img" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<p id="position-detail" class="position-detail"></p>
|
||||
<div class="position-actions">
|
||||
<button id="position-play" type="button"></button>
|
||||
<button id="position-copy" type="button" class="secondary"></button>
|
||||
<a id="position-back" href="#/" class="button secondary"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Menu : une feuille qui monte du bas. -->
|
||||
<div id="menu-veil" class="veil sheet-veil" hidden>
|
||||
<div id="menu" class="sheet" role="dialog" aria-modal="true" aria-labelledby="menu-title">
|
||||
@@ -93,9 +120,10 @@
|
||||
<button id="menu-close" type="button" class="icon-button"></button>
|
||||
</div>
|
||||
<button id="menu-new-game" type="button" class="item"></button>
|
||||
<button id="menu-games" type="button" class="item"></button>
|
||||
<a id="menu-games" href="#/parties" class="item"></a>
|
||||
<button id="menu-copy-link" type="button" class="item"></button>
|
||||
<button id="menu-fullscreen" type="button" class="item"></button>
|
||||
<button id="menu-help" type="button" class="item"></button>
|
||||
<a id="menu-help" href="#/aide" class="item"></a>
|
||||
<button id="menu-theme" type="button" class="item"><span class="item-label"></span><span class="item-value"></span></button>
|
||||
<button id="menu-vibrations" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
<button id="menu-screen-on" type="button" class="item" role="switch"><span class="item-label"></span><span class="switch" aria-hidden="true"></span></button>
|
||||
|
||||
Reference in New Issue
Block a user