Compare commits

...

46 Commits

Author SHA1 Message Date
MayaTheShy
42fc9950f5 chore: mark main branch package as unstable 2026-03-29 15:50:39 -04:00
MayaTheShy
f0ca8b407e fix: ensure configuration is loaded during initialization 2026-03-29 15:10:00 -04:00
MayaTheShy
099f5aa287 fix: enhance WebSocket bridge connection to flush pending commands and improve initial sync 2026-03-29 14:42:47 -04:00
MayaTheShy
16544b59bd fix: implement initial WebSocket sync for reliable command processing 2026-03-29 14:42:40 -04:00
MayaTheShy
f24b288de3 fix: add modem type and channel diagnostics to bridge startup 2026-03-29 01:36:06 -04:00
MayaTheShy
3c40cf9ef4 fix: change log level from info to debug for network message queuing and processing 2026-03-29 01:12:09 -04:00
MayaTheShy
aa5f711fe4 fix: acknowledge duplicate commands to prevent sender retries 2026-03-29 01:11:31 -04:00
MayaTheShy
5a83d89509 fix: implement reliable modem command delivery with acknowledgment and retry mechanism 2026-03-29 01:11:24 -04:00
MayaTheShy
bb15c78ca9 fix: improve monitor alias detection and registration for better peripheral handling 2026-03-29 00:23:59 -04:00
MayaTheShy
4be2d7be8f fix: replace sleep(0) with os.pullEvent() + add diagnostic logging
sleep(0) wakes the processor only on timer ticks, causing a
1-tick delay. os.pullEvent() with no filter wakes the processor
on ANY event — including the modem_message that triggered the
capture — so items are processed in the same event cycle.

Added logging to capture and processor tasks:
- NET-CAP: logs each queued message with type and queue depth
- NET-PROC: logs each message being processed
- Both log on startup to confirm they're running
2026-03-29 00:02:07 -04:00
MayaTheShy
9396fbd81a fix: enhance monitor detection to handle adjacent peripherals correctly 2026-03-28 23:41:20 -04:00
MayaTheShy
ec1a681924 fix: replace custom event wake-up with polling, remove craftItem double-capture
Two robustness improvements to the Network capture/processor split:

1. Processor wake-up: replaced os.queueEvent('network_queued') /
   os.pullEvent('network_queued') with sleep(0) polling. Custom events
   can be consumed by other coroutines (e.g. craftItem's unfiltered
   os.pullEvent()) or swallowed by the OS event layer. Polling the
   shared queue every tick is simpler and guaranteed reliable.

2. craftItem: removed ORDER_CHANNEL message buffering and re-queuing.
   With the dedicated Network-capture task, ORDER_CHANNEL messages are
   already safely captured into networkQueue. The old buffering caused
   double-capture: capture task adds to queue, craftItem also buffers,
   then re-queues via os.queueEvent -> capture captures again -> dup.
   The commandId dedup caught these, but removing the source is cleaner.
2026-03-28 23:40:09 -04:00
MayaTheShy
36612ecc9f fix: split Network-listener into capture/processor to prevent modem_message loss
CC:Tweaked's parallel.waitForAny drops events when a coroutine's
filter doesn't match. The old Network-listener processed commands
inline, causing it to yield with filter 'task_complete' during
peripheral calls (pushItems, list, etc). Any modem_message arriving
during that window was consumed by the scheduler and lost.

Split into two coroutines:
- Network-capture: only ever yields for 'modem_message', inserts
  into a shared networkQueue table, queues 'network_queued' event
- Network-processor: drains the queue and runs all handler logic,
  safe to yield for peripheral calls without losing messages

This fixes the ~80% message loss rate on dispense requests.
2026-03-28 23:01:14 -04:00
MayaTheShy
badde91336 fix(docker): clone platform from git instead of additional_contexts
The additional_contexts approach required cc-platform-core to exist on
the Docker host at a relative path. This fails on servers where the
repo layout differs. Instead, use a multi-stage build: stage 1 clones
cc-platform-core from Gitea (depth 1), stage 2 copies server/ into the
app and rewrites the file: path. Fully self-contained — no host deps.
2026-03-28 22:37:57 -04:00
MayaTheShy
c3344288a8 fix(docker): resolve @cc-platform/server file: dep in container build
Use additional_contexts to copy platform server package into the Docker
build context. Rewrites the file: dependency path and removes the
lockfile so npm install can resolve the local package correctly.
2026-03-28 22:35:39 -04:00
MayaTheShy
021b351248 feat: replace hand-rolled WebSocketServer with createWebSocketManager for improved connection handling and state updates 2026-03-26 16:00:58 -04:00
MayaTheShy
b782d5c8f9 feat: add @cc-platform/server dependency and update package-lock.json 2026-03-26 16:00:53 -04:00
MayaTheShy
45b264dbc4 feat: implement WebSocket support for real-time command handling and state updates 2026-03-26 15:52:05 -04:00
MayaTheShy
24ce637f6e feat: remove lava bucket from fuel items list 2026-03-26 15:22:59 -04:00
MayaTheShy
075d42ecab feat: update server log messages to use dynamic port variable 2026-03-26 15:20:01 -04:00
MayaTheShy
379d08b594 feat: refactor server setup to use createPlatformServer and streamline middleware 2026-03-26 15:19:27 -04:00
MayaTheShy
d54855bf19 feat: add missing dependency for cc-platform server in package.json 2026-03-26 15:19:22 -04:00
MayaTheShy
5eca4b7155 feat: refactor configuration loading and HTTP helpers to use WebBridge functions 2026-03-26 15:19:16 -04:00
MayaTheShy
e340368ef0 feat: add required platform field to package configuration 2026-03-26 15:18:59 -04:00
MayaTheShy
38ff3f61bd feat: enhance billboard rendering with pie chart and storage ring display 2026-03-26 14:44:51 -04:00
MayaTheShy
664741504f feat: update billboard monitor text scale configuration to use BILLBOARD_TEXT_SCALE 2026-03-26 14:27:31 -04:00
MayaTheShy
c830642dc8 feat: add BILLBOARD_TEXT_SCALE configuration option for billboard display size 2026-03-26 14:27:28 -04:00
MayaTheShy
29026175b7 feat: enhance billboard monitor setup with explicit configuration and auto-detection 2026-03-26 14:23:33 -04:00
MayaTheShy
fe6a6df6a3 feat: update BILLBOARD_MONITOR comment for clarity on auto-detection 2026-03-26 14:23:29 -04:00
MayaTheShy
3c4d76d4a9 feat: improve billboard monitor detection and logging 2026-03-26 14:23:24 -04:00
MayaTheShy
c7a1b7066a feat: rename BILLBOARD_MONITOR_SIDE to BILLBOARD_MONITOR for consistency 2026-03-26 14:21:15 -04:00
MayaTheShy
8a50bc586d feat: implement billboard monitor support and remove legacy code 2026-03-26 14:16:23 -04:00
MayaTheShy
c1b1713699 feat: add support for billboard role in startup script 2026-03-26 14:08:19 -04:00
MayaTheShy
c9d21bcfaa feat: add monitor billboard for displaying inventory goals and alerts 2026-03-26 14:08:14 -04:00
MayaTheShy
97983156c6 feat: expand upstream CDN mapping for additional modpack support 2026-03-26 13:43:50 -04:00
MayaTheShy
46f7b0c98a feat: expand supported mods in download-textures script for enhanced asset management 2026-03-26 13:43:47 -04:00
MayaTheShy
13b374d7f8 feat: implement smart texture resolution and indexing for improved asset management 2026-03-26 13:11:06 -04:00
MayaTheShy
981e561c2d feat: add download-textures script to package.json for texture management 2026-03-26 13:11:02 -04:00
MayaTheShy
a1ff2433e4 feat: refactor texture resolution logic to streamline item icon rendering 2026-03-26 13:10:58 -04:00
MayaTheShy
a50a6e9697 feat: add texture download logic to entrypoint script for initial setup 2026-03-26 13:10:53 -04:00
MayaTheShy
c74898049a feat: add download-textures script for bulk downloading Minecraft and Create textures 2026-03-26 13:10:49 -04:00
MayaTheShy
25623b735c feat: add aliased texture names for improved block texture handling 2026-03-25 23:06:03 -04:00
MayaTheShy
1050ed84f2 feat: enhance texture mapping with dynamic aliases and new Create mod textures 2026-03-25 23:05:05 -04:00
MayaTheShy
4cf1e550b7 feat: add error logging for HTTP requests and command processing in inventoryWebBridge 2026-03-25 22:42:52 -04:00
MayaTheShy
66ac81de65 feat: optimize broadcastState function to conditionally include recipe tables based on config changes 2026-03-25 22:42:45 -04:00
MayaTheShy
ea29136f25 feat: delegate recipe helper functions to shared lib/ui.lua for improved code organization 2026-03-25 22:42:39 -04:00
16 changed files with 1751 additions and 856 deletions

View File

@@ -1,6 +1,9 @@
{
title = "Inventory Manager",
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
required = {
'platform',
},
title = "Inventory Manager (Unstable)",
description = "UNSTABLE/DEV — Automated inventory management system for CC:Tweaked. Uses cc-platform-core. May have breaking changes. Install 'inventory-manager' for the stable version.",
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
exclude = {
"^web/", "^__tests__/", "^startup/",

View File

@@ -8,7 +8,6 @@ return {
{ name = "minecraft:coal_block", burn_time = 80 },
{ name = "minecraft:blaze_rod", burn_time = 12 },
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
{ name = "minecraft:lava_bucket", burn_time = 100 },
{ name = "minecraft:oak_planks", burn_time = 1.5 },
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
{ name = "minecraft:birch_planks", burn_time = 1.5 },

View File

@@ -42,7 +42,6 @@ end
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
@@ -56,6 +55,7 @@ local CLIENT_CONFIG_FILE = _configPath(".client_config")
-------------------------------------------------
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local function loadConfig()
if not fs.exists(CLIENT_CONFIG_FILE) then return end
@@ -184,46 +184,11 @@ local function getItemTotal(itemName)
return 0
end
function ops.getRecipeIngredients(recipe)
local ingredients = {}
for _, item in ipairs(recipe.grid) do
if item then
ingredients[item] = (ingredients[item] or 0) + 1
end
end
return ingredients
end
function ops.canCraftRecipe(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
for itemName, needed in pairs(ingredients) do
if (getItemTotal(itemName) or 0) < needed then return false end
end
return true
end
function ops.maxCraftBatches(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
local minBatches = math.huge
for itemName, needed in pairs(ingredients) do
local batches = math.floor((getItemTotal(itemName) or 0) / needed)
if batches < minBatches then minBatches = batches end
end
if minBatches == math.huge then return 0 end
return minBatches
end
function ops.getMissingIngredients(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
local missing = {}
for itemName, needed in pairs(ingredients) do
local have = getItemTotal(itemName) or 0
if have < needed then
table.insert(missing, { name = itemName, have = have, need = needed })
end
end
return missing
end
-- Delegate recipe helpers to shared lib/ui.lua with local stock lookup.
function ops.getRecipeIngredients(recipe) return ui.getRecipeIngredients(recipe) end
function ops.canCraftRecipe(recipe) return ui.canCraftRecipe(recipe, getItemTotal) end
function ops.maxCraftBatches(recipe) return ui.maxCraftBatches(recipe, getItemTotal) end
function ops.getMissingIngredients(recipe) return ui.getMissingIngredients(recipe, getItemTotal) end
function ops.orderItem(itemName, amount)
sendToMaster({

View File

@@ -39,7 +39,6 @@ local ok, err = xpcall(function()
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
@@ -58,6 +57,7 @@ local ui = dofile(_path("lib/ui.lua"))
-------------------------------------------------
local cfg = dofile(_path("manager/config.lua"))(log, _path)
cfg.loadConfig()
local state = dofile(_path("manager/state.lua"))()
-- Shared context table (Lua tables are by-reference, so all
@@ -148,9 +148,12 @@ local function broadcastState()
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
ctx.craftTurtleOk = payload.craftTurtleOk
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
-- Only include recipe tables when config has changed (they're large).
if state.configDirty then
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
end
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
state.lastBroadcastVersion = state.stateVersion
@@ -192,6 +195,14 @@ local function main()
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
end
-- Billboard monitor (optional — set billboardMonitorSide in .manager_config)
-- Billboard monitor (auto-detects any 3rd monitor, or set billboardMonitor in .manager_config)
if display.setupBillboardMonitor() then
log.info("INIT", "Billboard monitor: %s", display.billboardMonName)
else
log.info("INIT", "No billboard monitor found (optional)")
end
-- Find wired modem for client/turtle communication
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
@@ -373,6 +384,11 @@ local function main()
-- Parallel tasks
-----------------------------------------------
-- Shared queue: capture task writes here, processor task reads.
-- This ensures modem_message events are never lost while the
-- processor yields for peripheral calls (pushItems, list, etc).
local networkQueue = {}
parallel.waitForAny(
-- Task 1: Background inventory scanner
resilient("Scanner", function()
@@ -539,6 +555,23 @@ local function main()
end
end),
-- Task 8b: Billboard dashboard redraw (goals monitor)
resilient("Billboard", function()
if not display.billboardMon then
-- No billboard configured, sleep forever
while true do sleep(3600) end
end
state.billboardNeedsRedraw = true
while true do
if state.billboardNeedsRedraw then
state.billboardNeedsRedraw = false
local bok, berr = pcall(display.drawBillboard)
if not bok then log.error("DRAW", "Billboard: %s", tostring(berr)) end
end
sleep(0.5)
end
end),
-- Task 9: Touch event listener (both monitors)
resilient("Touch-listener", function()
while true do
@@ -603,16 +636,52 @@ local function main()
end
end),
-- Task 13: Network order/command listener
resilient("Network-listener", function()
-- Task 13a: Network message capture (fast — never yields to peripheral calls)
-- This coroutine's filter is ALWAYS "modem_message", so it can never
-- miss events while other tasks yield for "task_complete" etc.
resilient("Network-capture", function()
if not ctx.networkModem then
log.warn("NET-CAP", "No modem — capture task idle")
while true do sleep(3600) end
end
log.info("NET-CAP", "Capture task started, listening on ch %d", cfg.ORDER_CHANNEL)
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
table.insert(networkQueue, { replyChannel = replyChannel, message = message })
log.debug("NET-CAP", "Queued: type=%s queue=%d", tostring(message.type), #networkQueue)
end
end
end),
-- Task 13b: Network message processor (drains queue — safe to yield)
resilient("Network-processor", function()
if not ctx.networkModem then
log.warn("NET-PROC", "No modem — processor task idle")
while true do sleep(3600) end
end
log.info("NET-PROC", "Processor task started")
while true do
if #networkQueue == 0 then
os.pullEvent()
end
while #networkQueue > 0 do
local entry = table.remove(networkQueue, 1)
local message = entry.message
local replyChannel = entry.replyChannel
log.debug("NET-PROC", "Processing: type=%s id=%s queue=%d",
tostring(message.type), tostring(message.commandId), #networkQueue)
if isCommandDuplicate(message.commandId) then
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
-- Still ACK so the sender stops retrying
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
else
recordCommandId(message.commandId)
cleanupCommandIds()
@@ -648,6 +717,13 @@ local function main()
state.needsRedraw = true
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "toggle_pause" then
state.smeltingPaused = not state.smeltingPaused
@@ -657,6 +733,13 @@ local function main()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "toggle_recipe" and message.recipe then
if state.disabledRecipes[message.recipe] then
@@ -670,6 +753,13 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "enable_all" then
state.disabledRecipes = {}
@@ -679,6 +769,13 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "disable_all" then
for inputName in pairs(cfg.SMELTABLE) do
@@ -690,11 +787,25 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "sort_barrel" and message.barrelName then
log.info("NET", "Sort barrel: %s", message.barrelName)
pcall(ops.sortBarrel, message.barrelName)
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "register_droppers" and message.clientId and message.droppers then
local cid = tostring(message.clientId)
@@ -787,6 +898,13 @@ local function main()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
@@ -796,6 +914,13 @@ local function main()
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
@@ -805,6 +930,13 @@ local function main()
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "forget_recipe" and message.recipe then
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
@@ -817,6 +949,13 @@ local function main()
state.bumpStateVersion()
end
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "find_item" and message.items then
-- Return chest+slot locations for the first matching item
@@ -861,7 +1000,7 @@ local function main()
log.error("NET", "Handler error: %s", tostring(handlerErr))
end
end -- idempotency else
end
end -- queue loop
end
end)
)

View File

@@ -3,56 +3,51 @@
-- Listens to the inventory master's broadcasts via modem and
-- forwards state to the web server via HTTP.
-- Also polls the web server for commands and sends them to the master.
--
-- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, WS).
-- Service-specific logic (command dispatch, state forwarding) remains here.
--
-- Transport: WebSocket (primary) with HTTP polling (fallback).
-- When a WS connection to /ws/bridge is active, commands arrive in
-- real-time and state/results are pushed over the socket. If the WS
-- drops, the bridge seamlessly falls back to HTTP polling until
-- reconnection.
--
-- Channel mode: 'current' by default (legacy channels active).
-- Set channelMode = 'dual' or 'target' in platform config to migrate.
local WebBridge = require('platform.webbridge')
local Channels = require('platform.channels')
-------------------------------------------------
-- Configuration
-------------------------------------------------
-- Web server URL (change to your Docker host IP/hostname)
local SERVER_URL = "http://localhost"
local POLL_INTERVAL = 0.5 -- seconds between command polls
local STATE_INTERVAL = 1 -- seconds between state forwards
-- Modem channels (must match inventoryManager.lua)
local BROADCAST_CHANNEL = 4200
local ORDER_CHANNEL = 4201
local BRIDGE_REPLY_CHANNEL = 4206 -- dedicated reply channel for bridge
-------------------------------------------------
-- Load config from file if present
-- Configuration (via platform)
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
local config, configSource = WebBridge.loadConfig({
serverUrl = "http://localhost",
pollInterval = 0.5,
stateInterval = 1,
apiKey = nil,
}, {
"usr/config/inventory-manager/.webbridge_config",
fs.combine(_baseDir, ".webbridge_config"),
})
local SERVER_URL = config.serverUrl
local POLL_INTERVAL = config.pollInterval
local STATE_INTERVAL = config.stateInterval
local API_KEY = config.apiKey
if configSource then
print("[CONFIG] Loaded from " .. configSource)
end
local CONFIG_FILE = _configPath(".webbridge_config")
local API_KEY = nil -- optional API key for server auth
local function loadConfig()
if fs.exists(CONFIG_FILE) then
local f = fs.open(CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if ok and cfg then
if cfg.serverUrl then SERVER_URL = cfg.serverUrl end
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
if cfg.stateInterval then STATE_INTERVAL = cfg.stateInterval end
if cfg.apiKey then API_KEY = cfg.apiKey end
print("[CONFIG] Loaded from " .. CONFIG_FILE)
end
end
end
-- Channels from platform registry (matches inventoryManager.lua)
local BROADCAST_CHANNEL = Channels.get('inventory.broadcast')
local ORDER_CHANNEL = Channels.get('inventory.order')
local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge')
-------------------------------------------------
-- State
@@ -63,65 +58,42 @@ local modem = nil
local modemName = nil
local running = true
-------------------------------------------------
-- Find modem
-------------------------------------------------
-- WebSocket state (real-time transport, with HTTP polling fallback)
local ws = nil -- active WebSocket handle (nil when not connected)
local wsConnected = false -- gates WS vs HTTP transport selection
local wsHasSynced = false -- true after first command_batch received from server
local function findModem()
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
modem = peripheral.wrap(name)
modemName = name
modem.open(BROADCAST_CHANNEL)
modem.open(BRIDGE_REPLY_CHANNEL)
return true
end
end
return false
end
-- Reliable modem delivery: pending commands awaiting manager acknowledgment.
-- processCommand() inserts here; modemListener removes on result receipt.
-- A retry task periodically re-transmits unacknowledged commands.
local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries }
local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps
local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up
local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command)
-------------------------------------------------
-- HTTP helpers
-- HTTP helpers (thin wrappers around platform)
-------------------------------------------------
local function httpPost(path, body)
local url = SERVER_URL .. path
local data = textutils.serialiseJSON(body)
local headers = { ["Content-Type"] = "application/json" }
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
local ok, result = pcall(function()
local response = http.post(url, data, headers)
if response then
local responseData = response.readAll()
response.close()
return responseData
end
end)
if ok then
return result
local result, err = WebBridge.httpPost(SERVER_URL .. path, body,
WebBridge.authHeaders(API_KEY))
if not result and err then
print(string.format("[ERR] HTTP POST %s: %s", path, tostring(err)))
end
return nil
return result
end
local function httpGet(path)
local url = SERVER_URL .. path
local headers = nil
if API_KEY then headers = { ["Authorization"] = "Bearer " .. API_KEY } end
local ok, result = pcall(function()
local response = http.get(url, headers)
if response then
local data = response.readAll()
response.close()
return textutils.unserialiseJSON(data)
local rawBody, err = WebBridge.httpGet(SERVER_URL .. path,
WebBridge.authHeaders(API_KEY))
if not rawBody then
if err then
print(string.format("[ERR] HTTP GET %s: %s", path, tostring(err)))
end
end)
if ok then
return result
return nil
end
return nil
return textutils.unserialiseJSON(rawBody)
end
-------------------------------------------------
@@ -133,6 +105,32 @@ local function forwardState()
httpPost("/api/bridge/state", latestState)
end
-------------------------------------------------
-- WebSocket helpers (real-time transport)
-------------------------------------------------
--- Build the WebSocket bridge URL from server config.
-- Converts http(s):// to ws(s):// and appends /ws/bridge path.
-- @return string WebSocket URL with optional API key
local function getWsUrl()
local wsUrl = SERVER_URL:gsub("^http", "ws") .. "/ws/bridge"
if API_KEY then
wsUrl = wsUrl .. "?key=" .. textutils.urlEncode(API_KEY)
end
return wsUrl
end
--- Send a JSON message via WebSocket if connected.
-- @param data table Data to send (serialized to JSON automatically)
-- @return boolean true if sent successfully, false if WS unavailable
local function wsSend(data)
if ws and wsConnected then
local ok = pcall(ws.send, textutils.serialiseJSON(data))
return ok
end
return false
end
-------------------------------------------------
-- Process commands from web server
-------------------------------------------------
@@ -145,96 +143,55 @@ local function processCommand(cmd)
print(string.format("[CMD] %s", action))
-- Build the modem payload from the server command
local payload
if action == "order" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
payload = {
type = "order",
commandId = cmd.commandId,
itemName = cmd.itemName,
amount = cmd.amount,
dropperName = cmd.dropperName,
})
}
elseif action == "scan" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "scan",
commandId = cmd.commandId,
})
payload = { type = "scan", commandId = cmd.commandId }
elseif action == "toggle_pause" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "toggle_pause",
commandId = cmd.commandId,
})
payload = { type = "toggle_pause", commandId = cmd.commandId }
elseif action == "toggle_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "toggle_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
payload = { type = "toggle_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
elseif action == "enable_all" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "enable_all",
commandId = cmd.commandId,
})
payload = { type = "enable_all", commandId = cmd.commandId }
elseif action == "disable_all" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "disable_all",
commandId = cmd.commandId,
})
payload = { type = "disable_all", commandId = cmd.commandId }
elseif action == "sort_barrel" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "sort_barrel",
commandId = cmd.commandId,
barrelName = cmd.barrelName,
})
payload = { type = "sort_barrel", commandId = cmd.commandId, barrelName = cmd.barrelName }
elseif action == "craft" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "craft",
commandId = cmd.commandId,
recipeIdx = cmd.recipeIdx,
})
payload = { type = "craft", commandId = cmd.commandId, recipeIdx = cmd.recipeIdx }
elseif action == "recursive_craft" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "recursive_craft",
commandId = cmd.commandId,
itemName = cmd.itemName,
count = cmd.count,
})
payload = { type = "recursive_craft", commandId = cmd.commandId, itemName = cmd.itemName, count = cmd.count }
elseif action == "learn_crafting_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "learn_crafting_recipe",
commandId = cmd.commandId,
output = cmd.output,
count = cmd.count,
grid = cmd.grid,
})
payload = { type = "learn_crafting_recipe", commandId = cmd.commandId, output = cmd.output, count = cmd.count, grid = cmd.grid }
elseif action == "learn_smelting_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "learn_smelting_recipe",
commandId = cmd.commandId,
input = cmd.input,
result = cmd.result,
furnaces = cmd.furnaces,
})
payload = { type = "learn_smelting_recipe", commandId = cmd.commandId, input = cmd.input, result = cmd.result, furnaces = cmd.furnaces }
elseif action == "forget_recipe" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "forget_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
payload = { type = "forget_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
elseif action == "sync_disabled_recipes" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "sync_disabled_recipes",
commandId = cmd.commandId,
disabledRecipes = cmd.disabledRecipes,
smeltingPaused = cmd.smeltingPaused,
})
payload = { type = "sync_disabled_recipes", commandId = cmd.commandId, disabledRecipes = cmd.disabledRecipes, smeltingPaused = cmd.smeltingPaused }
elseif action == "reboot" then
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, {
type = "reboot",
commandId = cmd.commandId,
target = cmd.target or "all",
})
payload = { type = "reboot", commandId = cmd.commandId, target = cmd.target or "all" }
else
print("[CMD] Unknown action: " .. tostring(action))
return
end
-- Transmit and track for reliable delivery
modem.transmit(ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, payload)
if payload.commandId then
pendingModem[payload.commandId] = {
payload = payload,
sent = os.clock(),
retries = 0,
}
end
end
@@ -251,57 +208,94 @@ local function modemListener()
latestState = message
end
elseif channel == BRIDGE_REPLY_CHANNEL and type(message) == "table" then
-- Clear pending retry on any response matching a commandId
if message.commandId and pendingModem[message.commandId] then
pendingModem[message.commandId] = nil
end
-- Forward command results back to web server
local resultType = message.type
if resultType == "order_result" or resultType == "craft_result"
or resultType == "recursive_craft_result" or resultType == "find_item_result" then
pcall(httpPost, "/api/bridge/result", {
local resultPayload = {
action = resultType,
commandId = message.commandId,
success = message.success,
message = message.message,
error = message.error,
}
-- WS-first: send as command_result via WebSocket if connected
local sent = wsSend({
type = "command_result",
action = resultPayload.action,
commandId = resultPayload.commandId,
success = resultPayload.success,
message = resultPayload.message,
error = resultPayload.error,
})
-- HTTP fallback: POST to /api/bridge/result if WS unavailable
if not sent then
local fwdOk, fwdErr = pcall(httpPost, "/api/bridge/result", resultPayload)
if not fwdOk then
print(string.format("[ERR] Forward result %s: %s", resultType, tostring(fwdErr)))
end
end
end
end
end
end
-- Task 2: Forward state to web server periodically
-- Uses WebSocket if connected; falls back to HTTP POST.
local function stateForwarder()
while running do
local ok, err = pcall(forwardState)
if not ok then
-- Connection error, will retry
if latestState then
-- WS-first: send state directly via WebSocket
if not wsSend(latestState) then
-- HTTP fallback: POST to /api/bridge/state
local ok, err = pcall(forwardState)
if not ok then
print(string.format("[ERR] State forward: %s", tostring(err)))
end
end
end
sleep(STATE_INTERVAL)
end
end
-- Task 3: Poll web server for commands
-- Task 3: Poll web server for commands (HTTP fallback)
-- Active when WS is disconnected, or as safety net until first WS sync.
local lastProcessedId = 0 -- track highest processed command ID for dedup
local function commandPoller()
while running do
local ok, err = pcall(function()
local result = httpGet("/api/bridge/commands")
if result and result.commands and #result.commands > 0 then
local maxId = lastProcessedId
-- Process each command, skipping already-processed ones
for _, cmd in ipairs(result.commands) do
local cmdId = cmd.id or 0
if cmdId > lastProcessedId then
pcall(processCommand, cmd)
if cmdId > maxId then maxId = cmdId end
-- HTTP polling is a fallback; also runs until WS initial sync completes
if not wsConnected or not wsHasSynced then
local ok, err = pcall(function()
local result = httpGet("/api/bridge/commands")
if result and result.commands and #result.commands > 0 then
local maxId = lastProcessedId
-- Process each command, skipping already-processed ones
for _, cmd in ipairs(result.commands) do
local cmdId = cmd.id or 0
if cmdId > lastProcessedId then
local cmdOk, cmdErr = pcall(processCommand, cmd)
if not cmdOk then
print(string.format("[ERR] Process cmd %s: %s", tostring(cmd.action), tostring(cmdErr)))
end
if cmdId > maxId then maxId = cmdId end
end
end
-- Acknowledge up to the highest processed ID
if maxId > lastProcessedId then
lastProcessedId = maxId
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
end
end
-- Acknowledge up to the highest processed ID
if maxId > lastProcessedId then
lastProcessedId = maxId
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
end
end)
if not ok then
print(string.format("[ERR] Command poll: %s", tostring(err)))
end
end)
end
sleep(POLL_INTERVAL)
end
end
@@ -326,6 +320,91 @@ local function heartbeat()
end
end
-- Task 5: WebSocket real-time connection (primary transport)
-- Maintains a persistent WebSocket link to the server for:
-- - Receiving commands in real-time (replaces HTTP polling when active)
-- - Sending state updates and command results via wsSend()
-- Reconnects automatically on failure; HTTP polling resumes as fallback.
-- Channel mode: 'current' by default — dual/target configurable via platform.
local function wsConnector()
local wsUrl = getWsUrl()
print("[WS] Connecting to " .. wsUrl)
WebBridge.wsConnect(wsUrl, {
onConnect = function(wsHandle)
ws = wsHandle
wsConnected = true
print("[WS] Connected — real-time mode active")
-- Push current state immediately on reconnect
if latestState then
wsSend(latestState)
end
end,
onMessage = function(wsHandle, data)
-- Initial sync: server sends pending commands as a batch on connect
if data.type == 'command_batch' and data.commands then
wsHasSynced = true
for _, cmd in ipairs(data.commands) do
local cmdOk, cmdErr = pcall(processCommand, cmd)
if not cmdOk then
print(string.format("[ERR] WS batch cmd %s: %s",
tostring(cmd.action), tostring(cmdErr)))
end
end
if #data.commands > 0 then
print(string.format("[WS] Processed %d synced command(s)", #data.commands))
end
-- Server pushes commands via WebSocket (replaces HTTP polling)
elseif data.action then
local cmdOk, cmdErr = pcall(processCommand, data)
if not cmdOk then
print(string.format("[ERR] WS cmd %s: %s",
tostring(data.action), tostring(cmdErr)))
end
end
end,
onDisconnect = function()
ws = nil
wsConnected = false
wsHasSynced = false
print("[WS] Disconnected — HTTP polling fallback active")
end,
onError = function(err)
print(string.format("[WS] Connection error: %s", tostring(err)))
end,
}, {
reconnectDelay = 5,
receiveTimeout = 30,
})
end
-- Task 6: Retry unacknowledged modem commands
-- The manager deduplicates by commandId, so retransmits are safe.
local function modemRetry()
while running do
local now = os.clock()
for cmdId, entry in pairs(pendingModem) do
if now - entry.sent >= MODEM_RETRY_DELAY then
if entry.retries >= MODEM_RETRY_MAX then
print(string.format("[RETRY] Giving up on %s after %d retries",
tostring(cmdId), entry.retries))
pendingModem[cmdId] = nil
else
entry.retries = entry.retries + 1
entry.sent = now
print(string.format("[RETRY] Re-transmit %s (attempt %d/%d)",
tostring(cmdId), entry.retries, MODEM_RETRY_MAX))
pcall(modem.transmit, ORDER_CHANNEL, BRIDGE_REPLY_CHANNEL, entry.payload)
end
end
end
sleep(MODEM_RETRY_INTERVAL)
end
end
-------------------------------------------------
-- Main
-------------------------------------------------
@@ -336,10 +415,16 @@ local function main()
print("===================================")
print("")
loadConfig()
-- Config already loaded at require-time via WebBridge.loadConfig above
if findModem() then
print("[OK] Modem: " .. modemName)
modem, modemName = WebBridge.findModem()
if modem then
WebBridge.openChannels(modem,
{ 'inventory.broadcast', 'inventory.bridge' })
local modemType = modem.isWireless and (modem.isWireless() and "wireless" or "wired") or "unknown"
print(string.format("[OK] Modem: %s (%s)", modemName, modemType))
print(string.format("[OK] TX ch %d, listen ch %d/%d",
ORDER_CHANNEL, BROADCAST_CHANNEL, BRIDGE_REPLY_CHANNEL))
else
print("[WARN] No modem found! Bridge needs a modem.")
print(" Attach a modem and restart.")
@@ -354,6 +439,7 @@ local function main()
else
print("[WARN] No API key set (open access)")
end
print("[OK] Transport: WebSocket (primary) + HTTP polling (fallback)")
print("")
print("Bridge is running. Press Ctrl+T to stop.")
print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL)
@@ -363,7 +449,9 @@ local function main()
modemListener,
stateForwarder,
commandPoller,
heartbeat
heartbeat,
wsConnector,
modemRetry
)
end

View File

@@ -34,6 +34,9 @@ C.COMPOST_INTERVAL = 3
C.ALERT_INTERVAL = 15
C.CACHE_FILE = _configPath(".inventory_cache")
C.SMELTER_MONITOR_SIDE = "top"
C.BILLBOARD_MONITOR = "" -- network name e.g. "monitor_0"; auto-detects if empty
C.BILLBOARD_TOP_ITEMS = 20 -- max items in billboard bar chart
C.BILLBOARD_TEXT_SCALE = 1 -- 0.5 = tiny, 1 = normal, 2 = large, 5 = huge
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
-- Network
@@ -117,7 +120,10 @@ function C.loadConfig()
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.billboardMonitor then C.BILLBOARD_MONITOR = cfg.billboardMonitor end
if cfg.billboardTopItems then C.BILLBOARD_TOP_ITEMS = cfg.billboardTopItems end
if cfg.billboardTextScale then C.BILLBOARD_TEXT_SCALE = cfg.billboardTextScale end
if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end

View File

@@ -26,6 +26,8 @@ D.mon = nil
D.monName = nil
D.smelterMon = nil
D.smelterMonName = nil
D.billboardMon = nil
D.billboardMonName = nil
-- Opus UI devices and pages
local mainDevice = nil
@@ -118,48 +120,142 @@ end
-- Monitor setup
-------------------------------------------------
local function findMonitor(side, excludeSide)
local mon = peripheral.wrap(side)
local monName
if mon and mon.setTextScale then
monName = side
else
mon = nil
end
if not mon then
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and name ~= excludeSide then
mon = peripheral.wrap(name)
monName = name
break
--- Track peripheral names already assigned to a role.
-- A single physical monitor can appear under multiple names (e.g. "left"
-- AND "monitor_0") when it is both side-attached and on a wired modem.
-- We detect aliases by mutating text scale on one name and checking
-- whether a known monitor's getSize() changes.
D._usedMonitorNames = {} -- set of names known to be taken
--- Detect whether 'candidateName' is the same physical block as 'knownMon'
-- (a wrapped peripheral table). We temporarily set the candidate's text
-- scale to an extreme value and check whether the known monitor reports a
-- size change. If it does, they share the same hardware.
local function isMonitorAlias(candidateName, knownMon)
if not candidateName or not knownMon then return false end
local refW, refH = knownMon.getSize()
-- Save candidate's current scale (getTextScale available CC:T 1.94+)
local ok, origScale = pcall(peripheral.call, candidateName, "getTextScale")
if not ok then origScale = 1 end
-- Pick a test scale far from the current one
local testScale = (origScale >= 3) and 0.5 or 5
pcall(peripheral.call, candidateName, "setTextScale", testScale)
local newW, newH = knownMon.getSize()
-- Restore
pcall(peripheral.call, candidateName, "setTextScale", origScale)
return newW ~= refW or newH ~= refH
end
--- Register all peripheral names that refer to the same physical block as
-- 'knownName' / 'knownMon'. This populates D._usedMonitorNames so that
-- later auto-detection can skip aliases by simple table lookup.
local function registerMonitorAliases(knownName, knownMon)
D._usedMonitorNames[knownName] = true
for _, name in ipairs(peripheral.getNames()) do
if name ~= knownName and peripheral.getType(name) == "monitor" then
if isMonitorAlias(name, knownMon) then
D._usedMonitorNames[name] = true
log.debug("DISPLAY", "Monitor alias: %s => %s", name, knownName)
end
end
end
return mon, monName
end
function D.setupMonitor()
D.mon, D.monName = findMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
local mon = peripheral.wrap(cfg.MONITOR_SIDE)
if not mon or not mon.setTextScale then
-- Fallback: find any monitor
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" then
mon = peripheral.wrap(name)
if mon and mon.setTextScale then
D.mon = mon
D.monName = name
break
end
mon = nil
end
end
else
D.mon = mon
D.monName = cfg.MONITOR_SIDE
end
if not D.mon then return false end
mainDevice = UI.Device({
device = D.mon,
textScale = 0.5,
})
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.monName, D.mon)
return true
end
function D.setupSmelterMonitor()
D.smelterMon, D.smelterMonName = findMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
if not D.smelterMon then return false end
-- Try configured side first
local mon = peripheral.wrap(cfg.SMELTER_MONITOR_SIDE)
local monName = cfg.SMELTER_MONITOR_SIDE
if not mon or not mon.setTextScale or D._usedMonitorNames[monName] then
mon = nil
monName = nil
-- Fallback: find any unused monitor
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
mon = peripheral.wrap(name)
if mon and mon.setTextScale then
monName = name
break
end
mon = nil
end
end
end
if not mon then return false end
D.smelterMon = mon
D.smelterMonName = monName
smelterDevice = UI.Device({
device = D.smelterMon,
textScale = 0.5,
})
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.smelterMonName, D.smelterMon)
return true
end
function D.setupBillboardMonitor()
local scale = cfg.BILLBOARD_TEXT_SCALE or 1
-- If explicitly configured, use that name
if cfg.BILLBOARD_MONITOR and cfg.BILLBOARD_MONITOR ~= "" then
local mon = peripheral.wrap(cfg.BILLBOARD_MONITOR)
if mon and mon.setTextScale then
D.billboardMon = mon
D.billboardMonName = cfg.BILLBOARD_MONITOR
D.billboardMon.setTextScale(scale)
registerMonitorAliases(D.billboardMonName, D.billboardMon)
return true
end
return false
end
-- Auto-detect: find any monitor not already used by main/smelter
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and not D._usedMonitorNames[name] then
local mon = peripheral.wrap(name)
if mon and mon.setTextScale then
D.billboardMon = mon
D.billboardMonName = name
D.billboardMon.setTextScale(scale)
registerMonitorAliases(D.billboardMonName, D.billboardMon)
return true
end
end
end
return false
end
-------------------------------------------------
-- Build main dashboard page
-------------------------------------------------
@@ -1416,6 +1512,500 @@ function D.handleSmelterTouch(x, y)
end
end
-------------------------------------------------
-- Billboard rendering (raw monitor API, no Opus UI)
-- Visual goals display with pie chart, storage
-- gauge, stock alerts, and activity indicators.
-------------------------------------------------
local BB = {} -- billboard color theme
BB.bg = colors.black
BB.headerBg = colors.blue
BB.headerFg = colors.white
BB.border = colors.gray
BB.label = colors.lightGray
BB.value = colors.white
BB.barFull = colors.lime
BB.barEmpty = colors.gray
BB.barWarn = colors.yellow
BB.barCrit = colors.red
BB.alertOk = colors.lime
BB.alertLow = colors.red
BB.alertWarn = colors.orange
BB.activityOn = colors.lime
BB.activityOff = colors.gray
BB.sectionHead = colors.yellow
-- Pie chart slice colors (12 distinct CC colors)
local PIE_COLORS = {
colors.red,
colors.orange,
colors.yellow,
colors.lime,
colors.green,
colors.cyan,
colors.lightBlue,
colors.blue,
colors.purple,
colors.magenta,
colors.pink,
colors.brown,
}
local function bbFormatNumber(n)
if n >= 1000000 then
return string.format("%.1fM", n / 1000000)
elseif n >= 10000 then
return string.format("%.1fK", n / 1000)
elseif n >= 1000 then
return string.format("%d,%03d", math.floor(n / 1000), n % 1000)
end
return tostring(n)
end
local function bbPadRight(s, w)
if #s >= w then return s:sub(1, w) end
return s .. string.rep(" ", w - #s)
end
local function bbPadLeft(s, w)
if #s >= w then return s:sub(1, w) end
return string.rep(" ", w - #s) .. s
end
-- Billboard drawing primitives (operate on D.billboardMon)
local bbW, bbH = 0, 0
local function bbSetColors(fg, bg)
D.billboardMon.setTextColor(fg)
D.billboardMon.setBackgroundColor(bg)
end
local function bbClearLine(y, bg)
D.billboardMon.setCursorPos(1, y)
D.billboardMon.setBackgroundColor(bg or BB.bg)
D.billboardMon.write(string.rep(" ", bbW))
end
local function bbWriteAt(x, y, text, fg, bg)
bbSetColors(fg or BB.value, bg or BB.bg)
D.billboardMon.setCursorPos(x, y)
D.billboardMon.write(text)
end
local function bbHLine(y, fg)
bbClearLine(y)
bbSetColors(fg or BB.border, BB.bg)
D.billboardMon.setCursorPos(1, y)
D.billboardMon.write(string.rep("\x8c", bbW))
end
local function bbDrawBar(x, y, width, filled, fgColor, bgColor)
local fillW = math.floor(filled * width + 0.5)
if fillW > width then fillW = width end
if fillW < 0 then fillW = 0 end
D.billboardMon.setCursorPos(x, y)
D.billboardMon.setBackgroundColor(fgColor or BB.barFull)
D.billboardMon.write(string.rep(" ", fillW))
D.billboardMon.setBackgroundColor(bgColor or BB.barEmpty)
D.billboardMon.write(string.rep(" ", width - fillW))
D.billboardMon.setBackgroundColor(BB.bg)
end
--- Draw a pie chart using colored character cells.
--- slices = { { fraction=0.0-1.0, color=colors.X }, ... }
--- Draws into the rectangle (x1,y1) to (x1+size-1, y1+rows-1)
--- size = width in chars, rows = height in chars
local function bbDrawPie(x1, y1, size, rows, slices)
local cx = size / 2 -- center in char coords
local cy = rows / 2
local radius = math.min(cx, cy) - 0.5
-- Character cells are ~1.5x taller than wide; squish Y
local aspect = 1.5
-- Build cumulative angle boundaries
local angles = {}
local cumulative = 0
for i, slice in ipairs(slices) do
angles[i] = { start = cumulative, stop = cumulative + slice.fraction, color = slice.color }
cumulative = cumulative + slice.fraction
end
for row = 0, rows - 1 do
D.billboardMon.setCursorPos(x1, y1 + row)
for col = 0, size - 1 do
local dx = (col + 0.5 - cx)
local dy = (row + 0.5 - cy) * aspect
local dist = math.sqrt(dx * dx + dy * dy)
if dist <= radius then
-- Compute angle 0-1 (0 = top, clockwise)
local angle = math.atan2(dx, -dy) -- top = 0, clockwise
if angle < 0 then angle = angle + 2 * math.pi end
local frac = angle / (2 * math.pi)
-- Find which slice
local cellColor = BB.bg
for _, s in ipairs(angles) do
if frac >= s.start and frac < s.stop then
cellColor = s.color
break
end
end
-- Catch rounding at the very end
if cellColor == BB.bg and #angles > 0 then
cellColor = angles[#angles].color
end
D.billboardMon.setBackgroundColor(cellColor)
D.billboardMon.write(" ")
else
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
end
end
end
D.billboardMon.setBackgroundColor(BB.bg)
end
--- Draw a storage ring/donut gauge.
--- Draws a ring showing used vs free with percentage in center.
local function bbDrawStorageRing(x1, y1, size, rows)
local cx = size / 2
local cy = rows / 2
local outerR = math.min(cx, cy) - 0.5
local innerR = outerR * 0.55
local aspect = 1.5
local ratio = cache.usedRatio or 0
local usedColor = BB.barFull
if ratio > 0.9 then usedColor = BB.barCrit
elseif ratio > 0.75 then usedColor = BB.barWarn end
for row = 0, rows - 1 do
D.billboardMon.setCursorPos(x1, y1 + row)
for col = 0, size - 1 do
local dx = (col + 0.5 - cx)
local dy = (row + 0.5 - cy) * aspect
local dist = math.sqrt(dx * dx + dy * dy)
if dist <= outerR and dist >= innerR then
-- In the ring — determine angle (top = 0, clockwise)
local angle = math.atan2(dx, -dy)
if angle < 0 then angle = angle + 2 * math.pi end
local frac = angle / (2 * math.pi)
if frac < ratio then
D.billboardMon.setBackgroundColor(usedColor)
else
D.billboardMon.setBackgroundColor(BB.barEmpty)
end
D.billboardMon.write(" ")
else
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
end
end
end
-- Write percentage in center of ring
local pct = tostring(math.floor(ratio * 100 + 0.5)) .. "%"
local textY = y1 + math.floor(cy)
local textX = x1 + math.floor(cx - #pct / 2)
bbWriteAt(textX, textY, pct, usedColor)
end
-- Billboard section: header
local function bbDrawHeader(y)
bbClearLine(y, BB.headerBg)
bbSetColors(BB.headerFg, BB.headerBg)
local title = " INVENTORY BILLBOARD "
D.billboardMon.setCursorPos(math.floor((bbW - #title) / 2) + 1, y)
D.billboardMon.write(title)
return y + 1
end
-- Billboard section: storage ring + stats (side by side)
local function bbDrawStorageSection(y)
bbHLine(y)
y = y + 1
-- Ring takes up a square area
local ringSize = math.min(math.floor(bbW * 0.35), bbH - y - 6)
if ringSize < 5 then ringSize = 5 end
local ringRows = math.floor(ringSize / 1.5) -- aspect correction
if ringRows < 3 then ringRows = 3 end
-- Draw ring on the left
bbDrawStorageRing(2, y, ringSize, ringRows)
-- Stats text to the right of the ring
local statsX = ringSize + 4
local statsY = y + 1
bbWriteAt(statsX, statsY, "STORAGE", BB.sectionHead)
statsY = statsY + 1
local ratio = cache.usedRatio or 0
local usedColor = BB.barFull
if ratio > 0.9 then usedColor = BB.barCrit
elseif ratio > 0.75 then usedColor = BB.barWarn end
bbWriteAt(statsX, statsY, string.format("%s / %s slots",
bbFormatNumber(cache.usedSlots), bbFormatNumber(cache.totalSlots)), BB.value)
statsY = statsY + 1
bbWriteAt(statsX, statsY, string.format("%s total items",
bbFormatNumber(cache.grandTotal)), BB.label)
statsY = statsY + 1
bbWriteAt(statsX, statsY, string.format("%d chests", cache.chestCount), BB.label)
statsY = statsY + 1
-- Mini capacity bar
local barW = bbW - statsX - 1
if barW > 3 then
bbWriteAt(statsX, statsY, "", BB.label)
bbDrawBar(statsX, statsY, barW, ratio, usedColor, BB.barEmpty)
end
return y + ringRows + 1
end
-- Billboard section: pie chart + legend
local function bbDrawPieSection(y, maxH)
bbHLine(y)
y = y + 1
bbWriteAt(2, y, "ITEM DISTRIBUTION", BB.sectionHead)
y = y + 1
state.ensureItemList()
local items = cache.itemList
if not items or #items == 0 then
bbWriteAt(2, y, "No items in storage", BB.label)
return y + 1
end
-- Sort and pick top N for pie slices
local sorted = {}
for i, item in ipairs(items) do sorted[i] = item end
table.sort(sorted, function(a, b) return a.total > b.total end)
local maxSlices = math.min(#PIE_COLORS, cfg.BILLBOARD_TOP_ITEMS or 12, #sorted)
local topTotal = 0
for i = 1, maxSlices do
topTotal = topTotal + sorted[i].total
end
-- "Other" bucket for remaining items
local otherTotal = (cache.grandTotal or 0) - topTotal
local total = cache.grandTotal or 1
if total < 1 then total = 1 end
-- Build slices
local slices = {}
local legendItems = {}
for i = 1, maxSlices do
local frac = sorted[i].total / total
if frac < 0.005 then break end -- skip tiny slices
table.insert(slices, { fraction = frac, color = PIE_COLORS[i] })
table.insert(legendItems, {
name = shortName(sorted[i].name),
count = sorted[i].total,
pct = math.floor(frac * 100 + 0.5),
color = PIE_COLORS[i],
})
end
if otherTotal > 0 then
table.insert(slices, { fraction = otherTotal / total, color = BB.border })
table.insert(legendItems, {
name = "Other",
count = otherTotal,
pct = math.floor(otherTotal / total * 100 + 0.5),
color = BB.border,
})
end
-- Layout: pie on left, legend on right
local pieSize = math.min(math.floor(bbW * 0.4), maxH - 1)
if pieSize < 5 then pieSize = 5 end
local pieRows = math.floor(pieSize / 1.5)
if pieRows < 3 then pieRows = 3 end
if pieRows > maxH - 1 then pieRows = maxH - 1 end
-- Draw pie
if #slices > 0 then
bbDrawPie(2, y, pieSize, pieRows, slices)
end
-- Draw legend to the right
local legX = pieSize + 4
local legY = y
local legW = bbW - legX - 1
for i, item in ipairs(legendItems) do
if legY >= y + pieRows then break end
if legW < 10 then break end
-- Color swatch
D.billboardMon.setCursorPos(legX, legY)
D.billboardMon.setBackgroundColor(item.color)
D.billboardMon.write(" ")
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.write(" ")
-- Name + count
local label = item.name
local countStr = bbFormatNumber(item.count)
local pctStr = item.pct .. "%"
local infoW = legW - 4 -- 2 swatch + 1 space + padding
local detail = string.format("%s %s", pctStr, countStr)
local nameW = infoW - #detail - 1
if nameW < 4 then nameW = 4 end
if #label > nameW then label = label:sub(1, nameW - 1) .. "." end
bbWriteAt(legX + 3, legY, bbPadRight(label, nameW), BB.value)
bbWriteAt(legX + 3 + nameW + 1, legY, detail, BB.label)
legY = legY + 1
end
return y + pieRows
end
-- Billboard section: stock alerts (compact)
local function bbDrawAlerts(y, maxRows)
bbHLine(y)
y = y + 1
local alerts = state.activeAlerts
if not alerts or #alerts == 0 then
bbWriteAt(2, y, "* All stocks OK", BB.alertOk)
return y + 1
end
bbWriteAt(2, y, "ALERTS", BB.sectionHead)
local countStr = string.format("(%d)", #alerts)
bbWriteAt(9, y, countStr, BB.alertWarn)
y = y + 1
local colW = math.floor(bbW / 2)
local twoCol = (bbW >= 30)
local row = 0
for i, alert in ipairs(alerts) do
if row >= maxRows then
bbWriteAt(2, y, string.format(" +%d more", #alerts - i + 1), BB.alertWarn)
y = y + 1
break
end
local label = alert.label or shortName(alert.name or "?")
local current = alert.current or 0
local minVal = alert.min or 0
local ratio = minVal > 0 and (current / minVal) or 1
local color = ratio < 0.5 and BB.alertLow or BB.alertWarn
local text = string.format("! %s %s/%s", label, bbFormatNumber(current), bbFormatNumber(minVal))
if twoCol then
local col = ((i - 1) % 2 == 0) and 2 or (colW + 1)
if col == 2 then bbClearLine(y) end
bbWriteAt(col, y, bbPadRight(text, colW - 1), color)
if (i - 1) % 2 == 1 or i == #alerts then
y = y + 1
row = row + 1
end
else
bbClearLine(y)
bbWriteAt(2, y, text, color)
y = y + 1
row = row + 1
end
end
return y
end
-- Billboard section: activity bar (single line)
local function bbDrawActivityBar(y)
bbClearLine(y, BB.headerBg)
bbSetColors(BB.headerFg, BB.headerBg)
local labels = {
{ key = "sorting", label = "SORT" },
{ key = "scanning", label = "SCAN" },
{ key = "smelting", label = "SMLT" },
{ key = "dispensing", label = "DISP" },
{ key = "defragging", label = "DEFR" },
{ key = "composting", label = "COMP" },
{ key = "crafting", label = "CRFT" },
{ key = "autocrafting", label = "AUTO" },
{ key = "discarding", label = "DISC" },
}
local parts = {}
for _, entry in ipairs(labels) do
if activity[entry.key] then
table.insert(parts, entry.label)
end
end
local text
if #parts > 0 then
text = " " .. table.concat(parts, " | ") .. " "
else
text = " IDLE "
end
D.billboardMon.setCursorPos(math.floor((bbW - #text) / 2) + 1, y)
if #parts > 0 then
bbSetColors(colors.white, colors.green)
else
bbSetColors(BB.label, BB.headerBg)
end
D.billboardMon.write(text)
return y + 1
end
-- Main billboard draw entry point
function D.drawBillboard()
if not D.billboardMon then return end
bbW, bbH = D.billboardMon.getSize()
D.billboardMon.setBackgroundColor(BB.bg)
D.billboardMon.clear()
local y = 1
-- Header bar
y = bbDrawHeader(y)
-- Storage ring + stats
y = bbDrawStorageSection(y)
-- Pie chart + legend (gets remaining space minus alerts + footer)
local alertCount = state.activeAlerts and #state.activeAlerts or 0
local alertH = math.max(2, math.min(5, math.ceil(alertCount / 2) + 2))
local footerH = 1 -- activity bar
local pieH = bbH - y - alertH - footerH
if pieH < 5 then pieH = 5 end
y = bbDrawPieSection(y, pieH)
-- Alerts
local alertMaxRows = bbH - y - footerH - 1
if alertMaxRows < 1 then alertMaxRows = 1 end
y = bbDrawAlerts(y, alertMaxRows)
-- Fill gap before footer
while y < bbH do
bbClearLine(y)
y = y + 1
end
-- Activity bar at very bottom
bbDrawActivityBar(bbH)
end
return D
end

View File

@@ -1338,7 +1338,6 @@ function O.craftItem(recipeIdx, batches)
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
local result = nil
local bufferedMessages = {}
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
@@ -1350,18 +1349,13 @@ function O.craftItem(recipeIdx, batches)
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
result = message
break
elseif channel == cfg.ORDER_CHANNEL then
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
end
-- ORDER_CHANNEL messages are captured by the Network-capture task
elseif event == "timer" and p1 == timerId then
-- Timeout tick
end
end
for _, msg in ipairs(bufferedMessages) do
os.queueEvent(table.unpack(msg))
end
activity.crafting = false
state.needsRedraw = true
state.smelterNeedsRedraw = true

View File

@@ -55,6 +55,7 @@ S.configDirty = true
function S.bumpStateVersion()
S.stateVersion = S.stateVersion + 1
S.billboardNeedsRedraw = true
end
-------------------------------------------------
@@ -63,6 +64,7 @@ end
S.needsRedraw = true
S.smelterNeedsRedraw = true
S.billboardNeedsRedraw = true
S.statusMessage = ""
S.statusColor = colors.white
S.statusTimer = 0

View File

@@ -2,274 +2,47 @@ import React, { useState } from 'react';
import { getItemEmoji } from '../utils/itemUtils';
import './ItemIcon.css';
// All textures are proxied & cached through our server
const TEXTURE_PROXY_BASE = '/api/texture';
// Server-side smart resolution endpoint — handles aliases, animated frames,
// carpet→wool, wood→log, derivatives, CC:Tweaked, Create, and prefix matching.
const RESOLVE_BASE = '/api/texture/resolve';
// Items whose texture file name differs from their registry name
const TEXTURE_ALIASES = {
// Renamed items in 1.20+
grass: 'short_grass',
scute: 'turtle_scute',
};
// CC:Tweaked texture paths (registry name → actual file in the CC repo)
const CC_TEXTURE_MAP = {
turtle_normal: 'block/turtle_normal_front',
turtle_advanced: 'block/turtle_advanced_front',
computer_normal: 'block/computer_normal_front',
computer_advanced: 'block/computer_advanced_front',
computer_command: 'block/computer_command_front',
monitor_normal: 'block/monitor_normal_0',
monitor_advanced: 'block/monitor_advanced_0',
wired_modem: 'block/wired_modem_face',
wired_modem_full: 'block/wired_modem_face',
wireless_modem_normal: 'block/wireless_modem_normal_face',
wireless_modem_advanced: 'block/wireless_modem_advanced_face',
speaker: 'block/speaker_front',
disk_drive: 'block/disk_drive_front',
printer: 'block/printer_front_empty',
cable: 'block/cable_core',
// CC item textures
pocket_computer_normal: 'item/pocket_computer_normal',
pocket_computer_advanced: 'item/pocket_computer_advanced',
disk: 'item/disk_frame',
printed_book: 'item/printed_book',
printed_page: 'item/printed_page',
printed_pages: 'item/printed_pages',
};
// 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',
'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',
'light_blue_glazed_terracotta', 'yellow_glazed_terracotta', 'lime_glazed_terracotta',
'pink_glazed_terracotta', 'gray_glazed_terracotta', 'light_gray_glazed_terracotta',
'cyan_glazed_terracotta', 'purple_glazed_terracotta', 'blue_glazed_terracotta',
'brown_glazed_terracotta', 'green_glazed_terracotta', 'red_glazed_terracotta',
'black_glazed_terracotta',
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
'light_blue_stained_glass', 'yellow_stained_glass', 'lime_stained_glass',
'pink_stained_glass', 'gray_stained_glass', 'light_gray_stained_glass',
'cyan_stained_glass', 'purple_stained_glass', 'blue_stained_glass',
'brown_stained_glass', 'green_stained_glass', 'red_stained_glass',
'black_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',
'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
'beacon', 'conduit', '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',
// Additional blocks commonly seen as items
'smooth_stone', 'smooth_sandstone', 'smooth_red_sandstone', 'smooth_quartz',
'chiseled_sandstone', 'cut_sandstone', 'chiseled_red_sandstone', 'cut_red_sandstone',
'quartz_pillar', 'chiseled_quartz_block', 'quartz_bricks',
'ladder', 'cobweb', 'torch', 'soul_torch', 'lantern', 'soul_lantern',
'redstone_torch', 'chain',
'rail', 'powered_rail', 'detector_rail', 'activator_rail',
'brown_mushroom', 'red_mushroom',
'oak_sapling', 'spruce_sapling', 'birch_sapling', 'jungle_sapling',
'acacia_sapling', 'dark_oak_sapling', 'cherry_sapling',
'dandelion', 'poppy', 'blue_orchid', 'allium', 'azure_bluet',
'red_tulip', 'orange_tulip', 'white_tulip', 'pink_tulip',
'oxeye_daisy', 'cornflower', 'lily_of_the_valley', 'sunflower',
'lilac', 'rose_bush', 'peony', 'wither_rose', 'torchflower',
'dead_bush', 'fern', 'short_grass', 'tall_grass', 'large_fern',
'vine', 'lily_pad', 'seagrass', 'kelp', 'hanging_roots', 'spore_blossom',
'tube_coral', 'brain_coral', 'bubble_coral', 'fire_coral', 'horn_coral',
'tube_coral_block', 'brain_coral_block', 'bubble_coral_block', 'fire_coral_block', 'horn_coral_block',
'tube_coral_fan', 'brain_coral_fan', 'bubble_coral_fan', 'fire_coral_fan', 'horn_coral_fan',
'white_concrete_powder', 'orange_concrete_powder', 'magenta_concrete_powder',
'light_blue_concrete_powder', 'yellow_concrete_powder', 'lime_concrete_powder',
'pink_concrete_powder', 'gray_concrete_powder', 'light_gray_concrete_powder',
'cyan_concrete_powder', 'purple_concrete_powder', 'blue_concrete_powder',
'brown_concrete_powder', 'green_concrete_powder', 'red_concrete_powder', 'black_concrete_powder',
'sculk', 'sculk_catalyst', 'sculk_shrieker', 'sculk_sensor', 'sculk_vein',
'mud_bricks', 'packed_mud', 'muddy_mangrove_roots',
'crimson_stem', 'warped_stem', 'stripped_crimson_stem', 'stripped_warped_stem',
'crimson_nylium', 'warped_nylium', 'shroomlight', 'nether_wart_block', 'warped_wart_block',
'crying_obsidian', 'blackstone', 'polished_blackstone', 'polished_blackstone_bricks',
'chiseled_polished_blackstone', 'gilded_blackstone', 'cracked_polished_blackstone_bricks',
'lodestone', 'respawn_anchor',
'pointed_dripstone', 'moss_carpet', 'azalea', 'flowering_azalea',
'powder_snow', 'mangrove_roots',
'copper_block', 'exposed_copper', 'weathered_copper', 'oxidized_copper',
'cut_copper', 'exposed_cut_copper', 'weathered_cut_copper', 'oxidized_cut_copper',
'waxed_copper_block', 'waxed_exposed_copper', 'waxed_weathered_copper', 'waxed_oxidized_copper',
// Items rendered as 3D entities in-game — no flat texture exists.
// Skip to emoji immediately to avoid a wasted network request.
const ENTITY_ONLY = new Set([
'chest', 'ender_chest', 'trapped_chest', 'shield', 'conduit',
'bell', 'decorated_pot', 'trident',
]);
// 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: '_top',
sticky_piston: '_top',
barrel: '_top',
crafting_table: '_top',
cartography_table: '_top',
fletching_table: '_top',
smithing_table: '_top',
grass_block: '_top',
mycelium: '_top',
podzol: '_top',
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',
quartz_pillar: '_side',
purpur_pillar: '_side',
tnt: '_side',
composter: '_side',
enchanting_table: '_top',
};
// Resolve derivative blocks (stairs, slabs, fences, walls, buttons) to parent block texture
const WOOD_TYPES = new Set([
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped',
const DYE_COLORS = new Set([
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
'brown', 'green', 'red', 'black',
]);
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',
};
function resolveDerivativeTexture(name) {
const suffixes = ['_stairs', '_slab', '_fence_gate', '_fence', '_wall', '_button', '_pressure_plate'];
for (const suffix of suffixes) {
if (!name.endsWith(suffix)) continue;
const base = name.slice(0, -suffix.length);
if (BLOCK_TEXTURES.has(base)) return base;
if (WOOD_TYPES.has(base)) return `${base}_planks`;
if (STONE_ALIASES[base]) return STONE_ALIASES[base];
return null;
}
return null;
/**
* Check if an item is known to have no flat texture (entity/model-only).
*/
function isEntityOnly(name) {
if (ENTITY_ONLY.has(name)) return true;
if (name.endsWith('_bed') && DYE_COLORS.has(name.slice(0, -'_bed'.length))) return true;
if (name.endsWith('_banner') && DYE_COLORS.has(name.slice(0, -'_banner'.length))) return true;
if (name.endsWith('_skull') || name.endsWith('_head')) return true;
return false;
}
/**
* Generate texture URLs to try in order.
* - CC:Tweaked → curated texture map (1 request)
* - Create → item/ then block/ (2 requests max)
* - Unknown mods → empty (instant emoji, no wasted requests)
* - Vanilla derivatives → parent block texture (1 request)
* - Vanilla → block/ or item/ with fallback
* Build the single resolve URL for an item.
* The server handles all name resolution (aliases, suffixes, derivatives, etc.)
*/
function getTextureUrls(fullItemName) {
function getTextureUrl(fullItemName) {
const colonIdx = (fullItemName || '').indexOf(':');
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
const urls = [];
// CC:Tweaked → use curated texture map
if (namespace === 'computercraft') {
const mapped = CC_TEXTURE_MAP[shortName];
if (mapped) {
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/${mapped}.png`);
} else {
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/item/${shortName}.png`);
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/block/${shortName}.png`);
}
return urls;
}
// Entity-only items → instant emoji, no network request
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null;
// Create mod → item/ then block/
if (namespace === 'create') {
urls.push(`${TEXTURE_PROXY_BASE}/create/item/${shortName}.png`);
urls.push(`${TEXTURE_PROXY_BASE}/create/block/${shortName}.png`);
return urls;
}
// Unknown mod namespace → no textures available, instant emoji fallback
if (namespace !== 'minecraft') {
return urls;
}
// === Vanilla (minecraft) ===
const alias = TEXTURE_ALIASES[shortName] || shortName;
// Derivative blocks (stairs, slabs, fences, walls, buttons) → parent texture
const parent = resolveDerivativeTexture(alias);
if (parent) {
const suffix = BLOCK_TEXTURE_SUFFIXES[parent] || '';
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${parent}${suffix}.png`);
return urls;
}
// Known block → try block/ first (with suffix if applicable)
if (BLOCK_TEXTURES.has(alias)) {
const suffix = BLOCK_TEXTURE_SUFFIXES[alias] || '';
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}${suffix}.png`);
if (suffix) urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
}
// Try item/ texture
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/item/${alias}.png`);
// If not a known block, also try block/ as last resort
if (!BLOCK_TEXTURES.has(alias)) {
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
}
return urls;
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`;
}
// Cache of resolved icon URLs (avoid re-fetching on every render)
@@ -277,21 +50,20 @@ const iconCache = new Map();
/**
* Renders a Minecraft item icon using the official game textures.
* Cascading fallback: mod texture → item texture → block texture → emoji
* The server's /api/texture/resolve endpoint does all the smart matching.
* Falls back to emoji when no texture is found.
*/
function ItemIcon({ itemName, size = 32 }) {
const [urlIndex, setUrlIndex] = useState(0);
const [allFailed, setAllFailed] = useState(false);
const [failed, setFailed] = useState(false);
if (!itemName) {
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
}
// Use full item name as cache key (handles mod namespaces correctly)
const cacheKey = itemName.replace(/^minecraft:/, '');
// Check if we already know this item has no texture
if (iconCache.get(cacheKey) === 'none' || allFailed) {
if (iconCache.get(cacheKey) === 'none' || failed) {
return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
{getItemEmoji(itemName)}
@@ -314,10 +86,9 @@ function ItemIcon({ itemName, size = 32 }) {
);
}
const urls = getTextureUrls(itemName);
const currentUrl = urls[urlIndex];
const url = getTextureUrl(itemName);
if (!currentUrl) {
if (!url) {
iconCache.set(cacheKey, 'none');
return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
@@ -329,21 +100,17 @@ function ItemIcon({ itemName, size = 32 }) {
return (
<img
className="item-icon-img"
src={currentUrl}
src={url}
alt={cacheKey}
width={size}
height={size}
loading="lazy"
onLoad={() => {
iconCache.set(cacheKey, currentUrl);
iconCache.set(cacheKey, url);
}}
onError={() => {
if (urlIndex + 1 < urls.length) {
setUrlIndex(urlIndex + 1);
} else {
iconCache.set(cacheKey, 'none');
setAllFailed(true);
}
iconCache.set(cacheKey, 'none');
setFailed(true);
}}
/>
);

View File

@@ -1,4 +1,12 @@
# Node.js backend
# Stage 1: Fetch platform server package from git
FROM alpine:3.20 AS platform
RUN apk add --no-cache git
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
ARG PLATFORM_BRANCH=master
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
&& rm -rf /src/server/node_modules /src/.git
# Stage 2: Node.js backend
FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation
@@ -8,8 +16,15 @@ RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
RUN npm install --omit=dev
# Remove build tools after install to keep image small

View File

@@ -8,6 +8,16 @@ mkdir -p /data
chown -R node:node /data
echo "[entrypoint] /data permissions fixed"
# Download textures if cache is empty (first run)
TEXTURE_DIR="/data/texture-cache/minecraft"
if [ ! -d "$TEXTURE_DIR" ] || [ -z "$(ls -A "$TEXTURE_DIR" 2>/dev/null)" ]; then
echo "[entrypoint] Downloading textures (first run)..."
su-exec node node /app/download-textures.js /data/texture-cache
echo "[entrypoint] Texture download complete"
else
echo "[entrypoint] Texture cache exists, skipping download"
fi
# Drop privileges and exec the CMD
echo "[entrypoint] Dropping to user 'node', running: $*"
exec su-exec node "$@"

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* download-textures.js — Bulk-download item/block textures for all supported
* mods into the local texture cache.
*
* Supported mods (Prominence Hasturian Era II modpack):
* Minecraft, Create, CC:Tweaked, Mythic Metals, Farmer's Delight,
* Ad Astra, BetterEnd, Applied Energistics 2, Twilight Forest
*
* Run once (or on container start) to pre-populate the cache so the proxy
* never needs to hit upstream for known textures.
*
* Usage: node download-textures.js [cacheDir]
* Default: /data/texture-cache (matches server.js)
*/
import fs from 'fs';
import path from 'path';
const CACHE_DIR = process.argv[2] || process.env.TEXTURE_CACHE_DIR || '/data/texture-cache';
// ── Upstream repos (same namespaces as TEXTURE_UPSTREAMS in server.js) ─────
const REPOS = {
minecraft: {
api: 'https://api.github.com/repos/InventivetalentDev/minecraft-assets/git/trees/1.21.4?recursive=1',
raw: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4',
prefix: 'assets/minecraft/textures/',
folders: ['item/', 'block/'],
},
create: {
api: 'https://api.github.com/repos/Creators-of-Create/Create/git/trees/mc1.20.1/dev?recursive=1',
raw: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev',
prefix: 'src/main/resources/assets/create/textures/',
folders: ['item/', 'block/'],
},
computercraft: {
api: 'https://api.github.com/repos/cc-tweaked/CC-Tweaked/git/trees/mc-1.20.x?recursive=1',
raw: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x',
prefix: 'projects/common/src/main/resources/assets/computercraft/textures/',
folders: ['item/', 'block/'],
},
mythicmetals: {
api: 'https://api.github.com/repos/Noaaan/MythicMetals/git/trees/1.20?recursive=1',
raw: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20',
prefix: 'src/main/resources/assets/mythicmetals/textures/',
folders: ['item/', 'block/'],
},
farmersdelight: {
api: 'https://api.github.com/repos/vectorwing/FarmersDelight/git/trees/1.20?recursive=1',
raw: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20',
prefix: 'src/main/resources/assets/farmersdelight/textures/',
folders: ['item/', 'block/'],
},
ad_astra: {
api: 'https://api.github.com/repos/terrarium-earth/Ad-Astra/git/trees/1.20.1?recursive=1',
raw: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1',
prefix: 'common/src/main/resources/assets/ad_astra/textures/',
folders: ['item/', 'block/'],
},
betterend: {
api: 'https://api.github.com/repos/quiqueck/BetterEnd/git/trees/1.20?recursive=1',
raw: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20',
prefix: 'src/main/resources/assets/betterend/textures/',
folders: ['item/', 'block/'],
},
ae2: {
api: 'https://api.github.com/repos/AppliedEnergistics/Applied-Energistics-2/git/trees/fabric/1.20.1?recursive=1',
raw: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1',
prefix: 'src/main/resources/assets/ae2/textures/',
folders: ['item/', 'block/'],
},
twilightforest: {
api: 'https://api.github.com/repos/TeamTwilight/twilightforest/git/trees/1.20.1?recursive=1',
raw: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1',
prefix: 'src/main/resources/assets/twilightforest/textures/',
folders: ['item/', 'block/'],
},
};
// ── Rate-limit-friendly parallel downloader ────────────────────────────────
const CONCURRENCY = 10;
const RETRY_DELAY = 1000;
const MAX_RETRIES = 3;
async function downloadFile(url, dest, retries = 0) {
try {
const res = await fetch(url);
if (res.status === 404) return false; // genuinely missing
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buffer);
return true;
} catch (err) {
if (retries < MAX_RETRIES) {
await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1)));
return downloadFile(url, dest, retries + 1);
}
console.error(`${url}: ${err.message}`);
return false;
}
}
async function runPool(tasks) {
let idx = 0;
let ok = 0;
let fail = 0;
async function worker() {
while (idx < tasks.length) {
const task = tasks[idx++];
const success = await downloadFile(task.url, task.dest);
if (success) ok++;
else fail++;
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => worker());
await Promise.all(workers);
return { ok, fail };
}
// ── Main ───────────────────────────────────────────────────────────────────
async function downloadNamespace(name, repo) {
console.log(`\n${name}: fetching file list…`);
const res = await fetch(repo.api, {
headers: { 'User-Agent': 'inventory-manager-texture-dl' },
});
if (!res.ok) {
console.error(` ✗ GitHub API ${res.status}: ${await res.text()}`);
return;
}
const data = await res.json();
// Filter to only PNG files in the target folders under the prefix
const tasks = [];
for (const entry of data.tree || []) {
if (entry.type !== 'blob') continue;
if (!entry.path.startsWith(repo.prefix)) continue;
if (!entry.path.endsWith('.png')) continue;
const relPath = entry.path.slice(repo.prefix.length); // e.g. "item/diamond.png"
const inFolder = repo.folders.some(f => relPath.startsWith(f));
if (!inFolder) continue;
const dest = path.join(CACHE_DIR, name, relPath);
if (fs.existsSync(dest)) continue; // already cached
tasks.push({
url: `${repo.raw}/${entry.path}`,
dest,
});
}
if (tasks.length === 0) {
console.log(`${name}: all textures already cached`);
return;
}
console.log(` ↓ downloading ${tasks.length} textures…`);
const { ok, fail } = await runPool(tasks);
console.log(`${name}: ${ok} downloaded, ${fail} failed`);
}
async function main() {
console.log(`Texture cache dir: ${CACHE_DIR}`);
fs.mkdirSync(CACHE_DIR, { recursive: true });
for (const [name, repo] of Object.entries(REPOS)) {
await downloadNamespace(name, repo);
}
// Clean up any .miss files so newly-downloaded textures take effect
let cleared = 0;
function cleanMiss(dir) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) cleanMiss(full);
else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; }
}
} catch (_) { /* ignore */ }
}
cleanMiss(CACHE_DIR);
if (cleared) console.log(`\n🗑 Cleared ${cleared} negative-cache entries`);
// Build and print index stats
const stats = {};
function countPngs(dir, ns) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) countPngs(full, ns);
else if (entry.name.endsWith('.png')) stats[ns] = (stats[ns] || 0) + 1;
}
} catch (_) { /* ignore */ }
}
for (const ns of Object.keys(REPOS)) {
countPngs(path.join(CACHE_DIR, ns), ns);
}
console.log('\n📊 Cache totals:');
for (const [ns, count] of Object.entries(stats)) {
console.log(` ${ns}: ${count} textures`);
}
console.log('\n✅ Done');
}
main().catch(err => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@@ -8,6 +8,7 @@
"name": "inventory-manager-server",
"version": "1.0.0",
"dependencies": {
"@cc-platform/server": "file:../../../cc-platform-core/server",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.18.2",
@@ -18,6 +19,23 @@
"vitest": "^3.2.1"
}
},
"../../../cc-platform-core/server": {
"name": "@cc-platform/server",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.0.0",
"express": "^4.21.0",
"ws": "^8.18.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@cc-platform/server": {
"resolved": "../../../cc-platform-core/server",
"link": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",

View File

@@ -7,10 +7,12 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"download-textures": "node download-textures.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@cc-platform/server": "file:../../../cc-platform-core/server",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.18.2",

View File

@@ -1,91 +1,152 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
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);
const app = express();
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin.
// If you need direct server access during dev, add: app.use(require('cors')())
app.disable('x-powered-by');
app.use(express.json({ limit: '5mb' }));
// ========== API Key Authentication ==========
const API_KEY = process.env.API_KEY || '';
// Validate bearer token from Authorization header or ?key= query param
function extractApiKey(req) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) return auth.slice(7);
return req.query.key || '';
}
// Middleware: require API key on mutating endpoints
function requireAuth(req, res, next) {
if (!API_KEY) return next(); // Auth disabled when no key configured
const token = extractApiKey(req);
if (token === API_KEY) return next();
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
}
// Apply auth to all POST/PUT/DELETE routes
app.use((req, res, next) => {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next(); // Read-only endpoints stay open
}
return requireAuth(req, res, next);
// ========== 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,
}),
});
// ========== Rate Limiting (in-memory, no external dependencies) ==========
const { requireAuth } = auth;
function createRateLimiter(windowMs, max) {
const hits = new Map();
setInterval(() => {
const cutoff = Date.now() - windowMs;
for (const [key, entry] of hits) {
if (entry.start < cutoff) hits.delete(key);
}
}, windowMs);
return (req, res, next) => {
const key = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
hits.set(key, { start: now, count: 1 });
return next();
}
entry.count++;
if (entry.count > max) {
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
return res.status(429).json({ error: 'Too many requests — try again later' });
}
return next();
};
}
// API key reference needed for WebSocket upgrade authentication
const API_KEY = process.env.API_KEY || '';
// 30 mutating requests per minute per IP (excludes bridge state updates)
const commandLimiter = createRateLimiter(60_000, 30);
// 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.method !== 'POST' || req.path.startsWith('/api/bridge/')) return 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: (ws) => {
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
// Flush any pending commands queued while no WS bridge was connected.
// Replaces the implicit sync that HTTP polling previously provided.
const pending = getPendingCommands();
if (pending.length > 0) {
try {
ws.send(JSON.stringify({ type: 'command_batch', commands: pending }));
console.log(`[Bridge] Flushed ${pending.length} pending command(s) via WS`);
} catch (e) {
console.error('[Bridge] Failed to flush pending commands:', e.message);
}
}
},
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 ==========
const webClients = new Set();
const bridgeClients = new Set();
// Load persisted state from SQLite on startup
console.log('💾 Loading persisted state from database...');
@@ -138,31 +199,25 @@ function recordCommand(commandId, result) {
// ========== Helpers ==========
// Broadcasts data to all connected web dashboard clients.
// Delegates to platform WS manager (handles JSON serialization, error handling).
function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) {
try {
client.send(message);
} catch (err) {
console.error('❌ WS send error (client):', err.message);
}
}
});
wsManager.broadcastToClients(data);
}
// Returns a filtered copy of pending commands (clears expired entries).
// Used by both the HTTP polling endpoint and WS initial sync.
function getPendingCommands() {
const now = Date.now();
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
return [...pendingCommands];
}
// 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) {
let sent = false;
for (const bridge of bridgeClients) {
if (bridge.readyState === 1) {
try {
bridge.send(JSON.stringify(command));
sent = true;
} catch (err) {
console.error('❌ WS send error (bridge):', err.message);
}
}
}
const sent = wsManager.sendToBridge(command);
if (!sent) {
// Fallback: queue for HTTP polling with monotonic ID
const id = nextCommandId++;
@@ -171,27 +226,266 @@ function pushCommandToBridge(command) {
}
}
// ========== HTTP Server ==========
const server = createServer(app);
// ========== 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
// 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]; // e.g. "item/diamond.png"
const texturePath = req.params[0];
const upstream = TEXTURE_UPSTREAMS[namespace];
if (!upstream) {
return res.status(404).send('Unknown namespace');
@@ -282,16 +576,7 @@ app.delete('/api/texture-cache/negative', (req, res) => {
res.json({ cleared });
});
// Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
lastUpdate,
uptime: process.uptime(),
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
});
});
// Health endpoint provided by platform (createPlatformServer) with custom extras
// Get current inventory state
app.get('/api/inventory', (req, res) => {
@@ -721,11 +1006,7 @@ app.post('/api/bridge/state', (req, res) => {
// 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];
const commands = getPendingCommands();
res.json({ commands });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -901,187 +1182,19 @@ function updateStateFromBridge(data) {
}
}
// ========== WebSocket Server ==========
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
// ========== 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`);
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)');
}
// Authenticate WebSocket upgrades
server.on('upgrade', (req, socket, head) => {
if (API_KEY) {
// Extract key from query string: /ws?key=... or /ws/bridge?key=...
const urlObj = new URL(req.url, `http://${req.headers.host}`);
const token = urlObj.searchParams.get('key') || '';
if (token !== API_KEY) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
const url = req.url || '';
// ---- Bridge WebSocket connection ----
if (url.startsWith('/ws/bridge')) {
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
bridgeClients.add(ws);
ws.isAlive = true;
// Notify web clients that the bridge is now connected
broadcastToClients({
type: 'state_update',
bridgeConnected: true,
});
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw);
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (data.type === 'state') {
// Full state update from bridge
updateStateFromBridge(data);
} else if (data.type === 'command_result') {
// Command result from bridge — include commandId
broadcastToClients({
type: 'command_result',
commandId: data.commandId,
action: data.action,
success: data.success,
message: data.message,
error: data.error,
});
}
} catch (error) {
console.error('❌ Bridge WS message error:', error.message);
}
});
ws.on('close', () => {
console.log('🌉 CC:Tweaked bridge disconnected');
bridgeClients.delete(ws);
// Notify web clients that the bridge may be disconnected
broadcastToClients({
type: 'state_update',
bridgeConnected: bridgeClients.size > 0,
});
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('error', (error) => {
console.error('❌ Bridge WS error:', error);
bridgeClients.delete(ws);
broadcastToClients({
type: 'state_update',
bridgeConnected: bridgeClients.size > 0,
});
});
return;
}
// ---- Web client WebSocket connection ----
console.log('🌐 New web client connected');
webClients.add(ws);
ws.isAlive = true;
// Send current state to new client
ws.send(JSON.stringify({
type: 'initial_state',
inventory: inventoryState,
activity: activityState,
alerts: alertsState,
smeltingPaused,
disabledRecipes,
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
dropperNicknames,
}));
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
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
pushCommandToBridge(data);
if (data.commandId) {
recordCommand(data.commandId, { success: true, commandId: data.commandId });
}
}
} catch (error) {
console.error('❌ Error processing web client message:', error);
}
});
ws.on('close', () => {
console.log('👋 Web client disconnected');
webClients.delete(ws);
});
ws.on('error', (error) => {
console.error('❌ WebSocket error:', error);
});
});
// ========== WebSocket Keep-Alive ==========
// Ping all web clients and bridge connections every 25s to keep connections alive
const WS_PING_INTERVAL = setInterval(() => {
webClients.forEach((ws) => {
if (!ws.isAlive) {
webClients.delete(ws);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
bridgeClients.forEach((ws) => {
if (!ws.isAlive) {
console.log('🌉 Bridge connection stale — terminating');
bridgeClients.delete(ws);
broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0 });
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 25000);
wss.on('close', () => {
clearInterval(WS_PING_INTERVAL);
});
// ========== Cross-Project Integration API ==========
// These endpoints allow the RemoteTurtle system to query inventory state
@@ -1141,53 +1254,24 @@ app.get('/api/integration/low-stock', (req, res) => {
});
// Proxy to turtle server for combined dashboard info
app.get('/api/integration/turtle-status', async (req, res) => {
if (!TURTLE_SERVER_URL) {
return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' });
}
try {
const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` });
}
});
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
// ========== Start Server ==========
server.listen(PORT, HOST, () => {
console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
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`);
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}`);
}
});
// Graceful shutdown
function shutdown() {
console.log('\n🛑 Shutting down server...');
try {
wss.close();
server.close();
closeDb();
console.log('💾 Database closed');
} catch (err) {
console.error('❌ Error during shutdown:', err.message);
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Catch unhandled errors to prevent silent crashes
process.on('unhandledRejection', (reason) => {
console.error('❌ Unhandled rejection:', reason);
});
process.on('uncaughtException', (err) => {
console.error('❌ Uncaught exception:', err);
shutdown();
});