From d01e1327821eb477398ee26444c31be5a11543a2 Mon Sep 17 00:00:00 2001 From: Christophe Date: Sun, 12 Apr 2026 12:26:57 +0200 Subject: [PATCH] Automatise le passage au cube et rend les temps configurables --- README.md | 3 +- app.js | 243 +++++++++++++++++++++++++++++----------------------- chrono.html | 4 +- index.html | 38 +++++++- styles.css | 41 +++++---- 5 files changed, 198 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 95ef7d8..360b2aa 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@ Application web mobile-first pour téléphone et tablette, pensée comme applica - configure une rencontre `Twice` ou `Time` - sépare l'application en pages dédiées : configuration, phase chrono, phase cube -- gère les blocks de 180 secondes et le temps par coup de 20 secondes +- permet de définir librement le temps de block et le temps par coup - suit les quotas `FAST`, `FREEZE` et `MASTERS` - orchestre la phase cube avec désignation du cube, capture des temps et préparation du block suivant - applique la logique du double coup V2 en `Twice` - applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte - conserve un historique local dans le navigateur - propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur +- ouvre automatiquement la page cube dès que la phase chess du block est terminée ## Hypothèse de produit diff --git a/app.js b/app.js index 24c0157..873e3f7 100644 --- a/app.js +++ b/app.js @@ -2,8 +2,8 @@ const PAGE = document.body.dataset.page; const STORAGE_KEY = "chesscubing-arena-state-v2"; const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:"; -const BLOCK_DURATION_MS = 180000; -const MOVE_LIMIT_MS = 20000; +const DEFAULT_BLOCK_DURATION_MS = 180000; +const DEFAULT_MOVE_LIMIT_MS = 20000; const TIME_MODE_INITIAL_CLOCK_MS = 600000; const CUBE_TIME_CAP_MS = 120000; @@ -40,7 +40,9 @@ let match = readStoredMatch(); let dirty = false; if (match) { - normalizeRecoveredMatch(match); + if (normalizeRecoveredMatch(match)) { + dirty = true; + } if (syncRunningState(match)) { dirty = true; } @@ -90,6 +92,8 @@ function initSetupPage() { const mode = getRadioValue(form, "mode") || "twice"; const preset = getRadioValue(form, "preset") || "fast"; const quota = PRESETS[preset].quota; + const blockDurationMs = getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS); + const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS); const timeImpact = mode === "time" ? "Chronos cumules de 10 minutes, ajustes apres chaque phase cube avec plafond de 120 s pris en compte." @@ -98,7 +102,7 @@ function initSetupPage() { summary.innerHTML = ` ${MODES[mode].label} ${PRESETS[preset].description} - Chaque block dure 180 secondes, chaque coup est limite a 20 secondes. + Temps configures : block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}. ${timeImpact} Quota actif : ${quota} coups par joueur. `; @@ -116,9 +120,7 @@ function initSetupPage() { ? resultText(match) : match.phase === "cube" ? "Page cube prete" - : match.awaitingBlockClosure - ? "Fin de block a confirmer" - : "Page chrono prete"; + : "Page chrono prete"; resumeCard.innerHTML = ` ${escapeHtml(match.config.matchLabel)} @@ -155,6 +157,8 @@ function initSetupPage() { matchLabel: sanitizeText(data.get("matchLabel")) || "Rencontre ChessCubing", mode: getRadioValue(form, "mode") || "twice", preset: getRadioValue(form, "preset") || "fast", + blockDurationMs: getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS), + moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS), whiteName: sanitizeText(data.get("whiteName")) || "Blanc", blackName: sanitizeText(data.get("blackName")) || "Noir", arbiterName: sanitizeText(data.get("arbiterName")), @@ -242,14 +246,6 @@ function initChronoPage() { return; } - if (match.awaitingBlockClosure) { - openCubePhase(match); - dirty = true; - persistMatch(); - navigateTo("cube.html"); - return; - } - if (match.running) { pauseBlock(match); } else { @@ -266,14 +262,6 @@ function initChronoPage() { return; } - if (match.awaitingBlockClosure) { - openCubePhase(match); - dirty = true; - persistMatch(); - navigateTo("cube.html"); - return; - } - requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre."); dirty = true; render(); @@ -297,7 +285,7 @@ function initChronoPage() { } match.currentTurn = opponentOf(match.currentTurn); - match.moveRemainingMs = MOVE_LIMIT_MS; + match.moveRemainingMs = getMoveLimitMs(match); logEvent(match, "Trait corrige manuellement par l'arbitre."); dirty = true; render(); @@ -336,17 +324,6 @@ function initChronoPage() { syncRunningState(match); - if (match.awaitingBlockClosure) { - if (match.currentTurn !== color) { - return; - } - - registerReliefMove(match); - dirty = true; - render(); - return; - } - if (!match.running || match.currentTurn !== color) { return; } @@ -376,14 +353,6 @@ function initChronoPage() { return; } - if (match.awaitingBlockClosure) { - openCubePhase(match); - dirty = true; - persistMatch(); - navigateTo("cube.html"); - return; - } - if (match.running) { pauseBlock(match); } else { @@ -421,19 +390,6 @@ function initChronoPage() { return; } - if (match.awaitingBlockClosure) { - if (active) { - button.textContent = "Coup hors quota"; - button.disabled = false; - hint.textContent = "A utiliser seulement si un roi est encore en echec avant la page cube."; - } else { - button.textContent = "En attente"; - button.disabled = true; - hint.textContent = "Attente de la reponse adverse ou du passage a la page cube."; - } - return; - } - if (!match.running) { button.textContent = "Block en pause"; button.disabled = true; @@ -474,10 +430,17 @@ function initChronoPage() { return; } + if (!match.result && match.phase === "cube") { + persistMatch(); + navigateTo("cube.html"); + return; + } + refs.title.textContent = match.config.matchLabel; refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`; refs.blockTimer.textContent = formatClock(match.blockRemainingMs); refs.moveTimer.textContent = formatClock(match.moveRemainingMs); + refs.arbiterTimeoutButton.textContent = `Depassement ${formatClock(getMoveLimitMs(match))}`; if (match.result) { refs.centerLabel.textContent = "Resultat"; @@ -487,20 +450,13 @@ function initChronoPage() { refs.spineText.textContent = "Retournez a la configuration pour lancer une nouvelle rencontre."; refs.primaryButton.textContent = "Retour a l'accueil"; refs.arbiterStatus.textContent = "Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser."; - } else if (match.awaitingBlockClosure) { - refs.centerLabel.textContent = "Transition"; - refs.centerValue.textContent = "Fin de block"; - refs.spineLabel.textContent = "Page suivante"; - refs.spineHeadline.textContent = "Passer a la page cube"; - refs.spineText.textContent = `${match.closureReason} Jouez encore des coups hors quota si necessaire, puis ouvrez la page cube.`; - refs.primaryButton.textContent = "Ouvrir la page cube"; - refs.arbiterStatus.textContent = refs.spineText.textContent; } else if (match.running) { refs.centerLabel.textContent = "Trait"; refs.centerValue.textContent = playerName(match, match.currentTurn); refs.spineLabel.textContent = "Chrono en cours"; refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`; - refs.spineText.textContent = "Chaque joueur tape sur sa grande zone quand son coup est termine."; + refs.spineText.textContent = + "Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess."; refs.primaryButton.textContent = "Pause arbitre"; refs.arbiterStatus.textContent = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`; } else { @@ -508,14 +464,13 @@ function initChronoPage() { refs.centerValue.textContent = playerName(match, match.currentTurn); refs.spineLabel.textContent = "Pret"; refs.spineHeadline.textContent = `Block ${match.blockNumber}`; - refs.spineText.textContent = "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs."; + refs.spineText.textContent = + "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais."; refs.primaryButton.textContent = "Demarrer le block"; refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`; } - refs.arbiterCloseBlockButton.textContent = match.awaitingBlockClosure - ? "Ouvrir la page cube" - : "Clore le block"; + refs.arbiterCloseBlockButton.textContent = "Passer au cube"; renderZone("black"); renderZone("white"); @@ -799,15 +754,15 @@ function initCubePage() { function createMatch(config) { const quota = PRESETS[config.preset].quota; const newMatch = { - schemaVersion: 2, + schemaVersion: 3, config, phase: "block", running: false, lastTickAt: null, blockNumber: 1, currentTurn: "white", - blockRemainingMs: BLOCK_DURATION_MS, - moveRemainingMs: MOVE_LIMIT_MS, + blockRemainingMs: config.blockDurationMs, + moveRemainingMs: config.moveLimitMs, quota, moves: { white: 0, @@ -848,7 +803,10 @@ function createMatch(config) { history: [], }; - logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`); + logEvent( + newMatch, + `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`, + ); logEvent(newMatch, "Les Blancs commencent le block 1."); return newMatch; } @@ -863,7 +821,7 @@ function readStoredMatch() { } const parsed = JSON.parse(raw); - if (!parsed || parsed.schemaVersion !== 2) { + if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) { return fromWindowName; } @@ -874,8 +832,44 @@ function readStoredMatch() { } function normalizeRecoveredMatch(storedMatch) { + let changed = false; + + if (!storedMatch.config) { + storedMatch.config = {}; + changed = true; + } + + const blockDurationMs = getBlockDurationMs(storedMatch); + const moveLimitMs = getMoveLimitMs(storedMatch); + + if (storedMatch.schemaVersion !== 3) { + storedMatch.schemaVersion = 3; + changed = true; + } + + if (storedMatch.config.blockDurationMs !== blockDurationMs) { + storedMatch.config.blockDurationMs = blockDurationMs; + changed = true; + } + + if (storedMatch.config.moveLimitMs !== moveLimitMs) { + storedMatch.config.moveLimitMs = moveLimitMs; + changed = true; + } + if (storedMatch.phase === "block" && typeof storedMatch.lastTickAt !== "number") { storedMatch.lastTickAt = null; + changed = true; + } + + if (typeof storedMatch.blockRemainingMs !== "number") { + storedMatch.blockRemainingMs = blockDurationMs; + changed = true; + } + + if (typeof storedMatch.moveRemainingMs !== "number") { + storedMatch.moveRemainingMs = moveLimitMs; + changed = true; } if (!storedMatch.cube) { @@ -892,6 +886,7 @@ function normalizeRecoveredMatch(storedMatch) { round: 1, history: [], }; + changed = true; } if (!storedMatch.cube.playerState) { @@ -899,6 +894,7 @@ function normalizeRecoveredMatch(storedMatch) { white: createCubePlayerState(), black: createCubePlayerState(), }; + changed = true; } storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white); @@ -911,7 +907,15 @@ function normalizeRecoveredMatch(storedMatch) { step: 0, starter: "white", }; + changed = true; } + + if (storedMatch.awaitingBlockClosure && storedMatch.phase === "block") { + openCubePhase(storedMatch, storedMatch.closureReason || "La phase chess etait deja terminee."); + changed = true; + } + + return changed; } function syncRunningState(storedMatch) { @@ -938,7 +942,10 @@ function syncRunningState(storedMatch) { } if (storedMatch.blockRemainingMs === 0) { - requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees."); + requestBlockClosure( + storedMatch, + `Le temps de block ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`, + ); } else if (storedMatch.moveRemainingMs === 0) { registerMoveTimeout(storedMatch, true); } @@ -974,19 +981,20 @@ function pauseBlock(storedMatch) { } function requestBlockClosure(storedMatch, reason) { - if (!storedMatch || storedMatch.awaitingBlockClosure) { + if (!storedMatch || storedMatch.phase !== "block") { return; } storedMatch.running = false; storedMatch.lastTickAt = null; - storedMatch.awaitingBlockClosure = true; - storedMatch.closureReason = reason; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; - logEvent(storedMatch, `${reason} Verifier si des coups hors quota sont encore necessaires avant la page cube.`); + storedMatch.awaitingBlockClosure = false; + storedMatch.closureReason = ""; + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); + logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`); + openCubePhase(storedMatch, reason); } -function openCubePhase(storedMatch) { +function openCubePhase(storedMatch, reason = "") { if (!storedMatch) { return; } @@ -1006,7 +1014,10 @@ function openCubePhase(storedMatch) { black: createCubePlayerState(), }; storedMatch.cube.round = 1; - logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`); + logEvent( + storedMatch, + `${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`, + ); } function startCubeTimer(storedMatch, color) { @@ -1099,8 +1110,8 @@ function prepareNextTwiceBlock(storedMatch, winner) { storedMatch.phase = "block"; storedMatch.running = false; storedMatch.lastTickAt = null; - storedMatch.blockRemainingMs = BLOCK_DURATION_MS; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch); + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.moves = { white: 0, black: 0 }; storedMatch.currentTurn = winner; storedMatch.doubleCoup = { @@ -1130,8 +1141,8 @@ function prepareNextTimeBlock(storedMatch) { storedMatch.phase = "block"; storedMatch.running = false; storedMatch.lastTickAt = null; - storedMatch.blockRemainingMs = BLOCK_DURATION_MS; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch); + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.moves = { white: 0, black: 0 }; storedMatch.doubleCoup = { eligible: false, @@ -1219,7 +1230,7 @@ function registerCountedMove(storedMatch, { source }) { storedMatch.moves[actor] += 1; storedMatch.lastMover = actor; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); if (source === "double") { storedMatch.doubleCoup.step = 0; @@ -1241,7 +1252,7 @@ function registerFreeDoubleMove(storedMatch) { const actor = storedMatch.currentTurn; storedMatch.lastMover = actor; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.doubleCoup.step = 2; logEvent( storedMatch, @@ -1249,21 +1260,6 @@ function registerFreeDoubleMove(storedMatch) { ); } -function registerReliefMove(storedMatch) { - if (!storedMatch || storedMatch.phase !== "block") { - return; - } - - const actor = storedMatch.currentTurn; - storedMatch.lastMover = actor; - storedMatch.currentTurn = opponentOf(actor); - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; - logEvent( - storedMatch, - `Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`, - ); -} - function registerMoveTimeout(storedMatch, automatic) { if (!storedMatch || storedMatch.phase !== "block") { return; @@ -1273,7 +1269,7 @@ function registerMoveTimeout(storedMatch, automatic) { if (storedMatch.doubleCoup.step === 1) { storedMatch.doubleCoup.step = 0; - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); logEvent( storedMatch, `Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`, @@ -1290,10 +1286,10 @@ function registerMoveTimeout(storedMatch, automatic) { } storedMatch.currentTurn = opponentOf(actor); - storedMatch.moveRemainingMs = MOVE_LIMIT_MS; + storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); logEvent( storedMatch, - `${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName( + `${automatic ? "Temps par coup ecoule." : `Depassement manuel du temps par coup ${formatClock(getMoveLimitMs(storedMatch))}.`} ${playerName( storedMatch, actor, )} perd son coup, qui reste compte dans le quota.`, @@ -1455,6 +1451,8 @@ function loadDemo(form, onRender) { setInputValue(form, "matchLabel", "Demo officielle ChessCubing"); setRadioValue(form, "mode", "twice"); setRadioValue(form, "preset", "freeze"); + setInputValue(form, "blockSeconds", "180"); + setInputValue(form, "moveSeconds", "20"); setInputValue(form, "whiteName", "Nora"); setInputValue(form, "blackName", "Leo"); setInputValue(form, "arbiterName", "Arbitre demo"); @@ -1482,6 +1480,16 @@ function setRadioValue(form, name, value) { } } +function getDurationInputMs(form, name, fallbackMs) { + const input = form.querySelector(`[name="${name}"]`); + const seconds = Number.parseInt(String(input?.value || ""), 10); + if (!Number.isFinite(seconds) || seconds <= 0) { + return fallbackMs; + } + + return seconds * 1000; +} + function playerName(storedMatch, color) { return color === "white" ? storedMatch.config.whiteName : storedMatch.config.blackName; } @@ -1556,12 +1564,33 @@ function readWindowNameState() { const raw = window.name.slice(WINDOW_NAME_KEY.length); const parsed = JSON.parse(raw); - return parsed && parsed.schemaVersion === 2 ? parsed : null; + return parsed && isSupportedSchemaVersion(parsed.schemaVersion) ? parsed : null; } catch { return null; } } +function isSupportedSchemaVersion(version) { + return version === 2 || version === 3; +} + +function getBlockDurationMs(storedMatch) { + return normalizeDurationMs(storedMatch?.config?.blockDurationMs, DEFAULT_BLOCK_DURATION_MS); +} + +function getMoveLimitMs(storedMatch) { + return normalizeDurationMs(storedMatch?.config?.moveLimitMs, DEFAULT_MOVE_LIMIT_MS); +} + +function normalizeDurationMs(value, fallbackMs) { + const duration = Number(value); + if (!Number.isFinite(duration) || duration <= 0) { + return fallbackMs; + } + + return Math.round(duration); +} + function createCubePlayerState() { return { running: false, diff --git a/chrono.html b/chrono.html index 0e8b294..c767602 100644 --- a/chrono.html +++ b/chrono.html @@ -114,10 +114,10 @@ Pause / reprise