This commit is contained in:
2026-07-12 17:26:59 -04:00
parent 60663b8d4a
commit ba1bef8ff9
25 changed files with 158880 additions and 1529 deletions
+185
View File
@@ -163,6 +163,31 @@ class Database {
UNIQUE(site_id, player_name)
)
`);
// Application settings table (runtime-tunable, editable via web UI and LLM)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
category TEXT NOT NULL DEFAULT 'general',
label TEXT,
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Per-bot settings table (autoConnect, autoReConnect, onDemand, plugins, etc.)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS bot_settings (
bot_name TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(bot_name, key)
)
`);
}
async insertDefaultPermissions() {
@@ -181,6 +206,134 @@ class Database {
}
}
// ========================================
// Settings
// ========================================
async seedDefaultSettings(defaults) {
for (const entry of defaults) {
try {
await this.db.run(
'INSERT OR IGNORE INTO settings (key, value, type, category, label, description) VALUES (?, ?, ?, ?, ?, ?)',
[entry.key, entry.value, entry.type, entry.category, entry.label, entry.description || null]
);
} catch (error) {
console.error('Error seeding setting:', entry.key, error);
}
}
}
async getAllSettings() {
return await this.db.all('SELECT * FROM settings ORDER BY category, key');
}
async getSettingsByCategory(category) {
return await this.db.all('SELECT * FROM settings WHERE category = ? ORDER BY key', [category]);
}
async getSetting(key) {
return await this.db.get('SELECT * FROM settings WHERE key = ?', [key]);
}
async setSetting(key, value) {
const row = await this.db.get('SELECT type FROM settings WHERE key = ?', [key]);
if (!row) throw new Error(`Unknown setting: ${key}`);
const coerced = this._coerceValue(value, row.type);
await this.db.run(
'UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?',
[coerced, key]
);
return coerced;
}
_coerceValue(value, type) {
switch (type) {
case 'number': {
const n = Number(value);
if (isNaN(n)) throw new Error(`Expected number, got: ${value}`);
return String(n);
}
case 'boolean': return value === true || value === 'true' || value === '1' ? 'true' : 'false';
case 'json': return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value);
default: return String(value);
}
}
// ========================================
// Bot Settings (per-bot configuration)
// ========================================
async getAllBotSettings(botName) {
return await this.db.all('SELECT * FROM bot_settings WHERE bot_name = ? ORDER BY key', [botName]);
}
async getBotSetting(botName, key) {
return await this.db.get('SELECT * FROM bot_settings WHERE bot_name = ? AND key = ?', [botName, key]);
}
async setBotSetting(botName, key, value, type) {
const coerced = this._coerceValue(value, type || 'string');
await this.db.run(`
INSERT INTO bot_settings (bot_name, key, value, type)
VALUES (?, ?, ?, ?)
ON CONFLICT(bot_name, key) DO UPDATE SET
value = excluded.value,
type = excluded.type,
updated_at = CURRENT_TIMESTAMP
`, [botName, key, coerced, type || 'string']);
return coerced;
}
async setBotSettings(botName, settings) {
await this.db.run('SAVEPOINT setBotSettings');
try {
for (const [key, entry] of Object.entries(settings)) {
const type = entry.type || 'string';
const coerced = this._coerceValue(entry.value, type);
await this.db.run(`
INSERT INTO bot_settings (bot_name, key, value, type)
VALUES (?, ?, ?, ?)
ON CONFLICT(bot_name, key) DO UPDATE SET
value = excluded.value,
type = excluded.type,
updated_at = CURRENT_TIMESTAMP
`, [botName, key, coerced, type]);
}
await this.db.run('RELEASE setBotSettings');
} catch (error) {
await this.db.run('ROLLBACK TO setBotSettings');
throw error;
}
}
async seedDefaultBotSettings() {
const conf = require('../../conf');
const bots = conf.mc?.bots || {};
for (const [botName, botConfig] of Object.entries(bots)) {
const defaults = {
username: { value: String(botConfig.username || ''), type: 'string' },
password: { value: String(botConfig.password || ''), type: 'secret' },
auth: { value: String(botConfig.auth || 'microsoft'), type: 'string' },
autoConnect: { value: String(botConfig.autoConnect ?? true), type: 'boolean' },
autoReConnect: { value: String(botConfig.autoReConnect ?? true), type: 'boolean' },
onDemand: { value: String(botConfig.onDemand || false), type: 'boolean' },
idleTimeout: { value: String(botConfig.idleTimeout || 30000), type: 'number' },
commands: { value: JSON.stringify(botConfig.commands || []), type: 'json' },
plugins: { value: JSON.stringify(botConfig.plugins || {}), type: 'json' },
hasAi: { value: String(botConfig.hasAi || false), type: 'boolean' },
};
for (const [key, entry] of Object.entries(defaults)) {
try {
await this.db.run(
'INSERT OR IGNORE INTO bot_settings (bot_name, key, value, type) VALUES (?, ?, ?, ?)',
[botName, key, entry.value, entry.type]
);
} catch (e) { /* ignore duplicates */ }
}
}
console.log('Bot settings seeded from config defaults');
}
// ========================================
// Permissions
// ========================================
@@ -238,6 +391,11 @@ class Database {
return await this.db.get('SELECT * FROM chests WHERE id = ?', [id]);
}
async markChestLost(x, y, z) {
await this.db.run('DELETE FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?', [x, y, z]);
console.log(`Scanner: Removed lost chest at ${x},${y},${z}`);
}
async getChestByPosition(x, y, z) {
return await this.db.get(
'SELECT * FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?',
@@ -258,6 +416,29 @@ class Database {
`, values);
}
async batchUpsertChests(chests) {
if (!chests || chests.length === 0) return;
await this.db.run('BEGIN TRANSACTION');
try {
for (const c of chests) {
await this.db.run(`
INSERT INTO chests (pos_x, pos_y, pos_z, chest_type, row, column, category)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pos_x, pos_y, pos_z) DO UPDATE SET
chest_type = excluded.chest_type,
row = excluded.row,
column = excluded.column,
category = excluded.category,
last_scan = CURRENT_TIMESTAMP
`, [c.x, c.y, c.z, c.type, c.row, c.column, c.category]);
}
await this.db.run('COMMIT');
} catch (err) {
await this.db.run('ROLLBACK');
throw err;
}
}
// ========================================
// Shulkers
// ========================================
@@ -325,6 +506,10 @@ class Database {
return await this.db.run('DELETE FROM shulkers WHERE id = ?', [id]);
}
async deleteShulkersByChest(chestId) {
return await this.db.run('DELETE FROM shulkers WHERE chest_id = ?', [chestId]);
}
// Find a shulker that already stores this item type and has space (<27 slots used, not in-transit)
async findShulkerWithSpace(itemName, excludeId = null) {
return await this.db.get(`
+137 -29
View File
@@ -138,14 +138,15 @@ class Storage {
constructor(args) {
console.log('Storage: Constructor called');
this.bot = args.bot;
this.config = { ...conf.storage, ...args };
const settings = require('../settings/manager');
// conf.storage → DB/cache overrides → constructor args (highest priority)
this.config = { ...conf.storage, ...settings.getSection('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
}
@@ -286,6 +287,14 @@ class Storage {
throw new Error('Storage busy, scan operation timeout');
}
// Keep on-demand bots alive during long scan operations
const keepAliveInterval = setInterval(() => {
if (typeof this.bot._resetIdleTimer === 'function') {
this.bot._resetIdleTimer();
}
}, 15000);
const scanStart = Date.now();
try {
const chests = await this.scanner.discoverChests(this.bot, this.config.scanRadius || 30, Database);
// Register as interruptible task
@@ -294,7 +303,8 @@ class Storage {
this.bot, Database,
() => this.bot.wasInterrupted()
);
console.log(`Storage[${this.bot.name}]: Complete - ${chests.length} chests, ${shulkers} shulkers`);
const elapsed = ((Date.now() - scanStart) / 1000).toFixed(1);
console.log(`Storage[${this.bot.name}]: Complete — ${chests.length} chests discovered, ${shulkers} shulkers scanned, ${elapsed}s total`);
// Capture map images and index maps if not interrupted
if (!this.bot.wasInterrupted()) {
@@ -302,6 +312,7 @@ class Storage {
await this.indexMapsFromStorage();
}
} finally {
clearInterval(keepAliveInterval);
this.bot.clearTask();
this._releaseOperationLock();
}
@@ -453,8 +464,16 @@ class Storage {
const hotbarItems = this.config.hotbarItems || [];
if (hotbarItems.length === 0) return;
// Another task (scan, withdraw) is active — don't clobber its
// interrupt state, just skip this restock cycle
if (this.bot._currentTask || this._operationLock) return;
// Background task: yields to player commands via interruptTask
this.bot.registerTask('Storage', 'hotbar-restock', null);
try {
for (const spec of hotbarItems) {
if (this._busy) {
if (this._busy || this.bot.wasInterrupted()) {
console.log('Storage: Hotbar restock interrupted — storage operation in progress');
return;
}
@@ -495,6 +514,10 @@ class Storage {
let consecutiveFailures = 0;
for (const shulker of shulkers) {
if (remaining <= 0) break;
if (this.bot.wasInterrupted()) {
console.log(`Storage: Hotbar restock interrupted mid-${spec.name}, yielding`);
return;
}
if (consecutiveFailures >= 2) {
console.log(`Storage: Too many failures restocking ${spec.name}, giving up`);
break;
@@ -525,6 +548,10 @@ class Storage {
}
await Database.rebuildItemIndex();
} finally {
this.bot.clearTask();
}
}
// ========================================
@@ -546,6 +573,13 @@ class Storage {
if (hasShulkers) {
console.log('Storage: Traded items include shulker boxes, quick-stashing...');
shulkersStashed = await this.quickStashAllShulkers();
// Anything still in inventory means chests are out of empty slots
const leftOver = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')).length;
if (leftOver > 0) {
console.error(`Storage: ${leftOver} shulker(s) could not be stashed — no chest space`);
this.bot.whisper(playerName, `Warning: storage is out of chest space, ${leftOver} shulker(s) are still on me. Add more chests and run a scan.`);
}
}
// Deposit any non-shulker items currently in inventory
@@ -599,21 +633,28 @@ class Storage {
console.log(`Storage: Stashing ${batchSize} shulker(s) into chest at ${chestPos} (${chest.empty_slots} free slots)`);
await this.bot.goTo({ where: chestPos, range: 3 });
await this.bot.goToMust({ 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 dbUsedSlots = 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 (window.slots[s]) continue;
// If DB says occupied but window shows empty, delete stale record
if (dbUsedSlots.has(s)) {
const stale = existingShulkers.find(sh => sh.slot === s);
if (stale) {
console.log(`Storage: Removing stale shulker record #${stale.id} from chest ${chest.id} slot ${s}`);
await Database.deleteShulker(stale.id);
}
}
emptySlots.push(s);
if (emptySlots.length >= batchSize) break;
}
if (emptySlots.length === 0) {
@@ -707,7 +748,7 @@ class Storage {
}
const chestPos = new Vec3(emptySlot.pos_x, emptySlot.pos_y, emptySlot.pos_z);
await this.bot.goTo({ where: chestPos, range: 3 });
await this.bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = this.bot.bot.blockAt(chestPos);
const window = await this.bot.openContainer(chestBlock);
await sleep(300);
@@ -943,6 +984,9 @@ class Storage {
return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`);
}
// Interrupted task has released the lock — reset the interrupt flag
// so this withdrawal's own movement isn't treated as interrupted
this.bot.registerTask('Storage', 'withdraw', null);
this._busy = true;
try {
@@ -1021,6 +1065,7 @@ class Storage {
this.bot.whisper(playerName, `Failed to withdraw ${itemName}.`);
}
} finally {
this.bot.clearTask();
this._busy = false;
this._releaseOperationLock();
}
@@ -1029,7 +1074,8 @@ class Storage {
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
// Interrupt any active task then acquire operation lock
await this.bot.interruptTask(playerName);
try {
await this._acquireOperationLock();
} catch (error) {
@@ -1037,6 +1083,8 @@ class Storage {
return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`);
}
// Interrupted task has released the lock — reset the interrupt flag
this.bot.registerTask('Storage', 'withdraw-shulkers', null);
this._busy = true;
try {
@@ -1098,6 +1146,7 @@ class Storage {
this.bot.whisper(playerName, `Failed to withdraw shulkers of ${itemName}.`);
}
} finally {
this.bot.clearTask();
this._busy = false;
this._releaseOperationLock();
}
@@ -1207,17 +1256,33 @@ class Storage {
console.log(`Storage: Placed ${placed} stack(s) in trade window for ${playerName}`);
// Click 1: lock our items (ez locks first).
// Single left-click — moveSlotItem(37,37) sent a pickup+putdown
// pair that desyncs against the cancelled GUI slot and trips
// anti-cheat ("unusual packets" kick).
await sleep(500);
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
console.log('Storage trade: click 1 — items locked');
// 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);
let finalClicked = false;
const confirmationCheck = setInterval(async () => {
try {
if (finalClicked) return;
// Never click a window that is no longer open — invalid
// window IDs are an instant anti-cheat flag
if (this.bot.bot.currentWindow !== window) return;
const indicator = window.slots[53];
if (indicator && indicator.name === 'lime_dye') {
this.bot.bot.moveSlotItem(37, 37);
finalClicked = true;
// Click 2: finalize (once)
await this.bot.bot.clickWindow(37, 0, 0);
console.log('Storage trade: click 2 — final confirm');
}
} catch (e) { /* window may have closed */ }
}, 500);
@@ -1378,7 +1443,7 @@ class Storage {
throw new Error('No crafting table found nearby');
}
await this.bot.goTo({ where: craftingTable.position, range: 3 });
await this.bot.goToMust({ where: craftingTable.position, range: 3 });
// Craft shulker box manually (bot.craft() broken on 1.21+)
const shulkerBoxRecipes = this.bot.bot.recipesAll(
@@ -1453,7 +1518,7 @@ class Storage {
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 });
await this.bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = this.bot.bot.blockAt(chestPos);
const storeWindow = await this.bot.openContainer(chestBlock);
await sleep(300);
@@ -1546,7 +1611,7 @@ class Storage {
// Organize
// ========================================
async organizeLooseItems() {
async organizeLooseItems(skipConsolidation = false) {
console.log('Storage: Organizing loose items into shulkers...');
// Acquire operation lock to prevent concurrent storage operations
@@ -1562,6 +1627,14 @@ class Storage {
this.bot.registerTask('Storage', 'organize', async () => {});
let organized = 0;
// Keep on-demand bots alive — organize can run for many minutes and
// the idle timer would otherwise disconnect the bot mid-sort
const keepAliveInterval = setInterval(() => {
if (typeof this.bot._resetIdleTimer === 'function') {
this.bot._resetIdleTimer();
}
}, 15000);
try {
// Pre-flight: deposit any stray items in bot inventory
await this.cleanInventory();
@@ -1592,7 +1665,7 @@ class Storage {
const failedItems = new Set();
while (true) {
await this.bot.goTo({ where: chestPos, range: 3 });
await this.bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = this.bot.bot.blockAt(chestPos);
const window = await this.bot.openContainer(chestBlock);
await sleep(300);
@@ -1735,10 +1808,12 @@ class Storage {
}
// Consolidate partially filled shulkers of the same item type
try {
await this.consolidateShulkers();
} catch (error) {
console.error('Storage: Consolidation error:', error.message);
if (!skipConsolidation) {
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)
@@ -1748,6 +1823,7 @@ class Storage {
console.log(`Storage: Organized ${organized} item types into shulkers`);
return organized;
} finally {
clearInterval(keepAliveInterval);
this.bot.clearTask();
this._busy = false;
this._releaseOperationLock();
@@ -1836,11 +1912,39 @@ class Storage {
}
} 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 */ }
// Figure out where the box actually ended up before touching the
// DB. takeShulkerFromChest marks the record in-transit
// (slot_count = -1) the moment the box leaves the chest.
const record = await Database.getShulkerById(shulker.id).catch(() => null);
const leftChest = record && record.slot_count === -1;
const strayBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
if (error.message.includes('No shulker at chest slot')) {
// Slot was empty — record is stale, delete it
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
} else if (!leftChest) {
// Failure before the box left the chest (navigation, chest
// won't open) — leave the record alone so we retry next pass
console.log(`Storage: Mixed shulker #${shulker.id} still in chest, will retry next organize`);
} else if (strayBox) {
// Box made it back to inventory — store it (registers a fresh
// DB record via NBT scan) and drop the old record
try {
await this.storeShulker(strayBox);
await Database.deleteShulker(shulker.id);
} catch (e2) {
console.error(`Storage: Could not store recovered box:`, e2.message);
}
} else if (error.placedPos) {
// Box is physically on the ground and couldn't be collected
console.error(`Storage: MIXED SHULKER LEFT ON GROUND at ${error.placedPos} — manual pickup needed`);
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
} else {
// Left the chest but isn't in inventory or on known ground —
// delete the record; a rescan will re-register it if it turns up
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
}
}
}
}
@@ -1928,7 +2032,7 @@ class Storage {
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 });
await this.bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = this.bot.bot.blockAt(chestPos);
const window = await this.bot.openContainer(chestBlock);
await sleep(300);
@@ -2150,13 +2254,17 @@ class Storage {
}
case 'organize':
this.bot.whisper(from, 'Starting organize...');
if (from !== 'ai') this.bot.whisper(from, 'Starting organize...');
try {
const count = await this.organizeLooseItems();
this.bot.whisper(from, `Organize complete! Sorted ${count} item stacks.`);
const msg = `Organize complete! Sorted ${count} item stacks.`;
if (from === 'ai') return msg;
this.bot.whisper(from, msg);
} catch (error) {
console.error('Storage: Organize error:', error);
this.bot.whisper(from, `Organize failed: ${error.message}`);
const msg = `Organize failed: ${error.message}`;
if (from === 'ai') return msg;
this.bot.whisper(from, msg);
}
break;
+163 -181
View File
@@ -16,7 +16,7 @@ class Scanner {
}
}
this._scanRadius = radius;
const start = Date.now();
console.log(`Scanner: Discovering chests within ${radius} blocks...`);
const chestPositions = bot.bot.findBlocks({
matching: this.chestBlockType,
@@ -24,7 +24,8 @@ class Scanner {
count: Infinity,
});
console.log(`Scanner: Found ${chestPositions.length} chest block(s)`);
const elapsedFind = Date.now() - start;
console.log(`Scanner: Found ${chestPositions.length} chest block(s) in ${elapsedFind}ms`);
const discoveredChests = [];
const processed = new Set();
@@ -37,35 +38,27 @@ class Scanner {
const chestInfo = this.detectChestType(bot, pos);
// Skip the second half of double chests
if (chestInfo.type === 'skip') {
continue;
}
if (chestInfo.type === 'skip') continue;
const rowColumn = this.assignRowColumn(pos);
const category = this.columnToCategory(rowColumn.column);
await database.upsertChest(
pos.x, pos.y, pos.z,
chestInfo.type,
rowColumn.row,
rowColumn.column,
category
);
discoveredChests.push({
x: pos.x, y: pos.y, z: pos.z,
type: chestInfo.type,
...rowColumn,
category
row: rowColumn.row,
column: rowColumn.column,
category,
});
}
// Remove DB records for chest positions no longer discovered
// (e.g., the old canonical half of a double chest that switched sides)
// Batch UPSERT all discovered chests in a single transaction
if (discoveredChests.length > 0) {
await database.deleteOrphanChests(discoveredChests);
await database.batchUpsertChests(discoveredChests);
}
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s)`);
const elapsedTotal = Date.now() - start;
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s) in ${elapsedTotal}ms`);
return discoveredChests;
}
@@ -122,56 +115,53 @@ class Scanner {
}
async scanChest(bot, database, chestPosition) {
console.log(`Scanner: Scanning chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
try {
// Ensure bot is close enough to interact
const distance = bot.bot.entity.position.distanceTo(chestPosition);
if (distance > 4) {
await bot.goTo({ where: chestPosition, range: 3 });
// goToMust: if we never arrive, blockAt sees an unloaded chunk
// and the chest would be wrongly marked lost below
await bot.goToMust({ where: chestPosition, range: 3 });
}
const chestBlock = bot.bot.blockAt(chestPosition);
if (!chestBlock || !chestBlock.name.includes('chest')) {
console.log(`Scanner: Not a chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
return 0;
console.log(`Scanner: Block not a chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}, marking lost`);
await database.markChestLost(chestPosition.x, chestPosition.y, chestPosition.z);
return { shulkerCount: 0, lost: true };
}
// Get chest from database
const chest = await database.getChestByPosition(chestPosition.x, chestPosition.y, chestPosition.z);
if (!chest) {
console.log(`Scanner: Chest not in database`);
return 0;
console.log(`Scanner: Chest not in database at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
return { shulkerCount: 0 };
}
const window = await bot.openContainer(chestBlock);
const slots = window.slots;
let shulkerCount = 0;
// Only scan chest inventory slots (not player inventory)
const chestSlotCount = window.inventoryStart || 27;
console.log(`Scanner: Chest has ${chestSlotCount} slots`);
// Correct DB chest_type if it doesn't match the actual window size
const actualType = chestSlotCount > 27 ? 'double' : 'single';
if (chest.chest_type !== actualType) {
console.log(`Scanner: Correcting chest type: DB says '${chest.chest_type}', actual is '${actualType}'`);
await database.upsertChest(
chestPosition.x, chestPosition.y, chestPosition.z,
actualType, chest.row, chest.column, chest.category
);
}
// Clear previous loose item records before re-scanning
// Clear previous records before re-scanning
await database.clearLooseItems(chest.id);
await database.deleteShulkersByChest(chest.id);
const looseItems = [];
let shulkerCount = 0;
for (let i = 0; i < chestSlotCount; i++) {
const slot = slots[i];
if (!slot) continue;
if (slot.name.includes('shulker_box')) {
console.log(`Scanner: Found shulker at slot ${i}: ${slot.name}`);
await this.scanShulkerFromNBT(bot, database, chest.id, i, slot);
shulkerCount++;
} else {
@@ -184,123 +174,168 @@ class Scanner {
}
await bot.bot.closeWindow(window);
console.log(`Scanner: Found ${shulkerCount} shulkers in chest`);
return shulkerCount;
await sleep(300);
return { shulkerCount };
} catch (error) {
console.error(`Scanner: Error scanning chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}:`, error);
return 0;
return { shulkerCount: 0 };
}
}
async scanAllChests(bot, database, interruptCheck) {
const chests = await database.getChests();
const start = Date.now();
console.log(`Scanner: Scanning all ${chests.length} tracked chests`);
let totalShulkers = 0;
let scannedCount = 0;
let skippedCount = 0;
let lostCount = 0;
// Track scanned positions so we don't re-scan or re-queue
const scannedPositions = new Set();
// Build a row-major serpentine scan plan:
// Chests are in rows along Z (same X = one aisle). Walk down one aisle,
// step to the next, walk back the other way (serpentine). This eliminates
// the constant row-hopping of nearest-neighbor traversal.
const plan = this._buildSerpentinePlan(chests, bot.bot.entity.position);
// Visit chests in nearest-neighbor order to minimize travel
const remaining = chests.map(c => ({ ...c, pos: new Vec3(c.pos_x, c.pos_y, c.pos_z) }));
for (const c of remaining) {
scannedPositions.add(`${c.pos.x},${c.pos.y},${c.pos.z}`);
}
while (remaining.length > 0) {
const botPos = bot.bot.entity.position;
// Find the closest unscanned chest
let closestIdx = 0;
let closestDist = botPos.distanceTo(remaining[0].pos);
for (let i = 1; i < remaining.length; i++) {
const dist = botPos.distanceTo(remaining[i].pos);
if (dist < closestDist) {
closestDist = dist;
closestIdx = i;
}
for (let i = 0; i < plan.length; i++) {
if (interruptCheck && interruptCheck()) {
console.log(`Scanner: Interrupted after ${scannedCount} chests`);
break;
}
const chest = remaining.splice(closestIdx, 1)[0];
const chest = plan[i];
const key = `${chest.pos.x},${chest.pos.y},${chest.pos.z}`;
if (closestDist > 4.5) {
console.log(`Scanner: Walking to chest at ${chest.pos} (distance: ${closestDist.toFixed(1)})`);
// Scan all chests in plan that are currently within reach (including this one)
const botPos = bot.bot.entity.position;
const batch = [];
// First check: is the current chest reachable?
if (botPos.distanceTo(chest.pos) > 4.5) {
// Walk to it
console.log(`Scanner: Walking to chest at ${chest.pos.toArray()} (${botPos.distanceTo(chest.pos).toFixed(1)} blocks, ${plan.length - i} left)`);
try {
const reached = await bot.goTo({
where: chest.pos,
range: 3,
});
const reached = await bot.goTo({ where: chest.pos, range: 3 });
if (reached === false) {
console.log(`Scanner: Could not reach chest at ${chest.pos}: no path`);
skippedCount++;
continue;
}
} catch (error) {
console.log(`Scanner: Could not reach chest at ${chest.pos}: ${error.message}`);
skippedCount++;
continue;
}
}
// Wait for anti-ESP to reveal nearby blocks after arriving
if (interruptCheck && interruptCheck()) {
console.log(`Scanner: Interrupted after ${scannedCount} chests`);
return totalShulkers;
}
await sleep(250);
}
// Discover any new chests now visible from this position (every 5th stop or first)
if (scannedCount % 5 === 0) {
const newChests = await this.discoverChests(bot, this._scanRadius || 30, database);
for (const nc of newChests) {
const key = `${nc.x},${nc.y},${nc.z}`;
if (!scannedPositions.has(key)) {
scannedPositions.add(key);
remaining.push({ ...nc, pos: new Vec3(nc.x, nc.y, nc.z) });
console.log(`Scanner: Discovered new chest at ${nc.x},${nc.y},${nc.z} while walking`);
}
// Now batch-scan this chest and all upcoming chests within reach
const newPos = bot.bot.entity.position;
for (let j = i; j < plan.length && batch.length < 6; j++) {
const c = plan[j];
const ck = `${c.pos.x},${c.pos.y},${c.pos.z}`;
if (newPos.distanceTo(c.pos) <= 4.5) {
batch.push({ idx: j, chest: c, key: ck });
} else if (batch.length === 0) {
// Current chest somehow not in reach after walking to it — force it
batch.push({ idx: j, chest: c, key: ck });
} else {
break; // Only scan contiguous reachable chests
}
}
const shulkerCount = await this.scanChest(bot, database, chest.pos);
totalShulkers += shulkerCount;
scannedCount++;
for (const item of batch) {
if (item.idx > i) i = item.idx; // Skip ahead in plan
if (scannedCount % 10 === 0) {
console.log(`Scanner: Progress - ${scannedCount}/${scannedCount + remaining.length} chests scanned, ${totalShulkers} shulkers found`);
if (interruptCheck && interruptCheck()) {
console.log(`Scanner: Interrupted during batch after ${scannedCount} chests`);
break;
}
const result = await this.scanChest(bot, database, item.chest.pos);
totalShulkers += result.shulkerCount;
scannedCount++;
if (result.lost) lostCount++;
await sleep(400);
}
if (scannedCount > 0 && scannedCount % 50 === 0) {
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`Scanner: Progress — ${scannedCount} chests, ${totalShulkers} shulkers, ${lostCount} lost, ${elapsed}s elapsed`);
}
}
await database.rebuildItemIndex();
console.log(`Scanner: Scanned ${scannedCount} chests, skipped ${skippedCount}, found ${totalShulkers} shulkers`);
const elapsedTotal = ((Date.now() - start) / 1000).toFixed(1);
console.log(`Scanner: Done — ${scannedCount} chests, ${skippedCount} unreachable, ${lostCount} lost, ${totalShulkers} shulkers, ${elapsedTotal}s total`);
return totalShulkers;
}
/**
* Build a row-major serpentine traversal plan:
* - Group chests by X coordinate (each X = one aisle/row)
* - Sort rows by X
* - Within each row, sort by Z (alternating direction for serpentine)
* - Start from the row nearest to the bot's current position
*/
_buildSerpentinePlan(chests, botPos) {
// Group by X (row/aisle)
const rows = new Map();
for (const c of chests) {
const x = c.pos_x;
if (!rows.has(x)) rows.set(x, []);
rows.get(x).push({ ...c, pos: new Vec3(c.pos_x, c.pos_y, c.pos_z) });
}
// Sort rows by X
const sortedRows = [...rows.entries()].sort((a, b) => a[0] - b[0]);
// Find the row closest to the bot
let startRowIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < sortedRows.length; i++) {
const dist = Math.abs(botPos.x - sortedRows[i][0]);
if (dist < bestDist) { bestDist = dist; startRowIdx = i; }
}
// Build plan: start from nearest row, scan outward in serpentine order
const plan = [];
let direction = 1; // 1 = ascending Z, -1 = descending Z
// First: rows from startRowIdx to end
for (let i = startRowIdx; i < sortedRows.length; i++) {
const [, rowChests] = sortedRows[i];
rowChests.sort((a, b) => direction * (a.pos_z - b.pos_z));
plan.push(...rowChests);
direction *= -1;
}
// Then: remaining rows before startRowIdx (going backward in X)
for (let i = startRowIdx - 1; i >= 0; i--) {
const [, rowChests] = sortedRows[i];
rowChests.sort((a, b) => direction * (a.pos_z - b.pos_z));
plan.push(...rowChests);
direction *= -1;
}
console.log(`Scanner: Serpentine plan — ${sortedRows.length} rows, ${plan.length} chests, starting at row x=${sortedRows[startRowIdx][0]}`);
return plan;
}
// Read shulker contents from NBT data (no physical interaction needed)
async scanShulkerFromNBT(bot, database, chestId, chestSlot, shulkerItem) {
console.log(`Scanner: Reading shulker NBT at slot ${chestSlot}`);
try {
// Create/update shulker record and get its ID in one call
const shulkerRecord = await database.upsertAndGetShulker(
chestId,
chestSlot,
shulkerItem.name,
null // category will be set based on contents
chestId, chestSlot, shulkerItem.name, null
);
if (!shulkerRecord) {
console.error(`Scanner: No shulker record found for chest ${chestId} slot ${chestSlot}`);
console.error(`Scanner: No shulker record for chest ${chestId} slot ${chestSlot}`);
return [];
}
const shulkerId = shulkerRecord.id;
await database.clearShulkerItems(shulkerId);
// Extract items from shulker NBT
const items = this.extractShulkerContents(bot, shulkerItem);
let totalItems = 0;
@@ -313,67 +348,49 @@ class Scanner {
itemTypes.add(item.name);
}
// Update shulker stats
let itemFocus = itemTypes.size === 1 ? Array.from(itemTypes)[0] : null;
const usedSlots = items.length;
// If any item in the shulker is special, append #special to the focus
if (itemFocus) {
const hasSpecial = items.some(item => Scanner.isSpecialItem(item.nbt));
if (hasSpecial) {
itemFocus = itemFocus + '#special';
}
if (hasSpecial) itemFocus = itemFocus + '#special';
}
await database.updateShulkerCounts(shulkerId, usedSlots, totalItems);
await database.updateShulkerCounts(shulkerId, items.length, totalItems);
await database.updateShulkerItemFocus(shulkerId, itemFocus);
console.log(`Scanner: Shulker has ${items.length} slot(s) used, ${totalItems} total items`);
return items;
} catch (error) {
console.error(`Scanner: Error reading shulker NBT:`, error);
return [];
}
}
// Extract items from shulker box NBT data
extractShulkerContents(bot, shulkerItem) {
const items = [];
if (!shulkerItem.nbt) {
console.log('Scanner: Shulker has no NBT data (empty)');
return items;
}
if (!shulkerItem.nbt) return items;
try {
// Navigate the NBT structure to find Items 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 (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
() => nbt.value?.BlockEntityTag?.value?.Items?.value?.value,
() => nbt.value?.BlockEntityTag?.value?.Items?.value,
() => nbt.BlockEntityTag?.Items?.value?.value,
() => nbt.BlockEntityTag?.Items?.value,
() => nbt.BlockEntityTag?.Items,
() => nbt.value?.tag?.value?.BlockEntityTag?.value?.Items?.value?.value,
() => nbt.value?.tag?.value?.Items?.value?.value,
() => nbt.value?.tag?.value?.Items?.value,
() => nbt.value?.Items?.value?.value,
() => nbt.Items?.value?.value,
() => nbt.Items,
() => nbt.tag?.value?.BlockEntityTag?.value?.Items?.value?.value,
() => nbt.tag?.BlockEntityTag?.Items?.value?.value,
() => nbt.tag?.Items?.value?.value,
];
let nbtItems = null;
for (const pathFn of paths) {
const result = pathFn();
if (Array.isArray(result)) {
@@ -382,48 +399,36 @@ class Scanner {
}
}
if (!nbtItems || !Array.isArray(nbtItems)) {
console.log('Scanner: No items array found in shulker (may be empty)');
return items;
}
console.log(`Scanner: Found ${nbtItems.length} items in shulker NBT`);
if (!nbtItems || !Array.isArray(nbtItems)) return items;
for (const nbtItem of nbtItems) {
// Extract slot, id, count from NBT item
const slot = nbtItem.Slot?.value ?? nbtItem.Slot ?? 0;
const id = nbtItem.id?.value ?? nbtItem.id ?? 'unknown';
const count = nbtItem.Count?.value ?? nbtItem.Count ?? 1;
// Clean up the id (remove minecraft: prefix)
const cleanId = String(id).replace('minecraft:', '');
if (count <= 0 || cleanId === 'air') continue;
// tag may be a prismarine-nbt compound or a plain object
const tag = nbtItem.tag?.value ?? nbtItem.tag ?? null;
items.push({
slot: slot,
slot,
name: cleanId,
id: bot.mcData.itemsByName[cleanId]?.id || 0,
count: count,
nbt: tag ? this.parseNBT(tag) : null
count,
nbt: tag ? this.parseNBT(tag) : null,
});
}
} catch (error) {
console.error('Scanner: Error parsing shulker NBT:', error);
console.log('Scanner: Raw NBT:', JSON.stringify(shulkerItem.nbt).substring(0, 500));
}
return items;
}
// Recursively unwrap prismarine-nbt {type, value} structures into plain objects
simplifyNBT(nbt) {
if (nbt === null || nbt === undefined) return nbt;
if (typeof nbt !== 'object') return nbt;
// prismarine-nbt compound/value wrapper
if (nbt.type !== undefined && nbt.value !== undefined) {
return this.simplifyNBT(nbt.value);
}
@@ -442,14 +447,9 @@ class Scanner {
parseNBT(nbt) {
if (!nbt) return null;
if (typeof nbt === 'string') {
try {
nbt = JSON.parse(nbt);
} catch (e) {
return null;
}
try { nbt = JSON.parse(nbt); } catch (e) { return null; }
}
// Unwrap prismarine-nbt wrappers so we can access keys directly
nbt = this.simplifyNBT(nbt);
const result = {};
@@ -457,16 +457,11 @@ class Scanner {
if (nbt.Enchantments) {
let enchList = nbt.Enchantments;
if (Array.isArray(enchList)) {
result.enchantments = enchList.map(e => ({
id: e.id,
level: e.lvl
}));
result.enchantments = enchList.map(e => ({ id: e.id, level: e.lvl }));
}
}
if (nbt.Damage) {
result.damage = nbt.Damage;
}
if (nbt.Damage) result.damage = nbt.Damage;
if (nbt.display?.Name) {
const name = nbt.display.Name;
@@ -488,26 +483,13 @@ class Scanner {
});
}
if (nbt.CustomModelData) {
result.customModelData = nbt.CustomModelData;
}
if (nbt.RepairCost) {
result.repairCost = nbt.RepairCost;
}
// Map ID for filled_map items
if (nbt.map !== undefined) {
result.map = nbt.map;
}
if (nbt.CustomModelData) result.customModelData = nbt.CustomModelData;
if (nbt.RepairCost) result.repairCost = nbt.RepairCost;
if (nbt.map !== undefined) result.map = nbt.map;
return Object.keys(result).length > 0 ? result : null;
}
/**
* Check if parsed NBT data indicates a "special" item — one with a custom
* display name, lore, or custom model data that should be stored separately.
*/
static isSpecialItem(nbtData) {
if (!nbtData) return false;
if (typeof nbtData === 'string') {
@@ -517,4 +499,4 @@ class Scanner {
}
}
module.exports = Scanner;
module.exports = Scanner;
+86 -22
View File
@@ -57,14 +57,22 @@ class ShulkerHandler {
if (excludeSet.has(`${checkPos.x},${checkPos.y},${checkPos.z}`)) continue;
const blockAtPos = bot.bot.blockAt(checkPos);
const blockBelow = bot.bot.blockAt(checkPos.offset(0, -1, 0));
const blockAbove = bot.bot.blockAt(checkPos.offset(0, 1, 0));
if (blockAtPos && blockAtPos.name === 'air' && blockBelow && blockBelow.boundingBox === 'block') {
return {
position: checkPos,
placeOn: blockBelow,
faceVec: new Vec3(0, 1, 0),
};
}
if (!blockAtPos || blockAtPos.name !== 'air') continue;
if (!blockBelow || blockBelow.boundingBox !== 'block') continue;
// A shulker's lid opens upward — with a solid block above it the
// server refuses to open it ("Block wont open")
if (blockAbove && blockAbove.boundingBox === 'block') continue;
// Never place on top of storage blocks: a box sitting on a chest
// makes that chest unopenable (and abandons it if the cycle fails)
if (/chest|shulker|barrel|hopper|furnace/.test(blockBelow.name)) continue;
return {
position: checkPos,
placeOn: blockBelow,
faceVec: new Vec3(0, 1, 0),
};
}
throw new Error('No suitable placement spot found near bot');
@@ -237,7 +245,7 @@ class ShulkerHandler {
}
// Go to chest and open it
await bot.goTo({ where: chestPos, range: 3 });
await bot.goToMust({ 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}`);
@@ -296,7 +304,7 @@ class ShulkerHandler {
}
// Verify the shulker is actually back in the chest
await bot.goTo({ where: chestPos, range: 3 });
await bot.goToMust({ 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');
@@ -323,7 +331,7 @@ class ShulkerHandler {
async takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId) {
console.log(`ShulkerHandler: Taking shulker from chest at ${chestPos}, slot ${chestSlot}`);
await bot.goTo({ where: chestPos, range: 3 });
await bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = bot.bot.blockAt(chestPos);
if (!chestBlock || !chestBlock.name.includes('chest')) {
throw new Error(`No chest at ${chestPos} (found: ${chestBlock?.name || 'null'})`);
@@ -476,13 +484,30 @@ class ShulkerHandler {
throw new Error(`Failed to place shulker at ${spot.position} (found: ${placedBlock?.name || 'null'})`);
}
const window = await bot.openContainer(placedBlock);
let window;
try {
window = await bot.openContainer(placedBlock);
} catch (openError) {
// The box is placed but won't open — break it back into
// inventory before retrying, otherwise it's abandoned on the
// ground and the next attempt finds no box in inventory
console.log(`ShulkerHandler: Placed but cannot open (${openError.message}), collecting box back`);
const collected = await this.digAndCollectShulker(bot, spot.position);
if (!collected) {
openError.placedPos = spot.position;
}
throw openError;
}
await bot.bot.waitForTicks(5);
console.log(`ShulkerHandler: Shulker placed and opened at ${spot.position}`);
return { window, placedPos: spot.position };
} catch (error) {
console.log(`ShulkerHandler: Place attempt ${attempt + 1} failed (${error.message})`);
// A box we couldn't collect is sitting on the ground — retrying
// can't succeed (no box in inventory) and callers need placedPos
// to attempt recovery
if (error.placedPos) throw error;
if (attempt < 4) {
// Move the bot a few blocks so findPlacementSpot finds new spots
console.log('ShulkerHandler: Moving to find a better placement spot...');
@@ -524,7 +549,7 @@ class ShulkerHandler {
}
// Navigate back to chest and put shulker back
await bot.goTo({ where: chestPos, range: 3 });
await bot.goToMust({ where: chestPos, range: 3 });
const chestBlock = bot.bot.blockAt(chestPos);
const window = await bot.openContainer(chestBlock);
await sleep(300);
@@ -582,10 +607,12 @@ class ShulkerHandler {
console.log(`ShulkerHandler: Depositing ${count}x ${itemName} into shulker at chest ${chestPos} slot ${chestSlot}`);
let placedPos = null;
let taken = false;
try {
// Step 1: Take shulker from chest (DB immediately marks it in-transit)
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
taken = true;
// Step 2: Place shulker on ground and open it
let shulkerWindow;
@@ -597,7 +624,7 @@ class ShulkerHandler {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
@@ -659,7 +686,7 @@ class ShulkerHandler {
try {
// Shift-click handles stacking optimally — no cursor issues
await bot.bot.clickWindow(i, 0, 1);
await sleep(200);
await bot.bot.waitForTicks(3); // let server confirm the move
// Measure what actually left this slot
const afterItem = shulkerWindow.slots[i];
@@ -672,6 +699,9 @@ class ShulkerHandler {
}
}
// Let server confirm all shift-click moves before closing window
await bot.bot.waitForTicks(4);
// Step 5: Close, break, return to chest (DB synced inside closeBreakReturn)
try {
const updatedSlotItem = await this.closeBreakReturn(bot, shulkerWindow, placedPos, chestPos, chestSlot, chestId);
@@ -691,6 +721,8 @@ class ShulkerHandler {
}
} catch (error) {
// Box never left the chest — nothing to recover, report the real error
if (!taken) throw error;
console.error('ShulkerHandler: Deposit failed, attempting recovery:', error.message);
let recovered = false;
try {
@@ -766,13 +798,17 @@ class ShulkerHandler {
throw new Error(`Failed to place shulker at ${spot.position}`);
}
// Steps 3-5 run with the box placed on the ground — any failure must
// still break it and collect it, or the box is abandoned
const extracted = [];
let inventoryFull = false;
try {
// Step 3: Open the shulker
const shulkerWindow = await bot.openContainer(placedBlock);
await bot.bot.waitForTicks(5);
// Step 4: Move ALL items from shulker into bot inventory
const extracted = [];
let inventoryFull = false;
const shulkerSlotCount = shulkerWindow.inventoryStart;
for (let s = 0; s < shulkerSlotCount; s++) {
@@ -820,10 +856,27 @@ class ShulkerHandler {
await bot.bot.closeWindow(shulkerWindow);
await bot.bot.waitForTicks(30);
} catch (error) {
// Recovery: get the placed box back into inventory before rethrowing
console.error(`ShulkerHandler: Unpack failed mid-cycle (${error.message}), recovering placed box`);
try { await bot.bot.closeWindow(bot.bot.currentWindow); } catch (e) { /* may not be open */ }
await sleep(300);
const recovered = await this.digAndCollectShulker(bot, spot.position);
if (!recovered) {
error.placedPos = spot.position;
console.error(`ShulkerHandler: SHULKER LEFT ON GROUND at ${spot.position}`);
}
throw error;
}
// Step 6: Break the shulker block and pick it up
const collected = await this.digAndCollectShulker(bot, spot.position);
if (!collected) {
console.error('ShulkerHandler: Shulker not found in inventory after breaking');
// Don't return success — the caller assumes the box is back in
// inventory and would store a box that doesn't exist
const error = new Error(`Shulker not collected after unpack at ${spot.position}`);
error.placedPos = spot.position;
throw error;
}
console.log(`ShulkerHandler: Unpacked ${extracted.length} item stacks from shulker`);
@@ -840,10 +893,12 @@ class ShulkerHandler {
console.log(`ShulkerHandler: Withdrawing ${count}x ${itemName} from shulker at chest ${chestPos} slot ${chestSlot}`);
let placedPos = null;
let taken = false;
try {
// Step 1: Take shulker from chest (DB immediately marks it in-transit)
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
taken = true;
// Step 2: Place shulker on ground and open it
let shulkerWindow;
@@ -855,7 +910,7 @@ class ShulkerHandler {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
@@ -901,7 +956,7 @@ class ShulkerHandler {
if (remaining() >= beforeCount) {
// Need the whole stack or more — shift-click is optimal
await bot.bot.clickWindow(s, 0, 1);
await sleep(200);
await bot.bot.waitForTicks(3);
} else {
// Need fewer than the full stack — pick up, right-click exact amount, return rest
// Find an empty inventory slot to place items into
@@ -916,7 +971,7 @@ class ShulkerHandler {
// Left-click to pick up full stack onto cursor
await bot.bot.clickWindow(s, 0, 0);
await sleep(150);
await bot.bot.waitForTicks(2);
// Right-click on empty inventory slot N times to place exactly N items
for (let n = 0; n < remaining(); n++) {
@@ -926,7 +981,7 @@ class ShulkerHandler {
// Left-click back on shulker slot to return the remainder from cursor
await bot.bot.clickWindow(s, 0, 0);
await sleep(150);
await bot.bot.waitForTicks(2);
}
// Measure what actually left this slot
@@ -940,6 +995,9 @@ class ShulkerHandler {
}
}
// Let server confirm all shift-click moves before closing window
await bot.bot.waitForTicks(4);
// Step 4: Close, break, return to chest (DB synced inside closeBreakReturn)
try {
const updatedSlotItem = await this.closeBreakReturn(bot, shulkerWindow, placedPos, chestPos, chestSlot, chestId);
@@ -959,6 +1017,8 @@ class ShulkerHandler {
}
} catch (error) {
// Box never left the chest — nothing to recover, report the real error
if (!taken) throw error;
console.error('ShulkerHandler: Withdraw failed, attempting recovery:', error.message);
let recovered = false;
try {
@@ -980,10 +1040,12 @@ class ShulkerHandler {
console.log(`ShulkerHandler: Withdrawing from shulker slot ${shulkerSlot} at chest ${chestPos} slot ${chestSlot}`);
let placedPos = null;
let taken = false;
try {
// Step 1: Take shulker from chest
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
taken = true;
// Step 2: Place shulker on ground and open it
let shulkerWindow;
@@ -995,7 +1057,7 @@ class ShulkerHandler {
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
let recovered = false;
try {
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
recovered = true;
} catch (recoverError) {
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
@@ -1078,6 +1140,8 @@ class ShulkerHandler {
}
} catch (error) {
// Box never left the chest — nothing to recover, report the real error
if (!taken) throw error;
console.error('ShulkerHandler: Slot withdraw failed, attempting recovery:', error.message);
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
return { withdrawn: 0, updatedSlotItem: null };