581 lines
14 KiB
JavaScript
581 lines
14 KiB
JavaScript
const http = require("http");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const mineflayer = require("mineflayer");
|
|
const gui = require("mineflayer-gui");
|
|
const Vec3 = require("vec3");
|
|
|
|
require("dotenv").config();
|
|
|
|
const logs = [];
|
|
const MAX_LOGS = 200;
|
|
const LOOP_DELAY_MS = 10_000;
|
|
const MAX_ALLOWED_MOVE_BLOCKS = 10;
|
|
const REJOIN_DELAY_MS = 60_000;
|
|
const BULB_VERIFY_RETRIES = 3;
|
|
|
|
const ITEM_MODE = normalizeItemMode(
|
|
process.env.ITEM_MODE || process.env.SPAWNER_MODE || "bones",
|
|
);
|
|
const BUTTON_POSITION = parseEnvPosition("BUTTON");
|
|
const BULB_POSITION = parseEnvPosition("BULB");
|
|
|
|
let spawnerLoopRunning = false;
|
|
let joinPosition = null;
|
|
let hasJoined = false;
|
|
let spawnerPosition = null;
|
|
let reconnectTimer = null;
|
|
let bot = null;
|
|
let bothModeStep = 0;
|
|
|
|
function normalizeItemMode(value) {
|
|
const input = String(value || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (input === "both") return "both";
|
|
if (input === "arrow" || input === "arrows") return "arrows";
|
|
return "bones";
|
|
}
|
|
|
|
function parseEnvPosition(prefix) {
|
|
const compact = process.env[`${prefix}_POS`];
|
|
if (compact) {
|
|
const parsed = compact
|
|
.split(/[\s,]+/)
|
|
.filter(Boolean)
|
|
.map((v) => Number(v));
|
|
if (parsed.length === 3 && parsed.every(Number.isFinite)) {
|
|
return new Vec3(parsed[0], parsed[1], parsed[2]);
|
|
}
|
|
}
|
|
|
|
const x = Number(process.env[`${prefix}_X`]);
|
|
const y = Number(process.env[`${prefix}_Y`]);
|
|
const z = Number(process.env[`${prefix}_Z`]);
|
|
if ([x, y, z].every(Number.isFinite)) {
|
|
return new Vec3(x, y, z);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isBulbOn(block) {
|
|
if (!block) return null;
|
|
|
|
const props =
|
|
typeof block.getProperties === "function" ? block.getProperties() : null;
|
|
if (props && typeof props.lit === "boolean") return props.lit;
|
|
if (props && typeof props.powered === "boolean") return props.powered;
|
|
|
|
const name = String(block.name || "").toLowerCase();
|
|
if (name.includes("lit")) return true;
|
|
if (name.includes("unlit")) return false;
|
|
if (name.includes("redstone_lamp")) return false;
|
|
return null;
|
|
}
|
|
|
|
async function ensureBulbModeBeforeOpen(mode) {
|
|
if (!BULB_POSITION || !BUTTON_POSITION) {
|
|
pushLog("missing BULB_POS/BUTTON_POS in .env; skipping mode check");
|
|
return false;
|
|
}
|
|
|
|
const desiredBulbOn = mode === "arrows";
|
|
let bulbBlock = bot.blockAt(BULB_POSITION);
|
|
if (!bulbBlock || bulbBlock.name === "air") {
|
|
pushLog("bulb block not found at BULB_POS");
|
|
return false;
|
|
}
|
|
|
|
let currentBulbOn = isBulbOn(bulbBlock);
|
|
if (currentBulbOn === desiredBulbOn) {
|
|
return false;
|
|
}
|
|
|
|
const buttonBlock = bot.blockAt(BUTTON_POSITION);
|
|
if (!buttonBlock || buttonBlock.name === "air") {
|
|
pushLog("button block not found at BUTTON_POS");
|
|
return false;
|
|
}
|
|
|
|
for (let attempt = 1; attempt <= BULB_VERIFY_RETRIES; attempt += 1) {
|
|
try {
|
|
await bot.lookAt(buttonBlock.position.offset(0.5, 0.5, 0.5), true);
|
|
await bot.activateBlock(buttonBlock);
|
|
pushLog(
|
|
`clicked button to set bulb ${desiredBulbOn ? "on" : "off"} for ${mode} (attempt ${attempt}/${BULB_VERIFY_RETRIES})`,
|
|
);
|
|
await sleep(900);
|
|
|
|
bulbBlock = bot.blockAt(BULB_POSITION);
|
|
currentBulbOn = isBulbOn(bulbBlock);
|
|
if (currentBulbOn === desiredBulbOn) {
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
pushLog(`failed to click button: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
pushLog(
|
|
`bulb state still incorrect after ${BULB_VERIFY_RETRIES} attempts (wanted ${desiredBulbOn ? "on" : "off"})`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
function createBotInstance() {
|
|
hasJoined = false;
|
|
joinPosition = 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);
|
|
}
|
|
|
|
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(3000, () => {
|
|
pushLog("web ui running at http://localhost:3000");
|
|
});
|
|
|
|
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 =
|
|
typeof window.title === "string"
|
|
? window.title
|
|
: window.title && typeof window.title === "object"
|
|
? JSON.stringify(window.title)
|
|
: "custom gui";
|
|
|
|
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 stopSpawnerLoop(reason) {
|
|
if (!spawnerLoopRunning) return;
|
|
spawnerLoopRunning = 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);
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
const onOpen = () => {
|
|
clearTimeout(timeout);
|
|
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);
|
|
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}`);
|
|
}
|
|
|
|
async function dropAllArrowsFromCurrentWindow() {
|
|
if (!bot.currentWindow) {
|
|
pushLog("no window open to drop arrows");
|
|
return;
|
|
}
|
|
|
|
let dropped = 0;
|
|
while (true) {
|
|
const arrowStacks = getWindowItems().filter(
|
|
(item) => item && String(item.name || "").toLowerCase() === "arrow",
|
|
);
|
|
const arrowCount = arrowStacks.reduce(
|
|
(sum, item) => sum + (item.count || 0),
|
|
0,
|
|
);
|
|
|
|
if (arrowCount === 0) break;
|
|
|
|
const ok = await bot.gui
|
|
.Query()
|
|
.Window(comparator)
|
|
.Drop("Arrow", 64)
|
|
.end()
|
|
.run();
|
|
|
|
if (!ok) {
|
|
pushLog("failed to drop arrows from window");
|
|
break;
|
|
}
|
|
|
|
dropped += Math.min(64, arrowCount);
|
|
await sleep(150);
|
|
}
|
|
|
|
pushLog(`dropped arrows total: ${dropped}`);
|
|
}
|
|
|
|
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(mode) {
|
|
const toggled = await ensureBulbModeBeforeOpen(mode);
|
|
await sleep(toggled ? 1300 : 500);
|
|
const opened = await openSpawnerGui();
|
|
if (!opened) return;
|
|
|
|
if (mode === "arrows") {
|
|
await dropAllArrowsFromCurrentWindow();
|
|
} else {
|
|
await dropAllBonesFromCurrentWindow();
|
|
}
|
|
|
|
closeCurrentWindow();
|
|
}
|
|
|
|
async function runSpawnerCycle() {
|
|
if (ITEM_MODE === "both") {
|
|
const phase = bothModeStep < 2 ? "bones" : "arrows";
|
|
await runSpawnerPhase(phase);
|
|
bothModeStep = (bothModeStep + 1) % 4;
|
|
return;
|
|
}
|
|
|
|
await runSpawnerPhase(ITEM_MODE);
|
|
}
|
|
|
|
function startSpawnerLoop() {
|
|
if (spawnerLoopRunning) {
|
|
pushLog("spawner loop already running");
|
|
return;
|
|
}
|
|
|
|
if (!joinPosition) {
|
|
pushLog("join position not set yet; wait for spawn");
|
|
return;
|
|
}
|
|
|
|
spawnerLoopRunning = true;
|
|
bothModeStep = 0;
|
|
pushLog("spawner loop started");
|
|
|
|
(async () => {
|
|
while (spawnerLoopRunning) {
|
|
try {
|
|
await runSpawnerCycle();
|
|
} catch (err) {
|
|
pushLog(`cycle error: ${err.message}`);
|
|
}
|
|
|
|
if (!spawnerLoopRunning) break;
|
|
pushLog("waiting 10s for next cycle");
|
|
await sleep(LOOP_DELAY_MS);
|
|
}
|
|
})();
|
|
}
|
|
|
|
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)}`,
|
|
);
|
|
});
|
|
|
|
bot.on("physicsTick", () => {
|
|
if (
|
|
!spawnerLoopRunning ||
|
|
!joinPosition ||
|
|
!bot.entity ||
|
|
!bot.entity.position
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const movedDistance = bot.entity.position.distanceTo(joinPosition);
|
|
if (movedDistance > MAX_ALLOWED_MOVE_BLOCKS) {
|
|
stopSpawnerLoop(
|
|
`moved ${movedDistance.toFixed(2)} blocks from join position (> ${MAX_ALLOWED_MOVE_BLOCKS}), stopping loop`,
|
|
);
|
|
}
|
|
});
|
|
|
|
bot.on("kicked", (reason) => scheduleRejoinIfAlreadyOnline(reason));
|
|
bot.on("error", (err) => pushLog(`error: ${err.message}`));
|
|
}
|
|
|
|
function itemToString(item) {
|
|
if (item) {
|
|
return `${item.name} x ${item.count}`;
|
|
}
|
|
return "(nothing)";
|
|
}
|
|
|
|
createBotInstance();
|
|
|
|
if (BULB_POSITION && BUTTON_POSITION) {
|
|
pushLog(
|
|
`mode=${ITEM_MODE}, bulb=${BULB_POSITION.x},${BULB_POSITION.y},${BULB_POSITION.z}, button=${BUTTON_POSITION.x},${BUTTON_POSITION.y},${BUTTON_POSITION.z}`,
|
|
);
|
|
} else {
|
|
pushLog("set BULB_POS and BUTTON_POS in .env to enable mode checking");
|
|
}
|