const BLOCK_DURATION_MS = 180000; const MOVE_LIMIT_MS = 20000; const TIME_MODE_INITIAL_CLOCK_MS = 600000; const CUBE_TIME_CAP_MS = 120000; const STORAGE_KEY = "chesscubing-arena-state-v1"; const PRESETS = { fast: { label: "FAST", quota: 6, description: "Format nerveux : 6 coups par joueur et par block.", }, freeze: { label: "FREEZE", quota: 8, description: "Format intermédiaire : 8 coups par joueur et par block.", }, masters: { label: "MASTERS", quota: 10, description: "Format long : 10 coups par joueur et par block.", }, }; const MODES = { twice: { label: "ChessCubing Twice", subtitle: "Le gagnant du cube démarre le block suivant. Double coup V2 possible.", }, time: { label: "ChessCubing Time", subtitle: "Blocks identiques au Twice avec chronos cumulés et alternance bloc - / +.", }, }; const refs = { setupForm: document.querySelector("#setupForm"), setupSummary: document.querySelector("#setupSummary"), loadDemoButton: document.querySelector("#loadDemoButton"), livePanel: document.querySelector("#livePanel"), heroModeHint: document.querySelector("#heroModeHint"), matchTitle: document.querySelector("#matchTitle"), phaseBadge: document.querySelector("#phaseBadge"), blockLabel: document.querySelector("#blockLabel"), modeLabel: document.querySelector("#modeLabel"), blockMeta: document.querySelector("#blockMeta"), blockTimer: document.querySelector("#blockTimer"), moveTimer: document.querySelector("#moveTimer"), blockStatusText: document.querySelector("#blockStatusText"), turnLabel: document.querySelector("#turnLabel"), whiteNameDisplay: document.querySelector("#whiteNameDisplay"), blackNameDisplay: document.querySelector("#blackNameDisplay"), whiteMoveCount: document.querySelector("#whiteMoveCount"), blackMoveCount: document.querySelector("#blackMoveCount"), whiteClockLabel: document.querySelector("#whiteClockLabel"), blackClockLabel: document.querySelector("#blackClockLabel"), whiteCubeLabel: document.querySelector("#whiteCubeLabel"), blackCubeLabel: document.querySelector("#blackCubeLabel"), whiteCard: document.querySelector("#whiteCard"), blackCard: document.querySelector("#blackCard"), startPauseButton: document.querySelector("#startPauseButton"), confirmBlockButton: document.querySelector("#confirmBlockButton"), moveActionButton: document.querySelector("#moveActionButton"), reliefMoveButton: document.querySelector("#reliefMoveButton"), timeoutMoveButton: document.querySelector("#timeoutMoveButton"), switchTurnButton: document.querySelector("#switchTurnButton"), contextNotice: document.querySelector("#contextNotice"), doubleCard: document.querySelector("#doubleCard"), cubeNumber: document.querySelector("#cubeNumber"), startCubeButton: document.querySelector("#startCubeButton"), cubeElapsed: document.querySelector("#cubeElapsed"), cubeStatusText: document.querySelector("#cubeStatusText"), captureWhiteCubeButton: document.querySelector("#captureWhiteCubeButton"), captureBlackCubeButton: document.querySelector("#captureBlackCubeButton"), whiteCubeResult: document.querySelector("#whiteCubeResult"), blackCubeResult: document.querySelector("#blackCubeResult"), whiteCubeCap: document.querySelector("#whiteCubeCap"), blackCubeCap: document.querySelector("#blackCubeCap"), applyCubeButton: document.querySelector("#applyCubeButton"), redoCubeButton: document.querySelector("#redoCubeButton"), historyList: document.querySelector("#historyList"), resetMatchButton: document.querySelector("#resetMatchButton"), whiteWinButton: document.querySelector("#whiteWinButton"), blackWinButton: document.querySelector("#blackWinButton"), drawStopButton: document.querySelector("#drawStopButton"), }; const state = { match: restoreState(), lastTickAt: null, }; hydrateRecoveredState(); bindEvents(); renderSetupSummary(); render(); startTicker(); function bindEvents() { document.querySelectorAll("[data-scroll-target]").forEach((button) => { button.addEventListener("click", () => { const target = document.getElementById(button.dataset.scrollTarget); target?.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); refs.setupForm.addEventListener("submit", (event) => { event.preventDefault(); const formData = new FormData(refs.setupForm); const config = { matchLabel: sanitizeText(formData.get("matchLabel")) || "Rencontre ChessCubing", mode: formData.get("mode") || "twice", preset: formData.get("preset") || "fast", whiteName: sanitizeText(formData.get("whiteName")) || "Blanc", blackName: sanitizeText(formData.get("blackName")) || "Noir", arbiterName: sanitizeText(formData.get("arbiterName")) || "", eventName: sanitizeText(formData.get("eventName")) || "", notes: sanitizeText(formData.get("notes")) || "", }; state.match = createMatch(config); persistState(); refs.livePanel.classList.remove("hidden"); refs.livePanel.scrollIntoView({ behavior: "smooth", block: "start" }); render(); }); refs.setupForm.addEventListener("input", renderSetupSummary); refs.loadDemoButton.addEventListener("click", loadDemo); refs.startPauseButton.addEventListener("click", () => { if (!state.match || state.match.result) { return; } if (state.match.phase === "block" && state.match.running) { pauseBlock(); } else { startBlock(); } }); refs.confirmBlockButton.addEventListener("click", () => { if (!state.match || state.match.result) { return; } if (state.match.phase === "cube") { return; } syncRunningTimers(); if (!state.match.awaitingBlockClosure && state.match.phase === "block") { requestBlockClosure("Clôture manuelle du block demandée par l'arbitre."); } else { openCubePhase(); } }); refs.moveActionButton.addEventListener("click", () => { if (!state.match || state.match.phase !== "block" || !state.match.running) { return; } if (state.match.doubleCoup.step === 1) { registerFreeDoubleMove(); return; } const isSecondDoubleMove = state.match.doubleCoup.step === 2; registerCountedMove({ label: isSecondDoubleMove ? "Deuxième coup du double coup enregistré." : "Coup compté enregistré.", source: isSecondDoubleMove ? "double" : "standard", }); }); refs.reliefMoveButton.addEventListener("click", () => { if (!state.match || state.match.phase !== "block") { return; } registerReliefMove(); }); refs.timeoutMoveButton.addEventListener("click", () => { if (!state.match || state.match.phase !== "block") { return; } registerMoveTimeout(false); }); refs.switchTurnButton.addEventListener("click", () => { if (!state.match || state.match.result) { return; } state.match.currentTurn = opponentOf(state.match.currentTurn); state.match.moveRemainingMs = MOVE_LIMIT_MS; logEvent("Trait corrigé manuellement par l'arbitre."); persistState(); render(); }); refs.startCubeButton.addEventListener("click", startCubePhase); refs.captureWhiteCubeButton.addEventListener("click", () => captureCubeTime("white")); refs.captureBlackCubeButton.addEventListener("click", () => captureCubeTime("black")); refs.applyCubeButton.addEventListener("click", applyCubeOutcome); refs.redoCubeButton.addEventListener("click", replayCubePhase); refs.resetMatchButton.addEventListener("click", resetMatch); refs.whiteWinButton.addEventListener("click", () => setResult("white")); refs.blackWinButton.addEventListener("click", () => setResult("black")); refs.drawStopButton.addEventListener("click", () => setResult("stopped")); } function createMatch(config) { const quota = PRESETS[config.preset].quota; const match = { config, phase: "block", running: false, blockNumber: 1, currentTurn: "white", blockRemainingMs: BLOCK_DURATION_MS, moveRemainingMs: MOVE_LIMIT_MS, 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, }, round: 1, history: [], }, doubleCoup: { eligible: false, step: 0, starter: "white", }, history: [], }; logEvent( `Match créé en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`, match, ); logEvent("Les Blancs commencent le block 1.", match); return match; } function restoreState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) { return null; } const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } function hydrateRecoveredState() { if (!state.match) { return; } if (state.match.running) { state.match.running = false; state.match.awaitingBlockClosure = false; state.match.closureReason = ""; state.match.moveRemainingMs = Math.max(state.match.moveRemainingMs, 0); } if (state.match.cube?.running) { state.match.cube.running = false; state.match.cube.startedAt = null; } } function renderSetupSummary() { const formData = new FormData(refs.setupForm); const mode = formData.get("mode") || "twice"; const preset = formData.get("preset") || "fast"; const quota = PRESETS[preset].quota; refs.setupSummary.innerHTML = ` ${MODES[mode].label} ${PRESETS[preset].description} Chaque block dure 180 secondes et chaque coup est limité à 20 secondes. ${ mode === "time" ? "Chronos cumulés de 10 minutes par joueur, ajustés par les temps cube avec plafond de 120 s pris en compte." : "Le gagnant du cube ouvre le block suivant, avec double coup éventuel selon la règle V2." } Quota actif : ${quota} coups par joueur et par block. `; refs.heroModeHint.textContent = MODES[mode].subtitle; } function startTicker() { window.setInterval(() => { if (!state.match || state.match.result) { return; } syncRunningTimers(); render(); }, 100); } function startBlock() { if (!state.match || state.match.result) { return; } if (state.match.phase !== "block") { return; } state.match.running = true; state.match.awaitingBlockClosure = false; state.match.closureReason = ""; state.lastTickAt = Date.now(); const intro = state.match.blockNumber === 1 && state.match.moves.white === 0 && state.match.moves.black === 0 ? "Block 1 démarré." : `Block ${state.match.blockNumber} relancé.`; logEvent(intro); persistState(); render(); } function pauseBlock() { if (!state.match || !state.match.running) { return; } syncRunningTimers(); state.match.running = false; logEvent(`Block ${state.match.blockNumber} mis en pause.`); persistState(); render(); } function syncRunningTimers() { if (!state.match || !state.match.running || state.match.phase !== "block") { state.lastTickAt = Date.now(); return; } const now = Date.now(); const delta = now - (state.lastTickAt || now); state.lastTickAt = now; if (delta <= 0) { return; } state.match.blockRemainingMs = Math.max(0, state.match.blockRemainingMs - delta); state.match.moveRemainingMs = Math.max(0, state.match.moveRemainingMs - delta); if (state.match.clocks) { state.match.clocks[state.match.currentTurn] -= delta; } if (state.match.blockRemainingMs === 0) { requestBlockClosure("Les 180 secondes du block sont écoulées."); return; } if (state.match.moveRemainingMs === 0) { registerMoveTimeout(true); } } function requestBlockClosure(reason) { if (!state.match || state.match.awaitingBlockClosure) { return; } state.match.running = false; state.match.awaitingBlockClosure = true; state.match.closureReason = reason; state.match.moveRemainingMs = MOVE_LIMIT_MS; logEvent(`${reason} Vérifier un éventuel échec à parer avant la phase cube.`); persistState(); render(); } function openCubePhase() { if (!state.match) { return; } state.match.phase = "cube"; state.match.running = false; state.match.awaitingBlockClosure = false; state.match.closureReason = ""; state.match.cube.number = pickCubeNumber(); state.match.cube.running = false; state.match.cube.startedAt = null; state.match.cube.elapsedMs = 0; state.match.cube.times = { white: null, black: null }; state.match.cube.round = 1; logEvent(`Phase cube ouverte. Cube n°${state.match.cube.number} désigné par l'application.`); persistState(); render(); } function startCubePhase() { if (!state.match || state.match.phase !== "cube" || state.match.result) { return; } if (!state.match.cube.number) { state.match.cube.number = pickCubeNumber(); } state.match.cube.running = true; state.match.cube.startedAt = Date.now(); state.match.cube.elapsedMs = 0; logEvent(`Phase cube démarrée sur le cube n°${state.match.cube.number}.`); persistState(); render(); } function captureCubeTime(color) { if (!state.match || state.match.phase !== "cube" || !state.match.cube.running) { return; } if (state.match.cube.times[color] !== null) { return; } const elapsedMs = Date.now() - state.match.cube.startedAt; state.match.cube.times[color] = elapsedMs; state.match.cube.elapsedMs = Math.max(state.match.cube.elapsedMs, elapsedMs); logEvent(`${playerLabel(color)} arrêté en ${formatStopwatch(elapsedMs)} sur la phase cube.`); if ( state.match.cube.times.white !== null && state.match.cube.times.black !== null ) { state.match.cube.running = false; state.match.cube.history.push({ blockNumber: state.match.blockNumber, number: state.match.cube.number, white: state.match.cube.times.white, black: state.match.cube.times.black, }); } persistState(); render(); } function applyCubeOutcome() { if (!state.match || state.match.phase !== "cube") { return; } const { white, black } = state.match.cube.times; if (white === null || black === null) { return; } if (state.match.config.mode === "twice") { if (white === black) { logEvent("Égalité parfaite sur la phase cube : le règlement impose de rejouer la phase."); render(); return; } const winner = white < black ? "white" : "black"; prepareNextTwiceBlock(winner); } else { applyTimeAdjustments(white, black); prepareNextTimeBlock(); } persistState(); render(); } function prepareNextTwiceBlock(winner) { if (!state.match) { return; } const hadDouble = state.match.lastMover !== winner && state.match.lastMover !== null; logEvent(`${playerLabel(winner)} gagne la phase cube et commencera le block suivant.`); state.match.blockNumber += 1; state.match.phase = "block"; state.match.running = false; state.match.blockRemainingMs = BLOCK_DURATION_MS; state.match.moveRemainingMs = MOVE_LIMIT_MS; state.match.moves = { white: 0, black: 0 }; state.match.currentTurn = winner; state.match.doubleCoup = { eligible: hadDouble, step: hadDouble ? 1 : 0, starter: winner, }; state.match.cube.running = false; state.match.cube.startedAt = null; state.match.cube.elapsedMs = 0; state.match.cube.times = { white: null, black: null }; state.match.cube.number = null; if (hadDouble) { logEvent( `Double coup disponible pour ${playerLabel( winner, )} : premier coup gratuit sans échec, puis second coup compté.`, ); } else { logEvent("Aucun double coup accordé sur ce départ."); } } function prepareNextTimeBlock() { if (!state.match) { return; } state.match.blockNumber += 1; state.match.phase = "block"; state.match.running = false; state.match.blockRemainingMs = BLOCK_DURATION_MS; state.match.moveRemainingMs = MOVE_LIMIT_MS; state.match.moves = { white: 0, black: 0 }; state.match.doubleCoup = { eligible: false, step: 0, starter: state.match.currentTurn, }; state.match.cube.running = false; state.match.cube.startedAt = null; state.match.cube.elapsedMs = 0; state.match.cube.times = { white: null, black: null }; state.match.cube.number = null; logEvent( `Block ${state.match.blockNumber} prêt. Le trait est conservé en mode Time : ${playerLabel( state.match.currentTurn, )} reprend.`, ); } function applyTimeAdjustments(whiteTime, blackTime) { if (!state.match || !state.match.clocks) { return; } const blockType = getTimeBlockType(state.match.blockNumber); const cappedWhite = Math.min(whiteTime, CUBE_TIME_CAP_MS); const cappedBlack = Math.min(blackTime, CUBE_TIME_CAP_MS); if (blockType === "minus") { state.match.clocks.white -= cappedWhite; state.match.clocks.black -= cappedBlack; logEvent( `Bloc - : ${formatStopwatch(cappedWhite)} retiré au chrono Blanc, ${formatStopwatch( cappedBlack, )} retiré au chrono Noir.`, ); } else { state.match.clocks.white += cappedBlack; state.match.clocks.black += cappedWhite; logEvent( `Bloc + : ${formatStopwatch(cappedBlack)} ajouté au chrono Blanc, ${formatStopwatch( cappedWhite, )} ajouté au chrono Noir.`, ); } } function replayCubePhase() { if (!state.match || state.match.phase !== "cube") { return; } state.match.cube.running = false; state.match.cube.startedAt = null; state.match.cube.elapsedMs = 0; state.match.cube.times = { white: null, black: null }; state.match.cube.round += 1; logEvent(`Phase cube relancée (tentative ${state.match.cube.round}).`); persistState(); render(); } function registerCountedMove({ label, source }) { if (!state.match || state.match.phase !== "block") { return; } const actor = state.match.currentTurn; if (state.match.moves[actor] >= state.match.quota) { logEvent( `${playerLabel(actor)} a déjà atteint son quota de coups pour ce block. Utiliser un coup hors quota si nécessaire.`, ); render(); return; } state.match.moves[actor] += 1; state.match.lastMover = actor; state.match.moveRemainingMs = MOVE_LIMIT_MS; if (source === "double") { state.match.doubleCoup.step = 0; } logEvent( `${label} ${playerLabel(actor)} est à ${state.match.moves[actor]} / ${state.match.quota}.`, ); state.match.currentTurn = opponentOf(actor); verifyQuotaCompletion(); persistState(); render(); } function registerFreeDoubleMove() { if (!state.match || state.match.doubleCoup.step !== 1) { return; } const actor = state.match.currentTurn; state.match.lastMover = actor; state.match.moveRemainingMs = MOVE_LIMIT_MS; state.match.doubleCoup.step = 2; logEvent( `Premier coup gratuit du double coup joué par ${playerLabel( actor, )}. Rappel arbitre : il ne doit pas donner échec.`, ); persistState(); render(); } function registerReliefMove() { if (!state.match) { return; } const actor = state.match.currentTurn; state.match.lastMover = actor; state.match.currentTurn = opponentOf(actor); state.match.moveRemainingMs = MOVE_LIMIT_MS; logEvent( `Coup hors quota joué par ${playerLabel( actor, )} pour gérer une situation arbitrale ou parer un échec.`, ); persistState(); render(); } function registerMoveTimeout(fromTimer) { if (!state.match) { return; } const actor = state.match.currentTurn; if (state.match.doubleCoup.step === 1) { state.match.doubleCoup.step = 0; state.match.moveRemainingMs = MOVE_LIMIT_MS; logEvent( `Dépassement sur le premier coup gratuit de ${playerLabel( actor, )} : le double coup est annulé, mais le joueur conserve son coup normal.`, ); persistState(); render(); return; } if (state.match.moves[actor] < state.match.quota) { state.match.moves[actor] += 1; } if (state.match.doubleCoup.step === 2) { state.match.doubleCoup.step = 0; } state.match.currentTurn = opponentOf(actor); state.match.moveRemainingMs = MOVE_LIMIT_MS; logEvent( `${fromTimer ? "Temps par coup écoulé." : "Dépassement manuel 20 s."} ${playerLabel( actor, )} perd son coup, qui est comptabilisé dans le quota.`, ); verifyQuotaCompletion(); persistState(); render(); } function verifyQuotaCompletion() { if (!state.match) { return; } if ( state.match.moves.white >= state.match.quota && state.match.moves.black >= state.match.quota ) { requestBlockClosure("Les deux joueurs ont atteint leur quota de coups."); } } function setResult(winner) { if (!state.match || state.match.result) { return; } syncRunningTimers(); state.match.running = false; state.match.result = winner; if (winner === "white" || winner === "black") { logEvent(`${playerLabel(winner)} remporte la partie par décision arbitrale, mat ou abandon.`); } else { logEvent("La partie est arrêtée par abandon ou décision arbitrale."); } persistState(); render(); } function resetMatch() { localStorage.removeItem(STORAGE_KEY); state.match = null; refs.livePanel.classList.add("hidden"); render(); } function pickCubeNumber() { return Math.floor(Math.random() * 4) + 1; } function render() { const match = state.match; refs.livePanel.classList.toggle("hidden", !match); if (!match) { return; } refs.matchTitle.textContent = match.config.matchLabel; refs.modeLabel.textContent = MODES[match.config.mode].label; refs.phaseBadge.textContent = renderPhaseBadge(match); refs.blockLabel.textContent = `Block ${match.blockNumber}`; refs.blockMeta.textContent = renderBlockMeta(match); refs.blockTimer.textContent = formatClock(match.blockRemainingMs); refs.moveTimer.textContent = formatClock(match.moveRemainingMs); refs.blockStatusText.textContent = renderBlockStatus(match); refs.turnLabel.textContent = `Trait : ${playerLabel(match.currentTurn)}`; refs.whiteNameDisplay.textContent = match.config.whiteName; refs.blackNameDisplay.textContent = match.config.blackName; refs.whiteMoveCount.textContent = `${match.moves.white} / ${match.quota} coups`; refs.blackMoveCount.textContent = `${match.moves.black} / ${match.quota} coups`; refs.whiteClockLabel.textContent = renderClockLabel(match, "white"); refs.blackClockLabel.textContent = renderClockLabel(match, "black"); refs.whiteCubeLabel.textContent = renderLastCubeLabel(match, "white"); refs.blackCubeLabel.textContent = renderLastCubeLabel(match, "black"); refs.whiteCard.classList.toggle("active", match.currentTurn === "white" && !match.result); refs.blackCard.classList.toggle("active", match.currentTurn === "black" && !match.result); refs.startPauseButton.textContent = match.phase === "block" && match.running ? "Mettre en pause" : "Démarrer le block"; refs.startPauseButton.disabled = match.phase !== "block" || Boolean(match.result); refs.confirmBlockButton.disabled = match.phase !== "block" || Boolean(match.result); refs.confirmBlockButton.textContent = match.awaitingBlockClosure ? "Ouvrir la phase cube" : "Clore le block"; refs.moveActionButton.disabled = match.phase !== "block" || !match.running || Boolean(match.result); refs.moveActionButton.textContent = renderMoveButtonLabel(match); refs.reliefMoveButton.disabled = match.phase !== "block" || Boolean(match.result); refs.timeoutMoveButton.disabled = match.phase !== "block" || Boolean(match.result); refs.switchTurnButton.disabled = Boolean(match.result); refs.contextNotice.innerHTML = `Contexte du moment
${renderContextNotice( match, )}`; refs.doubleCard.innerHTML = renderDoubleCard(match); refs.cubeNumber.textContent = match.cube.number || "-"; refs.cubeElapsed.textContent = renderCubeElapsed(match); refs.cubeStatusText.textContent = renderCubeStatus(match); refs.startCubeButton.disabled = match.phase !== "cube" || match.cube.running || Boolean(match.result); refs.captureWhiteCubeButton.disabled = match.phase !== "cube" || !match.cube.running || match.cube.times.white !== null; refs.captureBlackCubeButton.disabled = match.phase !== "cube" || !match.cube.running || match.cube.times.black !== null; refs.applyCubeButton.disabled = match.phase !== "cube" || match.cube.times.white === null || match.cube.times.black === null || Boolean(match.result); refs.redoCubeButton.disabled = match.phase !== "cube" || Boolean(match.result); refs.whiteCubeResult.textContent = match.cube.times.white === null ? "--" : formatStopwatch(match.cube.times.white); refs.blackCubeResult.textContent = match.cube.times.black === null ? "--" : formatStopwatch(match.cube.times.black); refs.whiteCubeCap.textContent = renderCubeCap(match.cube.times.white); refs.blackCubeCap.textContent = renderCubeCap(match.cube.times.black); refs.historyList.innerHTML = [...match.history] .slice(-18) .reverse() .map( (item) => `
  • ${escapeHtml(item.time)} ${escapeHtml(item.message)}
  • `, ) .join(""); } function renderPhaseBadge(match) { if (match.result) { return "Partie terminée"; } if (match.phase === "cube") { return "Phase cube"; } if (match.awaitingBlockClosure) { return "Fin de block à confirmer"; } return match.running ? "Block en cours" : "Block prêt"; } function renderBlockMeta(match) { const preset = PRESETS[match.config.preset]; if (match.config.mode === "time") { const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; return `${type} • ${preset.label} • ${preset.quota} coups par joueur • Le trait est conservé après le cube.`; } return `${preset.label} • ${preset.quota} coups par joueur • Le gagnant du cube démarrera le block suivant.`; } function renderBlockStatus(match) { if (match.result) { return "La rencontre est archivée dans l'historique."; } if (match.phase === "cube") { return "Le jeu d'échecs est suspendu, aucun coup n'est autorisé."; } if (match.awaitingBlockClosure) { return match.closureReason; } return match.running ? "Le block tourne. Utiliser les commandes d'arbitrage à chaque coup." : "Le block est prêt, les chronos sont arrêtés."; } function renderClockLabel(match, color) { if (!match.clocks) { return "Mode Twice : pas de chrono cumulé."; } return `Chrono cumulé : ${formatSignedClock(match.clocks[color])}`; } function renderLastCubeLabel(match, color) { const last = match.cube.history.at(-1); if (!last) { return "Dernière phase cube : --"; } return `Dernière phase cube : ${formatStopwatch(last[color])}`; } function renderMoveButtonLabel(match) { if (match.doubleCoup.step === 1) { return "1er coup gratuit du double"; } if (match.doubleCoup.step === 2) { return "2e coup du double"; } return "Coup compté"; } function renderContextNotice(match) { if (match.result) { return "Le match est terminé. L'historique conserve les grandes étapes pour une reprise ultérieure."; } if (match.phase === "cube") { if (match.config.mode === "twice") { return "Le gagnant du cube déterminera le départ du block suivant. En cas d'égalité parfaite, la phase doit être rejouée."; } const type = getTimeBlockType(match.blockNumber) === "minus" ? "Bloc -" : "Bloc +"; return `${type} : le temps cube sera ${ type === "Bloc -" ? "retiré du chrono du joueur concerné" : "ajouté au chrono adverse" }, avec plafond pris en compte de 120 secondes.`; } if (match.awaitingBlockClosure) { return "Le block est théoriquement terminé. Si un roi est en échec, jouer les coups nécessaires hors quota, puis ouvrir la phase cube."; } if (match.config.mode === "time") { return "Mode Time : la partie ne se termine jamais au temps. Les chronos cumulés servent de ressource stratégique, mais seule la victoire par mat ou abandon compte."; } return "Mode Twice : surveiller la fin du block, la victoire en cube, et la règle du double coup V2 pour le départ suivant."; } function renderDoubleCard(match) { if (match.config.mode !== "twice") { return "Mode TimeAucun système de priorité ou de double coup n'existe dans cette variante."; } if (!match.doubleCoup.eligible && match.doubleCoup.step === 0) { return "Double coupPas de double coup actif pour ce départ."; } if (match.doubleCoup.step === 1) { return `Double coup actif pour ${escapeHtml( playerLabel(match.doubleCoup.starter), )}Premier coup gratuit, non compté, sans échec autorisé. Utiliser le bouton principal pour l'enregistrer.`; } if (match.doubleCoup.step === 2) { return `Deuxième coup du doubleCe coup compte comme premier coup du block. Capture autorisée uniquement sur pion ou pièce mineure. L'échec redevient autorisé.`; } return "Double coupLe départ est standard sur ce block."; } function renderCubeElapsed(match) { if (match.phase !== "cube") { return "00:00.0"; } if (match.cube.running) { const live = Date.now() - match.cube.startedAt; return formatStopwatch(live); } if (match.cube.elapsedMs > 0) { return formatStopwatch(match.cube.elapsedMs); } return "00:00.0"; } function renderCubeStatus(match) { if (match.phase !== "cube") { return "La phase cube se déclenche à la fin du block."; } if (match.cube.running) { return "Les deux joueurs sont en résolution. Arrêter chaque côté au moment de la fin."; } if (match.cube.times.white !== null && match.cube.times.black !== null) { if (match.config.mode === "twice" && match.cube.times.white === match.cube.times.black) { return "Égalité parfaite : le règlement impose de rejouer immédiatement la phase cube."; } return "Les deux temps sont saisis. Appliquer l'issue pour préparer le block suivant."; } return "Lancer la phase cube puis capturer chaque fin de résolution."; } function renderCubeCap(time) { if (time === null) { return ""; } if (!state.match || state.match.config.mode !== "time") { return ""; } if (time <= CUBE_TIME_CAP_MS) { return "plafond non atteint"; } return `pris en compte : ${formatStopwatch(CUBE_TIME_CAP_MS)}`; } function formatClock(ms) { const totalSeconds = 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 absolute = Math.abs(ms); const totalSeconds = Math.floor(absolute / 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(ms / 1000); const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0"); const seconds = String(totalSeconds % 60).padStart(2, "0"); const tenths = Math.floor((ms % 1000) / 100); return `${minutes}:${seconds}.${tenths}`; } function getTimeBlockType(blockNumber) { return blockNumber % 2 === 1 ? "minus" : "plus"; } function playerLabel(color) { if (!state.match) { return color === "white" ? "Blanc" : "Noir"; } return color === "white" ? state.match.config.whiteName : state.match.config.blackName; } function opponentOf(color) { return color === "white" ? "black" : "white"; } function logEvent(message, targetMatch = state.match) { if (!targetMatch) { return; } const now = new Date(); targetMatch.history.push({ message, time: now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit", }), }); } function persistState() { if (!state.match) { localStorage.removeItem(STORAGE_KEY); return; } localStorage.setItem(STORAGE_KEY, JSON.stringify(state.match)); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function sanitizeText(value) { return String(value || "") .replace(/[<>]/g, "") .trim() .replace(/\s+/g, " "); } function loadDemo() { refs.setupForm.matchLabel.value = "Démo officielle ChessCubing"; refs.setupForm.mode.value = "twice"; refs.setupForm.preset.value = "freeze"; refs.setupForm.whiteName.value = "Nora"; refs.setupForm.blackName.value = "Léo"; refs.setupForm.arbiterName.value = "Arbitre demo"; refs.setupForm.eventName.value = "Session tablette"; refs.setupForm.notes.value = "8 cubes vérifiés, variantes prêtes, tirage au sort effectué."; renderSetupSummary(); }