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,263 @@
|
||||
// Tab-completion + suggestion dropdown for the console command input.
|
||||
// - Ghost text shows the top match inline (Tab to accept)
|
||||
// - A dropdown list shows up to N matches with their argument signature
|
||||
// (MC-style: <required> [optional] <a|b|c> for choices)
|
||||
// - Click a list item to insert it; arrow keys + Enter also navigate the list
|
||||
// when it's open
|
||||
"use strict";
|
||||
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml } from "./api.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"help", "list", "say", "tell", "msg", "me", "w",
|
||||
"op", "deop",
|
||||
"whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist",
|
||||
"kick",
|
||||
"tp", "teleport",
|
||||
"give", "clear", "kill",
|
||||
"gamemode", "gamerule", "difficulty",
|
||||
"weather", "time", "seed", "spawnpoint", "setworldspawn",
|
||||
"save-all", "save-on", "save-off", "stop", "reload",
|
||||
"xp", "experience", "effect", "enchant",
|
||||
"summon", "data", "execute", "fill", "setblock", "locate", "tag",
|
||||
"ftbchunks", "ftbteams",
|
||||
"chunky",
|
||||
"kubejs", "kjs",
|
||||
];
|
||||
|
||||
const SUBCOMMANDS = {
|
||||
whitelist: ["add", "remove", "list", "reload", "on", "off"],
|
||||
gamemode: ["survival", "creative", "adventure", "spectator"],
|
||||
weather: ["clear", "rain", "thunder"],
|
||||
difficulty: ["peaceful", "easy", "normal", "hard"],
|
||||
time: ["set", "add", "query"],
|
||||
chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"],
|
||||
ftbchunks: ["claim", "unclaim", "load", "unload", "admin"],
|
||||
};
|
||||
|
||||
const TAKES_PLAYER_AT = {
|
||||
"op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1,
|
||||
"ban": 1, "pardon": 1, "kill": 1,
|
||||
"tell": 1, "msg": 1, "w": 1,
|
||||
"give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1,
|
||||
"whitelist add": 2, "whitelist remove": 2,
|
||||
"gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2,
|
||||
};
|
||||
|
||||
// MC-style argument signatures for each command. Shown as a hint after the name
|
||||
// in the suggestion list. <required> [optional] <a|b|c> for enum choices.
|
||||
const SIGNATURES = {
|
||||
help: "[command]",
|
||||
list: "",
|
||||
say: "<message>",
|
||||
tell: "<player> <message>",
|
||||
msg: "<player> <message>",
|
||||
me: "<action>",
|
||||
w: "<player> <message>",
|
||||
op: "<player>",
|
||||
deop: "<player>",
|
||||
whitelist: "<add|remove|list|reload|on|off>",
|
||||
"whitelist add": "<player>",
|
||||
"whitelist remove": "<player>",
|
||||
"whitelist list": "",
|
||||
"whitelist on": "",
|
||||
"whitelist off": "",
|
||||
"whitelist reload": "",
|
||||
ban: "<player> [reason…]",
|
||||
"ban-ip": "<ip|player> [reason…]",
|
||||
pardon: "<player>",
|
||||
"pardon-ip": "<ip>",
|
||||
banlist: "[ips|players]",
|
||||
kick: "<player> [reason…]",
|
||||
tp: "<target> [destination]",
|
||||
teleport: "<target> [destination]",
|
||||
give: "<player> <item> [count]",
|
||||
clear: "[player] [item]",
|
||||
kill: "[target]",
|
||||
gamemode: "<mode> [player]",
|
||||
"gamemode survival": "[player]",
|
||||
"gamemode creative": "[player]",
|
||||
"gamemode adventure": "[player]",
|
||||
"gamemode spectator": "[player]",
|
||||
gamerule: "<rule> [value]",
|
||||
difficulty: "<peaceful|easy|normal|hard>",
|
||||
weather: "<clear|rain|thunder> [duration]",
|
||||
time: "<set|add|query> <value>",
|
||||
seed: "",
|
||||
spawnpoint: "[player] [pos]",
|
||||
setworldspawn: "[pos]",
|
||||
"save-all": "[flush]",
|
||||
"save-on": "",
|
||||
"save-off": "",
|
||||
stop: "",
|
||||
reload: "",
|
||||
xp: "<amount> [player]",
|
||||
experience: "<add|set|query> <player> <amount>",
|
||||
effect: "<give|clear> <player> <effect>",
|
||||
enchant: "<player> <enchantment> [level]",
|
||||
summon: "<entity> [pos]",
|
||||
fill: "<from> <to> <block>",
|
||||
setblock: "<pos> <block>",
|
||||
locate: "<biome|structure> <id>",
|
||||
tag: "<target> <add|remove|list> [tag]",
|
||||
chunky: "<start|cancel|pause|continue|world|shape|center|radius|trim|...>",
|
||||
"chunky start": "[world] [shape] [center_x] [center_z] [radius]",
|
||||
"chunky cancel": "",
|
||||
"chunky pause": "",
|
||||
"chunky continue": "",
|
||||
"chunky world": "<world>",
|
||||
"chunky shape": "<square|circle|...>",
|
||||
"chunky center": "<x> <z>",
|
||||
"chunky radius": "<radius>",
|
||||
"chunky trim": "[world] [radius] [trim_radius]",
|
||||
ftbchunks: "<claim|unclaim|load|unload|admin>",
|
||||
ftbteams: "<list|info|invite|...>",
|
||||
kubejs: "<reload|hand|stages|...>",
|
||||
kjs: "<reload|hand|stages|...>",
|
||||
};
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
let activeIndex = 0;
|
||||
let currentSuggestions = [];
|
||||
|
||||
export function setupAutocomplete() {
|
||||
const cmdInput = document.getElementById("cmdInput");
|
||||
const cmdGhost = document.getElementById("cmdGhost");
|
||||
const cmdSuggest = document.getElementById("cmdSuggest");
|
||||
|
||||
function refresh() {
|
||||
const v = cmdInput.value;
|
||||
currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS);
|
||||
activeIndex = 0;
|
||||
|
||||
// Inline ghost = top suggestion (only if it extends what they typed)
|
||||
const top = currentSuggestions[0];
|
||||
if (top && top.text.startsWith(v) && top.text !== v) {
|
||||
const suffix = top.text.substring(v.length);
|
||||
cmdGhost.innerHTML = `<span class="typed">${escapeHtml(v)}</span>${escapeHtml(suffix)}`;
|
||||
cmdInput.dataset.suggestion = top.text;
|
||||
} else {
|
||||
cmdGhost.innerHTML = "";
|
||||
cmdInput.dataset.suggestion = "";
|
||||
}
|
||||
|
||||
renderList(cmdSuggest, currentSuggestions);
|
||||
}
|
||||
|
||||
cmdInput.addEventListener("input", refresh);
|
||||
cmdInput.addEventListener("focus", refresh);
|
||||
cmdInput.addEventListener("blur", () => {
|
||||
// Delay so a click on a list item registers before we hide
|
||||
setTimeout(() => cmdSuggest.classList.remove("show"), 150);
|
||||
});
|
||||
|
||||
cmdInput.addEventListener("keydown", e => {
|
||||
if (e.key === "Tab") {
|
||||
const sug = currentSuggestions[activeIndex];
|
||||
if (sug) {
|
||||
e.preventDefault();
|
||||
cmdInput.value = sug.text + " ";
|
||||
refresh();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
cmdGhost.innerHTML = "";
|
||||
cmdInput.dataset.suggestion = "";
|
||||
cmdSuggest.classList.remove("show");
|
||||
} else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % currentSuggestions.length;
|
||||
highlightActive(cmdSuggest);
|
||||
} else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
|
||||
highlightActive(cmdSuggest);
|
||||
}
|
||||
// Note: Enter is handled by console.js (sends the command)
|
||||
});
|
||||
|
||||
cmdSuggest.addEventListener("mousedown", e => {
|
||||
// mousedown (not click) so we beat the input blur handler
|
||||
const item = e.target.closest(".suggest-item");
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const idx = parseInt(item.dataset.idx, 10);
|
||||
const sug = currentSuggestions[idx];
|
||||
if (sug) {
|
||||
cmdInput.value = sug.text + " ";
|
||||
cmdInput.focus();
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function highlightActive(listEl) {
|
||||
[...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => {
|
||||
el.classList.toggle("active", i === activeIndex);
|
||||
if (i === activeIndex) el.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(listEl, suggestions) {
|
||||
if (suggestions.length === 0) {
|
||||
listEl.classList.remove("show");
|
||||
listEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = suggestions.map((s, i) => {
|
||||
const args = s.args ? `<span class="args">${escapeHtml(s.args)}</span>` : "";
|
||||
return `<div class="suggest-item${i === activeIndex ? " active" : ""}" data-idx="${i}">` +
|
||||
`<span>${escapeHtml(s.text)}</span>${args}</div>`;
|
||||
}).join("");
|
||||
listEl.classList.add("show");
|
||||
}
|
||||
|
||||
// Returns an array of {text, args} suggestions ordered by relevance.
|
||||
// args is the MC-style hint shown next to the name.
|
||||
function computeAllSuggestions(input) {
|
||||
if (!input) return [];
|
||||
const stripped = input.startsWith("/") ? input.substring(1) : input;
|
||||
const tokens = stripped.split(" ");
|
||||
const partial = tokens[tokens.length - 1].toLowerCase();
|
||||
const completed = tokens.slice(0, -1);
|
||||
const prefix = input.startsWith("/") ? "/" : "";
|
||||
|
||||
// First token: command name
|
||||
if (completed.length === 0) {
|
||||
const matches = COMMANDS.filter(c => c.startsWith(partial)).sort();
|
||||
return matches.map(name => ({
|
||||
text: prefix + name,
|
||||
args: SIGNATURES[name] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// Subcommands
|
||||
const headLower = completed[0].toLowerCase();
|
||||
if (completed.length === 1 && SUBCOMMANDS[headLower]) {
|
||||
const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial));
|
||||
return subs.map(sub => ({
|
||||
text: prefix + [...completed, sub].join(" "),
|
||||
args: SIGNATURES[`${headLower} ${sub}`] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// Player-name positions
|
||||
const cmdKey = completed.join(" ").toLowerCase();
|
||||
const playerSlot = TAKES_PLAYER_AT[cmdKey];
|
||||
if (playerSlot !== undefined && tokens.length === playerSlot + 1) {
|
||||
const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial));
|
||||
return matches.map(p => ({
|
||||
text: prefix + [...completed, p].join(" "),
|
||||
args: "",
|
||||
}));
|
||||
}
|
||||
|
||||
// No structured suggestion at this position -- but still show the current
|
||||
// command's signature as a contextual hint
|
||||
const sig = SIGNATURES[cmdKey];
|
||||
if (sig) {
|
||||
return [{ text: input, args: sig }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user