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
+75 -2
View File
@@ -130,6 +130,17 @@ class Database {
)
`);
// Maps table (filled map images)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS maps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
map_id INTEGER UNIQUE NOT NULL,
image_data TEXT,
pixel_data TEXT,
captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Invite sites table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS invite_sites (
@@ -340,7 +351,7 @@ class Database {
`, [excludeId, excludeId]);
}
// Find shulkers containing a specific item (for withdrawal, excludes in-transit)
// Find shulkers containing a specific item (for withdrawal, excludes in-transit and special/named items)
async findShulkersWithItem(itemName) {
return await this.db.all(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type,
@@ -349,6 +360,15 @@ class Database {
INNER JOIN chests c ON c.id = s.chest_id
INNER JOIN shulker_items si ON si.shulker_id = s.id
WHERE si.item_name = ? AND s.slot_count >= 0
AND NOT (
si.nbt_data IS NOT NULL
AND si.nbt_data != 'null'
AND (
si.nbt_data LIKE '%"displayName"%'
OR si.nbt_data LIKE '%"lore"%'
OR si.nbt_data LIKE '%"customModelData"%'
)
)
GROUP BY s.id
ORDER BY available_count ASC
`, [itemName]);
@@ -414,12 +434,21 @@ class Database {
return null;
}
// Get total count of a specific item across all shulkers
// Get total count of a specific item across all shulkers (excludes special/named items)
async getItemTotalCount(itemName) {
const result = await this.db.get(`
SELECT SUM(si.count) as total
FROM shulker_items si
WHERE si.item_name = ?
AND NOT (
si.nbt_data IS NOT NULL
AND si.nbt_data != 'null'
AND (
si.nbt_data LIKE '%"displayName"%'
OR si.nbt_data LIKE '%"lore"%'
OR si.nbt_data LIKE '%"customModelData"%'
)
)
`, [itemName]);
return result?.total || 0;
}
@@ -711,6 +740,50 @@ class Database {
);
}
// ========================================
// Maps
// ========================================
async upsertMap(mapId, imageData, pixelData) {
return await this.db.run(`
INSERT INTO maps (map_id, image_data, pixel_data)
VALUES (?, ?, ?)
ON CONFLICT(map_id) DO UPDATE SET
image_data = excluded.image_data,
pixel_data = excluded.pixel_data,
captured_at = CURRENT_TIMESTAMP
`, [mapId, imageData, pixelData]);
}
async getMap(mapId) {
return await this.db.get('SELECT * FROM maps WHERE map_id = ?', [mapId]);
}
async getAllMaps() {
return await this.db.all('SELECT id, map_id, image_data, captured_at FROM maps ORDER BY map_id');
}
async deleteMap(mapId) {
return await this.db.run('DELETE FROM maps WHERE map_id = ?', [mapId]);
}
/**
* Find filled_map items in shulkers that don't have captured images yet.
* Returns items with location info needed to withdraw them.
*/
async getUncapturedMaps() {
return await this.db.all(`
SELECT si.*, s.slot as shulker_slot, s.chest_id, s.id as shulker_id,
c.pos_x, c.pos_y, c.pos_z
FROM shulker_items si
INNER JOIN shulkers s ON s.id = si.shulker_id
INNER JOIN chests c ON c.id = s.chest_id
WHERE si.item_name = 'filled_map'
AND s.slot_count >= 0
ORDER BY si.id
`);
}
// ========================================
// Stats
// ========================================
+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.`);
}
+139
View File
@@ -0,0 +1,139 @@
'use strict';
const { PNG } = require('pngjs');
// Minecraft map color palette (base colors × 4 shades each)
// Source: https://minecraft.wiki/w/Map_item_format#Color_table
// Index 0-3 = NONE (transparent), 4-7 = GRASS, 8-11 = SAND, etc.
// Each base color has 4 multipliers: 0.71, 0.86, 1.0, 0.53
const BASE_COLORS = [
null, // 0: NONE
[127, 178, 56], // 1: GRASS
[247, 233, 163], // 2: SAND
[199, 199, 199], // 3: WOOL
[255, 0, 0], // 4: FIRE
[160, 160, 255], // 5: ICE
[167, 167, 167], // 6: METAL
[0, 124, 0], // 7: PLANT
[255, 255, 255], // 8: SNOW
[164, 168, 184], // 9: CLAY
[151, 109, 77], // 10: DIRT
[112, 112, 112], // 11: STONE
[64, 64, 255], // 12: WATER
[143, 119, 72], // 13: WOOD
[255, 252, 245], // 14: QUARTZ
[216, 127, 51], // 15: COLOR_ORANGE
[178, 76, 216], // 16: COLOR_MAGENTA
[102, 153, 216], // 17: COLOR_LIGHT_BLUE
[229, 229, 51], // 18: COLOR_YELLOW
[127, 204, 25], // 19: COLOR_LIGHT_GREEN
[242, 127, 165], // 20: COLOR_PINK
[76, 76, 76], // 21: COLOR_GRAY
[153, 153, 153], // 22: COLOR_LIGHT_GRAY
[76, 127, 153], // 23: COLOR_CYAN
[127, 63, 178], // 24: COLOR_PURPLE
[51, 76, 178], // 25: COLOR_BLUE
[102, 76, 51], // 26: COLOR_BROWN
[102, 127, 51], // 27: COLOR_GREEN
[153, 51, 51], // 28: COLOR_RED
[25, 25, 25], // 29: COLOR_BLACK
[250, 238, 77], // 30: GOLD
[92, 219, 213], // 31: DIAMOND
[74, 128, 255], // 32: LAPIS
[0, 217, 58], // 33: EMERALD
[129, 86, 49], // 34: PODZOL
[112, 2, 0], // 35: NETHER
[209, 177, 161], // 36: TERRACOTTA_WHITE
[159, 82, 36], // 37: TERRACOTTA_ORANGE
[149, 87, 108], // 38: TERRACOTTA_MAGENTA
[112, 108, 138], // 39: TERRACOTTA_LIGHT_BLUE
[186, 133, 36], // 40: TERRACOTTA_YELLOW
[103, 117, 53], // 41: TERRACOTTA_LIGHT_GREEN
[160, 77, 78], // 42: TERRACOTTA_PINK
[57, 41, 35], // 43: TERRACOTTA_GRAY
[135, 107, 98], // 44: TERRACOTTA_LIGHT_GRAY
[87, 92, 92], // 45: TERRACOTTA_CYAN
[122, 73, 88], // 46: TERRACOTTA_PURPLE
[76, 62, 92], // 47: TERRACOTTA_BLUE
[76, 50, 35], // 48: TERRACOTTA_BROWN
[76, 82, 42], // 49: TERRACOTTA_GREEN
[142, 60, 46], // 50: TERRACOTTA_RED
[37, 22, 16], // 51: TERRACOTTA_BLACK
[189, 48, 49], // 52: CRIMSON_NYLIUM
[148, 63, 97], // 53: CRIMSON_STEM
[92, 25, 29], // 54: CRIMSON_HYPHAE
[22, 126, 134], // 55: WARPED_NYLIUM
[58, 142, 140], // 56: WARPED_STEM
[86, 44, 62], // 57: WARPED_HYPHAE
[20, 180, 133], // 58: WARPED_WART_BLOCK
[100, 100, 100], // 59: DEEPSLATE
[216, 175, 147], // 60: RAW_IRON
[127, 167, 150], // 61: GLOW_LICHEN
];
const SHADE_MULTIPLIERS = [180, 220, 255, 135]; // out of 255
// Build the full 256-entry lookup: MAP_COLORS[colorIndex] → [r, g, b]
const MAP_COLORS = new Array(256).fill(null);
for (let base = 0; base < BASE_COLORS.length; base++) {
for (let shade = 0; shade < 4; shade++) {
const idx = base * 4 + shade;
if (!BASE_COLORS[base]) {
MAP_COLORS[idx] = [0, 0, 0, 0]; // transparent
} else {
const [r, g, b] = BASE_COLORS[base];
const m = SHADE_MULTIPLIERS[shade];
MAP_COLORS[idx] = [
Math.floor(r * m / 255),
Math.floor(g * m / 255),
Math.floor(b * m / 255),
255
];
}
}
}
/**
* Apply a partial map update from a protocol packet to a pixel buffer.
* @param {Uint8Array} pixels - 128×128 color index buffer (mutated in place)
* @param {Object} packet - Map protocol packet with columns, rows, x, y, data
*/
function applyMapUpdate(pixels, packet) {
const { columns, rows, x, y, data } = packet;
if (!columns || columns === 0 || !data) return;
for (let col = 0; col < columns; col++) {
for (let row = 0; row < rows; row++) {
const srcIdx = col * rows + row;
const dstX = x + col;
const dstY = y + row;
if (dstX < 128 && dstY < 128) {
pixels[dstY * 128 + dstX] = data[srcIdx];
}
}
}
}
/**
* Render a 128×128 color-index buffer to a base64 PNG string.
* @param {Uint8Array} pixels - 128×128 array of Minecraft color indices
* @returns {string} base64-encoded PNG
*/
function renderMapToPNG(pixels) {
const png = new PNG({ width: 128, height: 128 });
for (let i = 0; i < 128 * 128; i++) {
const colorIdx = pixels[i];
const color = MAP_COLORS[colorIdx] || [0, 0, 0, 0];
const offset = i * 4;
png.data[offset] = color[0]; // R
png.data[offset + 1] = color[1]; // G
png.data[offset + 2] = color[2]; // B
png.data[offset + 3] = color[3]; // A
}
const buffer = PNG.sync.write(png);
return buffer.toString('base64');
}
module.exports = { MAP_COLORS, applyMapUpdate, renderMapToPNG };
+20 -6
View File
@@ -345,20 +345,29 @@ class Scanner {
try {
// Navigate the NBT structure to find Items array
// Structure is: nbt.value.BlockEntityTag.value.Items.value.value (array)
// Structure varies between:
// - Placed+opened shulker: nbt.value.BlockEntityTag.value.Items.value.value
// - Trade window / freshly-crafted: nbt.value.tag.value.BlockEntityTag.value.Items.value.value
// - Simple forms: nbt.Items, nbt.BlockEntityTag.Items, etc.
let nbtItems = null;
const nbt = shulkerItem.nbt;
// Try multiple paths to find the items array
const paths = [
() => nbt.value?.BlockEntityTag?.value?.Items?.value?.value, // Full nested path
() => nbt.value?.BlockEntityTag?.value?.Items?.value, // One less nesting
() => nbt.BlockEntityTag?.Items?.value?.value, // Without top value
() => nbt.BlockEntityTag?.Items?.value, // Simpler
() => nbt.BlockEntityTag?.Items, // Direct
() => nbt.value?.BlockEntityTag?.value?.Items?.value?.value, // Full nested path (standard placed shulker)
() => nbt.value?.BlockEntityTag?.value?.Items?.value, // One less nesting level
() => nbt.BlockEntityTag?.Items?.value?.value, // Without top-level value wrapper
() => nbt.BlockEntityTag?.Items?.value, // Simpler BlockEntityTag path
() => nbt.BlockEntityTag?.Items, // Direct BlockEntityTag
() => nbt.value?.tag?.value?.BlockEntityTag?.value?.Items?.value?.value, // Trade window shulker (has extra tag wrapper)
() => nbt.value?.tag?.value?.Items?.value?.value, // Trade window with Items directly under tag
() => nbt.value?.tag?.value?.Items?.value, // Trade window simpler
() => nbt.value?.Items?.value?.value, // No BlockEntityTag
() => nbt.Items?.value?.value, // Even simpler
() => nbt.Items, // Direct Items
() => nbt.tag?.value?.BlockEntityTag?.value?.Items?.value?.value, // Tag wrapper path
() => nbt.tag?.BlockEntityTag?.Items?.value?.value, // Tag without value
() => nbt.tag?.Items?.value?.value, // Tag with Items direct
];
for (const pathFn of paths) {
@@ -483,6 +492,11 @@ class Scanner {
result.repairCost = nbt.RepairCost;
}
// Map ID for filled_map items
if (nbt.map !== undefined) {
result.map = nbt.map;
}
return Object.keys(result).length > 0 ? result : null;
}
+186 -93
View File
@@ -147,7 +147,7 @@ class ShulkerHandler {
// Wait a few ticks for the item entity to spawn
await bot.bot.waitForTicks(3);
// Jump onto the drop position to collect it (like a human player)
// Attempt 1: Jump onto the drop position to collect it (like a human player)
console.log(`ShulkerHandler: Jumping onto ${placedPos} to collect drop`);
try {
await bot.bot.lookAt(placedPos.offset(0.5, 0, 0.5), true);
@@ -167,14 +167,43 @@ class ShulkerHandler {
return true;
}
// If not collected yet, pathfind directly to the drop
// Attempt 2: Pathfind directly to the drop position
try {
await bot.goTo({ where: placedPos, range: 0 });
} catch (e) {
try { await bot.goTo({ where: placedPos, range: 1 }); } catch (e2) { /* ignore */ }
}
// Poll for pickup (up to 5 seconds)
await bot.bot.waitForTicks(5);
if (bot.bot.inventory.items().find(item => item.name.includes('shulker_box'))) {
return true;
}
// Attempt 3: Search for the dropped item entity nearby and walk to it
// Item may have bounced away from the block position
let droppedEntity = null;
for (const entity of Object.values(bot.bot.entities)) {
if (!entity.name || entity.name !== 'item') continue;
const dist = entity.position.distanceTo(placedPos);
if (dist < 5 && (!droppedEntity || dist < droppedEntity.position.distanceTo(placedPos))) {
droppedEntity = entity;
}
}
if (droppedEntity) {
console.log(`ShulkerHandler: Found item entity at ${droppedEntity.position}, walking to it`);
try {
await bot.goTo({ where: droppedEntity.position, range: 0 });
} catch (e) {
try { await bot.goTo({ where: droppedEntity.position, range: 1 }); } catch (e2) { /* ignore */ }
}
await bot.bot.waitForTicks(5);
if (bot.bot.inventory.items().find(item => item.name.includes('shulker_box'))) {
return true;
}
}
// Final poll — wait up to 5 seconds in case of lag
for (let i = 0; i < 10; i++) {
await sleep(500);
if (bot.bot.inventory.items().find(item => item.name.includes('shulker_box'))) {
@@ -182,101 +211,109 @@ class ShulkerHandler {
}
}
console.error('ShulkerHandler: Failed to collect shulker after all attempts');
return !!bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
}
/**
* Best-effort recovery: return a shulker from bot inventory back to its chest slot.
* Return a shulker from bot inventory back to its chest slot.
* If placedPos is given, dig the placed shulker first.
* Never throws — returns true on success, false on failure.
* @throws Error if the shulker cannot be returned (caller should handle the failure).
*/
async returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId) {
try {
// If shulker is placed on the ground, break it first
if (placedPos) {
try {
await bot.bot.closeWindow(bot.bot.currentWindow);
} catch (e) { /* ignore */ }
await sleep(300);
await this.digAndCollectShulker(bot, placedPos);
}
// Find shulker in bot inventory
const shulkerInInv = bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (!shulkerInInv) {
console.error('ShulkerHandler: Recovery failed — no shulker in inventory');
return false;
}
// Go to chest and open it
await bot.goTo({ where: chestPos, range: 3 });
const chestBlock = bot.bot.blockAt(chestPos);
if (!chestBlock || !chestBlock.name.includes('chest')) {
console.error(`ShulkerHandler: Recovery failed — no chest at ${chestPos}`);
return false;
}
const window = await bot.openContainer(chestBlock);
// If shulker is placed on the ground, break it first
if (placedPos) {
try {
await bot.bot.closeWindow(bot.bot.currentWindow);
} catch (e) { /* ignore */ }
await sleep(300);
// Find shulker in inventory portion of the window
let 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;
await this.digAndCollectShulker(bot, placedPos);
}
// Find shulker in bot inventory — throw if not found so caller knows recovery failed
const shulkerInInv = bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (!shulkerInInv) {
throw new Error('Recovery failed — no shulker in inventory');
}
// Go to chest and open it
await bot.goTo({ where: chestPos, range: 3 });
const chestBlock = bot.bot.blockAt(chestPos);
if (!chestBlock || !chestBlock.name.includes('chest')) {
throw new Error(`Recovery failed — no chest at ${chestPos}`);
}
const window = await bot.openContainer(chestBlock);
await sleep(300);
// Find shulker in inventory portion of the window
let 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 bot.bot.closeWindow(window);
throw new Error('Recovery failed — shulker not found in window inventory');
}
// Try original slot first; if occupied, find any empty chest slot
let targetSlot = chestSlot;
if (window.slots[chestSlot]) {
targetSlot = null;
for (let i = 0; i < window.inventoryStart; i++) {
if (!window.slots[i]) {
targetSlot = i;
break;
}
}
if (shulkerWindowSlot === null) {
await bot.bot.closeWindow(window);
console.error('ShulkerHandler: Recovery failed — shulker not found in window inventory');
return false;
}
// Try original slot first; if occupied, find any empty chest slot
let targetSlot = chestSlot;
if (window.slots[chestSlot]) {
targetSlot = null;
for (let i = 0; i < window.inventoryStart; i++) {
if (!window.slots[i]) {
targetSlot = i;
break;
}
}
}
if (targetSlot === null) {
await bot.bot.closeWindow(window);
console.error('ShulkerHandler: Recovery failed — no empty chest slot');
return false;
}
await bot.bot.moveSlotItem(shulkerWindowSlot, targetSlot);
await sleep(300);
// Read the NBT and sync DB immediately
const updatedSlotItem = window.slots[targetSlot];
await bot.bot.closeWindow(window);
await sleep(200);
if (updatedSlotItem && chestId && this.scanner) {
try {
await this.scanner.scanShulkerFromNBT(bot, Database, chestId, targetSlot, updatedSlotItem);
await Database.rebuildItemIndex();
console.log(`ShulkerHandler: Recovery DB synced — shulker at chest slot ${targetSlot}`);
} catch (e) {
console.error(`ShulkerHandler: Recovery DB sync failed: ${e.message}`);
}
}
console.log(`ShulkerHandler: Recovery succeeded — shulker returned to chest slot ${targetSlot}`);
return true;
} catch (error) {
console.error('ShulkerHandler: Recovery error:', error.message);
return false;
}
if (targetSlot === null) {
await bot.bot.closeWindow(window);
throw new Error('Recovery failed — no empty chest slot');
}
await bot.bot.moveSlotItem(shulkerWindowSlot, targetSlot);
await sleep(300);
// Read the NBT and sync DB immediately
const updatedSlotItem = window.slots[targetSlot];
await bot.bot.closeWindow(window);
await sleep(200);
if (updatedSlotItem && chestId && this.scanner) {
try {
await this.scanner.scanShulkerFromNBT(bot, Database, chestId, targetSlot, updatedSlotItem);
await Database.rebuildItemIndex();
console.log(`ShulkerHandler: Recovery DB synced — shulker at chest slot ${targetSlot}`);
} catch (e) {
console.error(`ShulkerHandler: Recovery DB sync failed: ${e.message}`);
}
}
// Verify the shulker is actually back in the chest
await bot.goTo({ where: chestPos, range: 3 });
const verifyBlock = bot.bot.blockAt(chestPos);
if (!verifyBlock || !verifyBlock.name.includes('chest')) {
throw new Error('Recovery verification failed — chest not found');
}
const verifyWindow = await bot.openContainer(verifyBlock);
await sleep(200);
const verifyItem = verifyWindow.slots[targetSlot];
await bot.bot.closeWindow(verifyWindow);
await sleep(200);
if (!verifyItem || !verifyItem.name.includes('shulker_box')) {
throw new Error(`Recovery verification failed — shulker not found at chest slot ${targetSlot} after return`);
}
console.log(`ShulkerHandler: Recovery succeeded — shulker returned to chest slot ${targetSlot}`);
return true;
}
/**
@@ -573,7 +610,18 @@ class ShulkerHandler {
placedPos = result.placedPos;
} catch (placeError) {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) {
// Recovery failed — shulker state is uncertain, propagate error upward
// so the caller knows not to retry this same shulker
throw recoverError;
}
return { deposited: 0, updatedSlotItem: null };
}
@@ -646,13 +694,27 @@ class ShulkerHandler {
return { deposited, updatedSlotItem };
} catch (returnError) {
console.error('ShulkerHandler: Failed to close/break/return, recovering:', returnError.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
return { deposited: 0, updatedSlotItem: null };
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw returnError;
return { deposited, updatedSlotItem: null };
}
} catch (error) {
console.error('ShulkerHandler: Deposit failed, attempting recovery:', error.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw error;
return { deposited: 0, updatedSlotItem: null };
} finally {
this.operationInProgress = false;
@@ -822,7 +884,14 @@ class ShulkerHandler {
placedPos = result.placedPos;
} catch (placeError) {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw placeError;
return { withdrawn: 0, updatedSlotItem: null };
}
@@ -841,6 +910,9 @@ class ShulkerHandler {
const shulkerItem = shulkerWindow.slots[s];
if (!shulkerItem || shulkerItem.name !== itemName) continue;
// Skip special/named items — only withdraw via special item handler
if (shulkerItem.nbt?.value?.display) continue;
// Check if inventory has space
const hasInvSpace = (() => {
for (let i = shulkerWindow.inventoryStart; i < shulkerWindow.inventoryEnd; i++) {
@@ -906,13 +978,27 @@ class ShulkerHandler {
return { withdrawn, updatedSlotItem };
} catch (returnError) {
console.error('ShulkerHandler: Failed to close/break/return, recovering:', returnError.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw returnError;
return { withdrawn: 0, updatedSlotItem: null };
}
} catch (error) {
console.error('ShulkerHandler: Withdraw failed, attempting recovery:', error.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw error;
return { withdrawn: 0, updatedSlotItem: null };
} finally {
this.operationInProgress = false;
@@ -945,7 +1031,14 @@ class ShulkerHandler {
placedPos = result.placedPos;
} catch (placeError) {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
}
if (!recovered) throw placeError;
return { withdrawn: 0, updatedSlotItem: null };
}
+50 -3
View File
@@ -145,6 +145,17 @@ function createRouter(getActiveInstance) {
}
});
router.get('/api/maps', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
const maps = await database.getAllMaps();
res.json({ maps });
} catch (error) {
console.error('API Error /api/maps:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/special-items', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Storage database not initialized' });
try {
@@ -323,6 +334,7 @@ const webUI = {
<div class="tab active" onclick="switchStorageSubTab('inventory')">Inventory</div>
<div class="tab" onclick="switchStorageSubTab('map')">Storage Map</div>
<div class="tab" onclick="switchStorageSubTab('special')">Special Items</div>
<div class="tab" onclick="switchStorageSubTab('maps')">Maps</div>
<div class="tab" onclick="switchStorageSubTab('withdraw')">Withdraw</div>
</div>
<div id="stab-inventory" class="stab-content active" style="margin-top:16px">
@@ -353,6 +365,12 @@ const webUI = {
<div id="specialItems"><div style="padding:20px;color:#6b7280;text-align:center">Click tab to load...</div></div>
</div>
</div>
<div id="stab-maps" class="stab-content" style="margin-top:16px">
<div class="panel">
<h3>Filled Maps</h3>
<div id="mapsGrid"><div style="padding:20px;color:#6b7280;text-align:center">Click tab to load...</div></div>
</div>
</div>
<div id="stab-withdraw" class="stab-content" style="margin-top:16px">
<div class="panel">
<h3>Request Withdrawal</h3>
@@ -455,6 +473,11 @@ const webUI = {
.special-card .sp-withdraw button{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:.8em}
.special-card .sp-withdraw button:hover{background:#1d4ed8}
.special-card .sp-status{font-size:.8em;margin-top:4px;min-height:16px}
.maps-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
.map-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:8px;text-align:center;transition:border-color .2s}
.map-card:hover{border-color:#60a5fa}
.map-card img{width:128px;height:128px;image-rendering:pixelated;border-radius:4px}
.map-card .map-label{font-size:.8em;color:#9ca3af;margin-top:6px}
.stab-content{display:none}
.stab-content.active{display:block}
.storage-sub-tabs{display:flex;border-bottom:1px solid #374151}
@@ -462,7 +485,7 @@ const webUI = {
`,
js: `
let allItems=[], mapData=[], sortKey='total_count', sortDir=-1;
let specialLoaded=false, storageSubTab='inventory';
let specialLoaded=false, mapsLoaded=false, storageSubTab='inventory';
function onStorageTabActive() {
if (allItems.length === 0) { loadStats(); loadInventory(); loadPlayers(); }
@@ -474,12 +497,13 @@ function switchStorageSubTab(name) {
document.querySelectorAll('.stab-content').forEach(t => t.classList.remove('active'));
const el = document.getElementById('stab-'+name);
if (el) el.classList.add('active');
const subNames=['inventory','map','special','withdraw'];
const subNames=['inventory','map','special','maps','withdraw'];
const tabs = document.querySelectorAll('.storage-sub-tabs .tab');
const idx = subNames.indexOf(name);
if (idx >= 0 && tabs[idx]) tabs[idx].classList.add('active');
if (name === 'map' && mapData.length === 0) loadMap();
if (name === 'special' && !specialLoaded) loadSpecialItems();
if (name === 'maps' && !mapsLoaded) loadMaps();
}
async function loadStats() {
@@ -875,7 +899,30 @@ async function loadPlayers(){
try{const r=await fetch('/api/players');if(!r.ok)return;const d=await r.json();playerNames=(d.players||[]).map(p=>p.player_name)}catch(e){}
}
function storageLoadAll(){loadStats();loadInventory();loadPlayers();if(storageSubTab==='map')loadMap();if(storageSubTab==='special')loadSpecialItems()}
async function loadMaps() {
const container = document.getElementById('mapsGrid');
container.innerHTML = '<div style="padding:20px;color:#6b7280;text-align:center">Loading...</div>';
try {
const r = await fetch('/api/maps');
const d = await r.json();
const maps = d.maps || [];
mapsLoaded = true;
if (maps.length === 0) {
container.innerHTML = '<div style="padding:20px;color:#6b7280;text-align:center">No maps captured yet</div>';
return;
}
container.innerHTML = '<div class="maps-grid">' + maps.map(m =>
'<div class="map-card">' +
'<img src="data:image/png;base64,' + m.image_data + '" alt="Map #' + m.map_id + '">' +
'<div class="map-label">Map #' + m.map_id + '</div>' +
'</div>'
).join('') + '</div>';
} catch(e) {
container.innerHTML = '<div style="padding:20px;color:#ef4444">Failed to load maps</div>';
}
}
function storageLoadAll(){loadStats();loadInventory();loadPlayers();if(storageSubTab==='map')loadMap();if(storageSubTab==='special')loadSpecialItems();if(storageSubTab==='maps')loadMaps()}
`,
};