a1331212cb
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.
223 lines
8.6 KiB
JavaScript
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);
|
|
});
|
|
}
|