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.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user