934 lines
27 KiB
JavaScript
934 lines
27 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)
|
|
)
|
|
`);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 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 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);
|
|
}
|
|
|
|
// ========================================
|
|
// 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]);
|
|
}
|
|
|
|
// 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 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 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(); |