Automatise le passage au cube et rend les temps configurables

This commit is contained in:
2026-04-12 12:26:57 +02:00
parent c10efa872c
commit d01e132782
5 changed files with 198 additions and 131 deletions

243
app.js
View File

@@ -2,8 +2,8 @@ const PAGE = document.body.dataset.page;
const STORAGE_KEY = "chesscubing-arena-state-v2";
const WINDOW_NAME_KEY = "chesscubing-arena-state-v2:";
const BLOCK_DURATION_MS = 180000;
const MOVE_LIMIT_MS = 20000;
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;
@@ -40,7 +40,9 @@ let match = readStoredMatch();
let dirty = false;
if (match) {
normalizeRecoveredMatch(match);
if (normalizeRecoveredMatch(match)) {
dirty = true;
}
if (syncRunningState(match)) {
dirty = true;
}
@@ -90,6 +92,8 @@ function initSetupPage() {
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."
@@ -98,7 +102,7 @@ function initSetupPage() {
summary.innerHTML = `
<strong>${MODES[mode].label}</strong>
<span>${PRESETS[preset].description}</span>
<span>Chaque block dure 180 secondes, chaque coup est limite a 20 secondes.</span>
<span>Temps configures : block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.</span>
<span>${timeImpact}</span>
<span>Quota actif : ${quota} coups par joueur.</span>
`;
@@ -116,9 +120,7 @@ function initSetupPage() {
? resultText(match)
: match.phase === "cube"
? "Page cube prete"
: match.awaitingBlockClosure
? "Fin de block a confirmer"
: "Page chrono prete";
: "Page chrono prete";
resumeCard.innerHTML = `
<strong>${escapeHtml(match.config.matchLabel)}</strong>
@@ -155,6 +157,8 @@ function initSetupPage() {
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")),
@@ -242,14 +246,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) {
pauseBlock(match);
} else {
@@ -266,14 +262,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
requestBlockClosure(match, "Cloture manuelle du block demandee par l'arbitre.");
dirty = true;
render();
@@ -297,7 +285,7 @@ function initChronoPage() {
}
match.currentTurn = opponentOf(match.currentTurn);
match.moveRemainingMs = MOVE_LIMIT_MS;
match.moveRemainingMs = getMoveLimitMs(match);
logEvent(match, "Trait corrige manuellement par l'arbitre.");
dirty = true;
render();
@@ -336,17 +324,6 @@ function initChronoPage() {
syncRunningState(match);
if (match.awaitingBlockClosure) {
if (match.currentTurn !== color) {
return;
}
registerReliefMove(match);
dirty = true;
render();
return;
}
if (!match.running || match.currentTurn !== color) {
return;
}
@@ -376,14 +353,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
openCubePhase(match);
dirty = true;
persistMatch();
navigateTo("cube.html");
return;
}
if (match.running) {
pauseBlock(match);
} else {
@@ -421,19 +390,6 @@ function initChronoPage() {
return;
}
if (match.awaitingBlockClosure) {
if (active) {
button.textContent = "Coup hors quota";
button.disabled = false;
hint.textContent = "A utiliser seulement si un roi est encore en echec avant la page cube.";
} else {
button.textContent = "En attente";
button.disabled = true;
hint.textContent = "Attente de la reponse adverse ou du passage a la page cube.";
}
return;
}
if (!match.running) {
button.textContent = "Block en pause";
button.disabled = true;
@@ -474,10 +430,17 @@ function initChronoPage() {
return;
}
if (!match.result && match.phase === "cube") {
persistMatch();
navigateTo("cube.html");
return;
}
refs.title.textContent = match.config.matchLabel;
refs.subtitle.textContent = `Block ${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";
@@ -487,20 +450,13 @@ function initChronoPage() {
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.awaitingBlockClosure) {
refs.centerLabel.textContent = "Transition";
refs.centerValue.textContent = "Fin de block";
refs.spineLabel.textContent = "Page suivante";
refs.spineHeadline.textContent = "Passer a la page cube";
refs.spineText.textContent = `${match.closureReason} Jouez encore des coups hors quota si necessaire, puis ouvrez la page cube.`;
refs.primaryButton.textContent = "Ouvrir la page cube";
refs.arbiterStatus.textContent = refs.spineText.textContent;
} else if (match.running) {
refs.centerLabel.textContent = "Trait";
refs.centerValue.textContent = playerName(match, match.currentTurn);
refs.spineLabel.textContent = "Chrono en cours";
refs.spineHeadline.textContent = `Block ${match.blockNumber} actif`;
refs.spineText.textContent = "Chaque joueur tape sur sa grande zone quand son coup est termine.";
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 = `Block en cours. Joueur au trait : ${playerName(match, match.currentTurn)}.`;
} else {
@@ -508,14 +464,13 @@ function initChronoPage() {
refs.centerValue.textContent = playerName(match, match.currentTurn);
refs.spineLabel.textContent = "Pret";
refs.spineHeadline.textContent = `Block ${match.blockNumber}`;
refs.spineText.textContent = "Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs.";
refs.spineText.textContent =
"Demarrez le block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
refs.primaryButton.textContent = "Demarrer le block";
refs.arbiterStatus.textContent = `Block pret. ${playerName(match, match.currentTurn)} commencera.`;
}
refs.arbiterCloseBlockButton.textContent = match.awaitingBlockClosure
? "Ouvrir la page cube"
: "Clore le block";
refs.arbiterCloseBlockButton.textContent = "Passer au cube";
renderZone("black");
renderZone("white");
@@ -799,15 +754,15 @@ function initCubePage() {
function createMatch(config) {
const quota = PRESETS[config.preset].quota;
const newMatch = {
schemaVersion: 2,
schemaVersion: 3,
config,
phase: "block",
running: false,
lastTickAt: null,
blockNumber: 1,
currentTurn: "white",
blockRemainingMs: BLOCK_DURATION_MS,
moveRemainingMs: MOVE_LIMIT_MS,
blockRemainingMs: config.blockDurationMs,
moveRemainingMs: config.moveLimitMs,
quota,
moves: {
white: 0,
@@ -848,7 +803,10 @@ function createMatch(config) {
history: [],
};
logEvent(newMatch, `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}.`);
logEvent(
newMatch,
`Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`,
);
logEvent(newMatch, "Les Blancs commencent le block 1.");
return newMatch;
}
@@ -863,7 +821,7 @@ function readStoredMatch() {
}
const parsed = JSON.parse(raw);
if (!parsed || parsed.schemaVersion !== 2) {
if (!parsed || !isSupportedSchemaVersion(parsed.schemaVersion)) {
return fromWindowName;
}
@@ -874,8 +832,44 @@ function readStoredMatch() {
}
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) {
@@ -892,6 +886,7 @@ function normalizeRecoveredMatch(storedMatch) {
round: 1,
history: [],
};
changed = true;
}
if (!storedMatch.cube.playerState) {
@@ -899,6 +894,7 @@ function normalizeRecoveredMatch(storedMatch) {
white: createCubePlayerState(),
black: createCubePlayerState(),
};
changed = true;
}
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
@@ -911,7 +907,15 @@ function normalizeRecoveredMatch(storedMatch) {
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) {
@@ -938,7 +942,10 @@ function syncRunningState(storedMatch) {
}
if (storedMatch.blockRemainingMs === 0) {
requestBlockClosure(storedMatch, "Les 180 secondes du block sont ecoulees.");
requestBlockClosure(
storedMatch,
`Le temps de block ${formatClock(getBlockDurationMs(storedMatch))} est ecoule.`,
);
} else if (storedMatch.moveRemainingMs === 0) {
registerMoveTimeout(storedMatch, true);
}
@@ -974,19 +981,20 @@ function pauseBlock(storedMatch) {
}
function requestBlockClosure(storedMatch, reason) {
if (!storedMatch || storedMatch.awaitingBlockClosure) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
}
storedMatch.running = false;
storedMatch.lastTickAt = null;
storedMatch.awaitingBlockClosure = true;
storedMatch.closureReason = reason;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(storedMatch, `${reason} Verifier si des coups hors quota sont encore necessaires avant la page cube.`);
storedMatch.awaitingBlockClosure = false;
storedMatch.closureReason = "";
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(storedMatch, `${reason} Passage automatique vers la page cube.`);
openCubePhase(storedMatch, reason);
}
function openCubePhase(storedMatch) {
function openCubePhase(storedMatch, reason = "") {
if (!storedMatch) {
return;
}
@@ -1006,7 +1014,10 @@ function openCubePhase(storedMatch) {
black: createCubePlayerState(),
};
storedMatch.cube.round = 1;
logEvent(storedMatch, `Page cube ouverte. Cube n${storedMatch.cube.number} designe.`);
logEvent(
storedMatch,
`${reason ? `${reason} ` : ""}Page cube ouverte. Cube n${storedMatch.cube.number} designe.`,
);
}
function startCubeTimer(storedMatch, color) {
@@ -1099,8 +1110,8 @@ function prepareNextTwiceBlock(storedMatch, winner) {
storedMatch.phase = "block";
storedMatch.running = false;
storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 };
storedMatch.currentTurn = winner;
storedMatch.doubleCoup = {
@@ -1130,8 +1141,8 @@ function prepareNextTimeBlock(storedMatch) {
storedMatch.phase = "block";
storedMatch.running = false;
storedMatch.lastTickAt = null;
storedMatch.blockRemainingMs = BLOCK_DURATION_MS;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.blockRemainingMs = getBlockDurationMs(storedMatch);
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.moves = { white: 0, black: 0 };
storedMatch.doubleCoup = {
eligible: false,
@@ -1219,7 +1230,7 @@ function registerCountedMove(storedMatch, { source }) {
storedMatch.moves[actor] += 1;
storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
if (source === "double") {
storedMatch.doubleCoup.step = 0;
@@ -1241,7 +1252,7 @@ function registerFreeDoubleMove(storedMatch) {
const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
storedMatch.doubleCoup.step = 2;
logEvent(
storedMatch,
@@ -1249,21 +1260,6 @@ function registerFreeDoubleMove(storedMatch) {
);
}
function registerReliefMove(storedMatch) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
}
const actor = storedMatch.currentTurn;
storedMatch.lastMover = actor;
storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
logEvent(
storedMatch,
`Coup hors quota joue par ${playerName(storedMatch, actor)} avant la page cube.`,
);
}
function registerMoveTimeout(storedMatch, automatic) {
if (!storedMatch || storedMatch.phase !== "block") {
return;
@@ -1273,7 +1269,7 @@ function registerMoveTimeout(storedMatch, automatic) {
if (storedMatch.doubleCoup.step === 1) {
storedMatch.doubleCoup.step = 0;
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(
storedMatch,
`Depassement sur le premier coup gratuit de ${playerName(storedMatch, actor)} : le double coup est annule.`,
@@ -1290,10 +1286,10 @@ function registerMoveTimeout(storedMatch, automatic) {
}
storedMatch.currentTurn = opponentOf(actor);
storedMatch.moveRemainingMs = MOVE_LIMIT_MS;
storedMatch.moveRemainingMs = getMoveLimitMs(storedMatch);
logEvent(
storedMatch,
`${automatic ? "Temps par coup ecoule." : "Depassement manuel 20 s."} ${playerName(
`${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.`,
@@ -1455,6 +1451,8 @@ 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");
@@ -1482,6 +1480,16 @@ function setRadioValue(form, name, value) {
}
}
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;
}
@@ -1556,12 +1564,33 @@ function readWindowNameState() {
const raw = window.name.slice(WINDOW_NAME_KEY.length);
const parsed = JSON.parse(raw);
return parsed && parsed.schemaVersion === 2 ? parsed : null;
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,