Files
mc-bot-town/nodejs/controller/storage/index.js
T
2026-05-03 11:13:06 -04:00

2220 lines
73 KiB
JavaScript

'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;