// World pre-generation controls + live status display. // // We use the canonical config-then-start sequence rather than the all-in-one // `chunky start ` form because the all-in-one // form's argument order varies between Chunky versions, and Brigadier silently // prints the usage hint instead of erroring when it doesn't match. // // Status is parsed from Chunky's own log lines (re-broadcast by console.js as // the `serverlog` custom event) -- no separate polling endpoint is needed. // // Chunky is intentionally only invoked from this panel -- it can punch holes // in chunks if it crashes mid-run, so we don't want it ticking on its own. "use strict"; import { apiJson } from "./api.js"; async function send(cmd) { await apiJson("/api/command", { command: cmd }); } async function startPregen(radius) { await send("chunky world minecraft:overworld"); await send("chunky shape square"); await send("chunky center 0 0"); await send(`chunky radius ${radius}`); await send("chunky start"); } // ─────────── status display ─────────── const els = {}; function setState(label, cssClass) { if (!els.state) return; els.state.textContent = label; els.state.className = "val " + cssClass; applyButtonStates(cssClass); } /// Enable/disable the Start/Pause/Resume/Cancel buttons based on the current /// pregen state. Called whenever setState changes the displayed status. function applyButtonStates(cssClass) { if (!els.btnStart) return; // Map the state CSS class to a logical state name. Default = idle. let s = "idle"; if (cssClass === "pg-state-running") s = "running"; else if (cssClass === "pg-state-paused") s = "paused"; else if (cssClass === "pg-state-cancelling") s = "cancelling"; els.btnStart.disabled = s !== "idle"; els.btnPause.disabled = s !== "running"; els.btnContinue.disabled = s !== "paused"; els.btnCancel.disabled = !(s === "running" || s === "paused"); } function resetMetrics() { if (!els.progressFill) return; els.progressFill.style.width = "0%"; els.progressText.textContent = "--"; els.chunks.textContent = "--"; els.rate.textContent = "--"; els.eta.textContent = "--"; } // Parse a Chunky log line. Returns an object describing what changed, or null // if this line isn't a Chunky message we recognise (or is for a different world). // // Chunky supports one task per world running concurrently, so we narrow our // display to the overworld -- that's the only world the Start button targets, // and it keeps the UI sane if someone kicks off other worlds via raw command. // // Real Chunky lines look roughly like: // "[Chunky] Task running for minecraft:overworld at 0,0. Progress: 12.50% (1234/9876 chunks), 45.20 cps, ETA: 0h 1m 30s" // "[Chunky] Task started for minecraft:overworld." // "[Chunky] Task stopped for minecraft:overworld." // "[Chunky] Task paused for minecraft:overworld." // "[Chunky] No task running." const TARGET_WORLD = "minecraft:overworld"; function parseChunky(text) { if (!/Chunky|chunky/.test(text)) return null; // If a world is named, only react when it's the one we're tracking. // Lines without a world (e.g. "No task running.") fall through. const worldMatch = text.match(/(minecraft:[a-z_]+|the_nether|the_end|overworld)/i); if (worldMatch) { const w = worldMatch[1].toLowerCase(); const normalised = w.startsWith("minecraft:") ? w : `minecraft:${w}`; if (normalised !== TARGET_WORLD) return null; } // State transitions if (/Task started/i.test(text)) return { state: "running" }; if (/Task paused/i.test(text)) return { state: "paused" }; if (/Task (stopped|cancelled|canceled|completed|finished)/i.test(text)) return { state: "idle", clear: true }; if (/No task running/i.test(text)) return { state: "idle", clear: true }; // Progress line: try to extract whatever pieces are present const out = {}; let matched = false; const pct = text.match(/(\d+(?:\.\d+)?)\s*%/); if (pct) { out.percent = parseFloat(pct[1]); matched = true; } const chunks = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*chunks?/i); if (chunks) { out.done = chunks[1].replace(/,/g, ""); out.total = chunks[2].replace(/,/g, ""); matched = true; } const cps = text.match(/(\d+(?:\.\d+)?)\s*cps/i); if (cps) { out.cps = parseFloat(cps[1]); matched = true; } const eta = text.match(/ETA[:\s]+([^,)\n]+?)(?=[,)\n]|$)/i); if (eta) { out.eta = eta[1].trim(); matched = true; } if (matched) { out.state = "running"; return out; } return null; } function applyParsed(p) { if (!els.state) return; if (p.state === "running") setState("Running", "pg-state-running"); if (p.state === "paused") setState("Paused", "pg-state-paused"); if (p.state === "idle") setState("Idle", "pg-state-idle"); if (p.clear) resetMetrics(); if (p.percent != null) { els.progressFill.style.width = `${Math.min(100, p.percent)}%`; els.progressText.textContent = `${p.percent.toFixed(2)}%`; } if (p.done && p.total) { const fmt = n => Number(n).toLocaleString(); els.chunks.textContent = `${fmt(p.done)} / ${fmt(p.total)}`; } if (p.cps != null) els.rate.textContent = `${p.cps.toFixed(1)} chunks/s`; if (p.eta) els.eta.textContent = p.eta; } function setupCollapsible() { // Persist collapsed state per card across reloads via localStorage. document.querySelectorAll(".card.collapsible").forEach(card => { const id = card.id || ""; const storageKey = id ? `bs-collapsed:${id}` : null; const startCollapsed = storageKey && localStorage.getItem(storageKey) === "1"; if (!startCollapsed) card.classList.add("expanded"); const toggle = card.querySelector(".collapsible-toggle"); if (!toggle) return; toggle.addEventListener("click", () => { card.classList.toggle("expanded"); if (storageKey) { localStorage.setItem(storageKey, card.classList.contains("expanded") ? "0" : "1"); } }); }); } export function setupPregen() { setupCollapsible(); els.state = document.getElementById("pgState"); els.progressFill = document.getElementById("pgProgressFill"); els.progressText = document.getElementById("pgProgressText"); els.chunks = document.getElementById("pgChunks"); els.rate = document.getElementById("pgRate"); els.eta = document.getElementById("pgEta"); els.btnStart = document.getElementById("pgStart"); els.btnPause = document.getElementById("pgPause"); els.btnContinue = document.getElementById("pgContinue"); els.btnCancel = document.getElementById("pgCancel"); // Idle by default -- disable everything except Start. applyButtonStates("pg-state-idle"); const radiusInput = document.getElementById("pgRadius"); document.getElementById("pgStart").addEventListener("click", async () => { const r = parseInt(radiusInput.value, 10); if (!Number.isFinite(r) || r < 100) { alert("Enter a radius of at least 100 blocks."); return; } if (r > 20000 && !confirm(`Radius ${r} is large and may take hours. Continue?`)) { return; } try { setState("Starting…", "pg-state-running"); resetMetrics(); await startPregen(r); } catch (e) { setState("Idle", "pg-state-idle"); alert(e.message); } }); document.getElementById("pgPause").addEventListener("click", async () => { try { await send("chunky pause"); } catch (e) { alert(e.message); } }); document.getElementById("pgContinue").addEventListener("click", async () => { try { await send("chunky continue"); setState("Running", "pg-state-running"); } catch (e) { alert(e.message); } }); document.getElementById("pgCancel").addEventListener("click", async () => { if (!confirm("Cancel the current pre-generation run?")) return; try { setState("Cancelling…", "pg-state-cancelling"); await send("chunky cancel"); } catch (e) { alert(e.message); } }); // Subscribe to the shared SSE re-broadcast from console.js document.addEventListener("serverlog", e => { const msg = e.detail?.m; if (typeof msg !== "string") return; const parsed = parseChunky(msg); if (parsed) applyParsed(parsed); }); }