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
+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;