const PAGE = document.body.dataset.page; const STORAGE_KEY = "chesscubing-arena-state-v2"; const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:"; 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; const PRESETS = { fast: { label: "FAST", quota: 6, description: "6 coups par joueur et par block.", }, freeze: { label: "FREEZE", quota: 8, description: "8 coups par joueur et par block.", }, masters: { label: "MASTERS", quota: 10, description: "10 coups par joueur et par block.", }, }; const MODES = { twice: { label: "ChessCubing Twice", subtitle: "Le gagnant du cube ouvre le block suivant.", }, time: { label: "ChessCubing Time", subtitle: "Chronos cumules et alternance bloc - / bloc +.", }, }; let match = readStoredMatch(); let dirty = false; if (match) { if (normalizeRecoveredMatch(match)) { dirty = true; } if (syncRunningState(match)) { dirty = true; } } window.addEventListener("beforeunload", flushState); document.addEventListener("visibilitychange", () => { if (document.hidden) { if (match) { syncRunningState(match); } flushState(); } }); window.setInterval(() => { if (dirty) { persistMatch(); } }, 1000); switch (PAGE) { case "setup": initSetupPage(); break; case "chrono": initChronoPage(); break; case "cube": initCubePage(); break; default: break; } function initSetupPage() { const form = document.querySelector("#setupForm"); const summary = document.querySelector("#setupSummary"); const loadDemoButton = document.querySelector("#loadDemoButton"); const resumeCard = document.querySelector("#resumeCard"); if (!form || !summary || !loadDemoButton || !resumeCard) { return; } const renderSummary = () => { 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." : "Le gagnant du cube commence le block suivant, avec double coup V2 possible."; summary.innerHTML = ` ${MODES[mode].label} ${PRESETS[preset].description} Temps configures : block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}. ${timeImpact} Quota actif : ${quota} coups par joueur. `; }; const renderResume = () => { if (!match) { resumeCard.classList.add("empty"); resumeCard.innerHTML = "

Aucun match en cours pour l'instant.

"; return; } resumeCard.classList.remove("empty"); const phaseLabel = match.result ? resultText(match) : match.phase === "cube" ? "Page cube prete" : "Page chrono prete"; resumeCard.innerHTML = ` ${escapeHtml(match.config.matchLabel)}

${escapeHtml(MODES[match.config.mode].label)}

${escapeHtml(match.config.whiteName)} vs ${escapeHtml(match.config.blackName)}

${escapeHtml(phaseLabel)}

`; resumeCard.querySelector("#resumeMatchButton")?.addEventListener("click", () => { navigateTo(routeForMatch(match)); }); resumeCard.querySelector("#clearMatchButton")?.addEventListener("click", () => { clearMatch(); renderResume(); }); }; form.addEventListener("input", renderSummary); loadDemoButton.addEventListener("click", () => loadDemo(form, renderSummary)); form.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(form); const config = { 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")), eventName: sanitizeText(data.get("eventName")), notes: sanitizeText(data.get("notes")), }; match = createMatch(config); dirty = true; persistMatch(); navigateTo("chrono.html"); }); renderSummary(); renderResume(); } function initChronoPage() { if (!match) { replaceTo("index.html"); return; } if (!match.result && match.phase === "cube") { replaceTo("cube.html"); return; } const refs = { title: document.querySelector("#chronoTitle"), subtitle: document.querySelector("#chronoSubtitle"), blockTimer: document.querySelector("#blockTimer"), moveTimer: document.querySelector("#moveTimer"), centerLabel: document.querySelector("#chronoCenterLabel"), centerValue: document.querySelector("#chronoCenterValue"), spineLabel: document.querySelector("#spineLabel"), spineHeadline: document.querySelector("#spineHeadline"), spineText: document.querySelector("#spineText"), primaryButton: document.querySelector("#primaryChronoButton"), whiteName: document.querySelector("#whiteNameChrono"), blackName: document.querySelector("#blackNameChrono"), whiteMoves: document.querySelector("#whiteMovesChrono"), blackMoves: document.querySelector("#blackMovesChrono"), whiteClock: document.querySelector("#whiteClockChrono"), blackClock: document.querySelector("#blackClockChrono"), whiteHint: document.querySelector("#whiteHintChrono"), blackHint: document.querySelector("#blackHintChrono"), whiteButton: document.querySelector("#whiteMoveButton"), blackButton: document.querySelector("#blackMoveButton"), whiteZone: document.querySelector("#whiteZone"), blackZone: document.querySelector("#blackZone"), openArbiterButton: document.querySelector("#openArbiterButton"), closeArbiterButton: document.querySelector("#closeArbiterButton"), arbiterModal: document.querySelector("#arbiterModal"), arbiterStatus: document.querySelector("#arbiterStatus"), arbiterPauseButton: document.querySelector("#arbiterPauseButton"), arbiterCloseBlockButton: document.querySelector("#arbiterCloseBlockButton"), arbiterTimeoutButton: document.querySelector("#arbiterTimeoutButton"), arbiterSwitchTurnButton: document.querySelector("#arbiterSwitchTurnButton"), arbiterWhiteWinButton: document.querySelector("#arbiterWhiteWinButton"), arbiterBlackWinButton: document.querySelector("#arbiterBlackWinButton"), arbiterStopButton: document.querySelector("#arbiterStopButton"), arbiterResetButton: document.querySelector("#arbiterResetButton"), }; const openModal = () => toggleModal(refs.arbiterModal, true); const closeModal = () => toggleModal(refs.arbiterModal, false); refs.openArbiterButton?.addEventListener("click", openModal); refs.closeArbiterButton?.addEventListener("click", closeModal); refs.arbiterModal?.addEventListener("click", (event) => { const target = event.target; if (target instanceof HTMLElement && target.dataset.closeModal === "true") { closeModal(); } }); refs.whiteButton?.addEventListener("click", () => handleChronoTap("white")); refs.blackButton?.addEventListener("click", () => handleChronoTap("black")); refs.primaryButton?.addEventListener("click", handlePrimaryChronoAction); refs.arbiterPauseButton?.addEventListener("click", () => { syncRunningState(match); if (match.result || match.phase !== "block") { return; } if (match.running) { pauseBlock(match); } else { startBlock(match); } dirty = true; render(); }); refs.arbiterCloseBlockButton?.addEventListener("click", () => { syncRunningState(match); if (match.result || match.phase !== "block") { return; } requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre."); dirty = true; render(); }); refs.arbiterTimeoutButton?.addEventListener("click", () => { syncRunningState(match); if (match.result || match.phase !== "block") { return; } registerMoveTimeout(match, false); dirty = true; render(); }); refs.arbiterSwitchTurnButton?.addEventListener("click", () => { syncRunningState(match); if (match.result || match.phase !== "block") { return; } match.currentTurn = opponentOf(match.currentTurn); match.moveRemainingMs = getMoveLimitMs(match); logEvent(match, "Trait corrige manuellement par l'arbitre."); dirty = true; render(); }); refs.arbiterWhiteWinButton?.addEventListener("click", () => { syncRunningState(match); setResult(match, "white"); dirty = true; render(); }); refs.arbiterBlackWinButton?.addEventListener("click", () => { syncRunningState(match); setResult(match, "black"); dirty = true; render(); }); refs.arbiterStopButton?.addEventListener("click", () => { syncRunningState(match); setResult(match, "stopped"); dirty = true; render(); }); refs.arbiterResetButton?.addEventListener("click", () => { clearMatch(); replaceTo("index.html"); }); function handleChronoTap(color) { if (!match || match.result || match.phase !== "block") { return; } syncRunningState(match); if (!match.running || match.currentTurn !== color) { return; } if (match.doubleCoup.step === 1) { registerFreeDoubleMove(match); } else { registerCountedMove(match, { source: match.doubleCoup.step === 2 ? "double" : "standard", }); } dirty = true; render(); } function handlePrimaryChronoAction() { syncRunningState(match); if (match.result) { navigateTo("index.html"); return; } if (match.phase !== "block") { navigateTo("cube.html"); return; } if (match.running) { pauseBlock(match); } else { startBlock(match); } dirty = true; render(); } function renderZone(color) { const isWhite = color === "white"; const button = isWhite ? refs.whiteButton : refs.blackButton; const name = isWhite ? refs.whiteName : refs.blackName; const moves = isWhite ? refs.whiteMoves : refs.blackMoves; const clock = isWhite ? refs.whiteClock : refs.blackClock; const hint = isWhite ? refs.whiteHint : refs.blackHint; const zone = isWhite ? refs.whiteZone : refs.blackZone; const actorName = playerName(match, color); const active = match.currentTurn === color; name.textContent = actorName; moves.textContent = `${match.moves[color]} / ${match.quota}`; clock.textContent = match.clocks ? `Chrono ${formatSignedClock(match.clocks[color])}` : `Dernier cube ${renderLastCube(match, color)}`; button.classList.toggle("active-turn", active && !match.result); zone.classList.toggle("active-zone", active && !match.result); if (match.result) { button.textContent = resultText(match); button.disabled = true; hint.textContent = "Le match est termine."; return; } if (!match.running) { button.textContent = "Block en pause"; button.disabled = true; hint.textContent = active ? "Le block n'a pas encore demarre ou a ete mis en pause." : `${playerName(match, match.currentTurn)} reprendra au demarrage.`; return; } if (!active) { button.textContent = "Attends"; button.disabled = true; hint.textContent = `${playerName(match, match.currentTurn)} est en train de jouer.`; return; } if (match.doubleCoup.step === 1) { button.textContent = "1er coup gratuit"; button.disabled = false; hint.textContent = "Ce coup ne compte pas et ne doit pas donner echec."; return; } if (match.doubleCoup.step === 2) { button.textContent = "2e coup du double"; button.disabled = false; hint.textContent = "Ce coup compte dans le quota et l'echec redevient autorise."; return; } button.textContent = "J'ai fini mon coup"; button.disabled = false; hint.textContent = "Tape des que ton coup est joue sur l'echiquier."; } function render() { if (!match) { 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"; refs.centerValue.textContent = resultText(match); refs.spineLabel.textContent = "Termine"; refs.spineHeadline.textContent = resultText(match); 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.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. 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 { refs.centerLabel.textContent = "Trait"; 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. 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 = "Passer au cube"; renderZone("black"); renderZone("white"); } render(); window.setInterval(() => { if (!match) { return; } const changed = syncRunningState(match); if (changed) { dirty = true; } if (!match.result && match.phase === "cube") { persistMatch(); navigateTo("cube.html"); return; } render(); }, 100); } function initCubePage() { if (!match) { replaceTo("index.html"); return; } if (!match.result && match.phase !== "cube") { replaceTo("chrono.html"); return; } const refs = { title: document.querySelector("#cubeTitle"), subtitle: document.querySelector("#cubeSubtitle"), blockLabel: document.querySelector("#cubeBlockLabel"), elapsed: document.querySelector("#cubeElapsed"), centerLabel: document.querySelector("#cubeCenterLabel"), centerValue: document.querySelector("#cubeCenterValue"), spineLabel: document.querySelector("#cubeSpineLabel"), spineHeadline: document.querySelector("#cubeSpineHeadline"), spineText: document.querySelector("#cubeSpineText"), primaryButton: document.querySelector("#primaryCubeButton"), whiteName: document.querySelector("#whiteNameCube"), blackName: document.querySelector("#blackNameCube"), whiteButton: document.querySelector("#whiteCubeButton"), blackButton: document.querySelector("#blackCubeButton"), whiteResult: document.querySelector("#whiteCubeResult"), blackResult: document.querySelector("#blackCubeResult"), whiteCap: document.querySelector("#whiteCubeCap"), blackCap: document.querySelector("#blackCubeCap"), whiteHint: document.querySelector("#whiteHintCube"), blackHint: document.querySelector("#blackHintCube"), openHelpButton: document.querySelector("#openCubeHelpButton"), closeHelpButton: document.querySelector("#closeCubeHelpButton"), helpModal: document.querySelector("#cubeHelpModal"), helpStatus: document.querySelector("#cubeHelpStatus"), replayCubeButton: document.querySelector("#replayCubeButton"), resetButton: document.querySelector("#cubeResetButton"), }; const openModal = () => toggleModal(refs.helpModal, true); const closeModal = () => toggleModal(refs.helpModal, false); refs.openHelpButton?.addEventListener("click", openModal); refs.closeHelpButton?.addEventListener("click", closeModal); refs.helpModal?.addEventListener("click", (event) => { const target = event.target; if (target instanceof HTMLElement && target.dataset.closeCubeModal === "true") { closeModal(); } }); refs.whiteButton?.addEventListener("click", () => { handleCubeTap("white"); dirty = true; render(); }); refs.blackButton?.addEventListener("click", () => { handleCubeTap("black"); dirty = true; render(); }); refs.primaryButton?.addEventListener("click", () => { if (match.result) { navigateTo("index.html"); return; } if (match.phase !== "cube") { navigateTo("chrono.html"); return; } if (match.cube.times.white !== null && match.cube.times.black !== null) { if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) { replayCubePhase(match); dirty = true; render(); return; } applyCubeOutcome(match); dirty = true; persistMatch(); navigateTo("chrono.html"); } }); refs.replayCubeButton?.addEventListener("click", () => { replayCubePhase(match); dirty = true; render(); }); refs.resetButton?.addEventListener("click", () => { clearMatch(); replaceTo("index.html"); }); function handleCubeTap(color) { if (match.result || match.phase !== "cube") { return; } if (match.cube.times[color] !== null) { return; } if (match.cube.playerState[color].running) { captureCubeTime(match, color); return; } startCubeTimer(match, color); } function renderCubeZone(color) { const isWhite = color === "white"; const button = isWhite ? refs.whiteButton : refs.blackButton; const name = isWhite ? refs.whiteName : refs.blackName; const result = isWhite ? refs.whiteResult : refs.blackResult; const cap = isWhite ? refs.whiteCap : refs.blackCap; const hint = isWhite ? refs.whiteHint : refs.blackHint; const playerState = match.cube.playerState[color]; const time = match.cube.times[color]; name.textContent = playerName(match, color); result.textContent = formatCubePlayerTime(match, color); cap.textContent = renderCubeCap(match, time); if (match.result) { button.textContent = resultText(match); button.disabled = true; hint.textContent = "Le match est termine."; return; } if (match.phase !== "cube") { button.textContent = "Retour chrono"; button.disabled = true; hint.textContent = "La page cube est terminee."; return; } if (time !== null) { button.textContent = "Temps enregistre"; button.disabled = true; hint.textContent = "Ce joueur a deja termine son cube."; return; } if (playerState.running) { button.textContent = "J'ai fini le cube"; button.disabled = false; hint.textContent = "Tape au moment exact ou le cube est resolu."; return; } button.textContent = "Demarrer mon chrono"; button.disabled = false; hint.textContent = "Chaque joueur lance son propre chrono quand il commence vraiment."; } function render() { refs.title.textContent = match.cube.number ? `Cube n${match.cube.number}` : "Phase cube"; refs.subtitle.textContent = `Block ${match.blockNumber} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`; refs.blockLabel.textContent = `${match.blockNumber}`; refs.elapsed.textContent = renderCubeElapsed(match); if (match.result) { refs.centerLabel.textContent = "Resultat"; refs.centerValue.textContent = resultText(match); refs.spineLabel.textContent = "Termine"; refs.spineHeadline.textContent = resultText(match); refs.spineText.textContent = "Retournez a la configuration pour relancer une rencontre."; refs.primaryButton.textContent = "Retour a l'accueil"; refs.helpStatus.textContent = "Le match est termine."; } else if ( match.cube.times.white !== null && match.cube.times.black !== null && match.config.mode === "twice" && match.cube.times.white === match.cube.times.black ) { refs.centerLabel.textContent = "Decision"; refs.centerValue.textContent = "Egalite parfaite"; refs.spineLabel.textContent = "Reglement"; refs.spineHeadline.textContent = "Rejouer la phase cube"; refs.spineText.textContent = "Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite."; refs.primaryButton.textContent = "Rejouer la phase cube"; refs.helpStatus.textContent = refs.spineText.textContent; } else if (match.cube.times.white !== null && match.cube.times.black !== null) { refs.centerLabel.textContent = "Decision"; refs.centerValue.textContent = "Phase cube complete"; refs.spineLabel.textContent = "Suite"; refs.spineHeadline.textContent = "Ouvrir la page chrono"; refs.spineText.textContent = "Appliquer le resultat du cube pour preparer le block suivant."; refs.primaryButton.textContent = "Appliquer et ouvrir la page chrono"; refs.helpStatus.textContent = refs.spineText.textContent; } else if (match.cube.running) { refs.centerLabel.textContent = "Etat"; refs.centerValue.textContent = "Chronos lances"; refs.spineLabel.textContent = "Arrets"; refs.spineHeadline.textContent = "Chaque joueur se chronometre"; refs.spineText.textContent = "Chaque joueur demarre quand il veut, puis retape sa zone une fois le cube termine."; refs.primaryButton.textContent = "Attendre les deux temps"; refs.helpStatus.textContent = refs.spineText.textContent; } else if (match.cube.times.white !== null || match.cube.times.black !== null) { refs.centerLabel.textContent = "Etat"; refs.centerValue.textContent = "Un temps saisi"; refs.spineLabel.textContent = "Suite"; refs.spineHeadline.textContent = "Attendre l'autre joueur"; refs.spineText.textContent = "Le deuxieme joueur peut encore demarrer puis arreter son propre chrono quand il le souhaite."; refs.primaryButton.textContent = "Attendre le deuxieme temps"; refs.helpStatus.textContent = refs.spineText.textContent; } else { refs.centerLabel.textContent = "Etat"; refs.centerValue.textContent = "Pret"; refs.spineLabel.textContent = "Depart libre"; refs.spineHeadline.textContent = "Chaque joueur lance son chrono"; refs.spineText.textContent = "Au debut de sa resolution, chaque joueur tape sur sa grande zone pour demarrer son propre chrono."; refs.primaryButton.textContent = "En attente des joueurs"; refs.helpStatus.textContent = refs.spineText.textContent; } renderCubeZone("black"); renderCubeZone("white"); refs.primaryButton.disabled = !match.result && !( match.cube.times.white !== null && match.cube.times.black !== null ); } render(); window.setInterval(() => { if (!match) { return; } if (!match.result && match.phase !== "cube") { persistMatch(); navigateTo("chrono.html"); return; } render(); }, 100); } function createMatch(config) { const quota = PRESETS[config.preset].quota; const newMatch = { schemaVersion: 3, config, phase: "block", running: false, lastTickAt: null, blockNumber: 1, currentTurn: "white", blockRemainingMs: config.blockDurationMs, moveRemainingMs: config.moveLimitMs, quota, moves: { white: 0, black: 0, }, clocks: config.mode === "time" ? { white: TIME_MODE_INITIAL_CLOCK_MS, black: TIME_MODE_INITIAL_CLOCK_MS, } : null, lastMover: null, awaitingBlockClosure: false, closureReason: "", result: null, cube: { number: null, running: false, startedAt: null, elapsedMs: 0, times: { white: null, black: null, }, playerState: { white: createCubePlayerState(), black: createCubePlayerState(), }, round: 1, history: [], }, doubleCoup: { eligible: false, step: 0, starter: "white", }, history: [], }; 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; } function readStoredMatch() { const fromWindowName = readWindowNameState(); try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) { return fromWindowName; } const parsed = JSON.parse(raw); if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) { return fromWindowName; } return parsed; } catch { return fromWindowName; } } 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) { storedMatch.cube = { number: null, running: false, startedAt: null, elapsedMs: 0, times: { white: null, black: null }, playerState: { white: createCubePlayerState(), black: createCubePlayerState(), }, round: 1, history: [], }; changed = true; } if (!storedMatch.cube.playerState) { storedMatch.cube.playerState = { white: createCubePlayerState(), black: createCubePlayerState(), }; changed = true; } storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white); storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black); storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch); if (!storedMatch.doubleCoup) { storedMatch.doubleCoup = { eligible: false, 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) { if (!storedMatch || storedMatch.result) { return false; } if (!storedMatch.running || storedMatch.phase !== "block" || !storedMatch.lastTickAt) { return false; } const now = Date.now(); const delta = now - storedMatch.lastTickAt; if (delta <= 0) { return false; } storedMatch.lastTickAt = now; storedMatch.blockRemainingMs = Math.max(0, storedMatch.blockRemainingMs - delta); storedMatch.moveRemainingMs = Math.max(0, storedMatch.moveRemainingMs - delta); if (storedMatch.clocks) { storedMatch.clocks[storedMatch.currentTurn] -= delta; } if (storedMatch.blockRemainingMs === 0) { requestBlockClosure( storedMatch, `Le temps de block ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`, ); } else if (storedMatch.moveRemainingMs === 0) { registerMoveTimeout(storedMatch, true); } return true; } function startBlock(storedMatch) { if (!storedMatch || storedMatch.phase !== "block" || storedMatch.result) { return; } storedMatch.running = true; storedMatch.awaitingBlockClosure = false; storedMatch.closureReason = ""; storedMatch.lastTickAt = Date.now(); logEvent( storedMatch, storedMatch.blockNumber === 1 && storedMatch.moves.white === 0 && storedMatch.moves.black === 0 ? "Block 1 demarre." : `Block ${storedMatch.blockNumber} relance.`, ); } function pauseBlock(storedMatch) { if (!storedMatch || !storedMatch.running) { return; } storedMatch.running = false; storedMatch.lastTickAt = null; logEvent(storedMatch, `Block ${storedMatch.blockNumber} mis en pause.`); } function requestBlockClosure(storedMatch, reason) { if (!storedMatch || storedMatch.phase !== "block") { return; } storedMatch.running = false; storedMatch.lastTickAt = null; storedMatch.awaitingBlockClosure = false; storedMatch.closureReason = ""; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`); openCubePhase(storedMatch, reason); } function openCubePhase(storedMatch, reason = "") { if (!storedMatch) { return; } storedMatch.phase = "cube"; storedMatch.running = false; storedMatch.lastTickAt = null; storedMatch.awaitingBlockClosure = false; storedMatch.closureReason = ""; storedMatch.cube.number = pickCubeNumber(); storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(), black: createCubePlayerState(), }; storedMatch.cube.round = 1; logEvent( storedMatch, `${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`, ); } function startCubeTimer(storedMatch, color) { if (!storedMatch || storedMatch.phase !== "cube" || storedMatch.result) { return; } if (!storedMatch.cube.number) { storedMatch.cube.number = pickCubeNumber(); } if (storedMatch.cube.times[color] !== null || storedMatch.cube.playerState[color].running) { return; } const playerState = storedMatch.cube.playerState[color]; playerState.running = true; playerState.startedAt = Date.now(); storedMatch.cube.running = true; logEvent( storedMatch, `${playerName(storedMatch, color)} demarre son chrono cube sur le cube n${storedMatch.cube.number}.`, ); } function captureCubeTime(storedMatch, color) { if ( !storedMatch || storedMatch.phase !== "cube" || !storedMatch.cube.playerState[color].running || storedMatch.cube.times[color] !== null ) { return; } const playerState = storedMatch.cube.playerState[color]; const elapsedMs = playerState.elapsedMs + (Date.now() - playerState.startedAt); storedMatch.cube.times[color] = elapsedMs; playerState.elapsedMs = elapsedMs; playerState.startedAt = null; playerState.running = false; storedMatch.cube.elapsedMs = Math.max( getCubePlayerElapsed(storedMatch, "white"), getCubePlayerElapsed(storedMatch, "black"), ); storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch); logEvent(storedMatch, `${playerName(storedMatch, color)} arrete le cube en ${formatStopwatch(elapsedMs)}.`); if ( storedMatch.cube.times.white !== null && storedMatch.cube.times.black !== null ) { storedMatch.cube.running = false; storedMatch.cube.history.push({ blockNumber: storedMatch.blockNumber, number: storedMatch.cube.number, white: storedMatch.cube.times.white, black: storedMatch.cube.times.black, }); } } function applyCubeOutcome(storedMatch) { if (!storedMatch || storedMatch.phase !== "cube") { return; } const white = storedMatch.cube.times.white; const black = storedMatch.cube.times.black; if (white === null || black === null) { return; } if (storedMatch.config.mode === "twice") { const winner = white < black ? "white" : "black"; prepareNextTwiceBlock(storedMatch, winner); return; } applyTimeAdjustments(storedMatch, white, black); prepareNextTimeBlock(storedMatch); } function prepareNextTwiceBlock(storedMatch, winner) { const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null; logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira le block suivant.`); storedMatch.blockNumber += 1; storedMatch.phase = "block"; storedMatch.running = false; storedMatch.lastTickAt = null; storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch); storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.moves = { white: 0, black: 0 }; storedMatch.currentTurn = winner; storedMatch.doubleCoup = { eligible: hadDouble, step: hadDouble ? 1 : 0, starter: winner, }; storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(), black: createCubePlayerState(), }; storedMatch.cube.number = null; if (hadDouble) { logEvent(storedMatch, `Double coup disponible pour ${playerName(storedMatch, winner)}.`); } else { logEvent(storedMatch, "Aucun double coup n'est accorde sur ce depart."); } } function prepareNextTimeBlock(storedMatch) { storedMatch.blockNumber += 1; storedMatch.phase = "block"; storedMatch.running = false; storedMatch.lastTickAt = null; storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch); storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.moves = { white: 0, black: 0 }; storedMatch.doubleCoup = { eligible: false, step: 0, starter: storedMatch.currentTurn, }; storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(), black: createCubePlayerState(), }; storedMatch.cube.number = null; logEvent( storedMatch, `Block ${storedMatch.blockNumber} pret. Le trait est conserve : ${playerName( storedMatch, storedMatch.currentTurn, )} reprend.`, ); } function applyTimeAdjustments(storedMatch, whiteTime, blackTime) { if (!storedMatch.clocks) { return; } const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS); const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS); const blockType = getTimeBlockType(storedMatch.blockNumber); if (blockType === "minus") { storedMatch.clocks.white -= cappedWhite; storedMatch.clocks.black -= cappedBlack; logEvent( storedMatch, `Bloc - : ${formatStopwatch(cappedWhite)} retire au chrono Blanc, ${formatStopwatch( cappedBlack, )} retire au chrono Noir.`, ); } else { storedMatch.clocks.white += cappedBlack; storedMatch.clocks.black += cappedWhite; logEvent( storedMatch, `Bloc + : ${formatStopwatch(cappedBlack)} ajoute au chrono Blanc, ${formatStopwatch( cappedWhite, )} ajoute au chrono Noir.`, ); } } function replayCubePhase(storedMatch) { if (!storedMatch || storedMatch.phase !== "cube") { return; } storedMatch.cube.running = false; storedMatch.cube.startedAt = null; storedMatch.cube.elapsedMs = 0; storedMatch.cube.times = { white: null, black: null }; storedMatch.cube.playerState = { white: createCubePlayerState(), black: createCubePlayerState(), }; storedMatch.cube.round += 1; logEvent(storedMatch, `Phase cube relancee (tentative ${storedMatch.cube.round}).`); } function registerCountedMove(storedMatch, { source }) { if (!storedMatch || storedMatch.phase !== "block") { return; } const actor = storedMatch.currentTurn; if (storedMatch.moves[actor] >= storedMatch.quota) { logEvent( storedMatch, `${playerName(storedMatch, actor)} a deja atteint son quota. Utiliser le mode hors quota si necessaire.`, ); return; } storedMatch.moves[actor] += 1; storedMatch.lastMover = actor; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); if (source === "double") { storedMatch.doubleCoup.step = 0; } logEvent( storedMatch, `${playerName(storedMatch, actor)} valide son coup (${storedMatch.moves[actor]} / ${storedMatch.quota}).`, ); storedMatch.currentTurn = opponentOf(actor); verifyQuotaCompletion(storedMatch); } function registerFreeDoubleMove(storedMatch) { if (!storedMatch || storedMatch.doubleCoup.step !== 1) { return; } const actor = storedMatch.currentTurn; storedMatch.lastMover = actor; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); storedMatch.doubleCoup.step = 2; logEvent( storedMatch, `Premier coup gratuit du double coup joue par ${playerName(storedMatch, actor)}.`, ); } function registerMoveTimeout(storedMatch, automatic) { if (!storedMatch || storedMatch.phase !== "block") { return; } const actor = storedMatch.currentTurn; if (storedMatch.doubleCoup.step === 1) { storedMatch.doubleCoup.step = 0; storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); logEvent( storedMatch, `Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`, ); return; } if (storedMatch.moves[actor] < storedMatch.quota) { storedMatch.moves[actor] += 1; } if (storedMatch.doubleCoup.step === 2) { storedMatch.doubleCoup.step = 0; } storedMatch.currentTurn = opponentOf(actor); storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch); logEvent( storedMatch, `${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.`, ); verifyQuotaCompletion(storedMatch); } function verifyQuotaCompletion(storedMatch) { if ( storedMatch.moves.white >= storedMatch.quota && storedMatch.moves.black >= storedMatch.quota ) { requestBlockClosure(storedMatch, "Les deux joueurs ont atteint leur quota de coups."); } } function setResult(storedMatch, winner) { if (!storedMatch || storedMatch.result) { return; } storedMatch.running = false; storedMatch.lastTickAt = null; storedMatch.result = winner; logEvent(storedMatch, `${resultText(storedMatch)}.`); } function renderModeContext(storedMatch) { if (storedMatch.config.mode === "time") { return getTimeBlockType(storedMatch.blockNumber) === "minus" ? "Bloc - : temps cube retire a son propre chrono" : "Bloc + : temps cube ajoute au chrono adverse"; } if (storedMatch.doubleCoup.step === 1) { return `Double coup actif pour ${playerName(storedMatch, storedMatch.doubleCoup.starter)}`; } if (storedMatch.doubleCoup.step === 2) { return "Deuxieme coup du double en attente"; } return "Le gagnant du cube ouvrira le prochain block"; } function renderLastCube(storedMatch, color) { const last = storedMatch.cube.history.at(-1); if (!last) { return "--"; } return formatStopwatch(last[color]); } function renderCubeElapsed(storedMatch) { if (storedMatch.phase !== "cube") { return "00:00.0"; } return formatStopwatch( Math.max(getCubePlayerElapsed(storedMatch, "white"), getCubePlayerElapsed(storedMatch, "black")), ); } function renderCubeCap(storedMatch, time) { if (time === null) { return ""; } if (storedMatch.config.mode !== "time") { return "temps capture"; } if (time <= CUBE_TIME_CAP_MS) { return "plafond non atteint"; } return `pris en compte ${formatStopwatch(CUBE_TIME_CAP_MS)}`; } function resultText(storedMatch) { if (storedMatch.result === "white") { return `Victoire de ${playerName(storedMatch, "white")}`; } if (storedMatch.result === "black") { return `Victoire de ${playerName(storedMatch, "black")}`; } return "Partie arretee"; } function routeForMatch(storedMatch) { if (!storedMatch) { return "index.html"; } return storedMatch.phase === "cube" && !storedMatch.result ? "cube.html" : "chrono.html"; } function navigateTo(target) { persistMatch(); window.location.href = target; } function replaceTo(target) { window.location.replace(target); } function persistMatch() { if (!match) { try { localStorage.removeItem(STORAGE_KEY); } catch { // Ignore storage errors and still clear the window-level fallback. } window.name = ""; dirty = false; return; } const serialized = JSON.stringify(match); window.name = `${WINDOW_NAME_KEY}${serialized}`; try { localStorage.setItem(STORAGE_KEY, serialized); } catch { // Keep the window.name fallback so cross-page navigation still works. } dirty = false; } function flushState() { if (match) { syncRunningState(match); } if (dirty) { persistMatch(); } } function clearMatch() { match = null; persistMatch(); } function toggleModal(element, open) { if (!element) { return; } element.classList.toggle("hidden", !open); element.setAttribute("aria-hidden", String(!open)); } 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"); setInputValue(form, "eventName", "Session telephone"); setInputValue(form, "notes", "8 cubes verifies, variante prete, tirage au sort effectue."); onRender(); } function setInputValue(form, name, value) { const input = form.querySelector(`[name="${name}"]`); if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { input.value = value; } } function getRadioValue(form, name) { const selected = form.querySelector(`input[name="${name}"]:checked`); return selected ? selected.value : ""; } function setRadioValue(form, name, value) { const input = form.querySelector(`input[name="${name}"][value="${value}"]`); if (input) { input.checked = true; } } 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; } function opponentOf(color) { return color === "white" ? "black" : "white"; } function getTimeBlockType(blockNumber) { return blockNumber % 2 === 1 ? "minus" : "plus"; } function pickCubeNumber() { return Math.floor(Math.random() * 4) + 1; } function logEvent(storedMatch, message) { storedMatch.history.push({ message, time: new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit", }), }); } function formatClock(ms) { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); const seconds = String(totalSeconds % 60).padStart(2, "0"); return `${minutes}:${seconds}`; } function formatSignedClock(ms) { const negative = ms < 0 ? "-" : ""; const totalSeconds = Math.floor(Math.abs(ms) / 1000); const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); const seconds = String(totalSeconds % 60).padStart(2, "0"); return `${negative}${minutes}:${seconds}`; } function formatStopwatch(ms) { const totalSeconds = Math.floor(Math.abs(ms) / 1000); const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); const seconds = String(totalSeconds % 60).padStart(2, "0"); const tenths = Math.floor((Math.abs(ms) % 1000) / 100); return `${minutes}:${seconds}.${tenths}`; } function sanitizeText(value) { return String(value || "") .replace(/[<>]/g, "") .trim() .replace(/\s+/g, " "); } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function readWindowNameState() { try { if (!window.name || !window.name.startsWith(WINDOW_NAME_KEY)) { return null; } const raw = window.name.slice(WINDOW_NAME_KEY.length); const parsed = JSON.parse(raw); 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, startedAt: null, elapsedMs: 0, }; } function normalizeCubePlayerState(playerState) { return { running: Boolean(playerState?.running), startedAt: typeof playerState?.startedAt === "number" ? playerState.startedAt : null, elapsedMs: typeof playerState?.elapsedMs === "number" ? playerState.elapsedMs : 0, }; } function isAnyCubeTimerRunning(storedMatch) { return storedMatch.cube.playerState.white.running || storedMatch.cube.playerState.black.running; } function getCubePlayerElapsed(storedMatch, color) { const playerState = storedMatch.cube.playerState[color]; if (storedMatch.cube.times[color] !== null) { return storedMatch.cube.times[color]; } if (playerState.running && playerState.startedAt) { return playerState.elapsedMs + (Date.now() - playerState.startedAt); } return playerState.elapsedMs; } function formatCubePlayerTime(storedMatch, color) { const elapsed = getCubePlayerElapsed(storedMatch, color); const playerState = storedMatch.cube.playerState[color]; if (playerState.running) { return formatStopwatch(elapsed); } if (elapsed <= 0 && storedMatch.cube.times[color] === null) { return "--"; } return formatStopwatch(elapsed); }