1348 lines
35 KiB
JavaScript
1348 lines
35 KiB
JavaScript
const http = require("http");
|
|
const https = require("https");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const mineflayer = require("mineflayer");
|
|
const gui = require("mineflayer-gui");
|
|
|
|
require("dotenv").config();
|
|
|
|
const logs = [];
|
|
const MAX_LOGS = 200;
|
|
const LOOP_DELAY_MIN_MS = 5 * 60_000;
|
|
const LOOP_DELAY_MAX_MS = 10 * 60_000;
|
|
const MAX_ALLOWED_MOVE_BLOCKS = 10;
|
|
const LOOP_RESUME_TOLERANCE_BLOCKS = 1.5;
|
|
const REJOIN_DELAY_MS = 60_000;
|
|
const SECURITY_ARM_DELAY_MS = 5_000;
|
|
const SECURITY_RADIUS = 10;
|
|
const EMERGENCY_HOTBAR_SLOT = 0;
|
|
const EMERGENCY_MISSING_RETRIES = 8;
|
|
const DEBUG_LOGS =
|
|
String(process.env.DEBUG_LOGS || "") === "1" ||
|
|
String(process.env.DEBUG_LOGS || "").toLowerCase() === "true";
|
|
|
|
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || "";
|
|
const ALLOWED_PLAYERS_FILE =
|
|
process.env.ALLOWED_PLAYERS_FILE || "allowed_players.txt";
|
|
|
|
let spawnerLoopRunning = false;
|
|
let joinPosition = null;
|
|
let hasJoined = false;
|
|
let spawnerPosition = null;
|
|
let reconnectTimer = null;
|
|
let bot = null;
|
|
let emergencyTriggered = false;
|
|
let securityMonitorInterval = null;
|
|
let securityArmed = false;
|
|
let securityArmTimer = null;
|
|
let securityPausedByMovement = false;
|
|
let loopStartPosition = null;
|
|
let pausedByMovementGuard = false;
|
|
|
|
const allowedPlayers = loadAllowedPlayers();
|
|
|
|
function loadAllowedPlayers() {
|
|
const filePath = path.isAbsolute(ALLOWED_PLAYERS_FILE)
|
|
? ALLOWED_PLAYERS_FILE
|
|
: path.join(__dirname, ALLOWED_PLAYERS_FILE);
|
|
|
|
try {
|
|
const data = fs.readFileSync(filePath, "utf8");
|
|
return new Set(
|
|
data
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim().toLowerCase())
|
|
.filter((line) => line && !line.startsWith("#")),
|
|
);
|
|
} catch {
|
|
pushLog(`allowed players file not found: ${filePath}`);
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function createBotInstance() {
|
|
hasJoined = false;
|
|
joinPosition = null;
|
|
spawnerPosition = null;
|
|
emergencyTriggered = false;
|
|
securityArmed = false;
|
|
securityPausedByMovement = false;
|
|
loopStartPosition = null;
|
|
pausedByMovementGuard = false;
|
|
if (securityArmTimer) {
|
|
clearTimeout(securityArmTimer);
|
|
securityArmTimer = null;
|
|
}
|
|
|
|
bot = mineflayer.createBot({
|
|
host: "java.donutsmp.net",
|
|
username: "Tayota4420",
|
|
auth: "microsoft",
|
|
version: "1.21.1",
|
|
});
|
|
|
|
bot.loadPlugin(gui);
|
|
bindBotEvents();
|
|
}
|
|
|
|
function stringifyReason(reason) {
|
|
if (typeof reason === "string") return reason;
|
|
try {
|
|
return JSON.stringify(reason);
|
|
} catch {
|
|
return String(reason);
|
|
}
|
|
}
|
|
|
|
function scheduleRejoinIfAlreadyOnline(reason) {
|
|
const reasonText = stringifyReason(reason);
|
|
pushLog(`kicked: ${reasonText}`);
|
|
|
|
if (!reasonText.includes("You are already online")) return;
|
|
if (reconnectTimer) {
|
|
pushLog("rejoin already scheduled");
|
|
return;
|
|
}
|
|
|
|
stopSpawnerLoop("stopping loop due to kick");
|
|
pushLog("already online kick detected, rejoining in 60s");
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
pushLog("rejoining now...");
|
|
createBotInstance();
|
|
}, REJOIN_DELAY_MS);
|
|
}
|
|
|
|
function pushLog(message) {
|
|
logs.push({
|
|
time: new Date().toISOString(),
|
|
message,
|
|
});
|
|
if (logs.length > MAX_LOGS) logs.shift();
|
|
console.log(message);
|
|
}
|
|
|
|
function debugLog(message) {
|
|
if (!DEBUG_LOGS) return;
|
|
pushLog(`[debug] ${message}`);
|
|
}
|
|
|
|
function getWindowTitle(window) {
|
|
if (!window) return "no-window";
|
|
if (typeof window.title === "string") return window.title;
|
|
if (window.title && typeof window.title === "object") {
|
|
try {
|
|
return JSON.stringify(window.title);
|
|
} catch {
|
|
return "[window-title-object]";
|
|
}
|
|
}
|
|
return "custom gui";
|
|
}
|
|
|
|
async function sendDiscordWebhook(content) {
|
|
if (!DISCORD_WEBHOOK_URL) {
|
|
pushLog("DISCORD_WEBHOOK_URL not set; skipping webhook");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body = JSON.stringify({
|
|
content: `@everyone ${content}`,
|
|
allowed_mentions: { parse: ["everyone"] },
|
|
});
|
|
const url = new URL(DISCORD_WEBHOOK_URL);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const req = https.request(
|
|
{
|
|
method: "POST",
|
|
hostname: url.hostname,
|
|
path: `${url.pathname}${url.search}`,
|
|
port: url.port || 443,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(body),
|
|
},
|
|
},
|
|
(res) => {
|
|
res.on("data", () => {});
|
|
res.on("end", () => resolve());
|
|
},
|
|
);
|
|
|
|
req.on("error", reject);
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
} catch (err) {
|
|
pushLog(`failed webhook send: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function isSpawnerBlock(block) {
|
|
if (!block) return false;
|
|
return ["spawner", "mob_spawner", "trial_spawner"].includes(block.name);
|
|
}
|
|
|
|
function getUnauthorizedNearbyPlayers() {
|
|
if (!bot || !bot.entity || !bot.entity.position) return [];
|
|
|
|
const intruders = [];
|
|
for (const [username, player] of Object.entries(bot.players)) {
|
|
if (!username || username === bot.username) continue;
|
|
if (allowedPlayers.has(username.toLowerCase())) continue;
|
|
if (!player || !player.entity || !player.entity.position) continue;
|
|
|
|
const distance = player.entity.position.distanceTo(bot.entity.position);
|
|
if (distance <= SECURITY_RADIUS) {
|
|
intruders.push(`${username} (${distance.toFixed(1)}m)`);
|
|
}
|
|
}
|
|
|
|
return intruders;
|
|
}
|
|
|
|
async function breakSpawnerUntilGone() {
|
|
await bot.setQuickBarSlot(EMERGENCY_HOTBAR_SLOT);
|
|
await bot.setControlState("sneak", true);
|
|
await sleep(250);
|
|
|
|
let missingChecks = 0;
|
|
|
|
try {
|
|
while (true) {
|
|
await bot.setControlState("sneak", true);
|
|
|
|
let targetBlock = null;
|
|
|
|
if (spawnerPosition) {
|
|
targetBlock = bot.blockAt(spawnerPosition);
|
|
}
|
|
|
|
if (!isSpawnerBlock(targetBlock)) {
|
|
targetBlock = findSpawnerBlock();
|
|
}
|
|
|
|
if (!isSpawnerBlock(targetBlock)) {
|
|
missingChecks += 1;
|
|
if (missingChecks >= EMERGENCY_MISSING_RETRIES) {
|
|
pushLog("spawner no longer present");
|
|
return true;
|
|
}
|
|
|
|
await sleep(200);
|
|
continue;
|
|
}
|
|
|
|
missingChecks = 0;
|
|
spawnerPosition = targetBlock.position.clone();
|
|
// Keep sneak forced on every iteration so dig always happens while sneaking.
|
|
await bot.setControlState("sneak", true);
|
|
await sleep(120);
|
|
await bot.lookAt(targetBlock.position.offset(0.5, 0.5, 0.5), true);
|
|
|
|
if (!bot.canDigBlock(targetBlock)) {
|
|
pushLog("cannot dig spawner yet, retrying");
|
|
await sleep(300);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await bot.dig(targetBlock, true);
|
|
await sleep(150);
|
|
} catch (err) {
|
|
pushLog(`spawner dig failed: ${err.message}`);
|
|
await sleep(300);
|
|
}
|
|
}
|
|
} finally {
|
|
bot.setControlState("sneak", false);
|
|
}
|
|
}
|
|
|
|
async function triggerSecurityShutdown(reason) {
|
|
if (emergencyTriggered) return;
|
|
emergencyTriggered = true;
|
|
|
|
pushLog(`security trigger: ${reason}`);
|
|
stopSpawnerLoop("spawner loop stopped due to security trigger");
|
|
|
|
const spawnerDestroyed = await breakSpawnerUntilGone();
|
|
const status = spawnerDestroyed ? "spawner removed" : "spawner not removed";
|
|
await sleep(600);
|
|
const stashResult = await stashSpawnerDropsInEnderChest();
|
|
const stashStatus = stashResult.attempted
|
|
? stashResult.remaining === 0
|
|
? `spawners stashed (moved=${stashResult.moved})`
|
|
: `stash incomplete (moved=${stashResult.moved}, remaining=${stashResult.remaining})`
|
|
: "no spawner items to stash";
|
|
|
|
await sendDiscordWebhook(
|
|
`MCBOT ALERT: ${reason} | ${status} | ${stashStatus}`,
|
|
);
|
|
|
|
try {
|
|
bot.quit("security shutdown");
|
|
} catch {
|
|
// ignore disconnect errors
|
|
}
|
|
}
|
|
|
|
function startSecurityMonitor() {
|
|
if (securityMonitorInterval) clearInterval(securityMonitorInterval);
|
|
|
|
securityMonitorInterval = setInterval(() => {
|
|
if (!hasJoined || !bot || emergencyTriggered || !securityArmed) return;
|
|
if (securityPausedByMovement) return;
|
|
|
|
const intruders = getUnauthorizedNearbyPlayers();
|
|
if (intruders.length > 0) {
|
|
triggerSecurityShutdown(
|
|
`unauthorized player nearby: ${intruders.join(", ")}`,
|
|
);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopSecurityMonitor() {
|
|
if (!securityMonitorInterval) return;
|
|
clearInterval(securityMonitorInterval);
|
|
securityMonitorInterval = null;
|
|
}
|
|
|
|
function armSecurityAfterJoinDelay() {
|
|
if (securityArmTimer) {
|
|
clearTimeout(securityArmTimer);
|
|
securityArmTimer = null;
|
|
}
|
|
|
|
securityArmed = false;
|
|
pushLog(`security will auto-enable in ${SECURITY_ARM_DELAY_MS / 1000}s`);
|
|
|
|
securityArmTimer = setTimeout(() => {
|
|
securityArmTimer = null;
|
|
if (!bot || !hasJoined || emergencyTriggered) return;
|
|
|
|
securityArmed = true;
|
|
pushLog("security auto-enabled");
|
|
}, SECURITY_ARM_DELAY_MS);
|
|
}
|
|
|
|
function pauseSecurityForMovement() {
|
|
if (securityPausedByMovement) return;
|
|
securityPausedByMovement = true;
|
|
pushLog("security paused: bot moved away from loop start");
|
|
}
|
|
|
|
function resumeSecurityForMovement() {
|
|
if (!securityPausedByMovement) return;
|
|
securityPausedByMovement = false;
|
|
pushLog("security resumed: bot returned to loop start");
|
|
}
|
|
|
|
const webPath = path.join(__dirname, "web", "index.html");
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
if (req.method === "GET" && req.url === "/") {
|
|
try {
|
|
const html = fs.readFileSync(webPath, "utf8");
|
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
} catch {
|
|
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("UI file missing");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && req.url === "/api/logs") {
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ logs }));
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/spawner") {
|
|
try {
|
|
startSpawnerLoop();
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ ok: true, running: spawnerLoopRunning }));
|
|
} catch (err) {
|
|
pushLog(`spawner error: ${err.message}`);
|
|
res.writeHead(500, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/stop") {
|
|
try {
|
|
stopSpawnerLoop("spawner loop stopped by user");
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ ok: true, running: spawnerLoopRunning }));
|
|
} catch (err) {
|
|
pushLog(`stop error: ${err.message}`);
|
|
res.writeHead(500, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
});
|
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("Not found");
|
|
});
|
|
|
|
server.listen(3008, () => {
|
|
pushLog("web ui running at http://localhost:3008");
|
|
});
|
|
|
|
function sayItems(items = []) {
|
|
const output = items.map(itemToString).join(", ");
|
|
pushLog(output || "empty");
|
|
}
|
|
|
|
function readCurrentWindowInventory() {
|
|
const window = bot.currentWindow;
|
|
if (!window) {
|
|
pushLog("no window open");
|
|
return;
|
|
}
|
|
|
|
const items =
|
|
typeof window.containerItems === "function"
|
|
? window.containerItems()
|
|
: typeof window.items === "function"
|
|
? window.items()
|
|
: [];
|
|
|
|
const title = getWindowTitle(window);
|
|
|
|
pushLog(`window opened: ${title}`);
|
|
sayItems(items);
|
|
}
|
|
|
|
function comparator(item, pItem) {
|
|
if (!pItem) return false;
|
|
const target = String(item).toLowerCase();
|
|
return (
|
|
String(pItem.displayName || "").toLowerCase() === target ||
|
|
String(pItem.name || "").toLowerCase() === target
|
|
);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getRandomLoopDelayMs() {
|
|
const span = LOOP_DELAY_MAX_MS - LOOP_DELAY_MIN_MS;
|
|
return LOOP_DELAY_MIN_MS + Math.floor(Math.random() * (span + 1));
|
|
}
|
|
|
|
function stopSpawnerLoop(reason, options = {}) {
|
|
if (!spawnerLoopRunning) return;
|
|
spawnerLoopRunning = false;
|
|
if (!options.keepMovementPause) {
|
|
pausedByMovementGuard = false;
|
|
}
|
|
pushLog(reason || "spawner loop stopped");
|
|
closeCurrentWindow();
|
|
}
|
|
|
|
function findSpawnerBlock() {
|
|
const matching = ["spawner", "mob_spawner", "trial_spawner"]
|
|
.filter((name) => bot.registry.blocksByName[name] !== undefined)
|
|
.map((name) => bot.registry.blocksByName[name].id);
|
|
|
|
if (matching.length === 0) return null;
|
|
|
|
return bot.findBlock({
|
|
matching,
|
|
maxDistance: 6,
|
|
});
|
|
}
|
|
|
|
async function rotateToSpawner() {
|
|
let targetBlock = null;
|
|
|
|
if (spawnerPosition) {
|
|
targetBlock = bot.blockAt(spawnerPosition);
|
|
}
|
|
|
|
if (!targetBlock) {
|
|
targetBlock = findSpawnerBlock();
|
|
if (targetBlock) {
|
|
spawnerPosition = targetBlock.position.clone();
|
|
pushLog(
|
|
`spawner position set: ${spawnerPosition.x}, ${spawnerPosition.y}, ${spawnerPosition.z}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!targetBlock) {
|
|
pushLog("no spawner found nearby");
|
|
return null;
|
|
}
|
|
|
|
await bot.lookAt(targetBlock.position.offset(0.5, 0.5, 0.5), true);
|
|
return targetBlock;
|
|
}
|
|
|
|
function waitForWindowOpen(timeoutMs = 5000) {
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
bot.removeListener("windowOpen", onOpen);
|
|
debugLog(`waitForWindowOpen timeout after ${timeoutMs}ms`);
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
const onOpen = () => {
|
|
clearTimeout(timeout);
|
|
debugLog(
|
|
`waitForWindowOpen resolved: ${getWindowTitle(bot.currentWindow)}`,
|
|
);
|
|
resolve(true);
|
|
};
|
|
|
|
bot.once("windowOpen", onOpen);
|
|
});
|
|
}
|
|
|
|
async function openSpawnerGui(maxAttempts = 2) {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const targetBlock = await rotateToSpawner();
|
|
if (!targetBlock || targetBlock.name === "air") {
|
|
pushLog("unable to target spawner block");
|
|
return false;
|
|
}
|
|
|
|
const openPromise = waitForWindowOpen(5000);
|
|
debugLog(
|
|
`openSpawnerGui attempt ${attempt}/${maxAttempts} block=${targetBlock.name}`,
|
|
);
|
|
closeCurrentWindow();
|
|
await bot.activateBlock(targetBlock);
|
|
const opened = await openPromise;
|
|
|
|
if (opened) {
|
|
return true;
|
|
}
|
|
|
|
pushLog(
|
|
`no gui opened from block '${targetBlock.name}' (attempt ${attempt}/${maxAttempts})`,
|
|
);
|
|
if (attempt < maxAttempts) {
|
|
await sleep(700);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function getWindowItems() {
|
|
const window = bot.currentWindow;
|
|
if (!window) return [];
|
|
if (typeof window.containerItems === "function")
|
|
return window.containerItems();
|
|
if (typeof window.items === "function") return window.items();
|
|
return [];
|
|
}
|
|
|
|
async function dropAllBonesFromCurrentWindow() {
|
|
if (!bot.currentWindow) {
|
|
pushLog("no window open to drop bones");
|
|
return;
|
|
}
|
|
|
|
let dropped = 0;
|
|
while (true) {
|
|
const boneStacks = getWindowItems().filter(
|
|
(item) => item && String(item.name || "").toLowerCase() === "bone",
|
|
);
|
|
const boneCount = boneStacks.reduce(
|
|
(sum, item) => sum + (item.count || 0),
|
|
0,
|
|
);
|
|
|
|
if (boneCount === 0) break;
|
|
|
|
const ok = await bot.gui
|
|
.Query()
|
|
.Window(comparator)
|
|
.Drop("Bone", 64)
|
|
.end()
|
|
.run();
|
|
|
|
if (!ok) {
|
|
pushLog("failed to drop bones from window");
|
|
break;
|
|
}
|
|
|
|
dropped += Math.min(64, boneCount);
|
|
await sleep(150);
|
|
}
|
|
|
|
pushLog(`dropped bones total: ${dropped}`);
|
|
}
|
|
|
|
function countItemByName(items, name) {
|
|
const target = String(name).toLowerCase();
|
|
return items
|
|
.filter((item) => item && String(item.name || "").toLowerCase() === target)
|
|
.reduce((sum, item) => sum + (item.count || 0), 0);
|
|
}
|
|
|
|
function isSpawnerItem(item) {
|
|
if (!item) return false;
|
|
return ["spawner", "mob_spawner", "trial_spawner"].includes(
|
|
String(item.name || "").toLowerCase(),
|
|
);
|
|
}
|
|
|
|
function countSpawnerItemsInInventory() {
|
|
if (!bot || !bot.inventory) return 0;
|
|
return bot.inventory
|
|
.items()
|
|
.filter((item) => isSpawnerItem(item))
|
|
.reduce((sum, item) => sum + (item.count || 0), 0);
|
|
}
|
|
|
|
function findNearbyEnderChest(maxDistance = 6) {
|
|
const blockDef = bot?.registry?.blocksByName?.ender_chest;
|
|
if (!blockDef) return null;
|
|
|
|
return bot.findBlock({
|
|
matching: blockDef.id,
|
|
maxDistance,
|
|
});
|
|
}
|
|
|
|
function getInventorySpawnerSlotsInCurrentWindow() {
|
|
if (!bot.currentWindow || !Array.isArray(bot.currentWindow.slots)) return [];
|
|
|
|
const slots = bot.currentWindow.slots;
|
|
const inventoryStart = getInventoryStart(bot.currentWindow);
|
|
const result = [];
|
|
|
|
for (let i = inventoryStart; i < slots.length; i += 1) {
|
|
const item = slots[i];
|
|
if (!isSpawnerItem(item)) continue;
|
|
result.push(i);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function moveAllInventorySpawnersToCurrentWindow() {
|
|
if (!bot.currentWindow) return 0;
|
|
|
|
let moved = 0;
|
|
let safety = 0;
|
|
while (safety < 120) {
|
|
safety += 1;
|
|
|
|
const before = countSpawnerItemsInInventory();
|
|
if (before === 0) break;
|
|
|
|
const spawnerSlots = getInventorySpawnerSlotsInCurrentWindow();
|
|
if (spawnerSlots.length === 0) break;
|
|
|
|
let clicked = false;
|
|
for (const slot of spawnerSlots) {
|
|
try {
|
|
// mode=1 is shift-click, which moves the stack to the open container.
|
|
await bot.clickWindow(slot, 0, 1);
|
|
clicked = true;
|
|
await sleep(120);
|
|
} catch (err) {
|
|
debugLog(
|
|
`moveAllInventorySpawnersToCurrentWindow: raw shift slot=${slot} failed: ${err.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const after = countSpawnerItemsInInventory();
|
|
if (after < before) {
|
|
moved += before - after;
|
|
continue;
|
|
}
|
|
|
|
if (!clicked) break;
|
|
break;
|
|
}
|
|
|
|
pushLog(`moved spawners from inventory: ${moved}`);
|
|
return moved;
|
|
}
|
|
|
|
async function stashSpawnerDropsInEnderChest() {
|
|
const before = countSpawnerItemsInInventory();
|
|
if (before === 0) {
|
|
pushLog("no spawner items in inventory to stash");
|
|
return { attempted: false, moved: 0, remaining: 0 };
|
|
}
|
|
|
|
const enderChest = findNearbyEnderChest(6);
|
|
if (!enderChest) {
|
|
pushLog("no nearby ender chest found for stashing spawners");
|
|
return { attempted: true, moved: 0, remaining: before };
|
|
}
|
|
|
|
try {
|
|
await bot.lookAt(enderChest.position.offset(0.5, 0.5, 0.5), true);
|
|
const openPromise = waitForWindowOpen(5000);
|
|
closeCurrentWindow();
|
|
await bot.activateBlock(enderChest);
|
|
|
|
const opened = await openPromise;
|
|
if (!opened || !bot.currentWindow) {
|
|
pushLog("ender chest did not open");
|
|
return {
|
|
attempted: true,
|
|
moved: 0,
|
|
remaining: countSpawnerItemsInInventory(),
|
|
};
|
|
}
|
|
|
|
await sleep(150);
|
|
const moved = await moveAllInventorySpawnersToCurrentWindow();
|
|
closeCurrentWindow();
|
|
|
|
const remaining = countSpawnerItemsInInventory();
|
|
if (remaining === 0) {
|
|
pushLog("all spawner items stashed in ender chest");
|
|
} else {
|
|
pushLog(`spawner stash incomplete, remaining in inventory: ${remaining}`);
|
|
}
|
|
|
|
return { attempted: true, moved, remaining };
|
|
} catch (err) {
|
|
pushLog(`failed stashing spawners in ender chest: ${err.message}`);
|
|
closeCurrentWindow();
|
|
return {
|
|
attempted: true,
|
|
moved: 0,
|
|
remaining: countSpawnerItemsInInventory(),
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizeLabel(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[_\s]+/g, "")
|
|
.trim();
|
|
}
|
|
|
|
function findWindowSlotByCandidates(candidates) {
|
|
if (!bot.currentWindow || !Array.isArray(bot.currentWindow.slots))
|
|
return null;
|
|
|
|
const normalizedCandidates = candidates.map(normalizeLabel);
|
|
|
|
for (const item of bot.currentWindow.slots) {
|
|
if (!item) continue;
|
|
|
|
const itemName = normalizeLabel(item.name);
|
|
const displayName = normalizeLabel(item.displayName);
|
|
const nbtName = normalizeLabel(item.customName || "");
|
|
|
|
const matched = normalizedCandidates.some(
|
|
(candidate) =>
|
|
itemName.includes(candidate) ||
|
|
displayName.includes(candidate) ||
|
|
nbtName.includes(candidate),
|
|
);
|
|
|
|
if (matched) return item.slot;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getInventoryStart(window = bot.currentWindow) {
|
|
if (!window) return 9;
|
|
if (Number.isFinite(window.inventoryStart)) return window.inventoryStart;
|
|
|
|
// Fallback for custom windows that don't expose inventoryStart.
|
|
// Most windows append 36 player inventory slots at the end.
|
|
if (Array.isArray(window.slots) && window.slots.length >= 36) {
|
|
return Math.max(0, window.slots.length - 36);
|
|
}
|
|
|
|
return 9;
|
|
}
|
|
|
|
function logWindowSlotSummary(label) {
|
|
if (
|
|
!DEBUG_LOGS ||
|
|
!bot.currentWindow ||
|
|
!Array.isArray(bot.currentWindow.slots)
|
|
)
|
|
return;
|
|
|
|
const inventoryStart = getInventoryStart(bot.currentWindow);
|
|
const slots = bot.currentWindow.slots;
|
|
const nonEmpty = [];
|
|
|
|
for (let i = 0; i < slots.length; i += 1) {
|
|
const item = slots[i];
|
|
if (!item) continue;
|
|
nonEmpty.push(`${i}:${item.name}x${item.count}`);
|
|
}
|
|
|
|
debugLog(
|
|
`${label} slots=${slots.length} inventoryStart=${inventoryStart} nonEmpty=${nonEmpty.join(" | ") || "none"}`,
|
|
);
|
|
}
|
|
|
|
function getInventoryBoneSlotsInCurrentWindow() {
|
|
if (!bot.currentWindow || !Array.isArray(bot.currentWindow.slots)) return [];
|
|
|
|
const slots = bot.currentWindow.slots;
|
|
const inventoryStart = getInventoryStart(bot.currentWindow);
|
|
const result = [];
|
|
|
|
for (let i = inventoryStart; i < slots.length; i += 1) {
|
|
const item = slots[i];
|
|
if (!item) continue;
|
|
if (String(item.name || "").toLowerCase() === "bone") {
|
|
result.push(i);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function manualPlaceInventoryBonesToGui() {
|
|
if (!bot.currentWindow || !Array.isArray(bot.currentWindow.slots))
|
|
return false;
|
|
|
|
const inventoryStart = getInventoryStart(bot.currentWindow);
|
|
const topSlots = [];
|
|
for (let slot = 0; slot < inventoryStart; slot += 1) {
|
|
topSlots.push(slot);
|
|
}
|
|
|
|
if (topSlots.length === 0) {
|
|
debugLog("manualPlaceInventoryBonesToGui: no top slots in current window");
|
|
return false;
|
|
}
|
|
|
|
const boneSlots = getInventoryBoneSlotsInCurrentWindow();
|
|
if (boneSlots.length === 0) {
|
|
debugLog(
|
|
"manualPlaceInventoryBonesToGui: no bone slots available in inventory range",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
let placedAny = false;
|
|
|
|
for (const sourceSlot of boneSlots) {
|
|
const sourceItem = bot.currentWindow.slots[sourceSlot];
|
|
if (!sourceItem || String(sourceItem.name || "").toLowerCase() !== "bone") {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Pick up stack from inventory slot.
|
|
await bot.clickWindow(sourceSlot, 0, 0);
|
|
await sleep(90);
|
|
} catch (err) {
|
|
debugLog(
|
|
`manualPlaceInventoryBonesToGui: pick failed slot=${sourceSlot} err=${err.message}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let placedStack = false;
|
|
for (const targetSlot of topSlots) {
|
|
try {
|
|
await bot.clickWindow(targetSlot, 0, 0);
|
|
await sleep(90);
|
|
|
|
const cursor = bot.currentWindow.selectedItem;
|
|
if (!cursor || String(cursor.name || "").toLowerCase() !== "bone") {
|
|
placedStack = true;
|
|
placedAny = true;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
debugLog(
|
|
`manualPlaceInventoryBonesToGui: place failed target=${targetSlot} err=${err.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If still holding bones, put them back to avoid cursor desync.
|
|
const cursor = bot.currentWindow.selectedItem;
|
|
if (cursor && String(cursor.name || "").toLowerCase() === "bone") {
|
|
try {
|
|
await bot.clickWindow(sourceSlot, 0, 0);
|
|
await sleep(60);
|
|
} catch (err) {
|
|
debugLog(
|
|
`manualPlaceInventoryBonesToGui: return-to-source failed slot=${sourceSlot} err=${err.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (placedStack) {
|
|
debugLog(
|
|
`manualPlaceInventoryBonesToGui: placed stack from source slot=${sourceSlot}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return placedAny;
|
|
}
|
|
|
|
async function clickWindowItem(itemName, clickType = "left", count = 1) {
|
|
if (!bot.currentWindow) return false;
|
|
|
|
const ok = await bot.gui
|
|
.Query()
|
|
.Window(comparator)
|
|
.Click(itemName, clickType, count)
|
|
.end()
|
|
.run();
|
|
|
|
return Boolean(ok);
|
|
}
|
|
|
|
async function clickWindowItemByCandidates(candidates) {
|
|
debugLog(`clickWindowItemByCandidates candidates=${candidates.join(" | ")}`);
|
|
for (const candidate of candidates) {
|
|
const ok = await clickWindowItem(candidate, "left", 1);
|
|
if (ok) {
|
|
debugLog(`matched candidate directly: ${candidate}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const slot = findWindowSlotByCandidates(candidates);
|
|
if (slot === null) {
|
|
debugLog("no slot match found for candidates");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await bot.clickWindow(slot, 0, 0);
|
|
debugLog(`clicked fallback slot=${slot}`);
|
|
return true;
|
|
} catch (err) {
|
|
pushLog(`slot click failed: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function moveAllWindowBonesToInventory() {
|
|
if (!bot.currentWindow) return 0;
|
|
|
|
let moved = 0;
|
|
let safety = 0;
|
|
while (safety < 120) {
|
|
safety += 1;
|
|
const before = countItemByName(getWindowItems(), "bone");
|
|
if (before === 0) {
|
|
debugLog("moveAllWindowBonesToInventory: no bones in window");
|
|
break;
|
|
}
|
|
|
|
const ok = await clickWindowItem("Bone", "shift", 1);
|
|
if (!ok) {
|
|
debugLog("moveAllWindowBonesToInventory: shift click failed");
|
|
break;
|
|
}
|
|
|
|
await sleep(120);
|
|
const after = countItemByName(getWindowItems(), "bone");
|
|
if (after >= before) {
|
|
debugLog(
|
|
`moveAllWindowBonesToInventory: no progress before=${before} after=${after}`,
|
|
);
|
|
break;
|
|
}
|
|
moved += before - after;
|
|
debugLog(
|
|
`moveAllWindowBonesToInventory: moved this step=${before - after}`,
|
|
);
|
|
}
|
|
|
|
pushLog(`moved bones to inventory: ${moved}`);
|
|
return moved;
|
|
}
|
|
|
|
async function moveAllInventoryBonesToCurrentWindow() {
|
|
if (!bot.currentWindow) return 0;
|
|
|
|
let moved = 0;
|
|
let safety = 0;
|
|
while (safety < 120) {
|
|
safety += 1;
|
|
const before = countItemByName(bot.inventory.items(), "bone");
|
|
if (before === 0) {
|
|
debugLog("moveAllInventoryBonesToCurrentWindow: no bones in inventory");
|
|
break;
|
|
}
|
|
|
|
const ok = await bot.gui
|
|
.Query()
|
|
.Inventory(comparator)
|
|
.Click("Bone", "shift", 1)
|
|
.end()
|
|
.run();
|
|
|
|
if (!ok) {
|
|
debugLog(
|
|
"moveAllInventoryBonesToCurrentWindow: inventory shift click failed, trying raw slot shift-click",
|
|
);
|
|
|
|
const boneSlots = getInventoryBoneSlotsInCurrentWindow();
|
|
if (boneSlots.length === 0) {
|
|
debugLog(
|
|
"moveAllInventoryBonesToCurrentWindow: no bone slots found in window inventory range",
|
|
);
|
|
break;
|
|
}
|
|
|
|
let rawAttemptWorked = false;
|
|
for (const slot of boneSlots) {
|
|
try {
|
|
// mode=1 is shift-click in the vanilla click-window protocol.
|
|
await bot.clickWindow(slot, 0, 1);
|
|
rawAttemptWorked = true;
|
|
await sleep(120);
|
|
} catch (err) {
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: raw shift slot=${slot} failed: ${err.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!rawAttemptWorked) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
await sleep(120);
|
|
const after = countItemByName(bot.inventory.items(), "bone");
|
|
if (after >= before) {
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: no progress before=${before} after=${after}, trying raw slot shift-click fallback`,
|
|
);
|
|
|
|
const boneSlots = getInventoryBoneSlotsInCurrentWindow();
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: candidate inventory bone slots=${boneSlots.join(",") || "none"}`,
|
|
);
|
|
|
|
let rawMovedSomething = false;
|
|
for (const slot of boneSlots) {
|
|
try {
|
|
await bot.clickWindow(slot, 0, 1);
|
|
rawMovedSomething = true;
|
|
await sleep(120);
|
|
} catch (err) {
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: raw slot shift failed slot=${slot} err=${err.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const afterRaw = countItemByName(bot.inventory.items(), "bone");
|
|
if (afterRaw < before) {
|
|
moved += before - afterRaw;
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: raw fallback moved=${before - afterRaw}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
debugLog(
|
|
"moveAllInventoryBonesToCurrentWindow: trying manual place fallback",
|
|
);
|
|
const manualPlaced = await manualPlaceInventoryBonesToGui();
|
|
if (manualPlaced) {
|
|
await sleep(150);
|
|
const afterManual = countItemByName(bot.inventory.items(), "bone");
|
|
if (afterManual < before) {
|
|
moved += before - afterManual;
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: manual fallback moved=${before - afterManual}`,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!rawMovedSomething) {
|
|
debugLog(
|
|
"moveAllInventoryBonesToCurrentWindow: raw fallback did not run",
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
moved += before - after;
|
|
debugLog(
|
|
`moveAllInventoryBonesToCurrentWindow: moved this step=${before - after}`,
|
|
);
|
|
}
|
|
|
|
pushLog(`moved bones from inventory: ${moved}`);
|
|
return moved;
|
|
}
|
|
|
|
async function runOrderForBones() {
|
|
debugLog("runOrderForBones: sending /order command");
|
|
bot.chat("/order ZareMate");
|
|
const opened = await waitForWindowOpen(5000);
|
|
if (!opened || !bot.currentWindow) {
|
|
pushLog("order gui did not open");
|
|
return;
|
|
}
|
|
debugLog(
|
|
`runOrderForBones: first window=${getWindowTitle(bot.currentWindow)}`,
|
|
);
|
|
logWindowSlotSummary("runOrderForBones:first");
|
|
|
|
const clickedBone = await clickWindowItem("Bone", "left", 1);
|
|
if (!clickedBone) {
|
|
pushLog("unable to click Bone in order gui");
|
|
closeCurrentWindow();
|
|
return;
|
|
}
|
|
|
|
await sleep(300);
|
|
debugLog(
|
|
`runOrderForBones: after bone click window=${getWindowTitle(bot.currentWindow)}`,
|
|
);
|
|
logWindowSlotSummary("runOrderForBones:deliver");
|
|
debugLog(
|
|
`runOrderForBones: inventory bones before move=${countItemByName(bot.inventory.items(), "bone")}`,
|
|
);
|
|
await moveAllInventoryBonesToCurrentWindow();
|
|
debugLog(
|
|
`runOrderForBones: inventory bones after move=${countItemByName(bot.inventory.items(), "bone")}`,
|
|
);
|
|
await sleep(250);
|
|
|
|
// Required sequence: close once, then click glass, then close.
|
|
closeCurrentWindow();
|
|
const reopened = await waitForWindowOpen(3000);
|
|
if (!reopened || !bot.currentWindow) {
|
|
pushLog("no gui after close-once; cannot click glass");
|
|
return;
|
|
}
|
|
|
|
const glassCandidates = [
|
|
"Lime Stained Glass Pane",
|
|
"lime_stained_glass_pane",
|
|
"Green Stained Glass Pane",
|
|
"green_stained_glass_pane",
|
|
"Glass Pane",
|
|
"stained_glass_pane",
|
|
"Confirm",
|
|
];
|
|
|
|
const clickedLime = await clickWindowItemByCandidates(glassCandidates);
|
|
|
|
if (!clickedLime) {
|
|
const available = getWindowItems()
|
|
.map((item) => item?.displayName || item?.name || "unknown")
|
|
.join(", ");
|
|
pushLog(`unable to click glass pane (available: ${available || "none"})`);
|
|
return;
|
|
}
|
|
|
|
await sleep(200);
|
|
closeCurrentWindow();
|
|
}
|
|
|
|
function closeCurrentWindow() {
|
|
if (!bot.currentWindow) return;
|
|
if (typeof bot.closeWindow === "function") {
|
|
bot.closeWindow(bot.currentWindow);
|
|
return;
|
|
}
|
|
if (typeof bot.currentWindow.close === "function") {
|
|
bot.currentWindow.close();
|
|
}
|
|
}
|
|
|
|
async function runSpawnerPhase() {
|
|
await sleep(500);
|
|
const opened = await openSpawnerGui();
|
|
if (!opened) return;
|
|
|
|
await moveAllWindowBonesToInventory();
|
|
closeCurrentWindow();
|
|
await sleep(300);
|
|
await runOrderForBones();
|
|
}
|
|
|
|
async function runSpawnerCycle() {
|
|
await runSpawnerPhase();
|
|
}
|
|
|
|
function startSpawnerLoop() {
|
|
if (spawnerLoopRunning) {
|
|
pushLog("spawner loop already running");
|
|
return;
|
|
}
|
|
|
|
if (!joinPosition) {
|
|
pushLog("join position not set yet; wait for spawn");
|
|
return;
|
|
}
|
|
|
|
spawnerLoopRunning = true;
|
|
pausedByMovementGuard = false;
|
|
if (bot && bot.entity && bot.entity.position) {
|
|
loopStartPosition = bot.entity.position.clone();
|
|
} else if (joinPosition) {
|
|
loopStartPosition = joinPosition.clone();
|
|
}
|
|
|
|
if (loopStartPosition) {
|
|
pushLog(
|
|
`loop start position: ${loopStartPosition.x.toFixed(1)}, ${loopStartPosition.y.toFixed(1)}, ${loopStartPosition.z.toFixed(1)}`,
|
|
);
|
|
}
|
|
pushLog("spawner loop started");
|
|
|
|
(async () => {
|
|
while (spawnerLoopRunning) {
|
|
try {
|
|
await runSpawnerCycle();
|
|
} catch (err) {
|
|
pushLog(`cycle error: ${err.message}`);
|
|
}
|
|
|
|
if (!spawnerLoopRunning) break;
|
|
const waitMs = getRandomLoopDelayMs();
|
|
const waitMinutes = (waitMs / 60_000).toFixed(2);
|
|
pushLog(`waiting ${waitMinutes} min for next cycle`);
|
|
await sleep(waitMs);
|
|
}
|
|
})();
|
|
}
|
|
|
|
function bindBotEvents() {
|
|
bot.on("windowOpen", () => {
|
|
readCurrentWindowInventory();
|
|
});
|
|
|
|
bot.on("windowClose", () => {
|
|
if (!hasJoined) return;
|
|
pushLog("window closed");
|
|
});
|
|
|
|
bot.once("spawn", () => {
|
|
hasJoined = true;
|
|
if (!bot.entity || !bot.entity.position) return;
|
|
joinPosition = bot.entity.position.clone();
|
|
pushLog(
|
|
`join position set: ${joinPosition.x.toFixed(1)}, ${joinPosition.y.toFixed(1)}, ${joinPosition.z.toFixed(1)}`,
|
|
);
|
|
startSecurityMonitor();
|
|
pushLog("security monitor started");
|
|
armSecurityAfterJoinDelay();
|
|
});
|
|
|
|
bot.on("blockUpdate", (oldBlock, newBlock) => {
|
|
if (
|
|
!hasJoined ||
|
|
emergencyTriggered ||
|
|
!securityArmed ||
|
|
securityPausedByMovement ||
|
|
!bot.entity ||
|
|
!bot.entity.position
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!oldBlock || !newBlock) return;
|
|
if (oldBlock.name === "air") return;
|
|
if (newBlock.name !== "air") return;
|
|
|
|
const distance = oldBlock.position.distanceTo(bot.entity.position);
|
|
if (distance <= SECURITY_RADIUS) {
|
|
triggerSecurityShutdown(
|
|
`block broken within ${SECURITY_RADIUS}m at ${oldBlock.position.x},${oldBlock.position.y},${oldBlock.position.z}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
bot.on("physicsTick", () => {
|
|
if (!bot.entity || !bot.entity.position) {
|
|
return;
|
|
}
|
|
|
|
const guardAnchor = loopStartPosition || joinPosition;
|
|
|
|
if (spawnerLoopRunning) {
|
|
if (!guardAnchor) return;
|
|
|
|
const movedDistance = bot.entity.position.distanceTo(guardAnchor);
|
|
if (movedDistance > MAX_ALLOWED_MOVE_BLOCKS) {
|
|
pauseSecurityForMovement();
|
|
pausedByMovementGuard = true;
|
|
stopSpawnerLoop(
|
|
`moved ${movedDistance.toFixed(2)} blocks from loop start (> ${MAX_ALLOWED_MOVE_BLOCKS}), pausing loop`,
|
|
{ keepMovementPause: true },
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!pausedByMovementGuard || !guardAnchor) return;
|
|
|
|
const distanceFromAnchor = bot.entity.position.distanceTo(guardAnchor);
|
|
if (distanceFromAnchor <= LOOP_RESUME_TOLERANCE_BLOCKS) {
|
|
resumeSecurityForMovement();
|
|
pushLog(
|
|
`returned to loop start (<= ${LOOP_RESUME_TOLERANCE_BLOCKS} blocks), re-enabling loop`,
|
|
);
|
|
startSpawnerLoop();
|
|
}
|
|
});
|
|
|
|
bot.on("kicked", (reason) => scheduleRejoinIfAlreadyOnline(reason));
|
|
bot.on("error", (err) => pushLog(`error: ${err.message}`));
|
|
bot.on("end", () => {
|
|
stopSecurityMonitor();
|
|
if (securityArmTimer) {
|
|
clearTimeout(securityArmTimer);
|
|
securityArmTimer = null;
|
|
}
|
|
securityArmed = false;
|
|
securityPausedByMovement = false;
|
|
});
|
|
}
|
|
|
|
function itemToString(item) {
|
|
if (item) {
|
|
return `${item.name} x ${item.count}`;
|
|
}
|
|
return "(nothing)";
|
|
}
|
|
|
|
createBotInstance();
|
|
|
|
pushLog("mode=bones (orders-only)");
|
|
|
|
pushLog(
|
|
`security radius=${SECURITY_RADIUS}m, allowed players file=${ALLOWED_PLAYERS_FILE}`,
|
|
);
|
|
pushLog(`debug logs=${DEBUG_LOGS ? "on" : "off"} (set DEBUG_LOGS=1 to enable)`);
|