Files
brass-and-sigil/server/wwwroot/modules/pregen.js
T
Matt Sijbers a1331212cb Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
2026-05-05 00:19:05 +01:00

223 lines
8.6 KiB
JavaScript

// World pre-generation controls + live status display.
//
// We use the canonical config-then-start sequence rather than the all-in-one
// `chunky start <world> <shape> <cx> <cz> <r>` 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);
});
}