forked from wmantly/mc-bot-town
503 lines
15 KiB
JavaScript
503 lines
15 KiB
JavaScript
'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;
|