1130 lines
33 KiB
JavaScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function sanitizeText(value) {
|
|
return String(value || "")
|
|
.replace(/[<>]/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
function loadDemo() {
|
|
refs.setupForm.matchLabel.value = "Démo officielle ChessCubing";
|
|
refs.setupForm.mode.value = "twice";
|
|
refs.setupForm.preset.value = "freeze";
|
|
refs.setupForm.whiteName.value = "Nora";
|
|
refs.setupForm.blackName.value = "Léo";
|
|
refs.setupForm.arbiterName.value = "Arbitre demo";
|
|
refs.setupForm.eventName.value = "Session tablette";
|
|
refs.setupForm.notes.value = "8 cubes vérifiés, variantes prêtes, tirage au sort effectué.";
|
|
renderSetupSummary();
|
|
}
|