'use strict'; const Vec3 = require('vec3'); const conf = require('../../conf'); const { sleep } = require('../../utils'); const Database = require('./database'); const Scanner = require('./scanner'); const ShulkerHandler = require('./shulker-handler'); const StorageWeb = require('./web'); class Storage { static createRouter = StorageWeb.createRouter; static webUI = StorageWeb.webUI; constructor(args) { console.log('Storage: Constructor called'); this.bot = args.bot; this.config = { ...conf.storage, ...args }; this.isReady = false; this.shulkerHandler = new ShulkerHandler(); this.pendingWithdrawals = new Map(); // playerName → { itemName, count, mode, timeoutId } this._craftAvailable = true; // reset when crafting fails, so we don't spam retries this._busy = false; // true during organize/withdraw/trade to block interval restock } init() { console.log('Storage: init() called'); return new Promise((resolve, reject) => { this.bot.on('onReady', async () => { try { console.log(`Storage: Bot ${this.bot.name} is ready, initializing storage system...`); // Initialize database if (!Database.db) { console.log('Storage: Initializing database...'); await Database.initialize(this.config.dbPath || './storage/storage.db'); } else { console.log('Storage: Database already initialized'); } // Initialize scanner console.log('Storage: Creating Scanner...'); this.scanner = new Scanner(); this.shulkerHandler.scanner = this.scanner; if (this.config.startupTasks) { console.log('Storage: Running startup tasks...'); await this.config.startupTasks(); } this.isReady = true; console.log('Storage: Initialization complete! Ready to use.'); if (!this.bot.onDemand) { // Initial hotbar restock try { await this.restockHotbar(); } catch (error) { console.error('Storage: Initial hotbar restock failed:', error.message); } // Start hotbar restock interval const restockInterval = this.config.hotbarRestockInterval || 60000; this.hotbarInterval = setInterval(async () => { try { await this.restockHotbar(); } catch (error) { console.error('Storage: Hotbar restock interval failed:', error.message); } }, restockInterval); // Start periodic inventory cleanup const cleanupInterval = this.config.inventoryCleanupInterval || 5 * 60 * 1000; this.cleanupInterval = setInterval(async () => { if (!this.isReady || this._busy || this.shulkerHandler.operationInProgress) return; try { await this.cleanInventory(); } catch (error) { console.error('Storage: Periodic inventory cleanup failed:', error.message); } }, cleanupInterval); } resolve(); } catch (error) { console.error('Storage: Error in init:', error); reject(error); } }); }); } async unload(keepDb = false) { console.log('Storage: Unloading...'); if (this.hotbarInterval) { clearInterval(this.hotbarInterval); this.hotbarInterval = null; } if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } // Clear any pending withdrawal timeouts for (const [, pending] of this.pendingWithdrawals) { if (pending.timeoutId) clearTimeout(pending.timeoutId); } this.pendingWithdrawals.clear(); if (this.scanner) delete this.scanner; if (!keepDb && Database.db) { console.log('Storage: Closing database...'); Database.close(); } this.isReady = false; console.log('Storage: Unload complete'); } async scanArea(force = false) { console.log(`Storage[${this.bot.name}]: Scanning storage area...`); const chests = await this.scanner.discoverChests(this.bot, this.config.scanRadius || 30, Database); const shulkers = await this.scanner.scanAllChests(this.bot, Database); console.log(`Storage[${this.bot.name}]: Complete - ${chests.length} chests, ${shulkers} shulkers`); } // ======================================== // Hotbar Management // ======================================== async restockHotbar() { if (!this.isReady) return; if (this._busy) { console.log('Storage: Skipping hotbar restock — storage operation in progress'); return; } if (this.shulkerHandler.operationInProgress) { console.log('Storage: Skipping hotbar restock — shulker operation in progress'); return; } const hotbarItems = this.config.hotbarItems || []; if (hotbarItems.length === 0) return; for (const spec of hotbarItems) { // Count current inventory let currentCount = 0; for (const item of this.bot.bot.inventory.items()) { if (item.name === spec.name) { currentCount += item.count; } } if (currentCount >= spec.min) continue; const needed = spec.target - currentCount; console.log(`Storage: Hotbar restock — ${spec.name}: have ${currentCount}, need ${needed} more (target ${spec.target})`); // Check storage availability const available = await Database.getItemTotalCount(spec.name); if (available === 0) { console.log(`Storage: No ${spec.name} in storage, skipping`); continue; } const toWithdraw = Math.min(needed, available); const shulkers = await Database.findShulkersWithItem(spec.name); let remaining = toWithdraw; let consecutiveFailures = 0; for (const shulker of shulkers) { if (remaining <= 0) break; if (consecutiveFailures >= 2) { console.log(`Storage: Too many failures restocking ${spec.name}, giving up`); break; } const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); try { const { withdrawn, updatedSlotItem } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, shulker.slot, spec.name, remaining, shulker.id, shulker.chest_id ); if (withdrawn > 0) { remaining -= withdrawn; consecutiveFailures = 0; } else { consecutiveFailures++; } } catch (error) { console.error(`Storage: Hotbar restock error for ${spec.name}:`, error.message); consecutiveFailures++; } } console.log(`Storage: Restocked ${toWithdraw - remaining}x ${spec.name}`); } await Database.rebuildItemIndex(); } // ======================================== // Deposit Flow // ======================================== async handleTrade(playerName, itemsReceived) { console.log(`Storage[${this.bot.name}]: Processing trade from ${playerName}, received ${itemsReceived.length} item types`); this._craftAvailable = true; // new items may include craft materials this._busy = true; try { // Log trade await Database.logTrade(playerName, 'deposit', itemsReceived); // Unpack any traded shulker boxes before depositing const hasShulkers = itemsReceived.some(item => item.name.includes('shulker_box')); if (hasShulkers) { console.log('Storage: Traded items include shulker boxes, unpacking...'); await this.unpackTradedShulkers(); } // After unpacking, read current bot inventory for depositing // Skip hotbar items and shulker boxes (already stored by unpack) const hotbarNames = new Set((this.config.hotbarItems || []).map(h => h.name)); const grouped = {}; for (const item of this.bot.bot.inventory.items()) { if (hotbarNames.has(item.name)) continue; if (item.name.includes('shulker_box')) continue; if (!grouped[item.name]) { grouped[item.name] = 0; } grouped[item.name] += item.count; } // Deposit each item type const results = []; for (const [itemName, totalCount] of Object.entries(grouped)) { try { const result = await this.depositItemType(itemName, totalCount); results.push({ itemName, requested: totalCount, deposited: result.deposited }); } catch (error) { console.error(`Storage: Error depositing ${itemName}:`, error); results.push({ itemName, requested: totalCount, deposited: 0, error: error.message }); } } // Rebuild index after all deposits await Database.rebuildItemIndex(); // Whisper summary to player const summary = results.map(r => { if (r.error) return `${r.itemName}: FAILED (${r.error})`; return `${r.itemName}: ${r.deposited}/${r.requested}`; }).join(', '); this.bot.whisper(playerName, `Storage complete: ${summary}`); return results; } finally { this._busy = false; } } /** * Process all shulker boxes in bot inventory from a trade. * Sorted shulkers (single item type) are stored directly — no unpack needed. * Mixed shulkers are unpacked, items deposited, then empty box stored. * Deposits between each shulker to keep inventory space free. */ async unpackTradedShulkers() { // Phase 1: Store ALL shulkers from inventory into chests. // This frees inventory space and registers contents in DB via NBT scan. // Sorted/empty shulkers are done after this. Mixed ones need phase 2. const mixedShulkers = []; // { chestId, chestSlot, chestPos, shulkerId } console.log('Storage: Phase 1 — stashing all traded shulkers into chests'); let stashCount = 0; while (stashCount < 50) { const shulkerItem = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (!shulkerItem) break; stashCount++; const contents = this.scanner.extractShulkerContents(this.bot, shulkerItem); const itemTypes = new Set(contents.map(c => c.name)); const isMixed = contents.length > 0 && itemTypes.size > 1; const label = isMixed ? `mixed (${itemTypes.size} types)` : contents.length === 0 ? 'empty' : `sorted (${[...itemTypes][0]})`; console.log(`Storage: Stashing shulker ${stashCount}: ${label}`); try { const info = await this.storeShulker(shulkerItem); if (!info) { console.error('Storage: No empty chest slot, cannot stash more shulkers'); break; } if (isMixed) { mixedShulkers.push(info); } } catch (error) { console.error(`Storage: Error stashing shulker:`, error.message); break; } } console.log(`Storage: Phase 1 complete — stashed ${stashCount} shulkers, ${mixedShulkers.length} need unpacking`); // Phase 2: Pull each mixed shulker back out, unpack it, deposit items, store empty box. // Bot inventory is now empty (aside from hotbar), so we have room. for (let i = 0; i < mixedShulkers.length; i++) { const info = mixedShulkers[i]; console.log(`Storage: Phase 2 — unpacking mixed shulker ${i + 1}/${mixedShulkers.length} (DB #${info.shulkerId})`); try { // Take the shulker from the chest into inventory await this.shulkerHandler.takeWholeShulker( this.bot, info.chestPos, info.chestSlot, info.shulkerId ); // Delete from DB since we're about to empty it await Database.deleteShulker(info.shulkerId); // Find the shulker in inventory and unpack it const shulkerItem = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (!shulkerItem) { console.error('Storage: Shulker not found in inventory after taking from chest'); continue; } const { extracted, inventoryFull } = await this.shulkerHandler.unpackShulkerFromInventory(this.bot, shulkerItem); console.log(`Storage: Extracted ${extracted.length} item stacks from mixed shulker`); // Deposit all extracted items await this._depositNonShulkerInventory(); // Store the empty shulker box back into a chest const emptyBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (emptyBox) { try { await this.storeShulker(emptyBox); } catch (e) { console.error(`Storage: Error storing empty box:`, e.message); } } } catch (error) { console.error(`Storage: Error unpacking mixed shulker #${info.shulkerId}:`, error.message); // Deposit whatever we managed to extract await this._depositNonShulkerInventory(); // Try to store any shulker box left in inventory const leftover = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (leftover) { try { await this.storeShulker(leftover); } catch (e) { /* best effort */ } } } } // Final cleanup: store any shulker boxes still in inventory let remaining = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')); for (const leftover of remaining) { try { await this.storeShulker(leftover); } catch (e) { console.error(`Storage: Error storing leftover shulker:`, e.message); } } console.log('Storage: All traded shulkers processed'); } /** * Deposit all non-shulker, non-hotbar items from bot inventory. * Used during trade unpack to free inventory space between shulkers. */ async _depositNonShulkerInventory() { const hotbarNames = new Set((this.config.hotbarItems || []).map(h => h.name)); const todeposit = {}; for (const item of this.bot.bot.inventory.items()) { if (item.name.includes('shulker_box')) continue; if (hotbarNames.has(item.name)) continue; if (!todeposit[item.name]) todeposit[item.name] = 0; todeposit[item.name] += item.count; } for (const [itemName, count] of Object.entries(todeposit)) { try { await this.depositItemType(itemName, count); } catch (error) { console.error(`Storage: Error depositing ${itemName} during unpack:`, error.message); } } } /** * Store a shulker box from bot inventory into an empty chest slot. * Scans NBT to register contents in DB. * @returns {{ chestId, chestSlot, shulkerId, itemFocus }} or false on failure */ async storeShulker(shulkerItem) { console.log(`Storage: Storing shulker box (${shulkerItem.name})`); const emptySlot = await Database.findEmptyChestSlot(); if (!emptySlot) { console.log('Storage: No empty chest slot for shulker'); return false; } const chestPos = new Vec3(emptySlot.pos_x, emptySlot.pos_y, emptySlot.pos_z); await this.bot.goTo({ where: chestPos, range: 3 }); const chestBlock = this.bot.bot.blockAt(chestPos); const window = await this.bot.openContainer(chestBlock); await sleep(300); // Find the specific shulker in inventory portion of the window by matching slot let shulkerWindowSlot = null; for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { const item = window.slots[i]; if (item && item.slot === shulkerItem.slot && item.name.includes('shulker_box')) { shulkerWindowSlot = i; break; } } // Fallback: find any shulker box if slot match failed if (shulkerWindowSlot === null) { for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { const item = window.slots[i]; if (item && item.name.includes('shulker_box')) { shulkerWindowSlot = i; break; } } } if (shulkerWindowSlot === null) { await this.bot.bot.closeWindow(window); console.log('Storage: Shulker not found in window inventory'); return false; } await this.bot.bot.moveSlotItem(shulkerWindowSlot, emptySlot.slot); await sleep(300); // Read NBT from the placed slot and scan actual contents const placedItem = window.slots[emptySlot.slot]; await this.bot.bot.closeWindow(window); await sleep(200); // Register in DB with actual contents via NBT scan let shulkerRecord = null; if (placedItem) { await this.scanner.scanShulkerFromNBT(this.bot, Database, emptySlot.chest_id, emptySlot.slot, placedItem); // Look up the record that was just created/updated const chestShulkers = await Database.getShulkersByChest(emptySlot.chest_id); shulkerRecord = chestShulkers.find(s => s.slot === emptySlot.slot); } const info = { chestId: emptySlot.chest_id, chestSlot: emptySlot.slot, chestPos, shulkerId: shulkerRecord ? shulkerRecord.id : null, itemFocus: shulkerRecord ? shulkerRecord.item_focus : null, }; console.log(`Storage: Shulker stored at chest ${info.chestId}, slot ${info.chestSlot} (focus: ${info.itemFocus || 'mixed/empty'})`); return info; } /** * Check whether a mineflayer inventory item has special NBT (custom name, lore, custom model data). */ _isItemSpecial(item) { if (!item.nbt) return false; const tag = item.nbt?.value?.tag?.value || item.nbt?.value || item.nbt; const parsed = this.scanner.parseNBT(tag); return Scanner.isSpecialItem(parsed); } async depositItemType(itemName, count, excludeShulkerId = null) { console.log(`Storage: Depositing ${count}x ${itemName}`); // Split inventory items into regular and special const allInvItems = this.bot.bot.inventory.items().filter(i => i.name === itemName); const regularCount = allInvItems.filter(i => !this._isItemSpecial(i)).reduce((s, i) => s + i.count, 0); const specialCount = allInvItems.filter(i => this._isItemSpecial(i)).reduce((s, i) => s + i.count, 0); let totalDeposited = 0; // Deposit regular items first (filter out special ones during shift-click) if (regularCount > 0) { const toDeposit = Math.min(regularCount, count); const result = await this._depositItemBatch( itemName, toDeposit, itemName, (item) => !this._isItemSpecial(item), excludeShulkerId ); totalDeposited += result.deposited; } // Then deposit special items (filter out regular ones during shift-click) if (specialCount > 0 && totalDeposited < count) { const remaining = Math.min(specialCount, count - totalDeposited); console.log(`Storage: Depositing ${remaining}x ${itemName} (special) separately`); const result = await this._depositItemBatch( itemName, remaining, itemName + '#special', (item) => this._isItemSpecial(item), excludeShulkerId ); totalDeposited += result.deposited; } console.log(`Storage: Deposited ${totalDeposited}/${count} ${itemName}`); return { deposited: totalDeposited }; } /** * Internal: deposit items of a given type into shulkers with a specific focus. * @param {string} itemName - The minecraft item name to match * @param {number} count - How many to deposit * @param {string} focus - The shulker item_focus to target (e.g. 'diamond_sword' or 'diamond_sword#special') * @param {Function|null} itemFilter - Filter passed to depositIntoShulker to control which slots get shift-clicked * @param {number|null} excludeShulkerId - Shulker ID to skip */ async _depositItemBatch(itemName, count, focus, itemFilter, excludeShulkerId = null) { let remaining = count; let totalDeposited = 0; let failedAttempts = 0; while (remaining > 0) { if (failedAttempts >= 3) { console.log(`Storage: ${failedAttempts} consecutive deposit failures for ${itemName} (focus=${focus}), giving up`); break; } // Verify we actually still have matching items in inventory const actualInvItems = this.bot.bot.inventory.items().filter(i => i.name === itemName); const matchingCount = itemFilter ? actualInvItems.filter(itemFilter).reduce((sum, i) => sum + i.count, 0) : actualInvItems.reduce((sum, i) => sum + i.count, 0); if (matchingCount === 0) { console.log(`Storage: No more matching ${itemName} in inventory, done`); break; } remaining = Math.min(remaining, matchingCount); // Try to find an existing shulker with this focus that has space let shulker = await Database.findShulkerWithSpace(focus, excludeShulkerId); let wasEmpty = false; console.log(`Storage: findShulkerWithSpace('${focus}') → ${shulker ? `shulker ${shulker.id} (slot_count=${shulker.slot_count}, focus=${shulker.item_focus})` : 'none'}`); if (!shulker) { // No matching shulker, find an empty one shulker = await Database.findEmptyShulker(excludeShulkerId); if (!shulker) { // No empty shulkers - try crafting one (skip if crafting already failed this cycle) if (this._craftAvailable) { console.log(`Storage: No empty shulkers, attempting to craft one`); try { await this.craftShulkerBox(); shulker = await Database.findEmptyShulker(); } catch (error) { console.error('Storage: Failed to craft shulker:', error.message); this._craftAvailable = false; } } if (!shulker) { console.log(`Storage: Cannot deposit ${remaining}x ${itemName} - no available shulkers`); break; } } // Set the item focus for this empty shulker await Database.updateShulkerItemFocus(shulker.id, focus); wasEmpty = true; } const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); try { const { deposited, updatedSlotItem } = await this.shulkerHandler.depositIntoShulker( this.bot, chestPos, shulker.slot, itemName, remaining, shulker.id, shulker.chest_id, itemFilter ); totalDeposited += deposited; remaining -= deposited; if (deposited === 0) { failedAttempts++; console.log(`Storage: Shulker full or deposit failed (attempt ${failedAttempts}/3), trying next`); // Reset item_focus if we just assigned it — don't orphan empty shulkers if (wasEmpty) { await Database.updateShulkerItemFocus(shulker.id, null); } continue; } failedAttempts = 0; } catch (error) { console.error(`Storage: Error in deposit cycle:`, error); if (wasEmpty) { await Database.updateShulkerItemFocus(shulker.id, null); } break; } } return { deposited: totalDeposited }; } // ======================================== // Withdrawal Flow // ======================================== async handleWithdrawRequest(playerName, itemName, count) { console.log(`Storage[${this.bot.name}]: Withdraw request from ${playerName}: ${itemName} x${count}`); this._busy = true; try { // Check total available const totalAvailable = await Database.getItemTotalCount(itemName); if (totalAvailable === 0) { return this.bot.whisper(playerName, `Item not found: ${itemName}`); } const actualCount = Math.min(count, totalAvailable); if (actualCount < count) { this.bot.whisper(playerName, `Only ${totalAvailable} ${itemName} available. Withdrawing ${actualCount}.`); } // Find shulkers containing this item const shulkers = await Database.findShulkersWithItem(itemName); if (!shulkers || shulkers.length === 0) { return this.bot.whisper(playerName, `Cannot find ${itemName} in any shulker`); } let remaining = actualCount; let totalWithdrawn = 0; let consecutiveFailures = 0; for (const shulker of shulkers) { if (remaining <= 0) break; if (consecutiveFailures >= 3) { console.log(`Storage: Too many withdraw failures for ${itemName}, stopping`); break; } const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); try { const { withdrawn, updatedSlotItem } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, shulker.slot, itemName, remaining, shulker.id, shulker.chest_id ); if (withdrawn > 0) { totalWithdrawn += withdrawn; remaining -= withdrawn; consecutiveFailures = 0; } else { consecutiveFailures++; } } catch (error) { console.error(`Storage: Error withdrawing from shulker ${shulker.id}:`, error.message); consecutiveFailures++; } } // Rebuild index after withdrawals await Database.rebuildItemIndex(); if (totalWithdrawn > 0) { // Set up timeout to re-store items if not collected const timeoutId = setTimeout(() => { this.restoreWithdrawal(playerName); }, 5 * 60 * 1000); // Store pending withdrawal for trade pickup this.pendingWithdrawals.set(playerName, { itemName, count: totalWithdrawn, mode: 'items', timeoutId, }); // Log trade await Database.logTrade(playerName, 'withdraw', [{ name: itemName, count: totalWithdrawn }]); this.bot.whisper(playerName, `${totalWithdrawn}x ${itemName} ready. Use /trade to collect within 5 minutes.`); } else { this.bot.whisper(playerName, `Failed to withdraw ${itemName}.`); } } finally { this._busy = false; } } async handleWithdrawShulkers(playerName, itemName, shulkerCount) { console.log(`Storage[${this.bot.name}]: Shulker withdraw request from ${playerName}: ${shulkerCount} shulkers of ${itemName}`); this._busy = true; try { if (shulkerCount > 12) { this.bot.whisper(playerName, `Trade window only has 12 slots. Limiting to 12 shulkers.`); shulkerCount = 12; } // Find shulkers with this item that have items in them const shulkers = await Database.findShulkersWithItem(itemName); if (!shulkers || shulkers.length === 0) { return this.bot.whisper(playerName, `No shulkers containing ${itemName} found`); } const available = shulkers.length; const actualCount = Math.min(shulkerCount, available); if (actualCount < shulkerCount) { this.bot.whisper(playerName, `Only ${available} shulkers of ${itemName} available. Withdrawing ${actualCount}.`); } let taken = 0; for (let i = 0; i < actualCount; i++) { const shulker = shulkers[i]; const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); try { await this.shulkerHandler.takeWholeShulker(this.bot, chestPos, shulker.slot, shulker.id); // Also fully delete from DB — it's leaving storage permanently await Database.deleteShulker(shulker.id); taken++; } catch (error) { console.error(`Storage: Error taking whole shulker ${shulker.id}:`, error.message); } } await Database.rebuildItemIndex(); if (taken > 0) { // Set up timeout to re-store shulkers if not collected const timeoutId = setTimeout(() => { this.restoreWithdrawal(playerName); }, 5 * 60 * 1000); this.pendingWithdrawals.set(playerName, { itemName, count: taken, mode: 'shulkers', timeoutId, }); await Database.logTrade(playerName, 'withdraw', [{ name: itemName, count: taken, mode: 'shulkers' }]); this.bot.whisper(playerName, `${taken} shulker(s) of ${itemName} ready. Use /trade to collect within 5 minutes.`); } else { this.bot.whisper(playerName, `Failed to withdraw shulkers of ${itemName}.`); } } finally { this._busy = false; } } async restoreWithdrawal(playerName) { const pending = this.pendingWithdrawals.get(playerName); if (!pending) return; console.log(`Storage: Withdrawal timeout for ${playerName} — restoring ${pending.count}x ${pending.itemName} (mode: ${pending.mode || 'items'})`); // Clear timeout ref if (pending.timeoutId) { clearTimeout(pending.timeoutId); } this.pendingWithdrawals.delete(playerName); try { if (pending.mode === 'shulkers') { // Re-store whole shulker boxes const shulkerItems = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')); for (const shulkerItem of shulkerItems) { await this.storeShulker(shulkerItem); } } else { // Re-deposit individual items const grouped = {}; for (const item of this.bot.bot.inventory.items()) { if (item.name === pending.itemName) { if (!grouped[item.name]) grouped[item.name] = 0; grouped[item.name] += item.count; } } for (const [itemName, count] of Object.entries(grouped)) { await this.depositItemType(itemName, count); } } await Database.rebuildItemIndex(); this.bot.whisper(playerName, `Withdrawal cancelled — ${pending.count}x ${pending.itemName} returned to storage.`); } catch (error) { console.error(`Storage: Error restoring withdrawal for ${playerName}:`, error.message); this.bot.whisper(playerName, `Withdrawal timed out but failed to re-store items. Please contact an admin.`); } } // ======================================== // Crafting // ======================================== async craftShulkerBox() { console.log('Storage: Attempting to craft shulker box'); // Check bot inventory first for materials let shellsInInv = 0; let chestsInInv = 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; } // Take enough shells for the recipe AND to restock to hotbar target (saves repeat trips) const shellHotbar = (this.config.hotbarItems || []).find(h => h.name === 'shulker_shell'); const shellTarget = shellHotbar ? shellHotbar.target : 2; let shellsNeeded = Math.max(0, Math.max(2, shellTarget) - shellsInInv); let chestsNeeded = Math.max(0, 1 - chestsInInv); console.log(`Storage: Craft materials — inventory: ${shellsInInv} shells, ${chestsInInv} chests. Need from storage: ${shellsNeeded} shells (target ${shellTarget}), ${chestsNeeded} chests`); // Only check/withdraw from storage if inventory doesn't have enough if (shellsNeeded > 0) { const shellCount = await Database.getItemTotalCount('shulker_shell'); const minForRecipe = Math.max(0, 2 - shellsInInv); if (shellCount < minForRecipe) { throw new Error(`Not enough shulker shells (have ${shellsInInv} inv + ${shellCount} storage, need 2)`); } // Take up to target, but at least enough for the recipe shellsNeeded = Math.min(shellsNeeded, shellCount); const shellShulkers = await Database.findShulkersWithItem('shulker_shell'); // Sort by distance so the bot tries nearby chests first const botPos = this.bot.bot.entity.position; shellShulkers.sort((a, b) => { const da = botPos.distanceTo(new Vec3(a.pos_x, a.pos_y, a.pos_z)); const db = botPos.distanceTo(new Vec3(b.pos_x, b.pos_y, b.pos_z)); return da - db; }); let shellsObtained = 0; for (const shulker of shellShulkers) { if (shellsObtained >= shellsNeeded) break; const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); const { withdrawn } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, shulker.slot, 'shulker_shell', shellsNeeded - shellsObtained, shulker.id, shulker.chest_id ); shellsObtained += withdrawn; } if (shellsObtained < minForRecipe) { throw new Error(`Failed to gather shells: got ${shellsObtained}/${minForRecipe} from storage`); } } if (chestsNeeded > 0) { const chestCount = await Database.getItemTotalCount('chest'); if (chestCount < chestsNeeded) { throw new Error(`Not enough chests (have ${chestsInInv} inv + ${chestCount} storage, need 1)`); } const chestShulkers = await Database.findShulkersWithItem('chest'); chestShulkers.sort((a, b) => { const da = botPos.distanceTo(new Vec3(a.pos_x, a.pos_y, a.pos_z)); const db = botPos.distanceTo(new Vec3(b.pos_x, b.pos_y, b.pos_z)); return da - db; }); let chestsObtained = 0; for (const shulker of chestShulkers) { if (chestsObtained >= chestsNeeded) break; const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); const { withdrawn } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, shulker.slot, 'chest', chestsNeeded - chestsObtained, shulker.id, shulker.chest_id ); chestsObtained += withdrawn; } if (chestsObtained < chestsNeeded) { throw new Error(`Failed to gather chests: got ${chestsObtained}/${chestsNeeded} from storage`); } } // Find or navigate to crafting table const craftingTablePos = this.config.craftingTablePos; let craftingTable; if (craftingTablePos) { craftingTable = this.bot.bot.blockAt(new Vec3(craftingTablePos.x, craftingTablePos.y, craftingTablePos.z)); } else { craftingTable = this.bot.bot.findBlock({ matching: this.bot.mcData.blocksByName.crafting_table?.id, maxDistance: 32, }); } if (!craftingTable) { throw new Error('No crafting table found nearby'); } await this.bot.goTo({ where: craftingTable.position, range: 3 }); // Craft shulker box manually (bot.craft() broken on 1.21+) const shulkerBoxRecipes = this.bot.bot.recipesAll( this.bot.mcData.itemsByName.shulker_box.id, null, craftingTable ); if (!shulkerBoxRecipes || shulkerBoxRecipes.length === 0) { throw new Error('No recipe found for shulker box'); } const recipe = shulkerBoxRecipes[0]; const window = await this.bot.openCraftingTable(craftingTable); const inventory = window.slots.slice(window.inventoryStart, window.inventoryEnd); // Group grid slots by ingredient type (handles single stack needing multiple grid slots) 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 each ingredient type: pick up stack, right-click into each grid slot, put stack back 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(`Missing ingredient type ${typeId} in inventory`); } const actualSlot = window.inventoryStart + invIdx; // Left-click to pick up the stack await this.bot.bot.clickWindow(actualSlot, 0, 0); await sleep(100); // Right-click on each grid slot to place one item for (const gridSlot of gridSlots) { await this.bot.bot.clickWindow(gridSlot, 1, 0); await sleep(100); } // Left-click to put remaining stack back await this.bot.bot.clickWindow(actualSlot, 0, 0); await sleep(100); } await sleep(500); // Take crafted shulker box from result slot 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(500); // Find an empty chest slot to store the new shulker const emptySlot = await Database.findEmptyChestSlot(); if (emptySlot) { const chestPos = new Vec3(emptySlot.pos_x, emptySlot.pos_y, emptySlot.pos_z); await this.bot.goTo({ where: chestPos, range: 3 }); const chestBlock = this.bot.bot.blockAt(chestPos); const storeWindow = await this.bot.openContainer(chestBlock); await sleep(300); // Find shulker in inventory portion of the window let shulkerWindowSlot = null; for (let i = storeWindow.inventoryStart; i < storeWindow.inventoryEnd; i++) { const item = storeWindow.slots[i]; if (item && item.name.includes('shulker_box')) { shulkerWindowSlot = i; break; } } if (shulkerWindowSlot !== null) { await this.bot.bot.moveSlotItem(shulkerWindowSlot, emptySlot.slot); await sleep(300); } await this.bot.bot.closeWindow(storeWindow); await sleep(200); // Register in DB as empty shulker await Database.upsertShulker(emptySlot.chest_id, emptySlot.slot, 'shulker_box', null, null); const craftedShulkers = await Database.getShulkersByChest(emptySlot.chest_id); const newShulker = craftedShulkers.find(s => s.slot === emptySlot.slot); if (newShulker) { await Database.updateShulkerCounts(newShulker.id, 0, 0); } } this._craftAvailable = true; // crafting succeeded, reset flag console.log('Storage: Successfully crafted shulker box'); } // ======================================== // Inventory Cleanup // ======================================== /** * Deposit any non-shulker, non-hotbar items from bot inventory into storage. * Called periodically and as pre/post-flight during organize. */ async cleanInventory() { const hotbarNames = new Set((this.config.hotbarItems || []).map(h => h.name)); const grouped = {}; for (const item of this.bot.bot.inventory.items()) { if (item.name.includes('shulker_box')) continue; if (hotbarNames.has(item.name)) continue; if (!grouped[item.name]) grouped[item.name] = 0; grouped[item.name] += item.count; } const itemTypes = Object.entries(grouped); if (itemTypes.length === 0) return 0; let cleaned = 0; for (const [itemName, count] of itemTypes) { try { console.log(`Storage: Cleaning inventory — depositing ${count}x ${itemName}`); await this.depositItemType(itemName, count); cleaned++; } catch (error) { console.log(`Storage: Inventory cleanup failed for ${itemName}: ${error.message}`); } } if (cleaned > 0) { await Database.rebuildItemIndex(); console.log(`Storage: Inventory cleanup deposited ${cleaned} item type(s)`); } return cleaned; } // ======================================== // Organize // ======================================== async organizeLooseItems() { console.log('Storage: Organizing loose items into shulkers...'); this._craftAvailable = true; // fresh organize, allow craft retry this._busy = true; let organized = 0; try { // Pre-flight: deposit any stray items in bot inventory await this.cleanInventory(); const chests = await Database.getChestsWithLooseItems(); console.log(`Storage: ${chests.length} chest(s) have loose items in DB`); for (const chest of chests) { // Check inventory has room before processing next chest (need 3+ free: items + shulker + buffer) const freeSlots = this.bot.bot.inventory.slots.filter((s, i) => !s && i >= 9).length; if (freeSlots < 3) { console.log(`Storage: Inventory too full (${freeSlots} free slots), stopping organize loop`); break; } const chestPos = new Vec3(chest.pos_x, chest.pos_y, chest.pos_z); try { // Process one item type at a time from this chest const failedItems = new Set(); while (true) { await this.bot.goTo({ where: chestPos, range: 3 }); const chestBlock = this.bot.bot.blockAt(chestPos); const window = await this.bot.openContainer(chestBlock); await sleep(300); // Find the first non-shulker item in the chest (skip already-failed types) const chestSlotCount = window.inventoryStart; let firstLooseSlot = null; for (let i = 0; i < chestSlotCount; i++) { const slot = window.slots[i]; if (slot && !slot.name.includes('shulker_box') && !failedItems.has(slot.name)) { firstLooseSlot = i; break; } } if (firstLooseSlot === null) { // No loose items left in this chest await Database.clearLooseItems(chest.id); await this.bot.bot.closeWindow(window); break; } const itemName = window.slots[firstLooseSlot].name; console.log(`Storage: Picking up ${itemName} from chest at ${chestPos}`); // Count empty inventory slots let emptyInvSlots = 0; for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { if (!window.slots[i]) emptyInvSlots++; } // Pick up all stacks of THIS item type only const pickedUpSlots = []; let totalCount = 0; for (let i = 0; i < chestSlotCount; i++) { const srcItem = window.slots[i]; if (!srcItem || srcItem.name !== itemName) continue; // Find stackable slot first (doesn't consume an empty slot) let targetSlot = null; for (let j = window.inventoryStart; j < window.inventoryEnd; j++) { const invItem = window.slots[j]; if (invItem && invItem.name === itemName && invItem.count < invItem.stackSize) { targetSlot = j; break; } } // Then find empty slot, but keep at least 1 free for shulker if (targetSlot === null) { if (emptyInvSlots <= 1) { console.log('Storage: Reserving last inventory slot for shulker operations'); break; } for (let j = window.inventoryStart; j < window.inventoryEnd; j++) { if (!window.slots[j]) { targetSlot = j; break; } } emptyInvSlots--; } if (targetSlot === null) { console.log('Storage: Bot inventory full'); break; } try { await this.bot.bot.moveSlotItem(i, targetSlot); await sleep(200); // Measure actual items removed from chest slot (handles partial stacking) const afterItem = window.slots[i]; const moved = srcItem.count - (afterItem ? afterItem.count : 0); pickedUpSlots.push(i); totalCount += moved; } catch (error) { console.log(`Storage: Could not pick up slot ${i}: ${error.message}`); } } await this.bot.bot.closeWindow(window); // Sync DB: remove picked-up items individually for (const slot of pickedUpSlots) { await Database.deleteLooseItem(chest.id, slot); } if (totalCount === 0) { console.log(`Storage: Could not pick up any ${itemName}, skipping chest`); break; } // Deposit into shulker(s) try { const result = await this.depositItemType(itemName, totalCount); organized++; // If deposit was partial, return remaining items to the chest if (result.deposited < totalCount) { console.log(`Storage: Partial deposit for ${itemName} (${result.deposited}/${totalCount}), returning remainder to chest`); await this._returnItemsToChest(chestPos, itemName); } } catch (error) { console.log(`Storage: Could not deposit ${itemName}: ${error.message}`); failedItems.add(itemName); // Try to return items to the chest instead of leaving them in inventory try { await this._returnItemsToChest(chestPos, itemName); } catch (returnError) { console.log(`Storage: Could not return ${itemName} to chest: ${returnError.message}`); break; // Can't recover, stop this chest } } // Check if inventory is nearly full before processing next item type const innerFreeSlots = this.bot.bot.inventory.slots.filter((s, i) => !s && i >= 9).length; if (innerFreeSlots < 3) { console.log(`Storage: Inventory nearly full (${innerFreeSlots} free), stopping this chest`); break; } await sleep(250); // Loop back to re-open chest for next item type } } catch (error) { console.log(`Storage: Error organizing chest at ${chestPos}: ${error.message}`); } // Check if inventory is too full to continue organizing other chests const outerFreeSlots = this.bot.bot.inventory.slots.filter((s, i) => !s && i >= 9).length; if (outerFreeSlots < 3) { console.log(`Storage: Inventory too full (${outerFreeSlots} free slots), stopping organize`); break; } await sleep(250); } // Consolidate partially filled shulkers of the same item type try { await this.consolidateShulkers(); } catch (error) { console.error('Storage: Consolidation error:', error.message); } // Post-flight: deposit any items still in inventory (may succeed now that organize freed shulker space) await this.cleanInventory(); await Database.rebuildItemIndex(); console.log(`Storage: Organized ${organized} item types into shulkers`); return organized; } finally { this._busy = false; } } /** * Consolidate partially filled shulkers of the same item type. * Withdraws from least-full shulker and deposits into the most-full one. */ async consolidateShulkers() { console.log('Storage: Consolidating partially filled shulkers...'); let consolidated = 0; const candidates = await Database.findConsolidatableItems(); console.log(`Storage: ${candidates.length} item type(s) have multiple non-full shulkers`); for (const candidate of candidates) { const itemName = candidate.item_focus; // Re-fetch shulkers each iteration (DB may have changed from previous consolidation) const shulkers = await Database.getShulkersByItemFocus(itemName); // Need at least 2 non-full shulkers to consolidate const nonFull = shulkers.filter(s => s.slot_count < 27); if (nonFull.length < 2) continue; // Withdraw from the least-full shulker (first in list, sorted ASC) const source = nonFull[0]; console.log(`Storage: Consolidating ${itemName} — withdrawing from shulker ${source.id} (${source.slot_count} slots used)`); const chestPos = new Vec3(source.pos_x, source.pos_y, source.pos_z); try { const { withdrawn } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, source.slot, itemName, source.total_items, source.id, source.chest_id ); if (withdrawn === 0) { console.log(`Storage: Could not withdraw from shulker ${source.id}, skipping`); continue; } console.log(`Storage: Withdrew ${withdrawn}x ${itemName}, depositing into fuller shulker(s)`); // Deposit will fill the most-full matching shulker first (exclude source to prevent depositing back) const result = await this.depositItemType(itemName, withdrawn, source.id); consolidated++; // If source shulker is now empty, clear its item_focus const updatedSource = await Database.getShulkerById(source.id); if (updatedSource && updatedSource.total_items === 0) { await Database.updateShulkerItemFocus(source.id, null); console.log(`Storage: Shulker ${source.id} is now empty, cleared item_focus`); } } catch (error) { console.error(`Storage: Error consolidating ${itemName}:`, error.message); } // Check inventory space before continuing const freeSlots = this.bot.bot.inventory.slots.filter((s, i) => !s && i >= 9).length; if (freeSlots < 3) { console.log('Storage: Inventory too full, stopping consolidation'); break; } await sleep(250); } if (consolidated > 0) { await Database.rebuildItemIndex(); } console.log(`Storage: Consolidated ${consolidated} item type(s)`); return consolidated; } /** * Return items of a given type from bot inventory back into a chest. * Used by organize when deposit is partial to prevent inventory buildup. */ async _returnItemsToChest(chestPos, itemName) { const itemsInInv = this.bot.bot.inventory.items().filter(i => i.name === itemName); if (itemsInInv.length === 0) return; console.log(`Storage: Returning ${itemsInInv.length} stack(s) of ${itemName} to chest at ${chestPos}`); await this.bot.goTo({ where: chestPos, range: 3 }); const chestBlock = this.bot.bot.blockAt(chestPos); const window = await this.bot.openContainer(chestBlock); await sleep(300); for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { const item = window.slots[i]; if (!item || item.name !== itemName) continue; // Find empty chest slot let targetSlot = null; for (let j = 0; j < window.inventoryStart; j++) { if (!window.slots[j]) { targetSlot = j; break; } } if (targetSlot === null) { console.log('Storage: Chest full, cannot return all items'); break; } await this.bot.bot.moveSlotItem(i, targetSlot); await sleep(200); } await this.bot.bot.closeWindow(window); await sleep(200); } // ======================================== // Special Item Withdrawal // ======================================== async handleWithdrawSpecialItem(playerName, shulkerItemId) { console.log(`Storage[${this.bot.name}]: Special item withdraw request from ${playerName}: shulker_item #${shulkerItemId}`); this._busy = true; try { // Look up the specific shulker_items row const itemRow = await Database.getShulkerItemById(shulkerItemId); if (!itemRow) { return this.bot.whisper(playerName, `Special item #${shulkerItemId} not found.`); } const shulker = await Database.getShulkerById(itemRow.shulker_id); if (!shulker) { return this.bot.whisper(playerName, `Shulker for item #${shulkerItemId} not found.`); } const chest = await Database.getChestById(shulker.chest_id); if (!chest) { return this.bot.whisper(playerName, `Chest for shulker #${shulker.id} not found.`); } const chestPos = new Vec3(chest.pos_x, chest.pos_y, chest.pos_z); try { const { withdrawn } = await this.shulkerHandler.withdrawFromShulkerSlot( this.bot, chestPos, shulker.slot, itemRow.slot, itemRow.count, shulker.id, shulker.chest_id ); if (withdrawn > 0) { await Database.rebuildItemIndex(); const timeoutId = setTimeout(() => { this.restoreWithdrawal(playerName); }, 5 * 60 * 1000); this.pendingWithdrawals.set(playerName, { itemName: itemRow.item_name, count: withdrawn, mode: 'items', timeoutId, }); await Database.logTrade(playerName, 'withdraw', [{ name: itemRow.item_name, count: withdrawn, special: true }]); this.bot.whisper(playerName, `${withdrawn}x ${itemRow.item_name} (special) ready. Use /trade to collect within 5 minutes.`); } else { this.bot.whisper(playerName, `Failed to withdraw special item.`); } } catch (error) { console.error(`Storage: Error withdrawing special item:`, error.message); this.bot.whisper(playerName, `Withdraw failed: ${error.message}`); } } finally { this._busy = false; } } // ======================================== // Status & Search // ======================================== async getStatus(playerName) { const stats = await Database.getStats(); this.bot.whisper(playerName, `Storage: ${stats.totalItems} items in ${stats.totalShulkers} shulkers (${stats.totalChests} chests)`); } async findItem(itemName) { return await Database.searchItems(itemName); } async checkPermission(name, requiredRole) { return await Database.checkPermission(name, requiredRole); } // ======================================== // Command Handler // ======================================== async handleCommand(from, command, ...args) { console.log(`Storage: Command '${command}' from ${from} with args:`, args); this._currentCommand = command; try { switch (command) { case 'scan': this.bot.whisper(from, 'Starting storage scan...'); try { await this.scanArea(true); const stats = await Database.getStats(); this.bot.whisper(from, `Scan complete! ${stats.totalChests} chests, ${stats.totalShulkers} shulkers, ${stats.totalItems} items`); } catch (error) { console.error('Storage: Scan error:', error); this.bot.whisper(from, `Scan failed: ${error.message}`); } break; case 'status': await this.getStatus(from); break; case 'withdraw': { const [itemName, count] = args; const parsedCount = parseInt(count) || 1; await this.handleWithdrawRequest(from, itemName, parsedCount); break; } case 'withdraw-shulkers': { const [itemName, shulkerCount] = args; await this.handleWithdrawShulkers(from, itemName, shulkerCount); break; } case 'find': { const [searchTerm] = args; const items = await this.findItem(searchTerm); if (items.length === 0) { this.bot.whisper(from, `No items found matching '${searchTerm}'`); } else { const results = items.slice(0, 5).map(i => `${i.item_name}: ${i.total_count}`); this.bot.whisper(from, `Found: ${results.join(', ')}`); } break; } case 'chests': { const chests = await Database.getChests(); this.bot.whisper(from, `Tracking ${chests.length} chests`); break; } case 'organize': this.bot.whisper(from, 'Starting organize...'); try { const count = await this.organizeLooseItems(); this.bot.whisper(from, `Organize complete! Sorted ${count} item stacks.`); } catch (error) { console.error('Storage: Organize error:', error); this.bot.whisper(from, `Organize failed: ${error.message}`); } break; case 'consolidate': this.bot.whisper(from, 'Starting consolidation...'); try { this._busy = true; const count = await this.consolidateShulkers(); this.bot.whisper(from, `Consolidation complete! Merged ${count} item type(s).`); } catch (error) { console.error('Storage: Consolidate error:', error); this.bot.whisper(from, `Consolidation failed: ${error.message}`); } finally { this._busy = false; } break; case 'addplayer': { const [playerName, role] = args; try { await Database.addPlayer(playerName, role || 'team'); this.bot.whisper(from, `Added ${playerName} as ${role || 'team'}`); } catch (error) { this.bot.whisper(from, `Failed to add player: ${error.message}`); } break; } case 'removeplayer': { const [removePlayer] = args; try { await Database.removePlayer(removePlayer); this.bot.whisper(from, `Removed ${removePlayer}`); } catch (error) { this.bot.whisper(from, `Failed to remove player: ${error.message}`); } break; } case 'players': { const players = await Database.getAllPlayers(); const playerList = players.map(p => `${p.player_name}(${p.role})`).join(', '); this.bot.whisper(from, `Players: ${playerList}`); break; } default: this.bot.whisper(from, `Unknown command: ${command}`); } } finally { this._currentCommand = null; } } } module.exports = Storage;