forked from wmantly/mc-bot-town
here
This commit is contained in:
@@ -22,6 +22,9 @@ class Database {
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
// Enable foreign key enforcement (required for ON DELETE CASCADE)
|
||||
await this.db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
await this.createTables();
|
||||
await this.insertDefaultPermissions();
|
||||
|
||||
@@ -65,10 +68,11 @@ class Database {
|
||||
shulker_type TEXT DEFAULT 'shulker_box',
|
||||
category TEXT,
|
||||
item_focus TEXT,
|
||||
slot_count INTEGER DEFAULT 27,
|
||||
slot_count INTEGER DEFAULT 0,
|
||||
total_items INTEGER DEFAULT 0,
|
||||
last_scan TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE,
|
||||
UNIQUE(chest_id, slot)
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -83,7 +87,7 @@ class Database {
|
||||
count INTEGER NOT NULL,
|
||||
nbt_data TEXT,
|
||||
FOREIGN KEY (shulker_id) REFERENCES shulkers(id) ON DELETE CASCADE,
|
||||
UNIQUE(shulker_id, item_id),
|
||||
UNIQUE(shulker_id, slot),
|
||||
CHECK(slot >= 0 AND slot <= 26),
|
||||
CHECK(count > 0 AND count <= 64)
|
||||
)
|
||||
@@ -100,16 +104,17 @@ class Database {
|
||||
)
|
||||
`);
|
||||
|
||||
// Pending withdrawals table
|
||||
// Chest loose items table (non-shulker items sitting directly in chests)
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pending_withdrawals (
|
||||
CREATE TABLE IF NOT EXISTS chest_loose_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
chest_id INTEGER NOT NULL,
|
||||
slot INTEGER NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
requested_count INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'ready', 'completed', 'cancelled')),
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
item_id INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE,
|
||||
UNIQUE(chest_id, slot)
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -124,6 +129,29 @@ class Database {
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Invite sites table
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS invite_sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
bot_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Invite permissions table
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS invite_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
FOREIGN KEY (site_id) REFERENCES invite_sites(id) ON DELETE CASCADE,
|
||||
UNIQUE(site_id, player_name)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async insertDefaultPermissions() {
|
||||
@@ -227,45 +255,44 @@ class Database {
|
||||
const result = await this.db.run(`
|
||||
INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
slot_count = excluded.slot_count,
|
||||
total_items = excluded.total_items,
|
||||
ON CONFLICT(chest_id, slot) DO UPDATE SET
|
||||
shulker_type = excluded.shulker_type,
|
||||
category = COALESCE(excluded.category, shulkers.category),
|
||||
item_focus = COALESCE(excluded.item_focus, shulkers.item_focus),
|
||||
last_scan = CURRENT_TIMESTAMP
|
||||
`, [chestId, slot, shulkerType, category, itemFocus]);
|
||||
|
||||
return result.lastID;
|
||||
}
|
||||
|
||||
async getShulkersByChest(chestId) {
|
||||
return await this.db.all('SELECT * FROM shulkers WHERE chest_id = ? ORDER BY slot', [chestId]);
|
||||
async upsertAndGetShulker(chestId, slot, shulkerType, category = null) {
|
||||
await this.db.run(`
|
||||
INSERT INTO shulkers (chest_id, slot, shulker_type, category)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(chest_id, slot) DO UPDATE SET
|
||||
shulker_type = excluded.shulker_type,
|
||||
category = COALESCE(excluded.category, shulkers.category),
|
||||
last_scan = CURRENT_TIMESTAMP
|
||||
`, [chestId, slot, shulkerType, category]);
|
||||
return await this.db.get(
|
||||
'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?',
|
||||
[chestId, slot]
|
||||
);
|
||||
}
|
||||
|
||||
async getAllShulkers() {
|
||||
return await this.db.all('SELECT * FROM shulkers ORDER BY id');
|
||||
async getShulkersByChest(chestId) {
|
||||
return await this.db.all('SELECT * FROM shulkers WHERE chest_id = ? ORDER BY slot', [chestId]);
|
||||
}
|
||||
|
||||
async getShulkerById(id) {
|
||||
return await this.db.get('SELECT * FROM shulkers WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async findShulkerForItem(itemId, categoryName) {
|
||||
// Find shulker with matching item and space
|
||||
return await this.db.get(`
|
||||
SELECT s.*, si.count as slot_item_count
|
||||
FROM shulkers s
|
||||
INNER JOIN shulker_items si ON s.id = si.shulker_id
|
||||
WHERE s.item_focus = (SELECT item_name FROM shulker_items WHERE item_id = ? LIMIT 1)
|
||||
AND s.category = ?
|
||||
AND s.slot_count < 27
|
||||
LIMIT 1
|
||||
`, [itemId, categoryName]);
|
||||
}
|
||||
|
||||
async createEmptyShulker(chestId, slot, categoryName, shulkerType = 'shulker_box') {
|
||||
return await this.db.run(`
|
||||
INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus, slot_count, total_items)
|
||||
VALUES (?, ?, ?, ?, NULL, 0, 0)
|
||||
`, [chestId, slot, shulkerType, categoryName]);
|
||||
async getShulkerByChestSlot(chestId, slot) {
|
||||
return await this.db.get(
|
||||
'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?',
|
||||
[chestId, slot]
|
||||
);
|
||||
}
|
||||
|
||||
async updateShulkerCounts(shulkerId, slotCount, totalItems) {
|
||||
@@ -276,10 +303,127 @@ class Database {
|
||||
`, [slotCount, totalItems, shulkerId]);
|
||||
}
|
||||
|
||||
async updateShulkerItemFocus(shulkerId, itemFocus) {
|
||||
return await this.db.run(
|
||||
'UPDATE shulkers SET item_focus = ? WHERE id = ?',
|
||||
[itemFocus, shulkerId]
|
||||
);
|
||||
}
|
||||
|
||||
async deleteShulker(id) {
|
||||
return await this.db.run('DELETE FROM shulkers WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// 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(`
|
||||
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
|
||||
FROM shulkers s
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
WHERE s.item_focus = ? AND s.slot_count >= 0 AND s.slot_count < 27
|
||||
AND (? IS NULL OR s.id != ?)
|
||||
ORDER BY s.slot_count DESC
|
||||
LIMIT 1
|
||||
`, [itemName, excludeId, excludeId]);
|
||||
}
|
||||
|
||||
// Find any empty shulker (no item_focus, no items, not in-transit)
|
||||
async findEmptyShulker(excludeId = null) {
|
||||
return await this.db.get(`
|
||||
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
|
||||
FROM shulkers s
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
WHERE s.item_focus IS NULL AND s.total_items = 0 AND s.slot_count >= 0
|
||||
AND (? IS NULL OR s.id != ?)
|
||||
ORDER BY s.id ASC
|
||||
LIMIT 1
|
||||
`, [excludeId, excludeId]);
|
||||
}
|
||||
|
||||
// Find shulkers containing a specific item (for withdrawal, excludes in-transit)
|
||||
async findShulkersWithItem(itemName) {
|
||||
return await this.db.all(`
|
||||
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type,
|
||||
SUM(si.count) as available_count
|
||||
FROM shulkers s
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
INNER JOIN shulker_items si ON si.shulker_id = s.id
|
||||
WHERE si.item_name = ? AND s.slot_count >= 0
|
||||
GROUP BY s.id
|
||||
ORDER BY available_count ASC
|
||||
`, [itemName]);
|
||||
}
|
||||
|
||||
// Find item types that have more than one non-full shulker (candidates for consolidation)
|
||||
async findConsolidatableItems() {
|
||||
return await this.db.all(`
|
||||
SELECT s.item_focus, COUNT(*) as shulker_count,
|
||||
SUM(s.slot_count) as total_slots_used, SUM(s.total_items) as total_items
|
||||
FROM shulkers s
|
||||
WHERE s.item_focus IS NOT NULL
|
||||
AND s.slot_count >= 0
|
||||
AND s.slot_count < 27
|
||||
GROUP BY s.item_focus
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY total_slots_used ASC
|
||||
`);
|
||||
}
|
||||
|
||||
// Get all non-full shulkers for a given item, sorted least-full first
|
||||
async getShulkersByItemFocus(itemName) {
|
||||
return await this.db.all(`
|
||||
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
|
||||
FROM shulkers s
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
WHERE s.item_focus = ? AND s.slot_count >= 0
|
||||
ORDER BY s.slot_count ASC
|
||||
`, [itemName]);
|
||||
}
|
||||
|
||||
// Find a chest slot that doesn't have a shulker (for placing newly crafted ones)
|
||||
async findEmptyChestSlot() {
|
||||
const chests = await this.db.all(`
|
||||
SELECT c.*, COUNT(s.id) as shulker_count
|
||||
FROM chests c
|
||||
LEFT JOIN shulkers s ON s.chest_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING shulker_count < CASE WHEN c.chest_type = 'double' THEN 54 ELSE 27 END
|
||||
ORDER BY c.id ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (!chests || chests.length === 0) return null;
|
||||
|
||||
const chest = chests[0];
|
||||
const shulkers = await this.getShulkersByChest(chest.id);
|
||||
const usedSlots = new Set(shulkers.map(s => s.slot));
|
||||
const maxSlots = chest.chest_type === 'double' ? 54 : 27;
|
||||
|
||||
for (let i = 0; i < maxSlots; i++) {
|
||||
if (!usedSlots.has(i)) {
|
||||
return {
|
||||
chest_id: chest.id,
|
||||
pos_x: chest.pos_x,
|
||||
pos_y: chest.pos_y,
|
||||
pos_z: chest.pos_z,
|
||||
slot: i,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get total count of a specific item across all shulkers
|
||||
async getItemTotalCount(itemName) {
|
||||
const result = await this.db.get(`
|
||||
SELECT SUM(si.count) as total
|
||||
FROM shulker_items si
|
||||
WHERE si.item_name = ?
|
||||
`, [itemName]);
|
||||
return result?.total || 0;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Shulker Items
|
||||
// ========================================
|
||||
@@ -288,25 +432,72 @@ class Database {
|
||||
return await this.db.run(`
|
||||
INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(shulker_id, item_id) DO UPDATE SET
|
||||
slot = excluded.slot,
|
||||
ON CONFLICT(shulker_id, slot) DO UPDATE SET
|
||||
item_id = excluded.item_id,
|
||||
item_name = excluded.item_name,
|
||||
count = excluded.count,
|
||||
nbt_data = excluded.nbt_data
|
||||
`, [shulkerId, itemId, itemName, slot, count, nbt ? JSON.stringify(nbt) : null]);
|
||||
}
|
||||
|
||||
async batchUpsertShulkerItems(shulkerId, items) {
|
||||
if (!items.length) return;
|
||||
await this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
const stmt = await this.db.prepare(`
|
||||
INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(shulker_id, slot) DO UPDATE SET
|
||||
item_id = excluded.item_id,
|
||||
item_name = excluded.item_name,
|
||||
count = excluded.count,
|
||||
nbt_data = excluded.nbt_data
|
||||
`);
|
||||
for (const item of items) {
|
||||
await stmt.run(shulkerId, item.id, item.name, item.slot, item.count, item.nbt ? JSON.stringify(item.nbt) : null);
|
||||
}
|
||||
await stmt.finalize();
|
||||
await this.db.run('COMMIT');
|
||||
} catch (error) {
|
||||
await this.db.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getShulkerItems(shulkerId) {
|
||||
return await this.db.all('SELECT * FROM shulker_items WHERE shulker_id = ? ORDER BY slot', [shulkerId]);
|
||||
}
|
||||
|
||||
async deleteShulkerItem(shulkerId, itemId) {
|
||||
return await this.db.run('DELETE FROM shulker_items WHERE shulker_id = ? AND item_id = ?', [shulkerId, itemId]);
|
||||
}
|
||||
|
||||
async clearShulkerItems(shulkerId) {
|
||||
return await this.db.run('DELETE FROM shulker_items WHERE shulker_id = ?', [shulkerId]);
|
||||
}
|
||||
|
||||
async getShulkerItemById(id) {
|
||||
return await this.db.get('SELECT * FROM shulker_items WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all "special" items — those with NBT containing displayName, lore, or customModelData.
|
||||
* Returns items with location info (chest position, shulker slot).
|
||||
*/
|
||||
async getSpecialItems() {
|
||||
return await this.db.all(`
|
||||
SELECT si.*, s.slot as shulker_slot, s.chest_id, s.item_focus,
|
||||
c.pos_x, c.pos_y, c.pos_z
|
||||
FROM shulker_items si
|
||||
INNER JOIN shulkers s ON s.id = si.shulker_id
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
WHERE si.nbt_data IS NOT NULL
|
||||
AND si.nbt_data != 'null'
|
||||
AND (
|
||||
si.nbt_data LIKE '%"displayName"%'
|
||||
OR si.nbt_data LIKE '%"lore"%'
|
||||
OR si.nbt_data LIKE '%"customModelData"%'
|
||||
)
|
||||
ORDER BY si.item_name, si.id
|
||||
`);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Trades
|
||||
// ========================================
|
||||
@@ -332,64 +523,15 @@ class Database {
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Pending Withdrawals
|
||||
// ========================================
|
||||
|
||||
async queueWithdrawal(playerName, itemId, itemName, count) {
|
||||
return await this.db.run(`
|
||||
INSERT INTO pending_withdrawals (player_name, item_id, item_name, requested_count)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [playerName, itemId, itemName, count]);
|
||||
}
|
||||
|
||||
async getPendingWithdrawals(playerName) {
|
||||
return await this.db.all(`
|
||||
SELECT * FROM pending_withdrawals
|
||||
WHERE player_name = ? AND status IN ('pending', 'ready')
|
||||
ORDER BY timestamp ASC
|
||||
`, [playerName]);
|
||||
}
|
||||
|
||||
async getWithdrawalById(id) {
|
||||
return await this.db.get('SELECT * FROM pending_withdrawals WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async updateWithdrawStatus(id, status) {
|
||||
return await this.db.run(
|
||||
'UPDATE pending_withdrawals SET status = ? WHERE id = ?',
|
||||
[status, id]
|
||||
);
|
||||
}
|
||||
|
||||
async markCompletedWithdrawals(playerName) {
|
||||
return await this.db.run(`
|
||||
UPDATE pending_withdrawals
|
||||
SET status = 'completed'
|
||||
WHERE player_name = ? AND status = 'ready'
|
||||
`, [playerName]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Item Index
|
||||
// ========================================
|
||||
|
||||
async updateItemIndex(itemId, itemName, shulkerId, count) {
|
||||
// This is a simplified version - in production, you'd want to handle
|
||||
// the shulker_ids JSON aggregation more carefully
|
||||
return await this.db.run(`
|
||||
INSERT INTO item_index (item_id, item_name, total_count)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
total_count = total_count + ?,
|
||||
last_updated = CURRENT_TIMESTAMP
|
||||
`, [itemId, itemName, count, count]);
|
||||
}
|
||||
|
||||
async rebuildItemIndex() {
|
||||
// Rebuild entire index from shulker_items
|
||||
return await this.db.exec(`
|
||||
INSERT OR REPLACE INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
|
||||
// Clear and rebuild from shulker_items
|
||||
await this.db.run('DELETE FROM item_index');
|
||||
await this.db.run(`
|
||||
INSERT INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
|
||||
SELECT
|
||||
si.item_id,
|
||||
si.item_name,
|
||||
@@ -397,18 +539,36 @@ class Database {
|
||||
GROUP_CONCAT('{"id":' || si.shulker_id || ',"count":' || si.count || '}') as shulker_ids,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM shulker_items si
|
||||
GROUP BY si.item_id, si.item_name
|
||||
GROUP BY si.item_name
|
||||
`);
|
||||
const count = await this.db.get('SELECT COUNT(*) as c FROM item_index');
|
||||
console.log(`Database: Rebuilt item index with ${count?.c || 0} entries`);
|
||||
}
|
||||
|
||||
async searchItems(query) {
|
||||
// Query shulker_items plus empty shulkers as a virtual item
|
||||
if (!query) {
|
||||
return await this.db.all('SELECT * FROM item_index ORDER BY item_name ASC');
|
||||
return await this.db.all(`
|
||||
SELECT item_name, SUM(count) as total_count FROM (
|
||||
SELECT item_name, count FROM shulker_items
|
||||
UNION ALL
|
||||
SELECT shulker_type AS item_name, 1 AS count
|
||||
FROM shulkers WHERE total_items = 0 AND item_focus IS NULL
|
||||
)
|
||||
GROUP BY item_name
|
||||
ORDER BY total_count DESC
|
||||
`);
|
||||
}
|
||||
return await this.db.all(
|
||||
"SELECT * FROM item_index WHERE item_name LIKE ? ORDER BY item_name ASC",
|
||||
[`%${query}%`]
|
||||
);
|
||||
return await this.db.all(`
|
||||
SELECT item_name, SUM(count) as total_count FROM (
|
||||
SELECT item_name, count FROM shulker_items WHERE item_name LIKE ?
|
||||
UNION ALL
|
||||
SELECT shulker_type AS item_name, 1 AS count
|
||||
FROM shulkers WHERE total_items = 0 AND item_focus IS NULL AND shulker_type LIKE ?
|
||||
)
|
||||
GROUP BY item_name
|
||||
ORDER BY total_count DESC
|
||||
`, [`%${query}%`, `%${query}%`]);
|
||||
}
|
||||
|
||||
async getItemDetails(itemId) {
|
||||
@@ -432,6 +592,125 @@ class Database {
|
||||
return { ...item, locations };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Map / Aggregation
|
||||
// ========================================
|
||||
|
||||
// Get all chests with a summary of their shulker contents (for map view)
|
||||
async getChestsWithSummary() {
|
||||
return await this.db.all(`
|
||||
SELECT
|
||||
c.id, c.pos_x, c.pos_y, c.pos_z, c.chest_type, c.row, c.column, c.category,
|
||||
COUNT(s.id) as shulker_count,
|
||||
COALESCE(SUM(s.total_items), 0) as total_items,
|
||||
GROUP_CONCAT(DISTINCT s.item_focus) as item_focuses,
|
||||
COALESCE((SELECT COUNT(*) FROM chest_loose_items cli WHERE cli.chest_id = c.id), 0) as loose_item_count
|
||||
FROM chests c
|
||||
LEFT JOIN shulkers s ON s.chest_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.pos_x, c.pos_z, c.pos_y
|
||||
`);
|
||||
}
|
||||
|
||||
// Get detailed shulker info with all items
|
||||
async getShulkerWithItems(shulkerId) {
|
||||
const shulker = await this.db.get(`
|
||||
SELECT s.*, c.pos_x, c.pos_y, c.pos_z
|
||||
FROM shulkers s
|
||||
INNER JOIN chests c ON c.id = s.chest_id
|
||||
WHERE s.id = ?
|
||||
`, [shulkerId]);
|
||||
if (!shulker) return null;
|
||||
|
||||
const items = await this.getShulkerItems(shulkerId);
|
||||
return { ...shulker, items };
|
||||
}
|
||||
|
||||
// Get all shulkers for a chest with their items
|
||||
async getChestContents(chestId) {
|
||||
const chest = await this.getChestById(chestId);
|
||||
if (!chest) return null;
|
||||
|
||||
const shulkers = await this.db.all(`
|
||||
SELECT s.*,
|
||||
GROUP_CONCAT(si.item_name || ':' || si.count) as item_summary
|
||||
FROM shulkers s
|
||||
LEFT JOIN shulker_items si ON si.shulker_id = s.id
|
||||
WHERE s.chest_id = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY s.slot
|
||||
`, [chestId]);
|
||||
|
||||
return { chest, shulkers };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Chest Loose Items
|
||||
// ========================================
|
||||
|
||||
async clearLooseItems(chestId) {
|
||||
return await this.db.run('DELETE FROM chest_loose_items WHERE chest_id = ?', [chestId]);
|
||||
}
|
||||
|
||||
async upsertLooseItem(chestId, slot, itemName, itemId, count) {
|
||||
return await this.db.run(`
|
||||
INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chest_id, slot) DO UPDATE SET
|
||||
item_name = excluded.item_name,
|
||||
item_id = excluded.item_id,
|
||||
count = excluded.count
|
||||
`, [chestId, slot, itemName, itemId, count]);
|
||||
}
|
||||
|
||||
async batchUpsertLooseItems(chestId, items) {
|
||||
if (!items.length) return;
|
||||
await this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
const stmt = await this.db.prepare(`
|
||||
INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chest_id, slot) DO UPDATE SET
|
||||
item_name = excluded.item_name,
|
||||
item_id = excluded.item_id,
|
||||
count = excluded.count
|
||||
`);
|
||||
for (const item of items) {
|
||||
await stmt.run(chestId, item.slot, item.name, item.id, item.count);
|
||||
}
|
||||
await stmt.finalize();
|
||||
await this.db.run('COMMIT');
|
||||
} catch (error) {
|
||||
await this.db.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getChestsWithLooseItems() {
|
||||
return await this.db.all(`
|
||||
SELECT DISTINCT c.*
|
||||
FROM chests c
|
||||
INNER JOIN chest_loose_items cli ON cli.chest_id = c.id
|
||||
ORDER BY c.id
|
||||
`);
|
||||
}
|
||||
|
||||
async getAllLooseItems() {
|
||||
return await this.db.all(`
|
||||
SELECT cli.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
|
||||
FROM chest_loose_items cli
|
||||
INNER JOIN chests c ON c.id = cli.chest_id
|
||||
ORDER BY c.id, cli.slot
|
||||
`);
|
||||
}
|
||||
|
||||
async deleteLooseItem(chestId, slot) {
|
||||
return await this.db.run(
|
||||
'DELETE FROM chest_loose_items WHERE chest_id = ? AND slot = ?',
|
||||
[chestId, slot]
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Stats
|
||||
// ========================================
|
||||
@@ -442,6 +721,11 @@ class Database {
|
||||
const totalChests = await this.db.get('SELECT COUNT(*) as total FROM chests');
|
||||
const emptyShulkers = await this.db.get("SELECT COUNT(*) as total FROM shulkers WHERE slot_count = 0");
|
||||
const recentTrades = await this.db.get('SELECT COUNT(*) as total FROM trades WHERE timestamp > datetime("now", "-1 day")');
|
||||
const looseItems = await this.db.get('SELECT COALESCE(SUM(count), 0) as total FROM chest_loose_items');
|
||||
const chestCapacity = await this.db.get(`
|
||||
SELECT COALESCE(SUM(CASE WHEN chest_type = 'double' THEN 54 ELSE 27 END), 0) as total_slots
|
||||
FROM chests
|
||||
`);
|
||||
|
||||
// Category breakdown
|
||||
const categories = await this.db.all(`
|
||||
@@ -462,10 +746,110 @@ class Database {
|
||||
totalChests: totalChests?.total || 0,
|
||||
emptyShulkers: emptyShulkers?.total || 0,
|
||||
recentTrades: recentTrades?.total || 0,
|
||||
looseItemCount: looseItems?.total || 0,
|
||||
totalChestSlots: chestCapacity?.total_slots || 0,
|
||||
categories: categoryMap
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Invite Sites
|
||||
// ========================================
|
||||
|
||||
async getInviteSites() {
|
||||
return await this.db.all(`
|
||||
SELECT s.*,
|
||||
GROUP_CONCAT(p.player_name) as players
|
||||
FROM invite_sites s
|
||||
LEFT JOIN invite_permissions p ON p.site_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.name
|
||||
`);
|
||||
}
|
||||
|
||||
async getInviteSiteByName(name) {
|
||||
return await this.db.get('SELECT * FROM invite_sites WHERE name = ?', [name]);
|
||||
}
|
||||
|
||||
async getInviteSitePlayers(siteId) {
|
||||
const rows = await this.db.all(
|
||||
'SELECT player_name FROM invite_permissions WHERE site_id = ? ORDER BY player_name',
|
||||
[siteId]
|
||||
);
|
||||
return rows.map(r => r.player_name);
|
||||
}
|
||||
|
||||
async isPlayerAllowedAtSite(siteName, playerName) {
|
||||
const row = await this.db.get(`
|
||||
SELECT 1 FROM invite_sites s
|
||||
INNER JOIN invite_permissions p ON p.site_id = s.id
|
||||
WHERE s.name = ? AND p.player_name = ?
|
||||
`, [siteName, playerName]);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async addInviteSite(name, label, botName, description) {
|
||||
return await this.db.run(
|
||||
'INSERT INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)',
|
||||
[name, label, botName, description || null]
|
||||
);
|
||||
}
|
||||
|
||||
async updateInviteSite(id, fields) {
|
||||
const allowed = ['name', 'label', 'bot_name', 'description'];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) {
|
||||
sets.push(`${key} = ?`);
|
||||
values.push(fields[key]);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return;
|
||||
values.push(id);
|
||||
return await this.db.run(
|
||||
`UPDATE invite_sites SET ${sets.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
async deleteInviteSite(id) {
|
||||
return await this.db.run('DELETE FROM invite_sites WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async addInvitePermission(siteId, playerName) {
|
||||
return await this.db.run(
|
||||
'INSERT OR IGNORE INTO invite_permissions (site_id, player_name) VALUES (?, ?)',
|
||||
[siteId, playerName]
|
||||
);
|
||||
}
|
||||
|
||||
async removeInvitePermission(siteId, playerName) {
|
||||
return await this.db.run(
|
||||
'DELETE FROM invite_permissions WHERE site_id = ? AND player_name = ?',
|
||||
[siteId, playerName]
|
||||
);
|
||||
}
|
||||
|
||||
async seedInviteSites(sites) {
|
||||
for (const site of sites) {
|
||||
try {
|
||||
await this.db.run(
|
||||
'INSERT OR IGNORE INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)',
|
||||
[site.name, site.label, site.bot, site.description || null]
|
||||
);
|
||||
const row = await this.getInviteSiteByName(site.name);
|
||||
if (row && site.allowed) {
|
||||
for (const player of site.allowed) {
|
||||
await this.addInvitePermission(row.id, player);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error seeding invite site:', site.name, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.db) {
|
||||
await this.db.close();
|
||||
|
||||
+1390
-55
File diff suppressed because it is too large
Load Diff
@@ -1,103 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Vec3 = require('vec3');
|
||||
const conf = require('../../conf');
|
||||
|
||||
class Organizer {
|
||||
constructor() {
|
||||
this.categories = conf.storage?.categories || {
|
||||
minerals: ['diamond', 'netherite_ingot', 'gold_ingot', 'iron_ingot'],
|
||||
food: ['bread', 'cooked_porkchop', 'steak'],
|
||||
tools: ['diamond_sword', 'diamond_pickaxe', 'netherite_pickaxe'],
|
||||
armor: ['diamond_chestplate', 'netherite_helmet'],
|
||||
blocks: ['stone', 'dirt', 'cobblestone'],
|
||||
redstone: ['redstone', 'repeater', 'piston'],
|
||||
misc: []
|
||||
};
|
||||
}
|
||||
|
||||
categorizeItem(itemName) {
|
||||
// Fast path: check each category
|
||||
for (const [category, items] of Object.entries(this.categories)) {
|
||||
if (items.includes(itemName)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
async findShulkerForItem(database, itemId, categoryName) {
|
||||
// Find shulker with matching item that has space
|
||||
const shulker = await database.findShulkerForItem(itemId, categoryName);
|
||||
return shulker;
|
||||
}
|
||||
|
||||
async findEmptyShulkerSlot(database, categoryName) {
|
||||
// Find an empty shulker in the appropriate category and row (prefer row 4 for empty storage)
|
||||
const chests = await database.getChests();
|
||||
|
||||
// Filter chests by category and row 4 (top row for empty/new shulkers)
|
||||
const categoryChests = chests.filter(c =>
|
||||
c.category === categoryName && c.row === 4
|
||||
).sort((a, b) => a.column - b.column); // Left to right
|
||||
|
||||
for (const chest of categoryChests) {
|
||||
const shulkers = await database.getShulkersByChest(chest.id);
|
||||
|
||||
// Find first shulker that's empty (slotCount = 0) or has space
|
||||
for (const shulker of shulkers) {
|
||||
if (!shulker.item_focus) {
|
||||
// Empty shulker available
|
||||
return {
|
||||
chest_id: chest.id,
|
||||
chestPosition: new Vec3(chest.pos_x, chest.pos_y, chest.pos_z),
|
||||
chestSlot: shulker.slot,
|
||||
shulker_id: shulker.id
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no empty shulker, look for first available slot in row 4
|
||||
// ... this would need to scan actual chest for empty slots
|
||||
return null;
|
||||
}
|
||||
|
||||
async sortItemIntoStorage(bot, database, item, categoryName) {
|
||||
// Find existing shulker with same item and space
|
||||
const existingShulker = await this.findShulkerForItem(database, item.id, categoryName);
|
||||
|
||||
if (existingShulker) {
|
||||
// Space available, add to existing shulker
|
||||
console.log(`Organizer: Found shulker ${existingShulker.id} for ${item.name}`);
|
||||
return existingShulker;
|
||||
} else {
|
||||
// Need new shulker
|
||||
console.log(`Organizer: Creating new shulker for ${item.name} (${categoryName})`);
|
||||
|
||||
const shulkerSlot = await this.findEmptyShulkerSlot(database, categoryName);
|
||||
if (!shulkerSlot) {
|
||||
console.log(`Organizer: No available shulker slot for ${item.name}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create/prepare new shulker
|
||||
await database.upsertShulker(
|
||||
shulkerSlot.chest_id,
|
||||
shulkerSlot.chestSlot,
|
||||
'shulker_box',
|
||||
categoryName,
|
||||
item.name // item_focus
|
||||
);
|
||||
|
||||
console.log(`Organizer: Created shulker at chest ${shulkerSlot.chest_id}, slot ${shulkerSlot.chestSlot}`);
|
||||
return {
|
||||
chest_id: shulkerSlot.chest_id,
|
||||
slot: shulkerSlot.chestSlot,
|
||||
new: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Organizer;
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const Vec3 = require('vec3');
|
||||
const { sleep } = require('../../utils');
|
||||
|
||||
class Scanner {
|
||||
constructor() {
|
||||
@@ -15,11 +16,12 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
this._scanRadius = radius;
|
||||
console.log(`Scanner: Discovering chests within ${radius} blocks...`);
|
||||
const chestPositions = bot.bot.findBlocks({
|
||||
matching: this.chestBlockType,
|
||||
maxDistance: radius,
|
||||
count: 1000, // Find up to 1000 chests
|
||||
count: Infinity,
|
||||
});
|
||||
|
||||
console.log(`Scanner: Found ${chestPositions.length} chest block(s)`);
|
||||
@@ -58,13 +60,29 @@ class Scanner {
|
||||
});
|
||||
}
|
||||
|
||||
// Don't delete orphans for now - just add new ones
|
||||
// await database.deleteOrphanChests(discoveredChests);
|
||||
// Remove DB records for chest positions no longer discovered
|
||||
// (e.g., the old canonical half of a double chest that switched sides)
|
||||
if (discoveredChests.length > 0) {
|
||||
await database.deleteOrphanChests(discoveredChests);
|
||||
}
|
||||
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s)`);
|
||||
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),
|
||||
@@ -73,10 +91,10 @@ class Scanner {
|
||||
];
|
||||
|
||||
for (const dir of directions) {
|
||||
const adjacentPos = position.offset(dir);
|
||||
const adjacentPos = position.offset(dir.x, dir.y, dir.z);
|
||||
const adjacentBlock = bot.bot.blockAt(adjacentPos);
|
||||
|
||||
if (adjacentBlock && adjacentBlock.name.includes('chest')) {
|
||||
if (adjacentBlock && adjacentBlock.name === 'chest') {
|
||||
if (dir.x === -1 || dir.z === -1) {
|
||||
return { type: 'double' };
|
||||
}
|
||||
@@ -107,20 +125,26 @@ class Scanner {
|
||||
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 });
|
||||
}
|
||||
|
||||
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 [];
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
return 0;
|
||||
}
|
||||
|
||||
const window = await bot.bot.openChest(chestBlock);
|
||||
const window = await bot.openContainer(chestBlock);
|
||||
const slots = window.slots;
|
||||
let shulkerCount = 0;
|
||||
|
||||
@@ -128,17 +152,37 @@ class Scanner {
|
||||
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
|
||||
await database.clearLooseItems(chest.id);
|
||||
|
||||
const looseItems = [];
|
||||
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(database, chest.id, i, slot);
|
||||
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);
|
||||
console.log(`Scanner: Found ${shulkerCount} shulkers in chest`);
|
||||
return shulkerCount;
|
||||
@@ -157,39 +201,74 @@ class Scanner {
|
||||
let scannedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const chest of chests) {
|
||||
const position = new Vec3(chest.pos_x, chest.pos_y, chest.pos_z);
|
||||
// Track scanned positions so we don't re-scan or re-queue
|
||||
const scannedPositions = new Set();
|
||||
|
||||
// Check distance to chest
|
||||
// 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;
|
||||
const distance = botPos.distanceTo(position);
|
||||
|
||||
if (distance > 4.5) {
|
||||
// Try to walk to the chest
|
||||
console.log(`Scanner: Walking to chest at ${position} (distance: ${distance.toFixed(1)})`);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
const chest = remaining.splice(closestIdx, 1)[0];
|
||||
|
||||
if (closestDist > 4.5) {
|
||||
console.log(`Scanner: Walking to chest at ${chest.pos} (distance: ${closestDist.toFixed(1)})`);
|
||||
try {
|
||||
await bot.goTo({
|
||||
where: position,
|
||||
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 ${position}: ${error.message}`);
|
||||
console.log(`Scanner: Could not reach chest at ${chest.pos}: ${error.message}`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const shulkerCount = await this.scanChest(bot, database, position);
|
||||
// Wait for anti-ESP to reveal nearby blocks after arriving
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shulkerCount = await this.scanChest(bot, database, chest.pos);
|
||||
totalShulkers += shulkerCount;
|
||||
scannedCount++;
|
||||
|
||||
// Progress update every 10 chests
|
||||
if (scannedCount % 10 === 0) {
|
||||
console.log(`Scanner: Progress - ${scannedCount}/${chests.length} chests scanned, ${totalShulkers} shulkers found`);
|
||||
console.log(`Scanner: Progress - ${scannedCount}/${scannedCount + remaining.length} chests scanned, ${totalShulkers} shulkers found`);
|
||||
}
|
||||
|
||||
// Small delay between chests to avoid overwhelming the server
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
await database.rebuildItemIndex();
|
||||
@@ -198,51 +277,53 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Read shulker contents from NBT data (no physical interaction needed)
|
||||
async scanShulkerFromNBT(database, chestId, chestSlot, shulkerItem) {
|
||||
async scanShulkerFromNBT(bot, database, chestId, chestSlot, shulkerItem) {
|
||||
console.log(`Scanner: Reading shulker NBT at slot ${chestSlot}`);
|
||||
|
||||
try {
|
||||
// Create/update shulker record
|
||||
const shulkerId = await database.upsertShulker(
|
||||
// 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
|
||||
);
|
||||
if (!shulkerRecord) {
|
||||
console.error(`Scanner: No shulker record found for chest ${chestId} slot ${chestSlot}`);
|
||||
return [];
|
||||
}
|
||||
const shulkerId = shulkerRecord.id;
|
||||
|
||||
await database.clearShulkerItems(shulkerId);
|
||||
|
||||
// Extract items from shulker NBT
|
||||
const items = this.extractShulkerContents(shulkerItem);
|
||||
const items = this.extractShulkerContents(bot, shulkerItem);
|
||||
|
||||
let totalItems = 0;
|
||||
const itemTypes = new Set();
|
||||
|
||||
for (const item of items) {
|
||||
await database.upsertShulkerItem(
|
||||
shulkerId,
|
||||
item.id,
|
||||
item.name,
|
||||
item.slot,
|
||||
item.count,
|
||||
item.nbt
|
||||
);
|
||||
await database.batchUpsertShulkerItems(shulkerId, items);
|
||||
|
||||
for (const item of items) {
|
||||
totalItems += item.count;
|
||||
itemTypes.add(item.name);
|
||||
}
|
||||
|
||||
// Update shulker stats
|
||||
const itemFocus = itemTypes.size === 1 ? Array.from(itemTypes)[0] : null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
await database.updateShulkerCounts(shulkerId, usedSlots, totalItems);
|
||||
|
||||
if (itemFocus && database.db) {
|
||||
await database.db.run(
|
||||
'UPDATE shulkers SET item_focus = ? WHERE id = ?',
|
||||
[itemFocus, shulkerId]
|
||||
);
|
||||
}
|
||||
await database.updateShulkerItemFocus(shulkerId, itemFocus);
|
||||
|
||||
console.log(`Scanner: Shulker has ${items.length} slot(s) used, ${totalItems} total items`);
|
||||
return items;
|
||||
@@ -254,7 +335,7 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Extract items from shulker box NBT data
|
||||
extractShulkerContents(shulkerItem) {
|
||||
extractShulkerContents(bot, shulkerItem) {
|
||||
const items = [];
|
||||
|
||||
if (!shulkerItem.nbt) {
|
||||
@@ -304,12 +385,16 @@ class Scanner {
|
||||
// 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,
|
||||
name: cleanId,
|
||||
id: typeof nbtItem.id === 'object' ? 0 : nbtItem.id,
|
||||
id: bot.mcData.itemsByName[cleanId]?.id || 0,
|
||||
count: count,
|
||||
nbt: nbtItem.tag ? this.parseNBT(nbtItem.tag) : null
|
||||
nbt: tag ? this.parseNBT(tag) : null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -320,6 +405,27 @@ class Scanner {
|
||||
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);
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -330,13 +436,19 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap prismarine-nbt wrappers so we can access keys directly
|
||||
nbt = this.simplifyNBT(nbt);
|
||||
|
||||
const result = {};
|
||||
|
||||
if (nbt.Enchantments) {
|
||||
result.enchantments = nbt.Enchantments.map(e => ({
|
||||
id: e.id,
|
||||
level: e.lvl
|
||||
}));
|
||||
let enchList = nbt.Enchantments;
|
||||
if (Array.isArray(enchList)) {
|
||||
result.enchantments = enchList.map(e => ({
|
||||
id: e.id,
|
||||
level: e.lvl
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (nbt.Damage) {
|
||||
@@ -344,7 +456,23 @@ class Scanner {
|
||||
}
|
||||
|
||||
if (nbt.display?.Name) {
|
||||
result.displayName = 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) {
|
||||
@@ -357,6 +485,18 @@ class Scanner {
|
||||
|
||||
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') {
|
||||
try { nbtData = JSON.parse(nbtData); } catch (e) { return false; }
|
||||
}
|
||||
return !!(nbtData.displayName || nbtData.lore || nbtData.customModelData);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scanner;
|
||||
File diff suppressed because it is too large
Load Diff
+841
-364
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user