'use strict'; const Vec3 = require('vec3'); const { sleep } = require('../../utils'); class Scanner { constructor() { this.chestBlockType = null; } async discoverChests(bot, radius, database) { if (!this.chestBlockType) { this.chestBlockType = bot.mcData.blocksByName.chest?.id; if (!this.chestBlockType) { throw new Error('Chest block not found in minecraft-data'); } } const start = Date.now(); console.log(`Scanner: Discovering chests within ${radius} blocks...`); const chestPositions = bot.bot.findBlocks({ matching: this.chestBlockType, maxDistance: radius, count: Infinity, }); const elapsedFind = Date.now() - start; console.log(`Scanner: Found ${chestPositions.length} chest block(s) in ${elapsedFind}ms`); const discoveredChests = []; const processed = new Set(); for (const pos of chestPositions) { const key = `${pos.x},${pos.y},${pos.z}`; if (processed.has(key)) continue; processed.add(key); const chestInfo = this.detectChestType(bot, pos); // Skip the second half of double chests if (chestInfo.type === 'skip') continue; const rowColumn = this.assignRowColumn(pos); const category = this.columnToCategory(rowColumn.column); discoveredChests.push({ x: pos.x, y: pos.y, z: pos.z, type: chestInfo.type, row: rowColumn.row, column: rowColumn.column, category, }); } // Batch UPSERT all discovered chests in a single transaction if (discoveredChests.length > 0) { await database.batchUpsertChests(discoveredChests); } const elapsedTotal = Date.now() - start; console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s) in ${elapsedTotal}ms`); return discoveredChests; } detectChestType(bot, position) { const block = bot.bot.blockAt(position); if (!block) return { type: 'single' }; // Use block state properties (Minecraft 1.13+ has type: single/left/right) const props = typeof block.getProperties === 'function' ? block.getProperties() : null; if (props && props.type) { if (props.type === 'single') return { type: 'single' }; // Register the 'left' half as canonical, skip 'right' if (props.type === 'left') return { type: 'double' }; return { type: 'skip' }; // 'right' half } // Fallback: adjacency check for older versions const directions = [ new Vec3(1, 0, 0), new Vec3(-1, 0, 0), new Vec3(0, 0, 1), new Vec3(0, 0, -1) ]; for (const dir of directions) { const adjacentPos = position.offset(dir.x, dir.y, dir.z); const adjacentBlock = bot.bot.blockAt(adjacentPos); if (adjacentBlock && adjacentBlock.name === 'chest') { if (dir.x === -1 || dir.z === -1) { return { type: 'double' }; } return { type: 'skip' }; } } return { type: 'single' }; } assignRowColumn(position) { const row = Math.floor(position.y / 4) + 1; const column = position.x; return { row, column }; } columnToCategory(column) { if (column <= 1) return 'minerals'; if (column === 2) return 'food'; if (column === 3) return 'tools'; if (column === 4) return 'armor'; if (column === 5) return 'blocks'; if (column === 6) return 'redstone'; return 'misc'; } async scanChest(bot, database, chestPosition) { try { const distance = bot.bot.entity.position.distanceTo(chestPosition); if (distance > 4) { // 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: 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 }; } const chest = await database.getChestByPosition(chestPosition.x, chestPosition.y, chestPosition.z); if (!chest) { 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; const chestSlotCount = window.inventoryStart || 27; // Correct DB chest_type if it doesn't match the actual window size const actualType = chestSlotCount > 27 ? 'double' : 'single'; if (chest.chest_type !== actualType) { await database.upsertChest( chestPosition.x, chestPosition.y, chestPosition.z, actualType, chest.row, chest.column, chest.category ); } // 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')) { await this.scanShulkerFromNBT(bot, database, chest.id, i, slot); shulkerCount++; } else { looseItems.push({ slot: i, name: slot.name, id: slot.type, count: slot.count }); } } if (looseItems.length > 0) { await database.batchUpsertLooseItems(chest.id, looseItems); } await bot.bot.closeWindow(window); await sleep(300); return { shulkerCount }; } catch (error) { console.error(`Scanner: Error scanning chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}:`, error); 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; // 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); for (let i = 0; i < plan.length; i++) { if (interruptCheck && interruptCheck()) { console.log(`Scanner: Interrupted after ${scannedCount} chests`); break; } const chest = plan[i]; const key = `${chest.pos.x},${chest.pos.y},${chest.pos.z}`; // 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 }); if (reached === false) { skippedCount++; continue; } } catch (error) { skippedCount++; continue; } await sleep(250); } // 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 } } for (const item of batch) { if (item.idx > i) i = item.idx; // Skip ahead in plan 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(); 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) { try { const shulkerRecord = await database.upsertAndGetShulker( chestId, chestSlot, shulkerItem.name, null ); if (!shulkerRecord) { console.error(`Scanner: No shulker record for chest ${chestId} slot ${chestSlot}`); return []; } const shulkerId = shulkerRecord.id; await database.clearShulkerItems(shulkerId); const items = this.extractShulkerContents(bot, shulkerItem); let totalItems = 0; const itemTypes = new Set(); await database.batchUpsertShulkerItems(shulkerId, items); for (const item of items) { totalItems += item.count; itemTypes.add(item.name); } let itemFocus = itemTypes.size === 1 ? Array.from(itemTypes)[0] : null; if (itemFocus) { const hasSpecial = items.some(item => Scanner.isSpecialItem(item.nbt)); if (hasSpecial) itemFocus = itemFocus + '#special'; } await database.updateShulkerCounts(shulkerId, items.length, totalItems); await database.updateShulkerItemFocus(shulkerId, itemFocus); return items; } catch (error) { console.error(`Scanner: Error reading shulker NBT:`, error); return []; } } extractShulkerContents(bot, shulkerItem) { const items = []; if (!shulkerItem.nbt) return items; try { const nbt = shulkerItem.nbt; const paths = [ () => 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)) { nbtItems = result; break; } } if (!nbtItems || !Array.isArray(nbtItems)) return items; for (const nbtItem of nbtItems) { const slot = nbtItem.Slot?.value ?? nbtItem.Slot ?? 0; const id = nbtItem.id?.value ?? nbtItem.id ?? 'unknown'; const count = nbtItem.Count?.value ?? nbtItem.Count ?? 1; const cleanId = String(id).replace('minecraft:', ''); if (count <= 0 || cleanId === 'air') continue; const tag = nbtItem.tag?.value ?? nbtItem.tag ?? null; items.push({ slot, name: cleanId, id: bot.mcData.itemsByName[cleanId]?.id || 0, count, nbt: tag ? this.parseNBT(tag) : null, }); } } catch (error) { console.error('Scanner: Error parsing shulker NBT:', error); } return items; } simplifyNBT(nbt) { if (nbt === null || nbt === undefined) return nbt; if (typeof nbt !== 'object') return nbt; if (nbt.type !== undefined && nbt.value !== undefined) { return this.simplifyNBT(nbt.value); } if (Array.isArray(nbt)) { return nbt.map(v => this.simplifyNBT(v)); } const out = {}; for (const key of Object.keys(nbt)) { out[key] = this.simplifyNBT(nbt[key]); } return out; } parseNBT(nbt) { if (!nbt) return null; if (typeof nbt === 'string') { try { nbt = JSON.parse(nbt); } catch (e) { return null; } } nbt = this.simplifyNBT(nbt); const result = {}; if (nbt.Enchantments) { let enchList = nbt.Enchantments; if (Array.isArray(enchList)) { result.enchantments = enchList.map(e => ({ id: e.id, level: e.lvl })); } } if (nbt.Damage) result.damage = nbt.Damage; if (nbt.display?.Name) { const name = nbt.display.Name; if (typeof name === 'string') { try { result.displayName = JSON.parse(name).text || name; } catch (e) { result.displayName = name; } } else { result.displayName = name?.text || String(name); } } if (nbt.display?.Lore) { let lore = nbt.display.Lore; if (!Array.isArray(lore)) lore = [lore]; result.lore = lore.map(l => { if (typeof l === 'string') { try { return JSON.parse(l).text || l; } catch (e) { return l; } } return l?.text || String(l); }); } 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; } static isSpecialItem(nbtData) { if (!nbtData) return false; if (typeof nbtData === 'string') { try { nbtData = JSON.parse(nbtData); } catch (e) { return false; } } return !!(nbtData.displayName || nbtData.lore || nbtData.customModelData); } } module.exports = Scanner;