Files
mc-bot-town/nodejs/controller/storage/database.js
T
2026-07-12 17:26:59 -04:00

1133 lines
34 KiB
JavaScript

'use strict';
const sqlite3 = require('sqlite3').verbose();
const { open } = require('sqlite');
const path = require('path');
const fs = require('fs');
class Database {
constructor() {
this.db = null;
}
async initialize(dbPath) {
// Ensure directory exists
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = await open({
filename: dbPath,
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();
console.log('Database initialized:', dbPath);
return this.db;
}
async createTables() {
// Permissions table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT UNIQUE NOT NULL,
role TEXT DEFAULT 'team' NOT NULL CHECK(role IN ('owner', 'team', 'readonly')),
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Chests table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS chests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pos_x INTEGER NOT NULL,
pos_y INTEGER NOT NULL,
pos_z INTEGER NOT NULL,
chest_type TEXT NOT NULL CHECK(chest_type IN ('single', 'double')),
row INTEGER NOT NULL,
column INTEGER NOT NULL,
category TEXT,
last_scan TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(pos_x, pos_y, pos_z)
)
`);
// Shulkers table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS shulkers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chest_id INTEGER NOT NULL,
slot INTEGER NOT NULL,
shulker_type TEXT DEFAULT 'shulker_box',
category TEXT,
item_focus TEXT,
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,
UNIQUE(chest_id, slot)
)
`);
// Shulker items table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS shulker_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shulker_id INTEGER NOT NULL,
item_name TEXT NOT NULL,
item_id INTEGER NOT NULL,
slot INTEGER NOT NULL,
count INTEGER NOT NULL,
nbt_data TEXT,
FOREIGN KEY (shulker_id) REFERENCES shulkers(id) ON DELETE CASCADE,
UNIQUE(shulker_id, slot),
CHECK(slot >= 0 AND slot <= 26),
CHECK(count > 0 AND count <= 64)
)
`);
// Trades table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('deposit', 'withdraw')),
items TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Chest loose items table (non-shulker items sitting directly in chests)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS chest_loose_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chest_id INTEGER NOT NULL,
slot INTEGER NOT NULL,
item_name TEXT NOT NULL,
item_id INTEGER NOT NULL,
count INTEGER NOT NULL,
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE,
UNIQUE(chest_id, slot)
)
`);
// Item index for fast searches
await this.db.exec(`
CREATE TABLE IF NOT EXISTS item_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER UNIQUE NOT NULL,
item_name TEXT NOT NULL,
total_count INTEGER DEFAULT 0,
shulker_ids TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Maps table (filled map images)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS maps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
map_id INTEGER UNIQUE NOT NULL,
image_data TEXT,
pixel_data TEXT,
captured_at 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)
)
`);
// Application settings table (runtime-tunable, editable via web UI and LLM)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
category TEXT NOT NULL DEFAULT 'general',
label TEXT,
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Per-bot settings table (autoConnect, autoReConnect, onDemand, plugins, etc.)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS bot_settings (
bot_name TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(bot_name, key)
)
`);
}
async insertDefaultPermissions() {
const conf = require('../../conf');
const defaultPlayers = conf.storage?.defaultPlayers || [];
for (const player of defaultPlayers) {
try {
await this.db.run(
'INSERT OR IGNORE INTO permissions (player_name, role) VALUES (?, ?)',
[player.name, player.role]
);
} catch (error) {
console.error('Error inserting default player:', player.name, error);
}
}
}
// ========================================
// Settings
// ========================================
async seedDefaultSettings(defaults) {
for (const entry of defaults) {
try {
await this.db.run(
'INSERT OR IGNORE INTO settings (key, value, type, category, label, description) VALUES (?, ?, ?, ?, ?, ?)',
[entry.key, entry.value, entry.type, entry.category, entry.label, entry.description || null]
);
} catch (error) {
console.error('Error seeding setting:', entry.key, error);
}
}
}
async getAllSettings() {
return await this.db.all('SELECT * FROM settings ORDER BY category, key');
}
async getSettingsByCategory(category) {
return await this.db.all('SELECT * FROM settings WHERE category = ? ORDER BY key', [category]);
}
async getSetting(key) {
return await this.db.get('SELECT * FROM settings WHERE key = ?', [key]);
}
async setSetting(key, value) {
const row = await this.db.get('SELECT type FROM settings WHERE key = ?', [key]);
if (!row) throw new Error(`Unknown setting: ${key}`);
const coerced = this._coerceValue(value, row.type);
await this.db.run(
'UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?',
[coerced, key]
);
return coerced;
}
_coerceValue(value, type) {
switch (type) {
case 'number': {
const n = Number(value);
if (isNaN(n)) throw new Error(`Expected number, got: ${value}`);
return String(n);
}
case 'boolean': return value === true || value === 'true' || value === '1' ? 'true' : 'false';
case 'json': return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value);
default: return String(value);
}
}
// ========================================
// Bot Settings (per-bot configuration)
// ========================================
async getAllBotSettings(botName) {
return await this.db.all('SELECT * FROM bot_settings WHERE bot_name = ? ORDER BY key', [botName]);
}
async getBotSetting(botName, key) {
return await this.db.get('SELECT * FROM bot_settings WHERE bot_name = ? AND key = ?', [botName, key]);
}
async setBotSetting(botName, key, value, type) {
const coerced = this._coerceValue(value, type || 'string');
await this.db.run(`
INSERT INTO bot_settings (bot_name, key, value, type)
VALUES (?, ?, ?, ?)
ON CONFLICT(bot_name, key) DO UPDATE SET
value = excluded.value,
type = excluded.type,
updated_at = CURRENT_TIMESTAMP
`, [botName, key, coerced, type || 'string']);
return coerced;
}
async setBotSettings(botName, settings) {
await this.db.run('SAVEPOINT setBotSettings');
try {
for (const [key, entry] of Object.entries(settings)) {
const type = entry.type || 'string';
const coerced = this._coerceValue(entry.value, type);
await this.db.run(`
INSERT INTO bot_settings (bot_name, key, value, type)
VALUES (?, ?, ?, ?)
ON CONFLICT(bot_name, key) DO UPDATE SET
value = excluded.value,
type = excluded.type,
updated_at = CURRENT_TIMESTAMP
`, [botName, key, coerced, type]);
}
await this.db.run('RELEASE setBotSettings');
} catch (error) {
await this.db.run('ROLLBACK TO setBotSettings');
throw error;
}
}
async seedDefaultBotSettings() {
const conf = require('../../conf');
const bots = conf.mc?.bots || {};
for (const [botName, botConfig] of Object.entries(bots)) {
const defaults = {
username: { value: String(botConfig.username || ''), type: 'string' },
password: { value: String(botConfig.password || ''), type: 'secret' },
auth: { value: String(botConfig.auth || 'microsoft'), type: 'string' },
autoConnect: { value: String(botConfig.autoConnect ?? true), type: 'boolean' },
autoReConnect: { value: String(botConfig.autoReConnect ?? true), type: 'boolean' },
onDemand: { value: String(botConfig.onDemand || false), type: 'boolean' },
idleTimeout: { value: String(botConfig.idleTimeout || 30000), type: 'number' },
commands: { value: JSON.stringify(botConfig.commands || []), type: 'json' },
plugins: { value: JSON.stringify(botConfig.plugins || {}), type: 'json' },
hasAi: { value: String(botConfig.hasAi || false), type: 'boolean' },
};
for (const [key, entry] of Object.entries(defaults)) {
try {
await this.db.run(
'INSERT OR IGNORE INTO bot_settings (bot_name, key, value, type) VALUES (?, ?, ?, ?)',
[botName, key, entry.value, entry.type]
);
} catch (e) { /* ignore duplicates */ }
}
}
console.log('Bot settings seeded from config defaults');
}
// ========================================
// Permissions
// ========================================
async addPlayer(name, Role = 'team') {
return await this.db.run(
'INSERT INTO permissions (player_name, role) VALUES (?, ?)',
[name, Role]
);
}
async removePlayer(name) {
return await this.db.run('DELETE FROM permissions WHERE player_name = ?', [name]);
}
async getPlayerRole(name) {
const row = await this.db.get('SELECT role FROM permissions WHERE player_name = ?', [name]);
return row ? row.role : null;
}
async getAllPlayers() {
return await this.db.all('SELECT * FROM permissions ORDER BY role DESC, player_name ASC');
}
async checkPermission(name, requiredRole) {
const role = await this.getPlayerRole(name);
if (!role) return false;
const roles = ['readonly', 'team', 'owner'];
return roles.indexOf(role) >= roles.indexOf(requiredRole);
}
// ========================================
// Chests
// ========================================
async upsertChest(x, y, z, chestType, row, column, category = null) {
return await this.db.run(`
INSERT INTO chests (pos_x, pos_y, pos_z, chest_type, row, column, category)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pos_x, pos_y, pos_z) DO UPDATE SET
chest_type = excluded.chest_type,
row = excluded.row,
column = excluded.column,
category = excluded.category,
last_scan = CURRENT_TIMESTAMP
`, [x, y, z, chestType, row, column, category]);
}
async getChests() {
return await this.db.all('SELECT * FROM chests ORDER BY row, column');
}
async getChestById(id) {
return await this.db.get('SELECT * FROM chests WHERE id = ?', [id]);
}
async markChestLost(x, y, z) {
await this.db.run('DELETE FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?', [x, y, z]);
console.log(`Scanner: Removed lost chest at ${x},${y},${z}`);
}
async getChestByPosition(x, y, z) {
return await this.db.get(
'SELECT * FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?',
[x, y, z]
);
}
async deleteOrphanChests(knownChestPositions) {
// knownChestPositions is array of {x, y, z}
if (knownChestPositions.length === 0) return;
const placeholders = knownChestPositions.map(() => '(?, ?, ?)').join(', ');
const values = knownChestPositions.flatMap(p => [p.x, p.y, p.z]);
return await this.db.run(`
DELETE FROM chests
WHERE (pos_x, pos_y, pos_z) NOT IN (${placeholders})
`, values);
}
async batchUpsertChests(chests) {
if (!chests || chests.length === 0) return;
await this.db.run('BEGIN TRANSACTION');
try {
for (const c of chests) {
await this.db.run(`
INSERT INTO chests (pos_x, pos_y, pos_z, chest_type, row, column, category)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pos_x, pos_y, pos_z) DO UPDATE SET
chest_type = excluded.chest_type,
row = excluded.row,
column = excluded.column,
category = excluded.category,
last_scan = CURRENT_TIMESTAMP
`, [c.x, c.y, c.z, c.type, c.row, c.column, c.category]);
}
await this.db.run('COMMIT');
} catch (err) {
await this.db.run('ROLLBACK');
throw err;
}
}
// ========================================
// Shulkers
// ========================================
async upsertShulker(chestId, slot, shulkerType, category = null, itemFocus = null) {
const result = await this.db.run(`
INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus)
VALUES (?, ?, ?, ?, ?)
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 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 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 getShulkerByChestSlot(chestId, slot) {
return await this.db.get(
'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?',
[chestId, slot]
);
}
async updateShulkerCounts(shulkerId, slotCount, totalItems) {
return await this.db.run(`
UPDATE shulkers
SET slot_count = ?, total_items = ?, last_scan = CURRENT_TIMESTAMP
WHERE id = ?
`, [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]);
}
async deleteShulkersByChest(chestId) {
return await this.db.run('DELETE FROM shulkers WHERE chest_id = ?', [chestId]);
}
// 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 and special/named items)
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
AND NOT (
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"%'
)
)
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 chests with available slots, ordered nearest-first from a reference point.
// Returns an array of { id, pos_x, pos_y, pos_z, chest_type, empty_slots }.
async findNearestChestsWithSpace(neededSlots, refX, refY, refZ) {
return await this.db.all(`
SELECT c.id, c.pos_x, c.pos_y, c.pos_z, c.chest_type,
(CASE WHEN c.chest_type = 'double' THEN 54 ELSE 27 END) - COUNT(s.id) as empty_slots
FROM chests c
LEFT JOIN shulkers s ON s.chest_id = c.id
GROUP BY c.id
HAVING empty_slots > 0
ORDER BY ((c.pos_x - ?) * (c.pos_x - ?) + (c.pos_y - ?) * (c.pos_y - ?) + (c.pos_z - ?) * (c.pos_z - ?)) ASC
`, [refX, refX, refY, refY, refZ, refZ]);
}
// 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 (excludes special/named items)
async getItemTotalCount(itemName) {
const result = await this.db.get(`
SELECT SUM(si.count) as total
FROM shulker_items si
WHERE si.item_name = ?
AND NOT (
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"%'
)
)
`, [itemName]);
return result?.total || 0;
}
// ========================================
// Shulker Items
// ========================================
async upsertShulkerItem(shulkerId, itemId, itemName, slot, count, nbt = null) {
return await this.db.run(`
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
`, [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 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
// ========================================
async logTrade(playerName, action, items) {
return await this.db.run(
'INSERT INTO trades (player_name, action, items) VALUES (?, ?, ?)',
[playerName, action, JSON.stringify(items)]
);
}
async getRecentTrades(limit = 50) {
return await this.db.all(
'SELECT * FROM trades ORDER BY timestamp DESC LIMIT ?',
[limit]
);
}
async getTradesByPlayer(playerName, limit = 50) {
return await this.db.all(
'SELECT * FROM trades WHERE player_name = ? ORDER BY timestamp DESC LIMIT ?',
[playerName, limit]
);
}
// ========================================
// Item Index
// ========================================
async rebuildItemIndex() {
// Clear and rebuild from shulker_items
await this.db.run('DELETE FROM item_index');
await this.db.run(`
INSERT OR REPLACE INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
SELECT
si.item_id,
si.item_name,
SUM(si.count) as total_count,
GROUP_CONCAT('{"id":' || si.shulker_id || ',"count":' || si.count || '}') as shulker_ids,
CURRENT_TIMESTAMP
FROM shulker_items si
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 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 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) {
const item = await this.db.get('SELECT * FROM item_index WHERE item_id = ?', [itemId]);
if (!item) return null;
// Parse shulker_ids and get chest details
const shulkerIds = JSON.parse(`[${item.shulker_ids}]`);
const locations = await this.db.all(`
SELECT
s.id as shulker_id,
s.chest_id,
c.pos_x, c.pos_y, c.pos_z,
js.value as count
FROM json_each(?) as js
INNER JOIN shulkers s ON s.id = JSON_EXTRACT(js.value, '$.id')
INNER JOIN chests c ON c.id = s.chest_id
`, [item.shulker_ids]);
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]
);
}
// ========================================
// Maps
// ========================================
async upsertMap(mapId, imageData, pixelData) {
return await this.db.run(`
INSERT INTO maps (map_id, image_data, pixel_data)
VALUES (?, ?, ?)
ON CONFLICT(map_id) DO UPDATE SET
image_data = excluded.image_data,
pixel_data = excluded.pixel_data,
captured_at = CURRENT_TIMESTAMP
`, [mapId, imageData, pixelData]);
}
async getMap(mapId) {
return await this.db.get('SELECT * FROM maps WHERE map_id = ?', [mapId]);
}
async getAllMaps() {
return await this.db.all('SELECT id, map_id, image_data, captured_at FROM maps ORDER BY map_id');
}
async deleteMap(mapId) {
return await this.db.run('DELETE FROM maps WHERE map_id = ?', [mapId]);
}
/**
* Find filled_map items in shulkers that don't have captured images yet.
* Returns items with location info needed to withdraw them.
*/
async getUncapturedMaps() {
return await this.db.all(`
SELECT si.*, s.slot as shulker_slot, s.chest_id, s.id as shulker_id,
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.item_name = 'filled_map'
AND s.slot_count >= 0
ORDER BY si.id
`);
}
// ========================================
// Stats
// ========================================
async getStats() {
const totalItems = await this.db.get('SELECT SUM(total_items) as total FROM shulkers');
const totalShulkers = await this.db.get('SELECT COUNT(*) as total FROM shulkers');
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(`
SELECT category, COUNT(*) as count
FROM shulkers
WHERE category IS NOT NULL
GROUP BY category
`);
const categoryMap = {};
for (const cat of categories) {
categoryMap[cat.category] = cat.count;
}
return {
totalItems: totalItems?.total || 0,
totalShulkers: totalShulkers?.total || 0,
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();
this.db = null;
}
}
}
module.exports = new Database();