1262 lines
43 KiB
JavaScript
1262 lines
43 KiB
JavaScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { createRequire } from 'module';
|
||
import {
|
||
loadFullState, saveFullState, recordItemHistory,
|
||
saveItems, saveFurnaces, saveAlerts, saveState, loadState,
|
||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||
} from './db.js';
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const {
|
||
createPlatformServer,
|
||
createWebSocketManager,
|
||
setupGracefulShutdown,
|
||
createRateLimiter,
|
||
createProxyEndpoint,
|
||
} = require('@cc-platform/server');
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// ========== Platform Server Setup ==========
|
||
// Provides Express app, HTTP server, auth middleware, and health endpoint.
|
||
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin.
|
||
const { app, server, auth, start, port } = createPlatformServer({
|
||
serviceName: 'inventory-manager',
|
||
cors: false,
|
||
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
|
||
healthExtras: () => ({
|
||
lastUpdate,
|
||
bridgeConnected: bridgeClients.size > 0,
|
||
webClients: webClients.size,
|
||
}),
|
||
});
|
||
|
||
const { requireAuth } = auth;
|
||
|
||
// API key reference needed for WebSocket upgrade authentication
|
||
const API_KEY = process.env.API_KEY || '';
|
||
|
||
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints
|
||
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 });
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/api/bridge/')) return next();
|
||
return commandLimiter(req, res, next);
|
||
});
|
||
|
||
// ========== WebSocket Manager (platform-managed) ==========
|
||
// Replaces hand-rolled WebSocketServer with createWebSocketManager() from
|
||
// @cc-platform/server. Handles bridge/client URL routing, API key auth on
|
||
// upgrade, ping/pong keepalive with stale-connection cleanup.
|
||
//
|
||
// Bridge path: /ws/bridge — receives state/results, pushes commands real-time
|
||
// Client path: /ws — receives initial_state on connect, sends commands
|
||
//
|
||
// HTTP polling fallback preserved via /api/bridge/* endpoints until WS
|
||
// adoption is fully verified.
|
||
const wsManager = createWebSocketManager(server, {
|
||
apiKey: API_KEY,
|
||
|
||
// ---- Bridge lifecycle ----
|
||
onBridgeConnect: () => {
|
||
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
|
||
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
|
||
},
|
||
|
||
onBridgeMessage: (ws, data) => {
|
||
if (data.type === 'state') {
|
||
// Full state update from bridge (same path as POST /api/bridge/state)
|
||
updateStateFromBridge(data);
|
||
} else if (data.type === 'command_result') {
|
||
// Command result from bridge — forward to all web clients
|
||
wsManager.broadcastToClients({
|
||
type: 'command_result',
|
||
commandId: data.commandId,
|
||
action: data.action,
|
||
success: data.success,
|
||
message: data.message,
|
||
error: data.error,
|
||
});
|
||
}
|
||
},
|
||
|
||
onBridgeDisconnect: () => {
|
||
console.log('\u{1F309} CC:Tweaked bridge disconnected');
|
||
wsManager.broadcastToClients({
|
||
type: 'state_update',
|
||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||
});
|
||
},
|
||
|
||
// ---- Web client lifecycle ----
|
||
onClientConnect: (ws) => {
|
||
console.log('\u{1F310} New web client connected');
|
||
// Send full current state to newly connected dashboard
|
||
ws.send(JSON.stringify({
|
||
type: 'initial_state',
|
||
inventory: inventoryState,
|
||
activity: activityState,
|
||
alerts: alertsState,
|
||
smeltingPaused,
|
||
disabledRecipes,
|
||
smeltable: smeltableRecipes,
|
||
craftable: craftableRecipes,
|
||
craftTurtleOk,
|
||
lastUpdate,
|
||
bridgeConnected: wsManager.bridgeClients.size > 0,
|
||
dropperNicknames,
|
||
}));
|
||
},
|
||
|
||
onClientMessage: (ws, data) => {
|
||
if (data.type === 'command') {
|
||
// Idempotency check for WS commands
|
||
const cached = checkIdempotent(data.commandId);
|
||
if (cached) {
|
||
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
|
||
return;
|
||
}
|
||
// Forward command to bridge (WS push or HTTP poll queue)
|
||
pushCommandToBridge(data);
|
||
if (data.commandId) {
|
||
recordCommand(data.commandId, { success: true, commandId: data.commandId });
|
||
}
|
||
}
|
||
},
|
||
|
||
onClientDisconnect: () => {
|
||
console.log('\u{1F44B} Web client disconnected');
|
||
},
|
||
});
|
||
|
||
// Aliases — backward compatibility for code referencing these Sets directly
|
||
const { bridgeClients, webClients } = wsManager;
|
||
|
||
// ========== State ==========
|
||
|
||
// Load persisted state from SQLite on startup
|
||
console.log('💾 Loading persisted state from database...');
|
||
const persisted = loadFullState();
|
||
|
||
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;
|
||
let dropperNicknames = loadState('dropperNicknames', {});
|
||
|
||
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 = [];
|
||
let nextCommandId = 1; // Monotonic ID for deduplication
|
||
|
||
// ========== Idempotent Command Tracking ==========
|
||
|
||
const processedCommands = new Map(); // commandId -> { result, timestamp }
|
||
const COMMAND_TTL = 5 * 60 * 1000; // 5 minutes
|
||
|
||
// Periodic cleanup of expired entries
|
||
setInterval(() => {
|
||
const cutoff = Date.now() - COMMAND_TTL;
|
||
for (const [id, entry] of processedCommands) {
|
||
if (entry.timestamp < cutoff) processedCommands.delete(id);
|
||
}
|
||
}, 60_000);
|
||
|
||
function checkIdempotent(commandId) {
|
||
if (!commandId) return null;
|
||
return processedCommands.get(commandId)?.result || null;
|
||
}
|
||
|
||
function recordCommand(commandId, result) {
|
||
if (!commandId) return;
|
||
processedCommands.set(commandId, { result, timestamp: Date.now() });
|
||
}
|
||
|
||
// ========== Helpers ==========
|
||
|
||
// Broadcasts data to all connected web dashboard clients.
|
||
// Delegates to platform WS manager (handles JSON serialization, error handling).
|
||
function broadcastToClients(data) {
|
||
wsManager.broadcastToClients(data);
|
||
}
|
||
|
||
// Pushes a command to the CC:Tweaked bridge.
|
||
// Primary: real-time push via WebSocket (wsManager.sendToBridge).
|
||
// Fallback: queues for HTTP polling if no WS bridge is connected.
|
||
function pushCommandToBridge(command) {
|
||
const sent = wsManager.sendToBridge(command);
|
||
if (!sent) {
|
||
// Fallback: queue for HTTP polling with monotonic ID
|
||
const id = nextCommandId++;
|
||
pendingCommands.push({ ...command, id, timestamp: Date.now() });
|
||
console.log(`[Bridge] Queued command #${id} via HTTP poll (no WS bridge)`);
|
||
}
|
||
}
|
||
|
||
// ========== Texture Proxy / Cache ==========
|
||
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
|
||
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
|
||
|
||
// Upstream CDN mapping (Prominence Hasturian Era II modpack coverage)
|
||
const TEXTURE_UPSTREAMS = {
|
||
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
|
||
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||
mythicmetals: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20/src/main/resources/assets/mythicmetals/textures',
|
||
farmersdelight: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20/src/main/resources/assets/farmersdelight/textures',
|
||
ad_astra: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1/common/src/main/resources/assets/ad_astra/textures',
|
||
betterend: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20/src/main/resources/assets/betterend/textures',
|
||
ae2: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1/src/main/resources/assets/ae2/textures',
|
||
twilightforest: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1/src/main/resources/assets/twilightforest/textures',
|
||
};
|
||
|
||
// Ensure cache directory exists
|
||
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
||
|
||
// ── Texture index: maps base_name → relative path for smart resolution ────
|
||
// Built on startup by scanning the texture cache. Key = short name (no ext),
|
||
// Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"])
|
||
const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } }
|
||
|
||
function buildTextureIndex() {
|
||
for (const ns of Object.keys(TEXTURE_UPSTREAMS)) {
|
||
textureIndex[ns] = {};
|
||
const nsDir = path.join(TEXTURE_CACHE_DIR, ns);
|
||
if (!fs.existsSync(nsDir)) continue;
|
||
(function walk(dir, rel) {
|
||
try {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||
if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel);
|
||
else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) {
|
||
const baseName = entry.name.slice(0, -4); // strip .png
|
||
if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = [];
|
||
textureIndex[ns][baseName].push(childRel);
|
||
}
|
||
}
|
||
} catch (_) { /* ignore unreadable dirs */ }
|
||
})(nsDir, '');
|
||
}
|
||
const total = Object.values(textureIndex).reduce(
|
||
(sum, idx) => sum + Object.keys(idx).length, 0);
|
||
console.log(`[Texture Index] Indexed ${total} unique texture names`);
|
||
}
|
||
|
||
buildTextureIndex();
|
||
|
||
// ── Smart texture resolution ──────────────────────────────────────────────
|
||
// Given a Minecraft item/block registry name, find the best matching texture.
|
||
// Returns the relative cache path or null.
|
||
|
||
// Patterns for items whose textures use numbered animation frames
|
||
const ANIMATED_ITEMS = new Set([
|
||
'compass', 'recovery_compass', 'clock',
|
||
'crossbow', // crossbow_standby, crossbow_pulling_0, etc.
|
||
'light', // light_0 .. light_15
|
||
]);
|
||
|
||
// Maps a registry short name to the actual texture base name
|
||
const SERVER_ALIASES = {
|
||
// Registry name differs from texture filename
|
||
magma_block: 'magma',
|
||
grass: 'short_grass',
|
||
scute: 'turtle_scute',
|
||
enchanted_golden_apple: 'golden_apple',
|
||
};
|
||
|
||
// Preferred animation frame for animated items
|
||
const ANIMATED_FRAME = {
|
||
compass: 'compass_16',
|
||
recovery_compass: 'recovery_compass_16',
|
||
clock: 'clock_22',
|
||
crossbow: 'crossbow_standby',
|
||
light: 'light_15',
|
||
};
|
||
|
||
// Dye color set for carpet/bed/banner resolution
|
||
const DYE_COLORS = new Set([
|
||
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
|
||
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
|
||
'brown', 'green', 'red', 'black',
|
||
]);
|
||
|
||
const WOOD_TYPES = new Set([
|
||
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
|
||
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak',
|
||
]);
|
||
|
||
// Blocks whose texture uses a suffix: name_front, name_top, etc.
|
||
const BLOCK_SUFFIXES = {
|
||
furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on',
|
||
dispenser: '_front', dropper: '_front', observer: '_front',
|
||
barrel: '_top', crafting_table: '_top', grass_block: '_top',
|
||
mycelium: '_top', podzol: '_top', dirt_path: '_top',
|
||
quartz_block: '_side',
|
||
pumpkin: '_side', carved_pumpkin: '_front', jack_o_lantern: '_front',
|
||
jukebox: '_top', loom: '_front', bee_nest: '_front', beehive: '_front',
|
||
respawn_anchor: '_top', bone_block: '_side',
|
||
basalt: '_side', polished_basalt: '_side',
|
||
tnt: '_side', composter: '_side', enchanting_table: '_top',
|
||
cartography_table: '_top', fletching_table: '_top', smithing_table: '_top',
|
||
};
|
||
|
||
// Derivative block suffixes → resolve to parent block
|
||
const DERIVATIVE_SUFFIXES = [
|
||
'_stairs', '_slab', '_fence_gate', '_fence', '_wall',
|
||
'_button', '_pressure_plate',
|
||
];
|
||
const STONE_ALIASES = {
|
||
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
|
||
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
|
||
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
|
||
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
|
||
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
|
||
smooth_stone: 'smooth_stone',
|
||
};
|
||
// "smooth_" blocks use the parent's top/bottom face
|
||
const SMOOTH_ALIASES = {
|
||
smooth_sandstone: 'sandstone_top',
|
||
smooth_red_sandstone: 'red_sandstone_top',
|
||
smooth_quartz: 'quartz_block_bottom',
|
||
};
|
||
|
||
function resolveTexturePath(ns, itemName) {
|
||
const idx = textureIndex[ns];
|
||
if (!idx) return null;
|
||
|
||
// Helper: check if a baseName exists and return preferred path
|
||
function find(baseName, preferFolder) {
|
||
const paths = idx[baseName];
|
||
if (!paths || paths.length === 0) return null;
|
||
if (preferFolder) {
|
||
const match = paths.find(p => p.startsWith(preferFolder + '/'));
|
||
if (match) return match;
|
||
}
|
||
return paths[0];
|
||
}
|
||
|
||
// Helper: check if a baseName with a suffix exists in block/
|
||
function findBlock(baseName) {
|
||
const suffix = BLOCK_SUFFIXES[baseName];
|
||
if (suffix) {
|
||
const hit = find(baseName + suffix, 'block') || find(baseName, 'block');
|
||
if (hit) return hit;
|
||
}
|
||
return find(baseName, 'block');
|
||
}
|
||
|
||
// 1. Direct lookup — try item/ then block/
|
||
let hit = find(itemName, 'item') || findBlock(itemName);
|
||
if (hit) return hit;
|
||
|
||
// 2. Server-side alias
|
||
if (SERVER_ALIASES[itemName]) {
|
||
const alias = SERVER_ALIASES[itemName];
|
||
hit = find(alias, 'item') || findBlock(alias);
|
||
if (hit) return hit;
|
||
}
|
||
|
||
// 3. Smooth blocks
|
||
if (SMOOTH_ALIASES[itemName]) {
|
||
hit = find(SMOOTH_ALIASES[itemName], 'block');
|
||
if (hit) return hit;
|
||
}
|
||
|
||
// 4. Animated items — pick a representative frame
|
||
if (ANIMATED_FRAME[itemName]) {
|
||
hit = find(ANIMATED_FRAME[itemName], 'item');
|
||
if (hit) return hit;
|
||
}
|
||
|
||
// 5. Carpets → wool texture
|
||
if (itemName.endsWith('_carpet')) {
|
||
const color = itemName.slice(0, -'_carpet'.length);
|
||
if (DYE_COLORS.has(color)) {
|
||
hit = find(color + '_wool', 'block');
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
|
||
// 6. Wood → log texture
|
||
if (itemName.endsWith('_wood')) {
|
||
const base = itemName.slice(0, -'_wood'.length);
|
||
const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null;
|
||
const woodType = stripped || base;
|
||
if (WOOD_TYPES.has(woodType)) {
|
||
const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`;
|
||
hit = find(logName, 'block');
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
|
||
// 7. Derivative blocks (stairs, slabs, fences, walls…) → parent block
|
||
for (const suffix of DERIVATIVE_SUFFIXES) {
|
||
if (!itemName.endsWith(suffix)) continue;
|
||
const base = itemName.slice(0, -suffix.length);
|
||
// Try direct parent
|
||
hit = findBlock(base);
|
||
if (hit) return hit;
|
||
// Wood → planks
|
||
if (WOOD_TYPES.has(base)) {
|
||
hit = findBlock(base + '_planks');
|
||
if (hit) return hit;
|
||
}
|
||
// Stone aliases (brick → bricks, etc.)
|
||
if (STONE_ALIASES[base]) {
|
||
hit = findBlock(STONE_ALIASES[base]);
|
||
if (hit) return hit;
|
||
}
|
||
break; // only one derivative suffix can match
|
||
}
|
||
|
||
// 8. Prefix match — for items like "compass" try "compass_*"
|
||
const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_'));
|
||
if (prefixMatches.length > 0) {
|
||
// Sort for deterministic result, prefer item/ folder
|
||
prefixMatches.sort();
|
||
for (const m of prefixMatches) {
|
||
hit = find(m, 'item');
|
||
if (hit) return hit;
|
||
}
|
||
hit = find(prefixMatches[0]);
|
||
if (hit) return hit;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Smart resolution endpoint ─────────────────────────────────────────────
|
||
// GET /api/texture/resolve/:namespace/:itemName
|
||
// Returns the texture PNG for a Minecraft registry item name, using smart
|
||
// matching (aliases, animation frames, carpet→wool, etc.)
|
||
app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => {
|
||
const { namespace, itemName } = req.params;
|
||
const name = itemName.replace(/\.png$/i, '');
|
||
|
||
const resolved = resolveTexturePath(namespace, name);
|
||
if (!resolved) {
|
||
return res.status(404).send('No texture found');
|
||
}
|
||
|
||
const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved);
|
||
if (!fs.existsSync(fullPath)) {
|
||
return res.status(404).send('Texture file missing');
|
||
}
|
||
|
||
res.set('Content-Type', 'image/png');
|
||
res.set('Cache-Control', 'public, max-age=604800');
|
||
res.set('X-Texture-Resolved', resolved);
|
||
return res.sendFile(fullPath);
|
||
});
|
||
|
||
// ── Legacy texture proxy (fallback for cache misses) ──────────────────────
|
||
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||
const { namespace } = req.params;
|
||
const texturePath = req.params[0];
|
||
const upstream = TEXTURE_UPSTREAMS[namespace];
|
||
if (!upstream) {
|
||
return res.status(404).send('Unknown namespace');
|
||
}
|
||
|
||
// Sanitize path to prevent directory traversal
|
||
const safePath = path.normalize(texturePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||
if (safePath.includes('..')) {
|
||
return res.status(400).send('Invalid path');
|
||
}
|
||
|
||
const cacheFile = path.join(TEXTURE_CACHE_DIR, namespace, safePath);
|
||
const cacheDir = path.dirname(cacheFile);
|
||
const missFile = cacheFile + '.miss';
|
||
|
||
// Check positive cache
|
||
try {
|
||
if (fs.existsSync(cacheFile)) {
|
||
res.set('Content-Type', 'image/png');
|
||
res.set('Cache-Control', 'public, max-age=604800'); // 7 days
|
||
res.set('X-Texture-Cache', 'HIT');
|
||
return res.sendFile(cacheFile);
|
||
}
|
||
} catch (_) { /* proceed to fetch */ }
|
||
|
||
// Check negative cache (cached 404s)
|
||
try {
|
||
if (fs.existsSync(missFile)) {
|
||
const stat = fs.statSync(missFile);
|
||
const age = Date.now() - stat.mtimeMs;
|
||
if (age < NEGATIVE_CACHE_TTL) {
|
||
res.set('X-Texture-Cache', 'MISS-HIT');
|
||
return res.status(404).send('Not found (cached)');
|
||
}
|
||
// Expired negative cache, remove it
|
||
fs.unlinkSync(missFile);
|
||
}
|
||
} catch (_) { /* proceed to fetch */ }
|
||
|
||
// Fetch from upstream
|
||
const upstreamUrl = `${upstream}/${safePath}`;
|
||
try {
|
||
const response = await fetch(upstreamUrl);
|
||
|
||
if (!response.ok) {
|
||
// Cache the 404 so we don't keep hitting upstream
|
||
try {
|
||
fs.mkdirSync(cacheDir, { recursive: true });
|
||
fs.writeFileSync(missFile, `${response.status}`);
|
||
} catch (_) { /* ignore cache write errors */ }
|
||
res.set('X-Texture-Cache', 'UPSTREAM-MISS');
|
||
return res.status(404).send('Not found');
|
||
}
|
||
|
||
// Save to disk cache
|
||
const buffer = Buffer.from(await response.arrayBuffer());
|
||
try {
|
||
fs.mkdirSync(cacheDir, { recursive: true });
|
||
fs.writeFileSync(cacheFile, buffer);
|
||
// Remove any stale negative cache
|
||
if (fs.existsSync(missFile)) fs.unlinkSync(missFile);
|
||
} catch (_) { /* ignore cache write errors */ }
|
||
|
||
res.set('Content-Type', response.headers.get('content-type') || 'image/png');
|
||
res.set('Cache-Control', 'public, max-age=604800');
|
||
res.set('X-Texture-Cache', 'UPSTREAM-HIT');
|
||
return res.send(buffer);
|
||
} catch (err) {
|
||
console.error(`[Texture Proxy] Error fetching ${upstreamUrl}:`, err.message);
|
||
return res.status(502).send('Upstream error');
|
||
}
|
||
});
|
||
|
||
// Clear negative texture cache (.miss files) — call after updating texture mappings
|
||
app.delete('/api/texture-cache/negative', (req, res) => {
|
||
let cleared = 0;
|
||
function walk(dir) {
|
||
try {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(full);
|
||
else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; }
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
walk(TEXTURE_CACHE_DIR);
|
||
console.log(`[Texture Cache] Cleared ${cleared} negative cache entries`);
|
||
res.json({ cleared });
|
||
});
|
||
|
||
// Health endpoint provided by platform (createPlatformServer) with custom extras
|
||
|
||
// Get current inventory state
|
||
app.get('/api/inventory', (req, res) => {
|
||
res.json({
|
||
inventory: inventoryState,
|
||
activity: activityState,
|
||
alerts: alertsState,
|
||
smeltingPaused,
|
||
disabledRecipes,
|
||
smeltable: smeltableRecipes,
|
||
craftable: craftableRecipes,
|
||
craftTurtleOk,
|
||
lastUpdate,
|
||
bridgeConnected: bridgeClients.size > 0,
|
||
dropperNicknames,
|
||
});
|
||
});
|
||
|
||
// Get just the item list
|
||
app.get('/api/items', (req, res) => {
|
||
res.json({ items: inventoryState.itemList || [] });
|
||
});
|
||
|
||
// Get furnace status
|
||
app.get('/api/furnaces', (req, res) => {
|
||
res.json({
|
||
furnaceCount: inventoryState.furnaceCount,
|
||
furnaceStatus: inventoryState.furnaceStatus,
|
||
smeltingPaused,
|
||
});
|
||
});
|
||
|
||
// Get alerts
|
||
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 aggregate storage history (total items over time)
|
||
app.get('/api/history-summary', (req, res) => {
|
||
const summary = getHistorySummary();
|
||
res.json({ history: summary });
|
||
});
|
||
|
||
// Get recipes (smeltable + craftable)
|
||
app.get('/api/recipes', (req, res) => {
|
||
res.json({
|
||
smeltable: smeltableRecipes,
|
||
craftable: craftableRecipes,
|
||
disabledRecipes,
|
||
smeltingPaused,
|
||
});
|
||
});
|
||
|
||
// ========== Command Endpoints ==========
|
||
|
||
// Order an item from storage
|
||
app.post('/api/order', (req, res) => {
|
||
try {
|
||
const { itemName, amount, dropperName, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!itemName || !amount) {
|
||
return res.status(400).json({ error: 'Missing itemName or amount' });
|
||
}
|
||
if (typeof itemName !== 'string' || itemName.length > 200) {
|
||
return res.status(400).json({ error: 'Invalid itemName' });
|
||
}
|
||
const parsedAmount = parseInt(amount);
|
||
if (!Number.isFinite(parsedAmount) || parsedAmount < 1 || parsedAmount > 100000) {
|
||
return res.status(400).json({ error: 'Invalid amount (1–100000)' });
|
||
}
|
||
|
||
const command = {
|
||
type: 'command',
|
||
action: 'order',
|
||
commandId,
|
||
itemName,
|
||
amount: parsedAmount,
|
||
dropperName: dropperName || null,
|
||
};
|
||
|
||
pushCommandToBridge(command);
|
||
const result = { success: true, commandId, message: `Order sent: ${itemName} x${amount}` };
|
||
recordCommand(commandId, result);
|
||
console.log(`📦 Order: ${itemName} x${amount}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// ========== Dropper Nicknames ==========
|
||
|
||
// Get all dropper nicknames
|
||
app.get('/api/dropper-nicknames', (req, res) => {
|
||
res.json({ nicknames: dropperNicknames });
|
||
});
|
||
|
||
// Set a single dropper nickname
|
||
app.post('/api/dropper-nicknames', (req, res) => {
|
||
try {
|
||
const { dropperName, nickname, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!dropperName || typeof dropperName !== 'string' || dropperName.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid dropperName' });
|
||
}
|
||
if (nickname && String(nickname).length > 50) {
|
||
return res.status(400).json({ error: 'Nickname too long (max 50)' });
|
||
}
|
||
|
||
if (nickname && nickname.trim()) {
|
||
dropperNicknames[dropperName] = nickname.trim();
|
||
} else {
|
||
delete dropperNicknames[dropperName];
|
||
}
|
||
|
||
saveState('dropperNicknames', dropperNicknames);
|
||
console.log(`🏷️ Dropper nickname: ${dropperName} → ${nickname || '(removed)'}`);
|
||
const result = { success: true, nicknames: dropperNicknames };
|
||
recordCommand(commandId, result);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Request inventory scan
|
||
app.post('/api/scan', (req, res) => {
|
||
try {
|
||
const { commandId } = req.body || {};
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'scan', commandId });
|
||
const result = { success: true, commandId, message: 'Scan requested' };
|
||
recordCommand(commandId, result);
|
||
console.log('🔍 Scan requested');
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Toggle smelting pause
|
||
app.post('/api/smelting/toggle', (req, res) => {
|
||
try {
|
||
const { commandId } = req.body || {};
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'toggle_pause', commandId });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
console.log('🔥 Smelting toggle requested');
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Toggle a specific recipe
|
||
app.post('/api/recipes/toggle', (req, res) => {
|
||
try {
|
||
const { recipe, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid recipe name' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
console.log(`🔄 Recipe toggle: ${recipe}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Enable/disable all recipes
|
||
app.post('/api/recipes/enable-all', (req, res) => {
|
||
try {
|
||
const { commandId } = req.body || {};
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/recipes/disable-all', (req, res) => {
|
||
try {
|
||
const { commandId } = req.body || {};
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Remote reboot CC:Tweaked computers
|
||
app.post('/api/reboot', (req, res) => {
|
||
try {
|
||
const { target, commandId } = req.body || {};
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
const validTargets = ['all', 'manager', 'client', 'turtle', 'bridge'];
|
||
const t = target || 'all';
|
||
// Allow valid role names or numeric computer IDs
|
||
if (!validTargets.includes(t) && !/^\d+$/.test(t)) {
|
||
return res.status(400).json({ error: 'Invalid target. Use: all, manager, client, turtle, bridge, or a computer ID' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'reboot', commandId, target: t });
|
||
const result = { success: true, commandId, message: `Reboot sent to: ${t}` };
|
||
recordCommand(commandId, result);
|
||
console.log(`🔄 Reboot requested: target=${t}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Sort barrel
|
||
app.post('/api/sort-barrel', (req, res) => {
|
||
try {
|
||
const { barrelName, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!barrelName) return res.status(400).json({ error: 'Missing barrelName' });
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'sort_barrel', commandId, barrelName });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
console.log(`📦 Sort barrel: ${barrelName}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Craft item
|
||
app.post('/api/craft', (req, res) => {
|
||
try {
|
||
const { recipeIdx, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
|
||
const parsedIdx = parseInt(recipeIdx);
|
||
if (!Number.isFinite(parsedIdx) || parsedIdx < 1 || parsedIdx > 1000) {
|
||
return res.status(400).json({ error: 'Invalid recipeIdx' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parsedIdx });
|
||
const result = { success: true, commandId };
|
||
recordCommand(commandId, result);
|
||
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Recursive craft (multi-step crafting chain)
|
||
app.post('/api/recursive-craft', (req, res) => {
|
||
try {
|
||
const { itemName, count, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!itemName || typeof itemName !== 'string' || itemName.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid itemName' });
|
||
}
|
||
const parsedCount = parseInt(count);
|
||
if (!Number.isFinite(parsedCount) || parsedCount < 1 || parsedCount > 100000) {
|
||
return res.status(400).json({ error: 'Invalid count (1–100000)' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'recursive_craft', commandId, itemName, count: parsedCount });
|
||
const result = { success: true, commandId, message: `Recursive craft sent: ${itemName} x${count}` };
|
||
recordCommand(commandId, result);
|
||
console.log(`🔨 Recursive craft: ${itemName} x${count}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Learn a crafting recipe
|
||
app.post('/api/recipes/learn-crafting', (req, res) => {
|
||
try {
|
||
const { output, count, grid, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!output || typeof output !== 'string' || output.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid output' });
|
||
}
|
||
if (!grid || !Array.isArray(grid) || grid.length !== 9) {
|
||
return res.status(400).json({ error: 'grid must be an array of 9 items' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'learn_crafting_recipe', commandId, output, count: count || 1, grid });
|
||
const result = { success: true, commandId, message: `Learned crafting recipe: ${output}` };
|
||
recordCommand(commandId, result);
|
||
console.log(`📖 Learn crafting recipe: ${output}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Learn a smelting recipe
|
||
app.post('/api/recipes/learn-smelting', (req, res) => {
|
||
try {
|
||
const { input, result: recipeResult, furnaces, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!input || typeof input !== 'string' || input.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid input' });
|
||
}
|
||
if (!recipeResult || typeof recipeResult !== 'string' || recipeResult.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid result' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'learn_smelting_recipe', commandId, input, result: recipeResult, furnaces });
|
||
const apiResult = { success: true, commandId, message: `Learned smelting recipe: ${input} → ${recipeResult}` };
|
||
recordCommand(commandId, apiResult);
|
||
console.log(`📖 Learn smelting recipe: ${input} → ${recipeResult}`);
|
||
res.json(apiResult);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Forget a recipe
|
||
app.post('/api/recipes/forget', (req, res) => {
|
||
try {
|
||
const { recipe, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
|
||
return res.status(400).json({ error: 'Missing or invalid recipe name' });
|
||
}
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'forget_recipe', commandId, recipe });
|
||
const result = { success: true, commandId, message: `Forget recipe: ${recipe}` };
|
||
recordCommand(commandId, result);
|
||
console.log(`🗑️ Forget recipe: ${recipe}`);
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Sync disabled recipes state
|
||
app.post('/api/recipes/sync', (req, res) => {
|
||
try {
|
||
const { disabledRecipes, smeltingPaused, commandId } = req.body;
|
||
|
||
const cached = checkIdempotent(commandId);
|
||
if (cached) return res.json(cached);
|
||
|
||
pushCommandToBridge({ type: 'command', action: 'sync_disabled_recipes', commandId, disabledRecipes, smeltingPaused });
|
||
const result = { success: true, commandId, message: 'Synced recipe state' };
|
||
recordCommand(commandId, result);
|
||
console.log('🔄 Sync disabled recipes');
|
||
res.json(result);
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// ========== Bridge Endpoints (for HTTP polling fallback) ==========
|
||
|
||
// Bridge sends inventory state
|
||
app.post('/api/bridge/state', (req, res) => {
|
||
try {
|
||
const data = req.body;
|
||
updateStateFromBridge(data);
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('❌ Error processing bridge state:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Bridge polls for pending commands (auth required — contains operational data)
|
||
app.get('/api/bridge/commands', requireAuth, (req, res) => {
|
||
try {
|
||
const now = Date.now();
|
||
// Clear old commands (>30s)
|
||
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
|
||
|
||
const commands = [...pendingCommands];
|
||
res.json({ commands });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Bridge acknowledges commands by last processed ID
|
||
// Only removes commands with id <= lastProcessedId, preventing
|
||
// race conditions where new commands arrive between GET and ACK.
|
||
app.post('/api/bridge/commands/ack', (req, res) => {
|
||
try {
|
||
const { lastProcessedId } = req.body || {};
|
||
if (lastProcessedId !== undefined && lastProcessedId !== null) {
|
||
// Remove only commands that have been processed
|
||
const before = pendingCommands.length;
|
||
pendingCommands = pendingCommands.filter(cmd => (cmd.id || 0) > lastProcessedId);
|
||
const cleared = before - pendingCommands.length;
|
||
res.json({ success: true, cleared });
|
||
} else {
|
||
// Legacy: clear all (backwards-compatible)
|
||
const cleared = pendingCommands.length;
|
||
pendingCommands = [];
|
||
res.json({ success: true, cleared });
|
||
}
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// Bridge sends command result
|
||
app.post('/api/bridge/result', (req, res) => {
|
||
try {
|
||
const { action, commandId, success, message, error: err } = req.body;
|
||
|
||
broadcastToClients({
|
||
type: 'command_result',
|
||
commandId,
|
||
action,
|
||
success,
|
||
message,
|
||
error: err,
|
||
});
|
||
|
||
console.log(`📩 Result: ${action} - ${success ? 'OK' : 'FAIL'}`);
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// ========== State Update ==========
|
||
|
||
function updateStateFromBridge(data) {
|
||
if (data.cache) {
|
||
// Normalize itemList: Lua sends { name, total }, frontend expects { name, count }
|
||
const rawItems = data.cache.itemList || [];
|
||
const normalizedItems = Array.isArray(rawItems) ? rawItems.map(item => ({
|
||
name: item.name || '',
|
||
count: item.total !== undefined ? item.total : (item.count || 0),
|
||
displayName: item.displayName || item.name || '',
|
||
})) : [];
|
||
|
||
// Normalize furnaceStatus: Lua sends array of { name, type, input, fuel, output, active }
|
||
// Frontend expects object { furnaceName: { active, input, fuel, output } }
|
||
const rawFurnaces = data.cache.furnaceStatus || [];
|
||
let normalizedFurnaces = {};
|
||
if (Array.isArray(rawFurnaces)) {
|
||
rawFurnaces.forEach(f => {
|
||
if (f && f.name) {
|
||
normalizedFurnaces[f.name] = {
|
||
active: f.active || false,
|
||
type: f.type || 'minecraft:furnace',
|
||
input: f.input || null,
|
||
fuel: f.fuel || null,
|
||
output: f.output || null,
|
||
};
|
||
}
|
||
});
|
||
} else if (typeof rawFurnaces === 'object') {
|
||
normalizedFurnaces = rawFurnaces;
|
||
}
|
||
|
||
inventoryState = {
|
||
itemList: normalizedItems,
|
||
grandTotal: data.cache.grandTotal || 0,
|
||
chestCount: data.cache.chestCount || 0,
|
||
totalSlots: data.cache.totalSlots || 0,
|
||
usedSlots: data.cache.usedSlots || 0,
|
||
freeSlots: data.cache.freeSlots || 0,
|
||
usedRatio: data.cache.usedRatio || 0,
|
||
dropperOk: data.cache.dropperOk || false,
|
||
barrelOk: data.cache.barrelOk || false,
|
||
furnaceCount: data.cache.furnaceCount || 0,
|
||
furnaceStatus: normalizedFurnaces,
|
||
droppers: Array.isArray(data.cache.droppers) ? data.cache.droppers : [],
|
||
};
|
||
}
|
||
|
||
if (data.activity !== undefined) activityState = data.activity || {};
|
||
|
||
// Normalize alerts: Lua sends [{ label, current, min }] (only triggered ones)
|
||
// Frontend expects [{ item, triggered, current, threshold }]
|
||
if (data.alerts !== undefined) {
|
||
const rawAlerts = data.alerts || [];
|
||
alertsState = Array.isArray(rawAlerts) ? rawAlerts.map(a => ({
|
||
item: a.name || a.item || a.label || '',
|
||
triggered: true,
|
||
current: a.current || 0,
|
||
threshold: a.min || a.threshold || 0,
|
||
})) : [];
|
||
}
|
||
|
||
if (data.smeltingPaused !== undefined) smeltingPaused = data.smeltingPaused;
|
||
if (data.disabledRecipes !== undefined) disabledRecipes = data.disabledRecipes || {};
|
||
if (data.smeltable !== undefined) smeltableRecipes = data.smeltable || {};
|
||
|
||
// Normalize craftable: Lua sends { output, count, grid } (grid = flat array of 9)
|
||
// Frontend expects { output, count, slots } (slots = object { slotIdx: itemName })
|
||
if (data.craftable !== undefined) {
|
||
const rawCraftable = data.craftable || [];
|
||
craftableRecipes = Array.isArray(rawCraftable) ? rawCraftable.map(recipe => {
|
||
const slots = {};
|
||
if (recipe.grid && Array.isArray(recipe.grid)) {
|
||
recipe.grid.forEach((item, idx) => {
|
||
if (item) slots[idx + 1] = item;
|
||
});
|
||
} else if (recipe.slots) {
|
||
Object.assign(slots, recipe.slots);
|
||
}
|
||
return {
|
||
output: recipe.output || '',
|
||
count: recipe.count || 1,
|
||
slots,
|
||
};
|
||
}) : [];
|
||
}
|
||
|
||
if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk;
|
||
|
||
lastUpdate = Date.now();
|
||
|
||
// Broadcast to all web clients FIRST (non-blocking, instant)
|
||
broadcastToClients({
|
||
type: 'state_update',
|
||
inventory: inventoryState,
|
||
activity: activityState,
|
||
alerts: alertsState,
|
||
smeltingPaused,
|
||
disabledRecipes,
|
||
smeltable: smeltableRecipes,
|
||
craftable: craftableRecipes,
|
||
craftTurtleOk,
|
||
dropperNicknames,
|
||
lastUpdate,
|
||
bridgeConnected: bridgeClients.size > 0,
|
||
});
|
||
|
||
// Persist to SQLite (debounced — won't block the broadcast)
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ========== Server Startup Info ==========
|
||
// WebSocket management is handled by createWebSocketManager() (initialized above).
|
||
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default)
|
||
|
||
console.log(`🚀 Inventory Manager Web Server starting...`);
|
||
console.log(`📡 HTTP Server: http://localhost:${port}`);
|
||
console.log(`🔌 WebSocket Server: ws://localhost:${port}/ws`);
|
||
if (API_KEY) {
|
||
console.log('🔒 API key authentication enabled');
|
||
} else {
|
||
console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)');
|
||
}
|
||
|
||
// ========== Cross-Project Integration API ==========
|
||
// These endpoints allow the RemoteTurtle system to query inventory state
|
||
|
||
const TURTLE_SERVER_URL = process.env.TURTLE_SERVER_URL || ''; // e.g. http://turtle-server:3001
|
||
|
||
// Find where a specific item is stored (for turtles to pick up items)
|
||
app.get('/api/integration/locate-item', (req, res) => {
|
||
const { name, minCount } = req.query;
|
||
if (!name) return res.status(400).json({ error: 'Item name required (?name=minecraft:diamond)' });
|
||
|
||
const items = inventoryState.itemList || [];
|
||
const match = items.find(i => i.name === name);
|
||
if (!match || match.count < (parseInt(minCount) || 1)) {
|
||
return res.json({ found: false, available: match ? match.count : 0 });
|
||
}
|
||
res.json({ found: true, name: match.name, count: match.count, displayName: match.displayName });
|
||
});
|
||
|
||
// Search items by partial name (for turtle autocomplete/fuzzy matching)
|
||
app.get('/api/integration/search-items', (req, res) => {
|
||
const { q, limit } = req.query;
|
||
if (!q) return res.status(400).json({ error: 'Search query required (?q=diamond)' });
|
||
|
||
const items = inventoryState.itemList || [];
|
||
const query = q.toLowerCase();
|
||
const results = items
|
||
.filter(i => i.name.toLowerCase().includes(query) || (i.displayName || '').toLowerCase().includes(query))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, parseInt(limit) || 20);
|
||
|
||
res.json({ results });
|
||
});
|
||
|
||
// Get storage summary (available space, total items) for turtle decision-making
|
||
app.get('/api/integration/storage-status', (req, res) => {
|
||
res.json({
|
||
grandTotal: inventoryState.grandTotal || 0,
|
||
chestCount: inventoryState.chestCount || 0,
|
||
totalSlots: inventoryState.totalSlots || 0,
|
||
usedSlots: inventoryState.usedSlots || 0,
|
||
freeSlots: (inventoryState.totalSlots || 0) - (inventoryState.usedSlots || 0),
|
||
lastUpdate,
|
||
bridgeConnected: bridgeClients.size > 0,
|
||
});
|
||
});
|
||
|
||
// Get full item list (for turtle dump target selection)
|
||
app.get('/api/integration/items', (req, res) => {
|
||
const items = inventoryState.itemList || [];
|
||
res.json({ items: items.map(i => ({ name: i.name, count: i.count })) });
|
||
});
|
||
|
||
// Get alerts (so turtles know what items are running low)
|
||
app.get('/api/integration/low-stock', (req, res) => {
|
||
const triggered = (alertsState || []).filter(a => a.triggered !== false);
|
||
res.json({ alerts: triggered });
|
||
});
|
||
|
||
// Proxy to turtle server for combined dashboard info
|
||
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
|
||
|
||
// ========== Start Server ==========
|
||
|
||
setupGracefulShutdown({
|
||
serviceName: 'inventory-manager',
|
||
cleanup: [
|
||
() => wsManager.close(),
|
||
() => server.close(),
|
||
() => { closeDb(); console.log('💾 Database closed'); },
|
||
],
|
||
});
|
||
|
||
start(() => {
|
||
console.log(`\nBridge HTTP endpoint: http://localhost:${port}/api/bridge/state`);
|
||
console.log(`Bridge WebSocket: ws://localhost:${port}/ws/bridge`);
|
||
console.log(`Web client WebSocket: ws://localhost:${port}/ws`);
|
||
if (TURTLE_SERVER_URL) {
|
||
console.log(`🐢 Turtle server integration: ${TURTLE_SERVER_URL}`);
|
||
}
|
||
});
|