From 9f322003db023f6ba3e32c025832b38eb41d9cae Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 21 Mar 2026 18:10:44 -0400 Subject: [PATCH] Add SQLite persistence + official Minecraft item icons Database (better-sqlite3): - Persist items, furnaces, alerts, recipes, settings to SQLite - Auto-restore last known state when server restarts or bridge disconnects - Item count history tracking (5-min snapshots, 7-day retention) - /api/history/:itemName endpoint for item count history - Docker volume for database file persistence - Graceful shutdown with DB connection cleanup Icons: - Replace mc-heads.net with official Minecraft game textures via CDN - Cascading fallback: item texture -> block texture -> emoji - In-memory URL cache to avoid redundant network requests - Block texture suffix mapping (furnace_front, barrel_top, etc.) - Crisp pixel-art rendering with image-rendering: pixelated --- web/client/src/components/ItemIcon.css | 7 + web/client/src/components/ItemIcon.jsx | 187 +++++++++++- web/client/src/utils/itemUtils.js | 16 +- web/docker-compose.yml | 5 + web/server/Dockerfile | 10 + web/server/db.js | 388 +++++++++++++++++++++++++ web/server/package.json | 1 + web/server/server.js | 88 ++++-- 8 files changed, 654 insertions(+), 48 deletions(-) create mode 100644 web/server/db.js diff --git a/web/client/src/components/ItemIcon.css b/web/client/src/components/ItemIcon.css index 4d7bd6d..ccf36f8 100644 --- a/web/client/src/components/ItemIcon.css +++ b/web/client/src/components/ItemIcon.css @@ -1,6 +1,12 @@ .item-icon-img { image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; display: block; + -webkit-user-drag: none; + user-select: none; + /* Slight border to separate from dark backgrounds */ + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5)); } .item-icon-emoji { @@ -8,4 +14,5 @@ align-items: center; justify-content: center; line-height: 1; + user-select: none; } diff --git a/web/client/src/components/ItemIcon.jsx b/web/client/src/components/ItemIcon.jsx index d0fc3d2..aa17a18 100644 --- a/web/client/src/components/ItemIcon.jsx +++ b/web/client/src/components/ItemIcon.jsx @@ -2,22 +2,180 @@ import React, { useState } from 'react'; import { getItemEmoji } from '../utils/itemUtils'; import './ItemIcon.css'; +// Minecraft assets CDN - actual game textures (16x16 pixel art) +const MC_ASSETS_BASE = 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures'; + +// Some items have texture names that differ from their registry name +const TEXTURE_ALIASES = { + // Crops / seeds + wheat_seeds: 'wheat_seeds', + melon_seeds: 'melon_seeds', + pumpkin_seeds: 'pumpkin_seeds', + beetroot_seeds: 'beetroot_seeds', + // Potions and such + experience_bottle: 'experience_bottle', + // Renamed textures + golden_apple: 'golden_apple', + enchanted_golden_apple: 'enchanted_golden_apple', +}; + +// Items whose texture lives in the block/ folder instead of item/ +const BLOCK_TEXTURES = new Set([ + 'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite', + 'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks', + 'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks', + 'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log', + 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block', + 'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log', + 'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log', + 'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud', + 'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian', + 'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt', + 'glowstone', 'glass', 'tinted_glass', + 'oak_slab', 'spruce_slab', 'birch_slab', 'jungle_slab', 'acacia_slab', 'dark_oak_slab', + 'stone_slab', 'cobblestone_slab', 'brick_slab', 'stone_brick_slab', + 'oak_stairs', 'spruce_stairs', 'birch_stairs', 'jungle_stairs', 'acacia_stairs', + 'stone_stairs', 'cobblestone_stairs', 'brick_stairs', 'stone_brick_stairs', + 'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks', + 'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks', + 'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon', + 'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine', + 'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice', + 'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block', + 'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block', + 'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore', + 'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris', + 'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore', + 'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore', + 'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block', + 'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block', + 'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block', + 'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar', + 'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block', + 'slime_block', 'honey_block', 'note_block', 'jukebox', + 'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool', + 'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool', + 'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool', + 'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete', + 'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete', + 'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete', + 'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete', + 'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta', + 'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta', + 'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta', + 'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta', + 'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta', + 'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass', + 'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table', + 'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone', + 'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table', + 'brewing_stand', 'cauldron', 'composter', 'barrel', 'chest', 'trapped_chest', + 'ender_chest', 'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer', + 'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever', + 'beacon', 'conduit', 'bell', 'lodestone', 'respawn_anchor', + 'cactus', 'sugar_cane', 'bamboo', + 'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block', + 'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves', + 'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves', +]); + +// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top) +// We use _front, _top, or _side variants for recognizable look +const BLOCK_TEXTURE_SUFFIXES = { + furnace: '_front', + blast_furnace: '_front', + smoker: '_front', + dispenser: '_front', + dropper: '_front', + observer: '_front', + piston: '_front', + sticky_piston: '_front', + barrel: '_top', + crafting_table: '_top', + cartography_table: '_top', + fletching_table: '_top', + smithing_table: '_top', + grass_block: '_top', + mycelium: '_top', + podzol: '_top', +}; + /** - * Renders a Minecraft item icon. - * First tries to load the actual item sprite from mc-heads.net. - * Falls back to an emoji representation if the image fails. + * Attempt multiple texture URLs in order. + * 1. item/{name}.png + * 2. block/{name}.png (with optional suffix) + * 3. emoji fallback + */ +function getTextureUrls(shortName) { + const alias = TEXTURE_ALIASES[shortName] || shortName; + const urls = []; + + if (BLOCK_TEXTURES.has(shortName)) { + // Known block — try block texture first + const suffix = BLOCK_TEXTURE_SUFFIXES[shortName] || ''; + urls.push(`${MC_ASSETS_BASE}/block/${alias}${suffix}.png`); + // Also try without suffix + if (suffix) urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`); + } + + // Always try item texture + urls.push(`${MC_ASSETS_BASE}/item/${alias}.png`); + + // If not a known block, also try block as fallback + if (!BLOCK_TEXTURES.has(shortName)) { + urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`); + } + + return urls; +} + +// Cache of resolved icon URLs (avoid re-fetching on every render) +const iconCache = new Map(); + +/** + * Renders a Minecraft item icon using the official game textures. + * Cascading fallback: item texture → block texture → emoji */ function ItemIcon({ itemName, size = 32 }) { - const [imgError, setImgError] = useState(false); + const [urlIndex, setUrlIndex] = useState(0); + const [allFailed, setAllFailed] = useState(false); if (!itemName) { return šŸ“¦; } - // Strip namespace for the icon URL - const shortName = itemName.replace(/^[a-z0-9_]+:/, ''); + // Strip namespace (minecraft:diamond → diamond) + const shortName = itemName.replace(/^[a-z0-9_.-]+:/, ''); - if (imgError) { + // Check if we already know this item has no texture + if (iconCache.get(shortName) === 'none' || allFailed) { + return ( + + {getItemEmoji(itemName)} + + ); + } + + // If we have a cached URL, use it directly + const cachedUrl = iconCache.get(shortName); + if (cachedUrl && cachedUrl !== 'none') { + return ( + {shortName} + ); + } + + const urls = getTextureUrls(shortName); + const currentUrl = urls[urlIndex]; + + if (!currentUrl) { + iconCache.set(shortName, 'none'); return ( {getItemEmoji(itemName)} @@ -28,13 +186,22 @@ function ItemIcon({ itemName, size = 32 }) { return ( {shortName} setImgError(true)} - style={{ imageRendering: 'pixelated' }} + onLoad={() => { + iconCache.set(shortName, currentUrl); + }} + onError={() => { + if (urlIndex + 1 < urls.length) { + setUrlIndex(urlIndex + 1); + } else { + iconCache.set(shortName, 'none'); + setAllFailed(true); + } + }} /> ); } diff --git a/web/client/src/utils/itemUtils.js b/web/client/src/utils/itemUtils.js index 81434dd..e943edc 100644 --- a/web/client/src/utils/itemUtils.js +++ b/web/client/src/utils/itemUtils.js @@ -1,27 +1,17 @@ /** - * Minecraft item icon utility - * Uses mc-heads.net item renders for actual Minecraft item icons + * Minecraft item utility functions + * Icons use official game textures via ItemIcon component */ // Format an item name for display export function formatItemName(name) { if (!name) return ''; return name - .replace(/^[a-z0-9_]+:/, '') // Strip mod prefix + .replace(/^[a-z0-9_.-]+:/, '') // Strip mod prefix .replace(/_/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); } -// Get icon URL for a Minecraft item -// Uses mc-heads.net which provides 2D item renders -export function getItemIconUrl(itemName, size = 32) { - if (!itemName) return null; - // mc-heads.net format: https://mc-heads.net/item/{item_name} - // Alternatively we can use crafatar or other MC icon APIs - // For items, we use a simple mapping approach - return `https://mc-heads.net/item/${itemName}/${size}`; -} - // Fallback emoji mapping for common item categories const CATEGORY_EMOJI = { ingot: 'šŸŖ™', diff --git a/web/docker-compose.yml b/web/docker-compose.yml index 36ed170..2ca6aae 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -3,6 +3,8 @@ services: build: ./server networks: - inventory-network + volumes: + - server-data:/data restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"] @@ -25,3 +27,6 @@ services: networks: inventory-network: driver: bridge + +volumes: + server-data: diff --git a/web/server/Dockerfile b/web/server/Dockerfile index f106652..2a59f8f 100644 --- a/web/server/Dockerfile +++ b/web/server/Dockerfile @@ -1,14 +1,24 @@ # Node.js backend FROM node:18-alpine +# Build tools needed for better-sqlite3 native compilation +RUN apk add --no-cache python3 make g++ + WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev +# Remove build tools after install to keep image small +RUN apk del python3 make g++ + COPY . . +# Create data directory for SQLite +RUN mkdir -p /data +VOLUME /data + EXPOSE 3001 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/web/server/db.js b/web/server/db.js new file mode 100644 index 0000000..6342dbd --- /dev/null +++ b/web/server/db.js @@ -0,0 +1,388 @@ +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; + +const DB_PATH = process.env.DB_PATH || '/data/inventory.db'; + +// Ensure the directory exists +const dbDir = dirname(DB_PATH); +if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); +} + +const db = new Database(DB_PATH); + +// Performance pragmas +db.pragma('journal_mode = WAL'); +db.pragma('synchronous = NORMAL'); +db.pragma('foreign_keys = ON'); + +// ========== Schema ========== + +db.exec(` + CREATE TABLE IF NOT EXISTS items ( + name TEXT PRIMARY KEY, + count INTEGER NOT NULL DEFAULT 0, + display_name TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS furnaces ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'minecraft:furnace', + active INTEGER NOT NULL DEFAULT 0, + input TEXT, + fuel TEXT, + output TEXT, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS alerts ( + item TEXT PRIMARY KEY, + triggered INTEGER NOT NULL DEFAULT 0, + current INTEGER NOT NULL DEFAULT 0, + threshold INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS item_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + count INTEGER NOT NULL, + recorded_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_item_history_name ON item_history(name); + CREATE INDEX IF NOT EXISTS idx_item_history_time ON item_history(recorded_at); +`); + +// ========== Prepared Statements ========== + +const upsertItem = db.prepare(` + INSERT INTO items (name, count, display_name, updated_at) + VALUES (@name, @count, @displayName, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + count = @count, + display_name = @displayName, + updated_at = @updatedAt +`); + +const upsertFurnace = db.prepare(` + INSERT INTO furnaces (name, type, active, input, fuel, output, updated_at) + VALUES (@name, @type, @active, @input, @fuel, @output, @updatedAt) + ON CONFLICT(name) DO UPDATE SET + type = @type, + active = @active, + input = @input, + fuel = @fuel, + output = @output, + updated_at = @updatedAt +`); + +const upsertAlert = db.prepare(` + INSERT INTO alerts (item, triggered, current, threshold, updated_at) + VALUES (@item, @triggered, @current, @threshold, @updatedAt) + ON CONFLICT(item) DO UPDATE SET + triggered = @triggered, + current = @current, + threshold = @threshold, + updated_at = @updatedAt +`); + +const upsertState = db.prepare(` + INSERT INTO state (key, value, updated_at) + VALUES (@key, @value, @updatedAt) + ON CONFLICT(key) DO UPDATE SET + value = @value, + updated_at = @updatedAt +`); + +const insertHistory = db.prepare(` + INSERT INTO item_history (name, count, recorded_at) + VALUES (@name, @count, @recordedAt) +`); + +const getState = db.prepare(`SELECT value FROM state WHERE key = ?`); +const getAllItems = db.prepare(`SELECT name, count, display_name as displayName FROM items ORDER BY count DESC`); +const getAllFurnaces = db.prepare(`SELECT * FROM furnaces`); +const getAllAlerts = db.prepare(`SELECT item, triggered, current, threshold FROM alerts WHERE triggered = 1`); + +const getItemHistory = db.prepare(` + SELECT count, recorded_at as recordedAt + FROM item_history + WHERE name = ? + ORDER BY recorded_at DESC + LIMIT ? +`); + +// Cleanup old history (keep last 7 days) +const cleanupHistory = db.prepare(` + DELETE FROM item_history WHERE recorded_at < ? +`); + +// ========== Batch Operations ========== + +const saveItemsBatch = db.transaction((items, now) => { + for (const item of items) { + upsertItem.run({ + name: item.name || '', + count: item.count || 0, + displayName: item.displayName || item.name || '', + updatedAt: now, + }); + } +}); + +const saveHistoryBatch = db.transaction((items, now) => { + for (const item of items) { + insertHistory.run({ + name: item.name || '', + count: item.count || 0, + recordedAt: now, + }); + } +}); + +const saveFurnacesBatch = db.transaction((furnaces, now) => { + for (const [name, f] of Object.entries(furnaces)) { + upsertFurnace.run({ + name, + type: f.type || 'minecraft:furnace', + active: f.active ? 1 : 0, + input: f.input || null, + fuel: f.fuel || null, + output: f.output || null, + updatedAt: now, + }); + } +}); + +const saveAlertsBatch = db.transaction((alerts, now) => { + // Clear old triggered status + db.prepare('UPDATE alerts SET triggered = 0').run(); + for (const alert of alerts) { + upsertAlert.run({ + item: alert.item || '', + triggered: alert.triggered ? 1 : 0, + current: alert.current || 0, + threshold: alert.threshold || 0, + updatedAt: now, + }); + } +}); + +// ========== Public API ========== + +/** + * Save inventory items to the database + */ +export function saveItems(items) { + const now = Date.now(); + saveItemsBatch(items, now); +} + +/** + * Record a snapshot of item counts for history tracking + */ +let lastHistoryRecord = 0; +const HISTORY_INTERVAL = 5 * 60 * 1000; // Record history every 5 minutes + +export function recordItemHistory(items) { + const now = Date.now(); + if (now - lastHistoryRecord < HISTORY_INTERVAL) return; + lastHistoryRecord = now; + saveHistoryBatch(items, now); + + // Cleanup entries older than 7 days + cleanupHistory.run(now - 7 * 24 * 60 * 60 * 1000); +} + +/** + * Save furnace status to the database + */ +export function saveFurnaces(furnaceStatus) { + const now = Date.now(); + saveFurnacesBatch(furnaceStatus, now); +} + +/** + * Save alerts to the database + */ +export function saveAlerts(alerts) { + const now = Date.now(); + saveAlertsBatch(alerts, now); +} + +/** + * Save a key-value state entry (JSON serialized) + */ +export function saveState(key, value) { + upsertState.run({ + key, + value: JSON.stringify(value), + updatedAt: Date.now(), + }); +} + +/** + * Load a key-value state entry + */ +export function loadState(key, defaultValue = null) { + const row = getState.get(key); + if (!row) return defaultValue; + try { + return JSON.parse(row.value); + } catch { + return defaultValue; + } +} + +/** + * Load all persisted items + */ +export function loadItems() { + return getAllItems.all(); +} + +/** + * Load all persisted furnace statuses + */ +export function loadFurnaces() { + const rows = getAllFurnaces.all(); + const result = {}; + for (const row of rows) { + result[row.name] = { + active: !!row.active, + type: row.type, + input: row.input, + fuel: row.fuel, + output: row.output, + }; + } + return result; +} + +/** + * Load all triggered alerts + */ +export function loadAlerts() { + return getAllAlerts.all().map(row => ({ + ...row, + triggered: !!row.triggered, + })); +} + +/** + * Get item count history for a specific item + */ +export function getHistory(itemName, limit = 100) { + return getItemHistory.all(itemName, limit); +} + +/** + * Load the full last-known inventory state from DB + */ +export function loadFullState() { + const items = loadItems(); + const furnaces = loadFurnaces(); + const alerts = loadAlerts(); + const smeltingPaused = loadState('smeltingPaused', false); + const disabledRecipes = loadState('disabledRecipes', {}); + const smeltableRecipes = loadState('smeltableRecipes', {}); + const craftableRecipes = loadState('craftableRecipes', []); + const craftTurtleOk = loadState('craftTurtleOk', false); + const activity = loadState('activity', {}); + const lastUpdate = loadState('lastUpdate', 0); + + // Reconstruct inventoryState + const inventoryMeta = loadState('inventoryMeta', {}); + const inventoryState = { + itemList: items, + grandTotal: inventoryMeta.grandTotal || 0, + chestCount: inventoryMeta.chestCount || 0, + totalSlots: inventoryMeta.totalSlots || 0, + usedSlots: inventoryMeta.usedSlots || 0, + freeSlots: inventoryMeta.freeSlots || 0, + usedRatio: inventoryMeta.usedRatio || 0, + dropperOk: inventoryMeta.dropperOk || false, + barrelOk: inventoryMeta.barrelOk || false, + furnaceCount: inventoryMeta.furnaceCount || 0, + furnaceStatus: furnaces, + }; + + return { + inventoryState, + activityState: activity, + alertsState: alerts, + smeltingPaused, + disabledRecipes, + smeltableRecipes, + craftableRecipes, + craftTurtleOk, + lastUpdate, + }; +} + +/** + * Persist the full state to DB in one transaction + */ +export const saveFullState = db.transaction((state) => { + const now = Date.now(); + + // Items + if (state.inventoryState?.itemList?.length) { + saveItemsBatch(state.inventoryState.itemList, now); + } + + // Inventory metadata (totals, slot info, etc.) + if (state.inventoryState) { + const { itemList, furnaceStatus, ...meta } = state.inventoryState; + upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now }); + } + + // Furnaces + if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') { + saveFurnacesBatch(state.inventoryState.furnaceStatus, now); + } + + // Alerts + if (Array.isArray(state.alertsState)) { + saveAlertsBatch(state.alertsState, now); + } + + // Key-value state + if (state.activityState !== undefined) { + upsertState.run({ key: 'activity', value: JSON.stringify(state.activityState), updatedAt: now }); + } + if (state.smeltingPaused !== undefined) { + upsertState.run({ key: 'smeltingPaused', value: JSON.stringify(state.smeltingPaused), updatedAt: now }); + } + if (state.disabledRecipes !== undefined) { + upsertState.run({ key: 'disabledRecipes', value: JSON.stringify(state.disabledRecipes), updatedAt: now }); + } + if (state.smeltableRecipes !== undefined) { + upsertState.run({ key: 'smeltableRecipes', value: JSON.stringify(state.smeltableRecipes), updatedAt: now }); + } + if (state.craftableRecipes !== undefined) { + upsertState.run({ key: 'craftableRecipes', value: JSON.stringify(state.craftableRecipes), updatedAt: now }); + } + if (state.craftTurtleOk !== undefined) { + upsertState.run({ key: 'craftTurtleOk', value: JSON.stringify(state.craftTurtleOk), updatedAt: now }); + } + upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now }); +}); + +/** + * Close the database connection gracefully + */ +export function closeDb() { + db.close(); +} + +export default db; diff --git a/web/server/package.json b/web/server/package.json index e2a943f..1108483 100644 --- a/web/server/package.json +++ b/web/server/package.json @@ -9,6 +9,7 @@ "dev": "nodemon server.js" }, "dependencies": { + "better-sqlite3": "^11.7.0", "cors": "^2.8.5", "express": "^4.18.2", "ws": "^8.14.2" diff --git a/web/server/server.js b/web/server/server.js index e395e20..2745ce6 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -2,6 +2,11 @@ import express from 'express'; import { WebSocketServer } from 'ws'; import cors from 'cors'; import { createServer } from 'http'; +import { + loadFullState, saveFullState, recordItemHistory, + saveItems, saveFurnaces, saveAlerts, saveState, + getHistory, closeDb, +} from './db.js'; const app = express(); const PORT = process.env.PORT || 3001; @@ -14,29 +19,26 @@ app.use(express.json({ limit: '5mb' })); const webClients = new Set(); const bridgeClients = new Set(); -// Latest inventory state from the CC:Tweaked bridge -let inventoryState = { - itemList: [], - grandTotal: 0, - chestCount: 0, - totalSlots: 0, - usedSlots: 0, - freeSlots: 0, - usedRatio: 0, - dropperOk: false, - barrelOk: false, - furnaceCount: 0, - furnaceStatus: {}, -}; +// Load persisted state from SQLite on startup +console.log('šŸ’¾ Loading persisted state from database...'); +const persisted = loadFullState(); -let activityState = {}; -let alertsState = []; -let smeltingPaused = false; -let disabledRecipes = {}; -let smeltableRecipes = {}; -let craftableRecipes = []; -let craftTurtleOk = false; -let lastUpdate = 0; +let inventoryState = persisted.inventoryState; +let activityState = persisted.activityState; +let alertsState = persisted.alertsState; +let smeltingPaused = persisted.smeltingPaused; +let disabledRecipes = persisted.disabledRecipes; +let smeltableRecipes = persisted.smeltableRecipes; +let craftableRecipes = persisted.craftableRecipes; +let craftTurtleOk = persisted.craftTurtleOk; +let lastUpdate = persisted.lastUpdate; + +if (lastUpdate > 0) { + const ago = Math.round((Date.now() - lastUpdate) / 1000); + console.log(`šŸ’¾ Restored state from ${ago}s ago (${inventoryState.itemList.length} items)`); +} else { + console.log('šŸ’¾ No previous state found, starting fresh'); +} // Pending commands for bridge HTTP polling (fallback) let pendingCommands = []; @@ -109,6 +111,13 @@ app.get('/api/alerts', (req, res) => { res.json({ alerts: alertsState }); }); +// Get item count history +app.get('/api/history/:itemName', (req, res) => { + const limit = parseInt(req.query.limit) || 100; + const history = getHistory(req.params.itemName, Math.min(limit, 1000)); + res.json({ item: req.params.itemName, history }); +}); + // Get recipes (smeltable + craftable) app.get('/api/recipes', (req, res) => { res.json({ @@ -368,7 +377,27 @@ function updateStateFromBridge(data) { if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk; lastUpdate = Date.now(); - + + // Persist to SQLite + try { + saveFullState({ + inventoryState, + activityState, + alertsState, + smeltingPaused, + disabledRecipes, + smeltableRecipes, + craftableRecipes, + craftTurtleOk, + }); + // Record history snapshot (throttled to every 5 min internally) + if (inventoryState.itemList?.length) { + recordItemHistory(inventoryState.itemList); + } + } catch (err) { + console.error('āŒ DB save error:', err.message); + } + // Broadcast to all web clients broadcastToClients({ type: 'state_update', @@ -490,7 +519,16 @@ server.listen(PORT, HOST, () => { }); // Graceful shutdown -process.on('SIGINT', () => { +function shutdown() { console.log('\nšŸ›‘ Shutting down server...'); + try { + closeDb(); + console.log('šŸ’¾ Database closed'); + } catch (err) { + console.error('āŒ Error closing database:', err.message); + } process.exit(0); -}); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown);