Files
chesscubing/app.js

1130 lines
33 KiB
JavaScript

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 = `
<strong>${MODES[mode].label}</strong>
<span>${PRESETS[preset].description}</span>
<span>Chaque block dure 180 secondes et chaque coup est limité à 20 secondes.</span>
<span>${
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."
}</span>
<span>Quota actif : ${quota} coups par joueur et par block.</span>
`;
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 = `<strong>Contexte du moment</strong><br />${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) => `
<li>
<small>${escapeHtml(item.time)}</small>
<span>${escapeHtml(item.message)}</span>
</li>
`,
)
.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 "<strong>Mode Time</strong><span>Aucun système de priorité ou de double coup n'existe dans cette variante.</span>";
}
if (!match.doubleCoup.eligible && match.doubleCoup.step === 0) {
return "<strong>Double coup</strong><span>Pas de double coup actif pour ce départ.</span>";
}
if (match.doubleCoup.step === 1) {
return `<strong>Double coup actif pour ${escapeHtml(
playerLabel(match.doubleCoup.starter),
)}</strong><span>Premier coup gratuit, non compté, sans échec autorisé. Utiliser le bouton principal pour l'enregistrer.</span>`;
}
if (match.doubleCoup.step === 2) {
return `<strong>Deuxième coup du double</strong><span>Ce coup compte comme premier coup du block. Capture autorisée uniquement sur pion ou pièce mineure. L'échec redevient autorisé.</span>`;
}
return "<strong>Double coup</strong><span>Le départ est standard sur ce block.</span>";
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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();
}