'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'); const { applyMapUpdate, renderMapToPNG } = require('./map-renderer'); class Storage { static createRouter = StorageWeb.createRouter; static webUI = StorageWeb.webUI; // AI tool documentation - commands available to AI static commands = [ { name: 'status', description: 'Get storage system status - chest count, shulker count, item count', parameters: [], examples: [ "Get overall statistics about the storage system", "Show how many chests, shulkers, and items are tracked" ], category: 'information' }, { name: 'scan', description: 'Scan the area for chests and update database', parameters: [], examples: [ "Scan the surrounding area to discover new chests", "Update the storage database with newly found containers" ], category: 'discovery' }, { name: 'find', description: 'Find an item in storage - returns location and quantity', parameters: [ { name: 'itemName', type: 'string', required: true, description: 'Item name to search for (e.g., "diamond", "iron_ingot")' } ], examples: [ "find diamond", "find iron_ingot" ], category: 'search' }, { name: 'withdraw', description: 'Withdraw items from storage', parameters: [ { name: 'itemName', type: 'string', required: true, description: 'Item name to withdraw' }, { name: 'count', type: 'number', required: false, description: 'Number of items (default: 1)' } ], examples: [ "withdraw diamond 5", "withdraw cooked_beef" ], category: 'retrieval' }, { name: 'withdraw-shulkers', description: 'Withdraw full shulker boxes of a specific item', parameters: [ { name: 'itemName', type: 'string', required: true, description: 'Item name stored in shulkers' }, { name: 'shulkerCount', type: 'number', required: false, description: 'Number of shulkers to withdraw (default: 1)' } ], examples: [ "withdraw-shulkers diamond 2", "withdraw-shulkers cooked_beef" ], category: 'retrieval' }, { name: 'chests', description: 'List all chests in storage with their contents summary', parameters: [], examples: [ "List all tracked chests in the storage system" ], category: 'retrieval' }, { name: 'organize', description: 'Organize loose items from bot inventory into storage', parameters: [], examples: [ "Organize items in the bot's inventory into storage shulkers" ], category: 'organization' }, { name: 'consolidate', description: 'Consolidate partial stacks of the same item type', parameters: [], examples: [ "Merge partially filled shulkers of the same item type" ], category: 'organization' }, { name: 'addplayer', description: 'Add a player to storage access control', parameters: [ { name: 'name', type: 'string', required: true, description: 'Player name to add' }, { name: 'role', type: 'string', required: true, description: 'Role for the player (e.g., "team", "admin")' } ], examples: [ "addplayer Steve team", "addplayer Alex admin" ], category: 'administration' }, { name: 'removeplayer', description: 'Remove a player from storage access control', parameters: [ { name: 'name', type: 'string', required: true, description: 'Player name to remove' } ], examples: [ "removeplayer Steve" ], category: 'administration' }, { name: 'players', description: 'List all players with storage access', parameters: [], examples: [ "Show all players who have access to storage" ], category: 'information' }, ]; 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 this._operationLock = false; console.trace('Storage: lock RELEASED'); // prevents concurrent storage operations this._operationQueue = []; // queue for pending operations } 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.'); // Map packet listener for capturing filled map images this._mapBuffers = {}; this._mapListener = (packet) => { const mapId = packet.itemDamage; if (!packet.columns || packet.columns === 0) return; if (!this._mapBuffers[mapId]) this._mapBuffers[mapId] = new Uint8Array(128 * 128); applyMapUpdate(this._mapBuffers[mapId], packet); }; this.bot.bot._client.on('map', this._mapListener); if (!this.bot.onDemand) { // 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 || 60 * 1000; this.cleanupInterval = setInterval(async () => { if (!this.isReady || this._busy) return; try { await this.cleanInventory(); } catch (error) { console.error('Storage: Periodic inventory cleanup failed:', error.message); } }, cleanupInterval); } resolve(); // Run initial hotbar restock AFTER resolve so it doesn't block // subsequent plugins (Ai, Navigation, etc.) from loading if (!this.bot.onDemand) { try { await this.restockHotbar(); } catch (error) { console.error('Storage: Initial hotbar restock failed:', error.message); } } } 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; } // Remove map listener if (this._mapListener && this.bot?.bot?._client) { this.bot.bot._client.removeListener('map', this._mapListener); this._mapListener = null; } this._mapBuffers = {}; // 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) { if (!this.scanner) { throw new Error('Storage scanner not initialized'); } if (!Database.db) { throw new Error('Database not initialized'); } console.log(`Storage[${this.bot.name}]: Scanning storage area...`); // Acquire operation lock to prevent concurrent storage operations try { await this._acquireOperationLock(); } catch (error) { console.log(`Storage: Operation lock timeout for scan operation`); throw new Error('Storage busy, scan operation timeout'); } try { const chests = await this.scanner.discoverChests(this.bot, this.config.scanRadius || 30, Database); // Register as interruptible task this.bot.registerTask('Storage', 'scan', async () => {}); const shulkers = await this.scanner.scanAllChests( this.bot, Database, () => this.bot.wasInterrupted() ); console.log(`Storage[${this.bot.name}]: Complete - ${chests.length} chests, ${shulkers} shulkers`); // Capture map images and index maps if not interrupted if (!this.bot.wasInterrupted()) { await this.captureMapImages(); await this.indexMapsFromStorage(); } } finally { this.bot.clearTask(); this._releaseOperationLock(); } } /** * Save any map pixel data received via protocol packets to the database as PNG images. */ async captureMapImages() { const mapIds = Object.keys(this._mapBuffers); if (mapIds.length === 0) return; let captured = 0; for (const mapId of mapIds) { const pixels = this._mapBuffers[mapId]; // Skip maps with no actual data (all zeros) if (!pixels.some(p => p !== 0)) continue; try { const base64png = renderMapToPNG(pixels); const pixelJson = JSON.stringify([...pixels]); await Database.upsertMap(parseInt(mapId), base64png, pixelJson); captured++; console.log(`Storage: Captured map #${mapId}`); } catch (error) { console.error(`Storage: Error capturing map #${mapId}:`, error.message); } } if (captured > 0) { console.log(`Storage: Captured ${captured} map image(s)`); } } /** * Actively index filled_map items from shulkers. * Withdraws each map, holds it in hand to trigger map data packets from server, * waits for data, captures the image, then returns the map to storage. */ async indexMapsFromStorage() { const uncaptured = await Database.getUncapturedMaps(); if (uncaptured.length === 0) { console.log('Storage: No filled maps found in storage'); return; } // Filter out maps we already have images for const existing = await Database.getAllMaps(); const existingIds = new Set(existing.map(m => m.map_id)); // Extract map IDs from the filled_map items' NBT data // The map ID is in the nbt_data field as parsed JSON const mapsToCapture = []; for (const item of uncaptured) { let mapId = null; try { if (item.nbt_data) { const nbt = JSON.parse(item.nbt_data); // map ID can be in nbt.map (pre-1.20.5) or nbt.map_id mapId = nbt.map ?? nbt.map_id ?? null; } } catch (e) { /* ignore parse errors */ } if (mapId !== null && !existingIds.has(mapId) && !this._mapBuffers[mapId]) { mapsToCapture.push({ ...item, mapId }); existingIds.add(mapId); // prevent duplicates in same batch } } if (mapsToCapture.length === 0) { console.log('Storage: All filled maps already captured'); return; } console.log(`Storage: Found ${mapsToCapture.length} uncaptured map(s), indexing...`); // Group by shulker to minimize shulker operations const byShulker = {}; for (const map of mapsToCapture) { if (!byShulker[map.shulker_id]) byShulker[map.shulker_id] = []; byShulker[map.shulker_id].push(map); } for (const [shulkerId, maps] of Object.entries(byShulker)) { const info = maps[0]; // all maps in this group share the same shulker location const chestPos = new Vec3(info.pos_x, info.pos_y, info.pos_z); console.log(`Storage: Withdrawing ${maps.length} map(s) from shulker #${shulkerId}`); try { // Withdraw all filled_maps from this shulker const { withdrawn } = await this.shulkerHandler.withdrawFromShulker( this.bot, chestPos, info.shulker_slot, 'filled_map', maps.length * 64, // withdraw all maps parseInt(shulkerId), info.chest_id ); if (withdrawn === 0) { console.log(`Storage: Could not withdraw maps from shulker #${shulkerId}`); continue; } // Hold each map in hand briefly to trigger map data packets const mapItems = this.bot.bot.inventory.items().filter(i => i.name === 'filled_map'); for (const mapItem of mapItems) { try { await this.bot.bot.equip(mapItem, 'hand'); // Wait for server to send map data packets await sleep(2000); } catch (e) { console.log(`Storage: Error holding map: ${e.message}`); } } // Flush received map data to DB await this.captureMapImages(); } catch (error) { console.error(`Storage: Error indexing maps from shulker #${shulkerId}:`, error.message); } // Re-deposit any maps left in inventory (outside try/catch so it always runs) const mapCount = this.bot.bot.inventory.items() .filter(i => i.name === 'filled_map') .reduce((sum, i) => sum + i.count, 0); if (mapCount > 0) { console.log(`Storage: Re-depositing ${mapCount} filled_map(s) back into storage`); try { const result = await this.depositItemType('filled_map', mapCount); console.log(`Storage: Re-deposited ${result.deposited}/${mapCount} filled_map(s)`); } catch (e) { console.error(`Storage: Failed to re-deposit maps: ${e.message}`); } } } await Database.rebuildItemIndex(); console.log('Storage: Map indexing complete'); } // ======================================== // Hotbar Management // ======================================== async restockHotbar() { if (!this.isReady) return; if (this._busy) return; const hotbarItems = this.config.hotbarItems || []; if (hotbarItems.length === 0) return; for (const spec of hotbarItems) { if (this._busy) { console.log('Storage: Hotbar restock interrupted — storage operation in progress'); return; } // 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; } // Acquire lock per-item so trades can interleave try { await this._acquireOperationLock(10000); } catch (error) { console.log(`Storage: Operation lock timeout for hotbar restock`); return; } try { 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}`); } finally { this._releaseOperationLock(); } } await Database.rebuildItemIndex(); } // ======================================== // Deposit Flow // ======================================== async handleTrade(playerName, itemsReceived) { console.log(`Storage[${this.bot.name}]: Processing trade from ${playerName}, received ${itemsReceived.length} item types`); // Caller holds the operation lock; we just process this._craftAvailable = true; // Log trade await Database.logTrade(playerName, 'deposit', itemsReceived); // Quick-stash all shulker boxes to nearest chest(s) let shulkersStashed = 0; const hasShulkers = itemsReceived.some(item => item.name.includes('shulker_box')); if (hasShulkers) { console.log('Storage: Traded items include shulker boxes, quick-stashing...'); shulkersStashed = await this.quickStashAllShulkers(); } // Deposit any non-shulker items currently in inventory const cleanedBefore = await this.cleanInventory(); await Database.rebuildItemIndex(); await this.captureMapImages(); const parts = []; if (shulkersStashed > 0) parts.push(`${shulkersStashed} shulker(s)`); if (cleanedBefore > 0) parts.push(`${cleanedBefore} loose item type(s)`); const msg = parts.length > 0 ? `Received ${parts.join(' + ')}. Organizing into storage now.` : `Trade processed.`; this.bot.whisper(playerName, msg); return { shulkersStashed, looseItemTypes: cleanedBefore, needsOrganize: shulkersStashed > 0 || cleanedBefore > 0 }; } /** * Batch-stash all shulker boxes from bot inventory into the nearest chest(s). * Opens each chest once, moves all shulkers that fit, NBT-scans them in one pass. * Does NOT synchronously unpack mixed shulkers — that's deferred to organize. * Returns the count of shulkers stashed. */ async quickStashAllShulkers() { const shulkerItems = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')); if (shulkerItems.length === 0) return 0; const botPos = this.bot.bot.entity.position; const neededSlots = shulkerItems.length; const candidates = await Database.findNearestChestsWithSpace( neededSlots, botPos.x, botPos.y, botPos.z ); if (candidates.length === 0) { console.log('Storage: No chests with empty slots available'); return 0; } let stashed = 0; let remaining = neededSlots; for (const chest of candidates) { if (remaining <= 0) break; const chestPos = new Vec3(chest.pos_x, chest.pos_y, chest.pos_z); const batchSize = Math.min(remaining, chest.empty_slots); console.log(`Storage: Stashing ${batchSize} shulker(s) into chest at ${chestPos} (${chest.empty_slots} free slots)`); 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); // Resolve which chest slots are actually empty right now const existingShulkers = await Database.getShulkersByChest(chest.id); const usedSlots = new Set(existingShulkers.map(s => s.slot)); const maxSlots = window.inventoryStart; // mineflayer returns the chest boundary const emptySlots = []; for (let s = 0; s < maxSlots; s++) { if (!usedSlots.has(s) && !window.slots[s]) { emptySlots.push(s); if (emptySlots.length >= batchSize) break; } } if (emptySlots.length === 0) { await this.bot.bot.closeWindow(window); continue; // next chest } const actualBatch = Math.min(batchSize, emptySlots.length); const stashSlots = []; // track (chestSlot, windowSlot) for NBT scan for (let i = 0; i < actualBatch; i++) { // Find a shulker box in the inventory portion of the window let srcSlot = null; for (let j = window.inventoryStart; j < window.inventoryEnd; j++) { const item = window.slots[j]; if (item && item.name.includes('shulker_box')) { srcSlot = j; break; } } if (srcSlot === null) break; const chestSlot = emptySlots[i]; try { await this.bot.bot.moveSlotItem(srcSlot, chestSlot); await sleep(150); stashSlots.push({ chestSlot, windowSlot: srcSlot }); stashed++; } catch (e) { console.error(`Storage: Error moving shulker to slot ${chestSlot}:`, e.message); } } // NBT-scan every placed shulker before closing (data is in window slots) for (const { chestSlot } of stashSlots) { const placed = window.slots[chestSlot]; if (placed) { await this.scanner.scanShulkerFromNBT(this.bot, Database, chest.id, chestSlot, placed); } } await this.bot.bot.closeWindow(window); await sleep(200); remaining -= actualBatch; console.log(`Storage: Stashed ${actualBatch} shulker(s) into this chest (${stashed} total so far)`); } console.log(`Storage: Quick stash complete — ${stashed}/${neededSlots} shulker(s) stored`); return stashed; } /** * 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. * @param {object} shulkerItem - The mineflayer item object for the shulker box * @param {object|null} targetSlot - Optional specific slot to store in: { chest_id, pos_x, pos_y, pos_z, slot } * @returns {{ chestId, chestSlot, chestPos, shulkerId, itemFocus }} or false on failure */ async storeShulker(shulkerItem, targetSlot = null) { console.log(`Storage: Storing shulker box (${shulkerItem.name})`); // Use provided target slot, or find any empty slot const emptySlot = targetSlot || 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, // Track if this was a forced slot placement (used by Phase 2 to return box to original slot) isOriginalSlot: targetSlot !== 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 _acquireOperationLock(timeout = 30000) { const startTime = Date.now(); while (this._operationLock) { if (Date.now() - startTime > timeout) { console.trace('Storage: lock ACQUIRE TIMEOUT'); throw new Error('Storage operation timeout'); } await sleep(100); } this._operationLock = true; console.trace('Storage: lock ACQUIRED'); } _releaseOperationLock() { this._operationLock = false; } async handleWithdrawRequest(playerName, itemName, count) { console.log(`Storage[${this.bot.name}]: Withdraw request from ${playerName}: ${itemName} x${count}`); // Interrupt any active task then acquire operation lock await this.bot.interruptTask(playerName); try { await this._acquireOperationLock(); } catch (error) { console.log(`Storage: Operation lock timeout for ${playerName}: ${itemName} x${count}`); return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`); } 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 }]); // Initiate trade with the player to hand over items console.log(`Storage: Items withdrawn for ${playerName}, initiating trade`); await this.initiateTradeWithPlayer(playerName); } else { this.bot.whisper(playerName, `Failed to withdraw ${itemName}.`); } } finally { this._busy = false; this._releaseOperationLock(); } } async handleWithdrawShulkers(playerName, itemName, shulkerCount) { console.log(`Storage[${this.bot.name}]: Shulker withdraw request from ${playerName}: ${shulkerCount} shulkers of ${itemName}`); // Acquire operation lock to prevent concurrent storage operations try { await this._acquireOperationLock(); } catch (error) { console.log(`Storage: Operation lock timeout for ${playerName}: ${shulkerCount} shulkers of ${itemName}`); return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`); } 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' }]); // Initiate trade with the player to hand over shulkers console.log(`Storage: Shulkers withdrawn for ${playerName}, initiating trade`); await this.initiateTradeWithPlayer(playerName); } else { this.bot.whisper(playerName, `Failed to withdraw shulkers of ${itemName}.`); } } finally { this._busy = false; this._releaseOperationLock(); } } 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.`); } } /** * Initiate a /trade with a player and hand over pending withdrawal items. * Sends the trade request, waits for the window, places items, and confirms. * Falls back to the old "use /trade" message if the player is offline or trade fails. */ async initiateTradeWithPlayer(playerName) { const pending = this.pendingWithdrawals.get(playerName); if (!pending) return; const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21]; // Check if player is online const player = this.bot.bot.players[playerName]; if (!player) { this.bot.whisper(playerName, `${pending.count}x ${pending.itemName} ready. Use /trade to collect within 5 minutes.`); return; } try { console.log(`Storage: Initiating /trade with ${playerName}`); this.bot.whisper(playerName, `${pending.count}x ${pending.itemName} ready — sending trade request...`); await this.bot.say(`/trade ${playerName}`); // Wait for trade window to open (player must accept) const window = await Promise.race([ this.bot.once('windowOpen'), sleep(60000).then(() => null), // 60s timeout ]); if (!window) { console.log(`Storage: Trade request to ${playerName} timed out`); this.bot.whisper(playerName, `Trade request timed out. Use /trade to collect within 5 minutes.`); return; } // Place items in bot's trade slots let placed = 0; for (const slotNum of botSlots) { if (placed >= 12) break; for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { const item = window.slots[i]; if (!item) continue; if (pending.mode === 'shulkers') { if (!item.name.includes('shulker_box')) continue; } else { if (item.name !== pending.itemName) continue; } try { await this.bot.bot.moveSlotItem(i, slotNum); await sleep(200); placed++; break; } catch (error) { console.log(`Storage: Could not move item to trade slot ${slotNum}: ${error.message}`); } } } console.log(`Storage: Placed ${placed} stack(s) in trade window for ${playerName}`); // Poll for customer confirmation (lime_dye at slot 53) const timeoutHandle = setTimeout(() => { try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ } this.bot.whisper(playerName, 'Trade timed out.'); }, 120000); const confirmationCheck = setInterval(async () => { try { const indicator = window.slots[53]; if (indicator && indicator.name === 'lime_dye') { this.bot.bot.moveSlotItem(37, 37); } } catch (e) { /* window may have closed */ } }, 500); // Wait for trade to complete await this.bot.once('windowClose'); clearInterval(confirmationCheck); if (timeoutHandle._destroyed) return; clearTimeout(timeoutHandle); // Withdrawal complete — clear pending if (pending.timeoutId) clearTimeout(pending.timeoutId); this.pendingWithdrawals.delete(playerName); this.bot.whisper(playerName, `Withdrawal complete! Enjoy your ${pending.itemName}.`); } catch (error) { console.error(`Storage: Error initiating trade with ${playerName}:`, error.message); this.bot.whisper(playerName, `Trade failed. Use /trade to collect within 5 minutes.`); } } /** * Place withdrawn items into an already-open trade window. * Called by AI trade tools for the feedback loop — the AI manages * the window lifecycle; this method just handles item placement. * @returns {number} Number of stacks placed */ async placeWithdrawnItemsInTrade(window, playerName) { const pending = this.pendingWithdrawals.get(playerName); if (!pending) return 0; const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21]; let placed = 0; for (const slotNum of botSlots) { if (placed >= 12) break; for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { const item = window.slots[i]; if (!item) continue; if (pending.mode === 'shulkers') { if (!item.name.includes('shulker_box')) continue; } else { if (item.name !== pending.itemName) continue; } try { await this.bot.bot.moveSlotItem(i, slotNum); await sleep(200); placed++; break; } catch (error) { console.log(`Storage: Could not move item to trade slot ${slotNum}: ${error.message}`); } } } console.log(`Storage: Placed ${placed} stack(s) in trade window for ${playerName}`); return placed; } // ======================================== // 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 hotbarTargets = {}; for (const h of (this.config.hotbarItems || [])) { hotbarTargets[h.name] = h.target || 0; } const hotbarSeen = {}; const grouped = {}; for (const item of this.bot.bot.inventory.items()) { if (item.name.includes('shulker_box')) continue; if (item.name in hotbarTargets) { const seen = (hotbarSeen[item.name] || 0); const keep = Math.max(0, hotbarTargets[item.name] - seen); hotbarSeen[item.name] = seen + item.count; const excess = item.count - keep; if (excess <= 0) continue; if (!grouped[item.name]) grouped[item.name] = 0; grouped[item.name] += excess; 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...'); // Acquire operation lock to prevent concurrent storage operations try { await this._acquireOperationLock(); } catch (error) { console.log(`Storage: Operation lock timeout for organize operation`); return 0; } this._craftAvailable = true; // fresh organize, allow craft retry this._busy = true; this.bot.registerTask('Storage', 'organize', async () => {}); let organized = 0; try { // Pre-flight: deposit any stray items in bot inventory await this.cleanInventory(); // Also detect and unpack any mixed shulkers that ended up in chests // (e.g., from failed Phase 2 unpacks or shulkers that were manually placed) await this.organizeMixedShulkers(); const chests = await Database.getChestsWithLooseItems(); console.log(`Storage: ${chests.length} chest(s) have loose items in DB`); for (const chest of chests) { if (this.bot.wasInterrupted()) { console.log('Storage: Organize interrupted'); break; } // 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.bot.clearTask(); this._busy = false; this._releaseOperationLock(); } } /** * Find and unpack mixed/unsorted shulkers during the organize pass. * This is the recovery path for shulkers that failed Phase 2 unpacking * or were otherwise left with mixed contents. */ async organizeMixedShulkers() { console.log('Storage: Checking for mixed shulkers to unpack...'); // Find shulkers with null item_focus (mixed/unsorted) that have items in them // These are candidates for unpacking const mixedShulkers = await Database.db.all(` SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type FROM shulkers s INNER JOIN chests c ON c.id = s.chest_id WHERE s.item_focus IS NULL AND s.total_items > 0 AND s.slot_count >= 0 ORDER BY s.id ASC `); if (mixedShulkers.length === 0) { return; } console.log(`Storage: Found ${mixedShulkers.length} mixed shulker(s) to unpack`); for (const shulker of mixedShulkers) { // Check inventory space 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 mixed shulker organize`); break; } const chestPos = new Vec3(shulker.pos_x, shulker.pos_y, shulker.pos_z); console.log(`Storage: Unpacking mixed shulker #${shulker.id} from chest at ${chestPos}`); try { // Clean up any leftover shulkers first const leftoverBoxes = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')); for (const leftover of leftoverBoxes) { try { await this.storeShulker(leftover); } catch (e) { /* ignore */ } } await this._depositNonShulkerInventory(); // takeWholeShulker marks slot_count=-1 in DB (in-transit) await this.shulkerHandler.takeWholeShulker(this.bot, chestPos, shulker.slot, shulker.id); const shulkerItem = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (!shulkerItem) { console.error('Storage: Shulker vanished during organizeMixedShulkers'); // Upsert it back so it's not orphaned await Database.upsertShulker(shulker.chest_id, shulker.slot, 'shulker_box', null, null); await Database.updateShulkerCounts(shulker.id, shulker.slot_count, shulker.total_items); continue; } const { extracted, inventoryFull } = await this.shulkerHandler.unpackShulkerFromInventory(this.bot, shulkerItem); console.log(`Storage: Mixed shulker unpacked — ${extracted.length} stacks extracted`); // Deposit all extracted items await this._depositNonShulkerInventory(); // Return empty (or partially-empty) box to its original slot const emptyBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box')); if (emptyBox) { try { const result = await this.storeShulker(emptyBox, { chest_id: shulker.chest_id, pos_x: shulker.pos_x, pos_y: shulker.pos_y, pos_z: shulker.pos_z, slot: shulker.slot, }); if (!result) { await this.storeShulker(emptyBox); } } catch (e) { console.error(`Storage: Error storing mixed shulker box:`, e.message); try { await this.storeShulker(emptyBox); } catch (e2) { /* give up */ } } } } catch (error) { console.error(`Storage: Error unpacking mixed shulker #${shulker.id}:`, error.message); // Upsert it back so it can be retried try { await Database.upsertShulker(shulker.chest_id, shulker.slot, 'shulker_box', null, null); await Database.updateShulkerCounts(shulker.id, shulker.slot_count, shulker.total_items); } catch (e2) { /* ignore */ } } } } /** * 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) { if (this.bot.wasInterrupted()) { console.log('Storage: Consolidation interrupted'); break; } 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 }]); // Initiate trade with the player to hand over special item console.log(`Storage: Special item withdrawn for ${playerName}, initiating trade`); await this.initiateTradeWithPlayer(playerName); } 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); if (!this.isReady || !this.scanner) { const msg = 'Storage system is not ready. Please wait for initialization to complete.'; if (from === 'ai') return msg; this.bot.whisper(from, msg); return msg; } this._currentCommand = command; try { switch (command) { case 'scan': // Reject if another operation is already in progress if (this._operationLock) { const msg = 'Scan already in progress — wait for it to complete.'; if (from === 'ai') return msg; this.bot.whisper(from, msg); return msg; } if (from === 'ai') { try { await this.scanArea(true); const stats = await Database.getStats(); return `Scan complete! ${stats.totalChests} chests, ${stats.totalShulkers} shulkers, ${stats.totalItems} items`; } catch (error) { console.error('Storage: Scan error:', error); return `Scan failed: ${error.message}`; } } 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': if (from === 'ai') { // Return result directly to AI instead of whispering const stats = await Database.getStats(); return `Storage: ${stats.totalItems} items in ${stats.totalShulkers} shulkers (${stats.totalChests} chests)`; } else { await this.getStatus(from); } break; case 'withdraw': { const [itemName, count, playerName] = args; const actualPlayer = playerName || (from !== 'ai' ? from : null); if (!actualPlayer) { return 'Cannot withdraw items — unable to determine which player requested them.'; } const parsedCount = parseInt(count) || 1; await this.handleWithdrawRequest(actualPlayer, itemName, parsedCount); break; } case 'withdraw-shulkers': { const [itemName, shulkerCount, playerName] = args; const actualPlayer = playerName || (from !== 'ai' ? from : null); if (!actualPlayer) { return 'Cannot withdraw shulkers - no player specified. Ask the player to use the /trade command instead.'; } await this.handleWithdrawShulkers(actualPlayer, itemName, shulkerCount); break; } case 'find': { const [searchTerm] = args; const items = await this.findItem(searchTerm); if (items.length === 0) { if (from === 'ai') { // Return result directly to AI instead of whispering return `No items found matching '${searchTerm}'`; } else { this.bot.whisper(from, `No items found matching '${searchTerm}'`); } } else { const results = items.slice(0, 5).map(i => `${i.item_name}: ${i.total_count}`); const responseText = `Found: ${results.join(', ')}`; if (from === 'ai') { // Return result directly to AI instead of whispering return responseText; } else { this.bot.whisper(from, responseText); } } break; } case 'chests': { const chests = await Database.getChests(); if (from === 'ai') { // Return result directly to AI instead of whispering return `Tracking ${chests.length} chests`; } else { 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': { if (from === 'ai') return 'Cannot manage storage access via AI.'; 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': { if (from === 'ai') return 'Cannot manage storage access via AI.'; 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(', '); if (from === 'ai') { return `Players: ${playerList}`; } else { this.bot.whisper(from, `Players: ${playerList}`); } break; } default: this.bot.whisper(from, `Unknown command: ${command}`); } } finally { this._currentCommand = null; } } } module.exports = Storage;