AI works here

This commit is contained in:
2026-04-30 12:48:29 -04:00
parent 92024c8a64
commit 6f4519894b
16 changed files with 1793 additions and 303 deletions
+570 -44
View File
@@ -7,6 +7,7 @@ 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;
@@ -30,6 +31,36 @@ class Storage {
try {
console.log(`Storage: Bot ${this.bot.name} is ready, initializing storage system...`);
const { Movements } = require('mineflayer-pathfinder')
/* console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
const mcData = require('minecraft-data')(this.bot.bot.version)
const defaultMovements = new Movements(this.bot.bot)
// 1. Identify the block we WANT to walk on
const targetBlockId = mcData.blocksByName.crafting_table.id
// 2. Add an exclusion area rule
// This function is called for every block the pathfinder considers stepping on.
defaultMovements.exclusionAreasStep.push((block) => {
// If the block the bot would stand on is NOT our aisle block,
// make it very "expensive" to move there.
if (block.type !== targetBlockId) {
return 100 // High cost forces the pathfinder to look for 0-cost blocks (crafting tables)
}
return 0 // No extra cost for crafting tables
})
// 3. (Optional) Disable sprinting to prevent sliding off the aisle
defaultMovements.allowSprinting = false
this.bot.bot.pathfinder.setMovements(defaultMovements)*/
// Initialize database
if (!Database.db) {
console.log('Storage: Initializing database...');
@@ -51,6 +82,16 @@ class Storage {
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) {
// Initial hotbar restock
try {
@@ -70,7 +111,7 @@ class Storage {
}, restockInterval);
// Start periodic inventory cleanup
const cleanupInterval = this.config.inventoryCleanupInterval || 5 * 60 * 1000;
const cleanupInterval = this.config.inventoryCleanupInterval || 60 * 1000;
this.cleanupInterval = setInterval(async () => {
if (!this.isReady || this._busy || this.shulkerHandler.operationInProgress) return;
try {
@@ -104,6 +145,13 @@ class Storage {
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);
@@ -126,6 +174,147 @@ class Storage {
const chests = await this.scanner.discoverChests(this.bot, this.config.scanRadius || 30, Database);
const shulkers = await this.scanner.scanAllChests(this.bot, Database);
console.log(`Storage[${this.bot.name}]: Complete - ${chests.length} chests, ${shulkers} shulkers`);
// Capture any map images received passively during scan
await this.captureMapImages();
// Actively withdraw and index any filled_maps not yet captured
await this.indexMapsFromStorage();
}
/**
* 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');
}
// ========================================
@@ -224,12 +413,25 @@ class Storage {
}
// After unpacking, read current bot inventory for depositing
// Skip hotbar items and shulker boxes (already stored by unpack)
const hotbarNames = new Set((this.config.hotbarItems || []).map(h => h.name));
// Keep hotbar items up to their target, deposit any excess
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 (hotbarNames.has(item.name)) continue;
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;
}
@@ -251,12 +453,18 @@ class Storage {
// Rebuild index after all deposits
await Database.rebuildItemIndex();
// Sweep any leftovers that didn't deposit on first pass
await this.cleanInventory();
// Whisper summary to player
const summary = results.map(r => {
if (r.error) return `${r.itemName}: FAILED (${r.error})`;
return `${r.itemName}: ${r.deposited}/${r.requested}`;
}).join(', ');
// Capture any map images from traded filled maps
await this.captureMapImages();
this.bot.whisper(playerName, `Storage complete: ${summary}`);
return results;
} finally {
@@ -266,15 +474,15 @@ class Storage {
/**
* Process all shulker boxes in bot inventory from a trade.
* Sorted shulkers (single item type) are stored directly — no unpack needed.
* Mixed shulkers are unpacked, items deposited, then empty box stored.
* Mixed shulkers (multiple item types) are unpacked, items deposited, then empty box stored.
* Sorted shulkers (single item type) and empty shulkers are stored directly — no unpack needed.
* Deposits between each shulker to keep inventory space free.
*/
async unpackTradedShulkers() {
// Phase 1: Store ALL shulkers from inventory into chests.
// This frees inventory space and registers contents in DB via NBT scan.
// Sorted/empty shulkers are done after this. Mixed ones need phase 2.
const mixedShulkers = []; // { chestId, chestSlot, chestPos, shulkerId }
const shulkersToUnpack = []; // { chestId, chestSlot, chestPos, shulkerId }
console.log('Storage: Phase 1 — stashing all traded shulkers into chests');
let stashCount = 0;
@@ -287,9 +495,9 @@ class Storage {
const itemTypes = new Set(contents.map(c => c.name));
const isMixed = contents.length > 0 && itemTypes.size > 1;
const label = isMixed
? `mixed (${itemTypes.size} types)`
: contents.length === 0 ? 'empty' : `sorted (${[...itemTypes][0]})`;
const label = !contents.length ? 'empty'
: isMixed ? `mixed (${itemTypes.size} types)`
: `sorted (${[...itemTypes][0]})`;
console.log(`Storage: Stashing shulker ${stashCount}: ${label}`);
try {
@@ -299,7 +507,7 @@ class Storage {
break;
}
if (isMixed) {
mixedShulkers.push(info);
shulkersToUnpack.push(info);
}
} catch (error) {
console.error(`Storage: Error stashing shulker:`, error.message);
@@ -307,53 +515,158 @@ class Storage {
}
}
console.log(`Storage: Phase 1 complete — stashed ${stashCount} shulkers, ${mixedShulkers.length} need unpacking`);
console.log(`Storage: Phase 1 complete — stashed ${stashCount} shulkers, ${shulkersToUnpack.length} mixed need unpacking`);
// Phase 2: Pull each mixed shulker back out, unpack it, deposit items, store empty box.
// Phase 2: Pull each mixed shulker back out, unpack it, deposit items, return empty box to ORIGINAL slot.
// Bot inventory is now empty (aside from hotbar), so we have room.
for (let i = 0; i < mixedShulkers.length; i++) {
const info = mixedShulkers[i];
console.log(`Storage: Phase 2 — unpacking mixed shulker ${i + 1}/${mixedShulkers.length} (DB #${info.shulkerId})`);
const failedToUnpack = []; // shulkers that failed and should be re-queued
for (let i = 0; i < shulkersToUnpack.length; i++) {
const info = shulkersToUnpack[i];
console.log(`Storage: Phase 2 — unpacking shulker ${i + 1}/${shulkersToUnpack.length} (DB #${info.shulkerId})`);
// Pre-iteration cleanup: store any leftover shulker boxes from previous iteration
// This prevents the wrong shulker being picked up by subsequent .find() calls
let leftoverBoxes = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box'));
for (const leftover of leftoverBoxes) {
console.log(`Storage: Storing leftover shulker box before next unpack`);
try { await this.storeShulker(leftover); } catch (e) {
console.error(`Storage: Error storing leftover shulker:`, e.message);
}
}
// Pre-iteration cleanup: deposit any non-shulker items clogging inventory
await this._depositNonShulkerInventory();
// Check inventory space — need at least 3 free slots (shulker + extracted items + buffer)
const freeSlots = this.bot.bot.inventory.slots.filter((s, idx) => !s && idx >= 9).length;
if (freeSlots < 3) {
console.log(`Storage: Inventory too full (${freeSlots} free slots), deferring remaining ${shulkersToUnpack.length - i} shulker(s)`);
// Re-queue all remaining shulkers for next organize pass
for (let j = i; j < shulkersToUnpack.length; j++) {
failedToUnpack.push(shulkersToUnpack[j]);
}
break;
}
// Re-verify the shulker still exists in the DB and at the expected chest/slot
// If it was already cleaned up by a previous failure, skip it
const currentShulker = await Database.getShulkerById(info.shulkerId);
if (!currentShulker) {
console.log(`Storage: Shulker #${info.shulkerId} no longer in DB, skipping (may have been recovered)`);
continue;
}
// takeWholeShulker already marks the shulker as in-transit (slot_count=-1) in the DB
// so we do NOT delete it beforehand — that would leave the DB in a deleted state with no in-transit marker
let unpackSucceeded = false;
let shulkerItem = null;
let extracted = [];
let inventoryFull = false;
try {
// Take the shulker from the chest into inventory
// This marks slot_count=-1 in DB to indicate in-transit
await this.shulkerHandler.takeWholeShulker(
this.bot, info.chestPos, info.chestSlot, info.shulkerId
);
// Delete from DB since we're about to empty it
await Database.deleteShulker(info.shulkerId);
// Find the shulker in inventory and unpack it
const shulkerItem = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
// Find the shulker in inventory — use the slot we know it landed in, not .find()
// The shulker is the only shulker box in inventory after pre-iteration cleanup
shulkerItem = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (!shulkerItem) {
console.error('Storage: Shulker not found in inventory after taking from chest');
continue;
// Mark as failed — it may still be at the chest if takeWholeShulker failed silently
throw new Error('Shulker vanished from inventory after take');
}
const { extracted, inventoryFull } = await this.shulkerHandler.unpackShulkerFromInventory(this.bot, shulkerItem);
console.log(`Storage: Extracted ${extracted.length} item stacks from mixed shulker`);
({ extracted, inventoryFull } = await this.shulkerHandler.unpackShulkerFromInventory(this.bot, shulkerItem));
console.log(`Storage: Extracted ${extracted.length} item stacks from shulker${inventoryFull ? ' (inventory was full, some items may remain in shulker)' : ''}`);
unpackSucceeded = true;
// Deposit all extracted items
// Deposit all extracted items grouped by type (prevents fragmentation across shulkers)
await this._depositNonShulkerInventory();
// Store the empty shulker box back into a chest
const emptyBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (emptyBox) {
try {
await this.storeShulker(emptyBox);
} catch (e) {
console.error(`Storage: Error storing empty box:`, e.message);
}
}
} catch (error) {
console.error(`Storage: Error unpacking mixed shulker #${info.shulkerId}:`, error.message);
console.error(`Storage: Error unpacking shulker #${info.shulkerId}:`, error.message);
// Deposit whatever we managed to extract
await this._depositNonShulkerInventory();
// Try to store any shulker box left in inventory
// If the shulker is still in inventory (unpack failed partway), store it back
const leftover = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (leftover) {
try { await this.storeShulker(leftover); } catch (e) { /* best effort */ }
try {
// Try to return to original slot first
const result = await this.storeShulker(leftover, {
chest_id: info.chestId,
pos_x: info.chestPos.x,
pos_y: info.chestPos.y,
pos_z: info.chestPos.z,
slot: info.chestSlot,
});
if (!result) {
// Original slot occupied, just store anywhere
await this.storeShulker(leftover);
}
} catch (e) {
console.error(`Storage: Error storing leftover shulker:`, e.message);
// Best-effort: just store anywhere
try { await this.storeShulker(leftover); } catch (e2) { /* give up */ }
}
}
// Mark shulker as back in DB (it was marked in-transit by takeWholeShulker)
// Restore it to the chest (re-insert so it can be found by the chest/slot lookup)
// The actual physical state is uncertain; this is a best-effort restore
try {
await Database.upsertShulker(info.chestId, info.chestSlot, shulkerItem?.name || 'shulker_box', null, null);
await Database.updateShulkerCounts(info.shulkerId, 0, 0);
} catch (e) {
console.error(`Storage: Could not restore shulker record:`, e.message);
}
failedToUnpack.push(info);
continue;
}
// Unpack succeeded: re-insert the shulker as empty (or partially-empty if inventory was full)
// into its ORIGINAL chest slot to preserve physical arrangement
const emptyBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (emptyBox) {
try {
// Try to place back in original slot
const result = await this.storeShulker(emptyBox, {
chest_id: info.chestId,
pos_x: info.chestPos.x,
pos_y: info.chestPos.y,
pos_z: info.chestPos.z,
slot: info.chestSlot,
});
if (!result) {
// Original slot occupied (shouldn't happen since we just emptied it)
await this.storeShulker(emptyBox);
}
} catch (e) {
console.error(`Storage: Error storing shulker box:`, e.message);
try { await this.storeShulker(emptyBox); } catch (e2) { /* give up */ }
}
}
// If the shulker was only partially unpacked (inventoryFull), re-queue it
if (inventoryFull) {
console.log(`Storage: Shulker was only partially unpacked — re-queueing for next organize`);
// Mark the shulker as "pending" in DB: it still has mixed contents
// We need to update its item_focus to null so organizeLooseItems will detect it
try {
await Database.upsertShulker(info.chestId, info.chestSlot, 'shulker_box', null, null);
// Rescan to get accurate remaining contents
const placedItem = emptyBox; // the box still in inventory
// Note: the box is already stored back via storeShulker above, so we can't rescan it here
// Instead, mark it as needing rescan on next organize
} catch (e) {
console.error(`Storage: Could not mark partial shulker for re-queue:`, e.message);
}
failedToUnpack.push(info);
}
}
@@ -394,12 +707,15 @@ class Storage {
/**
* Store a shulker box from bot inventory into an empty chest slot.
* Scans NBT to register contents in DB.
* @returns {{ chestId, chestSlot, shulkerId, itemFocus }} or false on failure
* @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) {
async storeShulker(shulkerItem, targetSlot = null) {
console.log(`Storage: Storing shulker box (${shulkerItem.name})`);
const emptySlot = await Database.findEmptyChestSlot();
// 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;
@@ -460,6 +776,8 @@ class Storage {
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'})`);
@@ -684,7 +1002,8 @@ class Storage {
// Log trade
await Database.logTrade(playerName, 'withdraw', [{ name: itemName, count: totalWithdrawn }]);
this.bot.whisper(playerName, `${totalWithdrawn}x ${itemName} ready. Use /trade to collect within 5 minutes.`);
// Initiate trade with the player to hand over items
await this.initiateTradeWithPlayer(playerName);
} else {
this.bot.whisper(playerName, `Failed to withdraw ${itemName}.`);
}
@@ -748,7 +1067,8 @@ class Storage {
await Database.logTrade(playerName, 'withdraw', [{ name: itemName, count: taken, mode: 'shulkers' }]);
this.bot.whisper(playerName, `${taken} shulker(s) of ${itemName} ready. Use /trade to collect within 5 minutes.`);
// Initiate trade with the player to hand over shulkers
await this.initiateTradeWithPlayer(playerName);
} else {
this.bot.whisper(playerName, `Failed to withdraw shulkers of ${itemName}.`);
}
@@ -798,6 +1118,102 @@ class Storage {
}
}
/**
* 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.`);
}
}
// ========================================
// Crafting
// ========================================
@@ -1020,11 +1436,24 @@ class Storage {
* Called periodically and as pre/post-flight during organize.
*/
async cleanInventory() {
const hotbarNames = new Set((this.config.hotbarItems || []).map(h => h.name));
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 (hotbarNames.has(item.name)) 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;
}
@@ -1065,6 +1494,10 @@ class Storage {
// 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`);
@@ -1242,6 +1675,97 @@ class Storage {
}
}
/**
* 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.
@@ -1400,7 +1924,9 @@ class Storage {
});
await Database.logTrade(playerName, 'withdraw', [{ name: itemRow.item_name, count: withdrawn, special: true }]);
this.bot.whisper(playerName, `${withdrawn}x ${itemRow.item_name} (special) ready. Use /trade to collect within 5 minutes.`);
// Initiate trade with the player to hand over special item
await this.initiateTradeWithPlayer(playerName);
} else {
this.bot.whisper(playerName, `Failed to withdraw special item.`);
}