2220 lines
73 KiB
JavaScript
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;
|