This commit is contained in:
2026-02-22 20:27:09 -05:00
parent 7b326a112e
commit 92024c8a64
37 changed files with 6785 additions and 3344 deletions
+484 -100
View File
@@ -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();