forked from wmantly/mc-bot-town
fable
This commit is contained in:
@@ -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(`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user