Files
mc-bot-town/nodejs/controller/farm-supply.js
T
2026-07-12 17:26:59 -04:00

729 lines
23 KiB
JavaScript

'use strict';
const { sleep } = require('../utils');
const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
class FarmSupply {
constructor(args) {
this.bot = args.bot;
const settings = require('./settings/manager');
this.config = {
enabled: settings.get('farmSupply.enabled'),
storageBotName: settings.get('farmSupply.storageBotName'),
};
this._storageBotKey = this.config.storageBotName;
this.isAction = true;
this._onTimeListen = null;
this._onReadyListen = null;
this._resupplying = false;
}
get storageBotName() {
const storageBot = this.bot.constructor.bots[this._storageBotKey];
if (storageBot && storageBot.bot && storageBot.bot.entity) {
return storageBot.bot.entity.username;
}
return this._storageBotKey;
}
async init() {
if (!this._storageBotKey) {
console.log('FarmSupply: No storageBotName configured, plugin disabled');
return false;
}
console.log(`FarmSupply: Initialized for bot ${this.bot.name}, storage bot: ${this._storageBotKey}`);
this._onReadyListen = this.bot.on('onReady', async () => {
await sleep(3000);
let lastTimeOfDay = this.bot.bot.time.timeOfDay;
this._onTimeListen = this.bot.bot.on('time', async () => {
const currentTime = this.bot.bot.time.timeOfDay;
if (lastTimeOfDay < 12000 && currentTime >= 12000 && !this._resupplying) {
lastTimeOfDay = currentTime;
console.log('FarmSupply: Sunset detected, starting resupply');
try {
await this.resupply();
} catch (error) {
console.error('FarmSupply: Sunset resupply error:', error);
}
} else {
lastTimeOfDay = currentTime;
}
});
});
return true;
}
unload() {
if (this._onReadyListen) this._onReadyListen();
if (this._onTimeListen) this._onTimeListen = null;
return true;
}
// ========================================
// Pause / Resume farm plugins
// ========================================
pauseFarmPlugins() {
const paused = [];
for (const [name, plugin] of Object.entries(this.bot.plunginsLoaded)) {
if (name === 'FarmSupply' || name === 'AutoEat') continue;
if (plugin.isAction) {
console.log(`FarmSupply: Pausing plugin ${name}`);
try {
plugin.unload();
} catch (error) {
console.error(`FarmSupply: Error pausing ${name}:`, error);
}
paused.push(name);
delete this.bot.plunginsLoaded[name];
}
}
return paused;
}
async resumeFarmPlugins(paused) {
for (const name of paused) {
console.log(`FarmSupply: Resuming plugin ${name}`);
try {
await this.bot.pluginLoad(name);
} catch (error) {
console.error(`FarmSupply: Error resuming ${name}:`, error);
}
}
}
// ========================================
// Wait after trade teleport — let server tp us back and chunks load
// ========================================
async settleAfterTrade() {
console.log('FarmSupply: Settling after trade teleport...');
await sleep(3000);
// Wait for pathfinder to be idle
while (this.bot.bot.pathfinder.isMoving()) {
this.bot.bot.clearControlStates();
await sleep(500);
}
this.bot.bot.clearControlStates();
await sleep(1000);
}
// ========================================
// Main resupply flow
// ========================================
async resupply() {
if (this._resupplying) {
console.log('FarmSupply: Already resupplying, skipping');
return;
}
this._resupplying = true;
console.log('FarmSupply: Starting resupply...');
const paused = this.pauseFarmPlugins();
try {
await this.emptyFilledBoxes();
await this.fillEmptyShulkers();
} catch (error) {
console.error('FarmSupply: Resupply error:', error);
} finally {
// Always resume the farm and clear the flag — a stuck trade must
// not leave the farm paused until restart
try {
await this.resumeFarmPlugins(paused);
} catch (resumeError) {
console.error('FarmSupply: Error resuming farm plugins:', resumeError);
}
this._resupplying = false;
console.log('FarmSupply: Resupply complete, farm resumed.');
}
}
// ========================================
// Storage bot access
// ========================================
/**
* Bring the on-demand storage bot online (with plugins loaded) and return
* its Storage plugin instance. Returns null if unavailable.
*/
async _ensureStorageOnline() {
const storageBot = this.bot.constructor.bots[this._storageBotKey];
if (!storageBot) {
console.log(`FarmSupply: Storage bot '${this._storageBotKey}' not configured`);
return null;
}
if (!storageBot.isReady) {
console.log('FarmSupply: Bringing storage bot online...');
try {
await Promise.race([
storageBot.ensureConnected(async () => {}),
sleep(60000).then(() => { throw new Error('Storage bot connect timeout'); }),
]);
} catch (error) {
console.error(`FarmSupply: Could not bring storage bot online: ${error.message}`);
return null;
}
}
return storageBot.plunginsLoaded['Storage'] || null;
}
// ========================================
// Empty "filled boxes" chest
// ========================================
async emptyFilledBoxes() {
let filledChest;
try {
filledChest = this.bot.findBlockBySign('filled boxes');
} catch (error) {
console.log('FarmSupply: No "filled boxes" chest found, skipping');
return;
}
if (!filledChest) {
console.log('FarmSupply: No "filled boxes" chest found, skipping');
return;
}
console.log('FarmSupply: Processing filled boxes chest');
// Boxes left in inventory by a previously failed deposit go first,
// before taking more from the chest
const leftover = this.bot.bot.inventory.items().filter(i => i.name.includes('shulker_box'));
if (leftover.length > 0) {
console.log(`FarmSupply: ${leftover.length} leftover box(es) in inventory, depositing those first`);
// Player inventory slot 9+k maps to window.inventoryStart+k
await this.tradeDeposit(leftover.slice(0, 12).map(i => i.slot - 9));
await this.waitForStorageBotIdle();
await this.settleAfterTrade();
}
let hasMore = true;
while (hasMore) {
await this.bot.goToMust({ where: filledChest.position, range: 2 });
let window = await this.bot.openContainer(this.bot.findChestBySign('filled boxes'));
await sleep(300);
// Track slots RELATIVE to inventoryStart — the trade window has a
// different inventoryStart than this chest window, so absolute slot
// numbers from here would point at the wrong items there
const takenSlots = [];
for (let chestSlot = 0; chestSlot < window.inventoryStart; chestSlot++) {
if (takenSlots.length >= 12) break;
const item = window.slots[chestSlot];
if (!item || !item.name.includes('shulker_box')) continue;
const destSlot = window.inventoryStart + takenSlots.length;
if (window.slots[destSlot]) {
console.log(`FarmSupply: Inventory slot ${destSlot} occupied, finding free slot`);
// Find an actually empty slot
let found = false;
for (let freeSlot = window.inventoryStart; freeSlot < window.inventoryEnd; freeSlot++) {
if (!window.slots[freeSlot]) {
await this.bot.bot.moveSlotItem(chestSlot, freeSlot);
await sleep(200);
takenSlots.push(freeSlot - window.inventoryStart);
found = true;
break;
}
}
if (!found) {
console.log('FarmSupply: No empty inventory slots, stopping');
break;
}
} else {
await this.bot.bot.moveSlotItem(chestSlot, destSlot);
await sleep(200);
takenSlots.push(destSlot - window.inventoryStart);
}
}
await this.bot.bot.closeWindow(window);
await sleep(300);
if (takenSlots.length === 0) {
console.log('FarmSupply: No more filled shulker boxes to deposit');
hasMore = false;
break;
}
console.log(`FarmSupply: Took ${takenSlots.length} shulker boxes, initiating trade deposit`);
await this.tradeDeposit(takenSlots);
await this.waitForStorageBotIdle();
await this.settleAfterTrade();
}
}
// ========================================
// Fill "empty shulkers" chest
// ========================================
async fillEmptyShulkers() {
let emptyChest;
try {
emptyChest = this.bot.findBlockBySign('empty shulkers');
} catch (error) {
console.log('FarmSupply: No "empty shulkers" chest found, skipping');
return;
}
if (!emptyChest) {
console.log('FarmSupply: No "empty shulkers" chest found, skipping');
return;
}
console.log('FarmSupply: Processing empty shulkers chest');
await this.bot.goToMust({ where: emptyChest.position, range: 2 });
let window = await this.bot.openContainer(this.bot.findChestBySign('empty shulkers'));
await sleep(300);
let emptySlots = 0;
for (let chestSlot = 0; chestSlot < window.inventoryStart; chestSlot++) {
if (!window.slots[chestSlot]) emptySlots++;
}
await this.bot.bot.closeWindow(window);
await sleep(300);
if (emptySlots === 0) {
console.log('FarmSupply: Empty shulkers chest is full, skipping');
return;
}
console.log(`FarmSupply: Need ${emptySlots} shulker boxes`);
// Check current inventory for materials
let shellsInInv = 0;
let chestsInInv = 0;
let shulkerBoxesInInv = 0;
for (const item of this.bot.bot.inventory.items()) {
if (item.name === 'shulker_shell') shellsInInv += item.count;
if (item.name === 'chest') chestsInInv += item.count;
if (item.name.includes('shulker_box')) shulkerBoxesInInv += item.count;
}
const canCraft = Math.min(Math.floor(shellsInInv / 2), chestsInInv);
const totalAvailable = shulkerBoxesInInv + canCraft;
const needed = Math.min(emptySlots, 27);
if (totalAvailable < needed) {
const toCraft = needed - shulkerBoxesInInv;
const shellsNeeded = Math.max(0, (toCraft * 2) - shellsInInv);
const chestsNeeded = Math.max(0, toCraft - chestsInInv);
if (shellsNeeded > 0) {
console.log(`FarmSupply: Withdrawing ${shellsNeeded} shulker_shell from storage`);
await this.tradeWithdraw('shulker_shell', shellsNeeded);
await this.waitForStorageBotIdle();
await this.settleAfterTrade();
}
if (chestsNeeded > 0) {
console.log(`FarmSupply: Withdrawing ${chestsNeeded} chest from storage`);
await this.tradeWithdraw('chest', chestsNeeded);
await this.waitForStorageBotIdle();
await this.settleAfterTrade();
}
}
// Recount after possible withdrawal
shellsInInv = 0;
chestsInInv = 0;
shulkerBoxesInInv = 0;
for (const item of this.bot.bot.inventory.items()) {
if (item.name === 'shulker_shell') shellsInInv += item.count;
if (item.name === 'chest') chestsInInv += item.count;
if (item.name.includes('shulker_box')) shulkerBoxesInInv += item.count;
}
// Craft shulker boxes
const craftCount = Math.min(Math.floor(shellsInInv / 2), chestsInInv, needed - shulkerBoxesInInv);
if (craftCount > 0) {
console.log(`FarmSupply: Crafting ${craftCount} shulker boxes`);
for (let i = 0; i < craftCount; i++) {
try {
await this.craftShulkerBox();
await sleep(300);
} catch (error) {
console.error('FarmSupply: Crafting error:', error);
break;
}
}
}
// Re-find chest and dump shulker boxes
emptyChest = this.bot.findChestBySign('empty shulkers');
await this.bot.goToMust({ where: emptyChest.position, range: 2 });
window = await this.bot.openContainer(emptyChest);
await sleep(300);
let deposited = 0;
for (let invSlot = window.inventoryStart; invSlot < window.inventoryEnd; invSlot++) {
const item = window.slots[invSlot];
if (item && item.name.includes('shulker_box')) {
let targetSlot = null;
for (let emptyChestSlot = 0; emptyChestSlot < window.inventoryStart; emptyChestSlot++) {
if (!window.slots[emptyChestSlot]) {
targetSlot = emptyChestSlot;
break;
}
}
if (targetSlot === null) break;
try {
await this.bot.bot.moveSlotItem(invSlot, targetSlot);
await sleep(200);
deposited++;
} catch (error) {
console.error('FarmSupply: Error depositing shulker box:', error);
}
}
}
await this.bot.bot.closeWindow(window);
console.log(`FarmSupply: Deposited ${deposited} shulker boxes into empty shulkers chest`);
}
// ========================================
// Trade: Withdraw items from storage bot
// ========================================
async tradeWithdraw(itemName, count) {
const storageName = this.storageBotName;
console.log(`FarmSupply: Requesting ${count}x ${itemName} from ${storageName}`);
// Both bots run in this process — call the storage plugin directly
// instead of whispering a chat command (which raced against the storage
// bot's command lock and required the bot to already be online)
const storage = await this._ensureStorageOnline();
if (!storage) throw new Error(`Storage bot unavailable for ${itemName} withdraw`);
// Listen for the "ready — sending trade request" whisper the storage
// bot sends right before initiating /trade with us
let cleanupWhisper = () => {};
const whisperPromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanupWhisper();
reject(new Error(`Timeout waiting for ${storageName} to prepare ${itemName}`));
}, 180000);
const onWhisper = (from, message) => {
if (from !== storageName) return;
if (message.includes('ready — sending trade')) {
cleanupWhisper();
resolve();
} else if (message.includes('Item not found') || message.includes('Failed to withdraw')
|| message.includes('Cannot find') || message.includes('Storage busy')) {
cleanupWhisper();
reject(new Error(`Storage bot: ${message}`));
}
};
cleanupWhisper = () => {
this.bot.bot.removeListener('whisper', onWhisper);
clearTimeout(timeout);
};
this.bot.bot.on('whisper', onWhisper);
});
// Fire the withdraw — it pulls items from shulkers (can take minutes),
// then trades with us. Don't await yet: we have to accept its trade
// for it to complete.
const myUsername = this.bot.bot.entity.username;
const requestPromise = storage.handleWithdrawRequest(myUsername, itemName, count)
.catch(error => console.error(`FarmSupply: Storage withdraw error: ${error.message}`));
try {
await whisperPromise;
} catch (error) {
await requestPromise;
throw error;
}
// Poll /trade accept until the trade window opens.
// Set up windowOpen listener BEFORE chat to avoid missing the event.
let window = null;
const tradeStart = Date.now();
while (!window && (Date.now() - tradeStart) < 45000) {
const winPromise = this.bot.once('windowOpen');
await this.bot.bot.chat('/trade accept');
window = await Promise.race([winPromise, sleep(2000).then(() => null)]);
}
if (!window) throw new Error(`Trade window did not open with ${storageName}`);
// Click 1: lock our items — single left-click (moveSlotItem's
// pickup+putdown pair desyncs on the cancelled GUI slot and trips
// anti-cheat)
await sleep(500);
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
console.log('FarmSupply: Trade click 1 — items locked');
// Wait for storage bot to lock (slot 53 turns lime_dye), bounded —
// an unbounded poll here used to hang resupply forever on a dead trade
const locked = await this._waitForTradeLock(window, 120000);
if (!locked) {
try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ }
await requestPromise;
throw new Error(`${storageName} never confirmed the ${itemName} trade`);
}
// Click 2: final confirmation
if (this.bot.bot.currentWindow === window) {
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
console.log('FarmSupply: Trade click 2 — final confirm');
}
// Wait for trade to complete
await Promise.race([
this.bot.once('windowClose'),
sleep(120000),
]);
await requestPromise;
await sleep(500);
console.log(`FarmSupply: Withdraw trade complete for ${itemName}`);
}
/**
* Poll the trade window for the other party's lock indicator (slot 53
* turning lime_dye). Resolves true when locked, false on timeout or if
* the window closes.
*/
async _waitForTradeLock(window, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const indicator = window.slots[53];
if (indicator && indicator.name === 'lime_dye') {
console.log('FarmSupply: Storage bot has locked');
return true;
}
} catch (e) {
return false; // window closed
}
if (this.bot.bot.currentWindow !== window) return false;
await sleep(500);
}
return false;
}
// ========================================
// Trade: Deposit items to storage bot
// ========================================
async tradeDeposit(slotsToTrade) {
console.log(`FarmSupply: Depositing ${slotsToTrade.length} shulker boxes to ${this.storageBotName}`);
// Storage bot is on-demand — it must be online to accept the trade
const storage = await this._ensureStorageOnline();
if (!storage) throw new Error('Storage bot unavailable for deposit');
const windowPromise = this.bot.once('windowOpen');
await this.bot.say(`/trade ${this.storageBotName}`);
let window = await Promise.race([
windowPromise,
sleep(45000).then(() => null),
]);
if (!window) throw new Error(`Trade window with ${this.storageBotName} never opened`);
// slotsToTrade holds inventory-relative indices — offset them into
// this window's inventory section
let placed = 0;
for (const tradeSlot of botSlots) {
if (placed >= slotsToTrade.length) break;
try {
await this.bot.bot.moveSlotItem(window.inventoryStart + slotsToTrade[placed], tradeSlot);
await sleep(200);
placed++;
} catch (error) {
console.error('FarmSupply: Error placing item in trade:', error);
}
}
console.log(`FarmSupply: Placed ${placed} shulker boxes in trade window`);
// Click 1: lock items (green wool — first confirmation, single click)
try {
await this.bot.bot.clickWindow(37, 0, 0);
console.log('FarmSupply: Trade click 1 — items locked');
} catch (error) {
console.error('FarmSupply: Error on trade click 1:', error);
}
// Wait for storage bot to lock (slot 53 turns lime_dye from grey_dye)
const locked = await this._waitForTradeLock(window, 120000);
if (!locked) {
try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ }
throw new Error('Storage bot never confirmed the deposit trade');
}
// Click 2: final confirmation (green wool — second click, both locked)
try {
if (this.bot.bot.currentWindow === window) {
await this.bot.bot.clickWindow(37, 0, 0);
console.log('FarmSupply: Trade click 2 — final confirm');
}
} catch (error) {
console.error('FarmSupply: Error on trade click 2:', error);
}
try {
await Promise.race([
this.bot.once('windowClose'),
sleep(30000),
]);
} catch (error) {
console.error('FarmSupply: Trade deposit timeout or error:', error);
}
await sleep(500);
console.log(`FarmSupply: Deposit trade complete`);
}
// ========================================
// Wait for storage bot to finish processing
// ========================================
async waitForStorageBotIdle() {
const storageBot = this.bot.constructor.bots[this._storageBotKey];
if (!storageBot) return;
const storage = storageBot.plunginsLoaded['Storage'];
if (!storage) return;
console.log('FarmSupply: Waiting for storage bot to finish processing...');
const maxWait = 300000;
const start = Date.now();
const isBusy = () => storage._busy || storage._operationLock;
while (Date.now() - start < maxWait) {
if (!isBusy()) {
// There's a short gap between the trade handler releasing the
// lock and the post-trade organize grabbing it — require the
// bot to stay idle across a re-check before trusting it
await sleep(3000);
if (!isBusy()) break;
}
await sleep(2000);
}
if (isBusy()) {
console.log('FarmSupply: Storage bot still busy after timeout, continuing anyway');
} else {
console.log('FarmSupply: Storage bot idle');
}
await sleep(1000);
}
// ========================================
// Craft a shulker box
// ========================================
async craftShulkerBox() {
let craftingTable;
try {
const signBlock = this.bot.findBlockBySign('crafting table');
if (signBlock) {
craftingTable = this.bot.bot.findBlock({
point: signBlock.position,
matching: this.bot.mcData.blocksByName.crafting_table.id,
maxDistance: 4,
});
}
} catch (error) {
// No sign, search nearby
}
if (!craftingTable) {
craftingTable = this.bot.bot.findBlock({
matching: this.bot.mcData.blocksByName.crafting_table.id,
maxDistance: 64,
});
}
if (!craftingTable) {
throw new Error('FarmSupply: No crafting table found nearby');
}
await this.bot.goToMust({ where: craftingTable.position, range: 2 });
const recipe = this.bot.bot.recipesAll(
this.bot.mcData.itemsByName.shulker_box.id,
null,
craftingTable
)[0];
if (!recipe) {
throw new Error('FarmSupply: No recipe found for shulker box');
}
const window = await this.bot.openCraftingTable(craftingTable);
const inventory = window.slots.slice(window.inventoryStart, window.inventoryEnd);
const ingredientsByType = {};
for (let row = 0; row < recipe.inShape.length; row++) {
for (let col = 0; col < recipe.inShape[row].length; col++) {
const shape = recipe.inShape[row][col];
if (shape.id === -1) continue;
const gridSlot = row * 3 + col + 1;
if (!ingredientsByType[shape.id]) ingredientsByType[shape.id] = [];
ingredientsByType[shape.id].push(gridSlot);
}
}
for (const [typeId, gridSlots] of Object.entries(ingredientsByType)) {
const invIdx = inventory.findIndex(el => el && el.type === parseInt(typeId));
if (invIdx === -1) {
await window.close();
throw new Error(`FarmSupply: Missing ingredient type ${typeId} in inventory`);
}
const actualSlot = window.inventoryStart + invIdx;
await this.bot.bot.clickWindow(actualSlot, 0, 0);
await sleep(100);
for (const gridSlot of gridSlots) {
await this.bot.bot.clickWindow(gridSlot, 1, 0);
await sleep(100);
}
await this.bot.bot.clickWindow(actualSlot, 0, 0);
await sleep(100);
}
await sleep(500);
if (window.slots[0]) {
let outputSlot = null;
for (let j = window.inventoryStart; j < window.inventoryEnd; j++) {
if (!window.slots[j]) { outputSlot = j; break; }
}
if (outputSlot === null) outputSlot = window.inventoryStart;
await this.bot.bot.clickWindow(0, 0, 0);
await sleep(100);
await this.bot.bot.clickWindow(outputSlot, 0, 0);
await sleep(100);
}
await window.close();
await sleep(300);
console.log('FarmSupply: Crafted a shulker box');
}
}
FarmSupply.getStatus = function(instance) {
return {
active: true,
storageBotName: instance._storageBotKey,
resupplying: instance._resupplying,
};
};
module.exports = FarmSupply;