const PAGE = document.body.dataset.page;
const SETUP_PAGE = "application.html";
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 partie.",
},
freeze: {
label: "FREEZE",
quota: 8,
description: "8 coups par joueur et par partie.",
},
masters: {
label: "MASTERS",
quota: 10,
description: "10 coups par joueur et par partie.",
},
};
const MODES = {
twice: {
label: "ChessCubing Twice",
subtitle: "Le gagnant du cube ouvre la partie suivante.",
},
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 la partie suivante, avec double coup V2 possible.";
summary.innerHTML = `
${MODES[mode].label}
${PRESETS[preset].description}
Temps configures : partie ${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(SETUP_PAGE);
return;
}
const goToCubePage = () => {
dirty = true;
persistMatch();
replaceTo("cube.html");
};
if (!match.result && match.phase === "cube") {
goToCubePage();
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 de la partie demandee par l'arbitre.");
dirty = true;
if (!match.result && match.phase === "cube") {
goToCubePage();
return;
}
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(SETUP_PAGE);
});
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;
if (!match.result && match.phase === "cube") {
goToCubePage();
return;
}
render();
}
function handlePrimaryChronoAction() {
syncRunningState(match);
if (match.result) {
navigateTo(SETUP_PAGE);
return;
}
if (match.phase !== "block") {
goToCubePage();
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 = "Partie en pause";
button.disabled = true;
hint.textContent = active
? "La partie n'a pas encore demarre ou a ete mise 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") {
goToCubePage();
return;
}
refs.title.textContent = match.config.matchLabel;
refs.subtitle.textContent = `Partie ${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 = `Partie ${match.blockNumber} active`;
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 = `Partie 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 = `Partie ${match.blockNumber}`;
refs.spineText.textContent =
"Demarrez la partie, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
refs.primaryButton.textContent = "Demarrer la partie";
refs.arbiterStatus.textContent = `Partie prete. ${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") {
goToCubePage();
return;
}
render();
}, 100);
}
function initCubePage() {
if (!match) {
replaceTo(SETUP_PAGE);
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(SETUP_PAGE);
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(SETUP_PAGE);
});
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 = `Partie ${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 la partie suivante.";
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}, partie ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`,
);
logEvent(newMatch, "Les Blancs commencent la partie 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 partie ${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
? "Partie 1 demarre."
: `Partie ${storedMatch.blockNumber} relance.`,
);
}
function pauseBlock(storedMatch) {
if (!storedMatch || !storedMatch.running) {
return;
}
storedMatch.running = false;
storedMatch.lastTickAt = null;
logEvent(storedMatch, `Partie ${storedMatch.blockNumber} mise 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 la partie suivante.`);
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,
`Partie ${storedMatch.blockNumber} prete. 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 la prochaine partie";
}
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 SETUP_PAGE;
}
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);
}