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.
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
// 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 [];
|
|
}
|