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 (
+
+ );
+ }
+
+ 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 (
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);