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) => `