Compare commits

2 Commits
main ... stable

Author SHA1 Message Date
MayaTheShy
9835556e6f diag: add verbose network listener logging to trace command delivery
Manager logs: listener start, every modem_message received, channel
match/mismatch, command acceptance count, handler result.
Client logs: every sendToMaster call with type and commandId.
2026-03-29 16:39:29 -04:00
MayaTheShy
033da0933c fix: point package repository to stable branch 2026-03-29 16:22:25 -04:00
25 changed files with 944 additions and 2677 deletions

View File

@@ -1,10 +1,7 @@
{ {
required = { title = "Inventory Manager",
'platform', description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
}, repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/stable/",
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 = { exclude = {
"^web/", "^__tests__/", "^startup/", "^web/", "^__tests__/", "^startup/",
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/", "%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",

110
README.md
View File

@@ -47,9 +47,7 @@ A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc
| `craftingTurtle.lua` | **Crafting Worker** | Runs on a Crafting Turtle. When the master places ingredients in its grid, it auto-crafts and reports results. Must be connected to the master via wired network. | | `craftingTurtle.lua` | **Crafting Worker** | Runs on a Crafting Turtle. When the master places ingredients in its grid, it auto-crafts and reports results. Must be connected to the master via wired network. |
| `dropperController.lua` | **Dropper Driver** | Pulses redstone to fire items out of a dropper block until empty. Runs on a computer adjacent to the dropper. | | `dropperController.lua` | **Dropper Driver** | Pulses redstone to fire items out of a dropper block until empty. Runs on a computer adjacent to the dropper. |
| `inventoryWebBridge.lua` | **Web Bridge** | Bridges the in-game modem network to the external web server over HTTP/WebSocket. Forwards state from the master and relays commands from the web dashboard back into the game. | | `inventoryWebBridge.lua` | **Web Bridge** | Bridges the in-game modem network to the external web server over HTTP/WebSocket. Forwards state from the master and relays commands from the web dashboard back into the game. |
| `miningTurtle.lua` | **Mining Turtle** | Runs on a mining turtle connected to the wired network. Continuously mines downward, auto-dumps inventory to networked storage, auto-refuels from storage, and triggers master scans after dumping. |
| `listDevicesByType.lua` | **Diagnostic Utility** | Lists all peripherals on the wired network grouped by type. Useful for discovering connected chests, furnaces, etc. | | `listDevicesByType.lua` | **Diagnostic Utility** | Lists all peripherals on the wired network grouped by type. Useful for discovering connected chests, furnaces, etc. |
| `autorun/startup.lua` | **Opus Autorun** | Auto-detects the computer's role from config files and launches the appropriate program. For use with [Opus OS](https://github.com/kepler155c/opus). |
### Web Stack (Docker) ### Web Stack (Docker)
@@ -64,27 +62,54 @@ A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc
### Prerequisites ### Prerequisites
- Minecraft with [CC:Tweaked](https://tweaked.cc/) installed - Minecraft with [CC:Tweaked](https://tweaked.cc/) installed
- [Opus OS](https://github.com/kepler155c/opus) installed on all CC:Tweaked computers (required for the UI framework)
- A CC:Tweaked computer with a wired modem and monitor attached - A CC:Tweaked computer with a wired modem and monitor attached
- HTTP access enabled in the CC:Tweaked config (for the web bridge) - HTTP access enabled in the CC:Tweaked config (for the web bridge)
### 1. Install via Opus Package Manager (Recommended) ### 1. Install the Master Controller
On any CC:Tweaked computer running Opus, open the shell and run: On your main CC:Tweaked computer (with the monitor and wired modem), open the terminal and run:
``` ```
package install inventory-manager wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryManager.lua startup.lua
``` ```
An interactive setup wizard will guide you through role selection (Manager, Client, Web Bridge, or Mining Turtle) and peripheral configuration. The package installs all required files and an autorun script that launches the correct program on boot. Reboot after installation. This downloads `inventoryManager.lua` and saves it as `startup.lua` so it runs automatically on boot. Reboot the computer or run `startup.lua` to start.
To update later: ### 2. Install a Display Client (Optional)
On a separate CC:Tweaked computer with a monitor:
``` ```
package update inventory-manager wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryClient.lua startup.lua
``` ```
### 2. Start the Web Dashboard (Optional) ### 3. Install the Crafting Turtle (Optional)
On a **Crafting Turtle** connected to the wired network:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/craftingTurtle.lua startup.lua
```
### 4. Install the Dropper Controller (Optional)
On a computer adjacent to a dropper block:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/dropperController.lua startup.lua
```
### 5. Install the Web Bridge (Optional)
On any CC:Tweaked computer with a wired modem and HTTP access:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryWebBridge.lua startup.lua
```
Configure the web server URL by editing `.webbridge_config` on the computer, or it will default to `http://localhost`.
### 6. Start the Web Dashboard (Optional)
On the host machine, navigate to the `web/` directory and run: On the host machine, navigate to the `web/` directory and run:
@@ -94,79 +119,14 @@ docker compose up -d --build
The dashboard will be available at `http://localhost` on port 80. The dashboard will be available at `http://localhost` on port 80.
### Manual Install (Without Opus)
If you are not using Opus, you can install individual components with `wget`. Note that the monitor dashboard requires the Opus UI framework (`opus.ui`), so Opus is still needed on computers with monitors.
<details>
<summary>Click to expand manual install instructions</summary>
#### Master Controller
On your main CC:Tweaked computer (with the monitor and wired modem):
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/manager.lua startup.lua
```
#### Display Client
On a separate CC:Tweaked computer with a monitor:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/client.lua startup.lua
```
#### Crafting Turtle
On a **Crafting Turtle** connected to the wired network:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/turtle.lua startup.lua
```
#### Mining Turtle
On a **Mining Turtle** connected to the wired network:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/miner.lua startup.lua
```
#### Web Bridge
On any CC:Tweaked computer with a wired modem and HTTP access:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/startup/bridge.lua startup.lua
```
These startup scripts auto-download all required files from the repository before launching.
</details>
## Modem Channels ## Modem Channels
| Channel | Purpose | | Channel | Purpose |
|---------|---------| |---------|---------|
| 4200 | Master → Clients/Bridge (state broadcast) | | 4200 | Master → Clients/Bridge (state broadcast) |
| 4201 | Clients/Bridge → Master (orders & commands) | | 4201 | Clients/Bridge → Master (orders & commands) |
| 4202 | Master → Client (order/craft result replies) |
| 4203 | Master → Crafting Turtle (craft requests) | | 4203 | Master → Crafting Turtle (craft requests) |
| 4204 | Crafting Turtle → Master (craft results) | | 4204 | Crafting Turtle → Master (craft results) |
| 4205 | System channel (remote reboot all computers) |
## Startup Scripts
The `startup/` directory contains self-updating startup scripts that download the latest code from the git repository before launching. These are used by the manual install method above and can also be used standalone:
| Script | Role |
|--------|------|
| `startup/manager.lua` | Master controller with auto-update for all manager modules |
| `startup/client.lua` | Client with auto-update, first-run setup wizard, and optional dropper |
| `startup/turtle.lua` | Crafting turtle with auto-update |
| `startup/bridge.lua` | Web bridge with auto-update |
| `startup/miner.lua` | Mining turtle with auto-update |
## License ## License

View File

@@ -180,56 +180,30 @@ local function handleCraftCommand(message)
for turtleSlotStr, info in pairs(slots) do for turtleSlotStr, info in pairs(slots) do
local turtleSlot = tonumber(turtleSlotStr) local turtleSlot = tonumber(turtleSlotStr)
local chestName = info.chestName
local chestSlot = info.chestSlot
local itemName = info.itemName local itemName = info.itemName
local count = info.count or 1 local count = info.count or 1
local placed = 0
-- Try the suggested chest+slot first print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
local chest = peripheral.wrap(info.chestName) itemName, chestName, chestSlot, turtleSlot))
if chest then
local ok, n = pcall(chest.pushItems, selfName, info.chestSlot, count, turtleSlot) local chest = peripheral.wrap(chestName)
if ok and n and n > 0 then if not chest then
placed = n print(string.format("[CRAFT] Cannot wrap chest: %s", chestName))
end allPlaced = false
break
end end
local ok, n = pcall(chest.pushItems, selfName, chestSlot, count, turtleSlot)
-- If we didn't get enough, search ALL chests on the network for this item if ok and n and n > 0 then
if placed < count then
local remaining = count - placed
if placed == 0 then
print(string.format("[CRAFT] %s not at %s:%d, searching network...",
itemName, info.chestName, info.chestSlot))
else
print(string.format("[CRAFT] Got %d/%d %s from hint, searching for %d more...",
placed, count, itemName, remaining))
end
for _, chestName in ipairs(returnChests) do
if remaining <= 0 then break end
local ch = peripheral.wrap(chestName)
if ch then
local contents = ch.list()
if contents then
for slot, slotItem in pairs(contents) do
if slotItem.name == itemName then
local ok, n = pcall(ch.pushItems, selfName, slot, remaining, turtleSlot)
if ok and n and n > 0 then
placed = placed + n
remaining = remaining - n
if remaining <= 0 then break end
end
end
end
end
end
end
end
if placed > 0 then
placedItems[turtleSlot] = itemName placedItems[turtleSlot] = itemName
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, placed, turtleSlot)) print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))
else else
print(string.format("[CRAFT] Could not find %s anywhere on network!", itemName)) if not ok then
print(string.format("[CRAFT] pullItems error: %s", tostring(n)))
else
print(string.format("[CRAFT] pullItems returned %s (expected %d)", tostring(n), count))
end
allPlaced = false allPlaced = false
break break
end end

View File

@@ -1,14 +0,0 @@
-- Auto-craft rules: items that should be automatically crafted when stock exceeds reserve.
-- The system will keep `reserve` of the input item and craft all excess into the output.
-- Requires a crafting turtle to be connected.
--
-- Format: { input = "mod:item", reserve = N, output = "mod:output_item" }
-- The output item must have a recipe in data/craftable.lua (or learned via recipeBook).
-- Recursive crafting is used, so intermediate steps are handled automatically.
return {
-- Convert excess bamboo into planks (keeps 128 raw bamboo as reserve)
{ input = "minecraft:bamboo", reserve = 128, output = "minecraft:bamboo_planks" },
-- Convert excess bamboo blocks into planks too (keeps 64 blocks as reserve)
{ input = "minecraft:bamboo_block", reserve = 64, output = "minecraft:bamboo_planks" },
}

View File

@@ -31,24 +31,6 @@ return {
nil, nil, nil, nil, nil, nil,
}, },
}, },
{
output = "minecraft:bamboo_block",
count = 1,
grid = {
"minecraft:bamboo", "minecraft:bamboo", nil,
"minecraft:bamboo", "minecraft:bamboo", nil,
"minecraft:bamboo", "minecraft:bamboo", nil,
},
},
{
output = "minecraft:bamboo_planks",
count = 2,
grid = {
"minecraft:bamboo_block", nil, nil,
nil, nil, nil,
nil, nil, nil,
},
},
{ {
output = "minecraft:stick", output = "minecraft:stick",
count = 4, count = 4,
@@ -123,114 +105,6 @@ return {
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
}, },
}, },
{
output = "minecraft:cobblestone_slab",
count = 6,
grid = {
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:cobblestone_stairs",
count = 4,
grid = {
"minecraft:cobblestone", nil, nil,
"minecraft:cobblestone", "minecraft:cobblestone", nil,
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
},
},
{
output = "minecraft:cobblestone_wall",
count = 6,
grid = {
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
nil, nil, nil,
},
},
{
output = "minecraft:stone_bricks",
count = 4,
grid = {
"minecraft:stone", "minecraft:stone", nil,
"minecraft:stone", "minecraft:stone", nil,
nil, nil, nil,
},
},
{
output = "minecraft:stone_slab",
count = 6,
grid = {
"minecraft:stone", "minecraft:stone", "minecraft:stone",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:stone_stairs",
count = 4,
grid = {
"minecraft:stone", nil, nil,
"minecraft:stone", "minecraft:stone", nil,
"minecraft:stone", "minecraft:stone", "minecraft:stone",
},
},
{
output = "minecraft:smooth_stone_slab",
count = 6,
grid = {
"minecraft:smooth_stone", "minecraft:smooth_stone", "minecraft:smooth_stone",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:polished_andesite",
count = 4,
grid = {
"minecraft:andesite", "minecraft:andesite", nil,
"minecraft:andesite", "minecraft:andesite", nil,
nil, nil, nil,
},
},
{
output = "minecraft:polished_diorite",
count = 4,
grid = {
"minecraft:diorite", "minecraft:diorite", nil,
"minecraft:diorite", "minecraft:diorite", nil,
nil, nil, nil,
},
},
{
output = "minecraft:polished_granite",
count = 4,
grid = {
"minecraft:granite", "minecraft:granite", nil,
"minecraft:granite", "minecraft:granite", nil,
nil, nil, nil,
},
},
{
output = "minecraft:polished_deepslate",
count = 4,
grid = {
"minecraft:cobbled_deepslate", "minecraft:cobbled_deepslate", nil,
"minecraft:cobbled_deepslate", "minecraft:cobbled_deepslate", nil,
nil, nil, nil,
},
},
{
output = "minecraft:polished_tuff",
count = 4,
grid = {
"minecraft:tuff", "minecraft:tuff", nil,
"minecraft:tuff", "minecraft:tuff", nil,
nil, nil, nil,
},
},
{ {
output = "minecraft:ladder", output = "minecraft:ladder",
count = 3, count = 3,

View File

@@ -8,6 +8,7 @@ return {
{ name = "minecraft:coal_block", burn_time = 80 }, { name = "minecraft:coal_block", burn_time = 80 },
{ name = "minecraft:blaze_rod", burn_time = 12 }, { name = "minecraft:blaze_rod", burn_time = 12 },
{ name = "minecraft:dried_kelp_block", burn_time = 20 }, { 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:oak_planks", burn_time = 1.5 },
{ name = "minecraft:spruce_planks",burn_time = 1.5 }, { name = "minecraft:spruce_planks",burn_time = 1.5 },
{ name = "minecraft:birch_planks", burn_time = 1.5 }, { name = "minecraft:birch_planks", burn_time = 1.5 },

View File

@@ -1,20 +0,0 @@
-- Stock limits: maximum quantities to keep in storage.
-- When an item exceeds its limit, the excess is pushed to the trash dropper.
-- Set up a dropper facing lava, cactus, or void to destroy excess items.
--
-- Format: ["mod:item"] = maxCount
-- Only items listed here are subject to auto-discard; everything else is unlimited.
return {
["minecraft:cobblestone"] = 8192, -- 128 slots (~5 chests)
["minecraft:stone"] = 4096,
["minecraft:smooth_stone"] = 4096,
["minecraft:cobbled_deepslate"] = 4096,
["minecraft:netherrack"] = 2048,
["minecraft:dirt"] = 2048,
["minecraft:gravel"] = 2048,
["minecraft:andesite"] = 2048,
["minecraft:diorite"] = 2048,
["minecraft:granite"] = 2048,
["minecraft:tuff"] = 1024,
}

View File

@@ -42,6 +42,7 @@ end
-- Override dofile to load modules into our _ENV so they inherit -- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead). -- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV) local fn, err = loadfile(path, nil, _ENV)
if fn then return fn() if fn then return fn()
@@ -55,7 +56,6 @@ local CLIENT_CONFIG_FILE = _configPath(".client_config")
------------------------------------------------- -------------------------------------------------
local log = dofile(_path("lib/log.lua")) local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local function loadConfig() local function loadConfig()
if not fs.exists(CLIENT_CONFIG_FILE) then return end if not fs.exists(CLIENT_CONFIG_FILE) then return end
@@ -105,10 +105,14 @@ end
------------------------------------------------- -------------------------------------------------
local function sendToMaster(message) local function sendToMaster(message)
if not networkModem then return end if not networkModem then
log.warn("NET", "sendToMaster: no modem, dropping %s", tostring(message.type))
return
end
if not message.commandId then if not message.commandId then
message.commandId = newCommandId() message.commandId = newCommandId()
end end
log.info("NET", "TX -> ch %d: type=%s cmdId=%s", ORDER_CHANNEL, tostring(message.type), tostring(message.commandId))
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message) networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
end end
@@ -120,6 +124,7 @@ end
local state = {} local state = {}
state.cache = { state.cache = {
catalogue = {},
itemList = {}, itemList = {},
itemListDirty = false, itemListDirty = false,
grandTotal = 0, grandTotal = 0,
@@ -184,11 +189,46 @@ local function getItemTotal(itemName)
return 0 return 0
end end
-- Delegate recipe helpers to shared lib/ui.lua with local stock lookup. function ops.getRecipeIngredients(recipe)
function ops.getRecipeIngredients(recipe) return ui.getRecipeIngredients(recipe) end local ingredients = {}
function ops.canCraftRecipe(recipe) return ui.canCraftRecipe(recipe, getItemTotal) end for _, item in ipairs(recipe.grid) do
function ops.maxCraftBatches(recipe) return ui.maxCraftBatches(recipe, getItemTotal) end if item then
function ops.getMissingIngredients(recipe) return ui.getMissingIngredients(recipe, getItemTotal) end 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
function ops.orderItem(itemName, amount) function ops.orderItem(itemName, amount)
sendToMaster({ sendToMaster({
@@ -356,8 +396,10 @@ local function main()
c.barrelOk = message.cache.barrelOk c.barrelOk = message.cache.barrelOk
c.furnaceCount = message.cache.furnaceCount ~= nil and message.cache.furnaceCount or c.furnaceCount c.furnaceCount = message.cache.furnaceCount ~= nil and message.cache.furnaceCount or c.furnaceCount
c.furnaceStatus = message.cache.furnaceStatus or c.furnaceStatus c.furnaceStatus = message.cache.furnaceStatus or c.furnaceStatus
if message.cache.droppers then -- Also build catalogue from itemList so display.lua
c.droppers = message.cache.droppers -- smelter tab can look up stock by item name
if message.cache.catalogue then
c.catalogue = message.cache.catalogue
end end
end end
if message.activity then if message.activity then

View File

@@ -39,6 +39,7 @@ local ok, err = xpcall(function()
-- Override dofile to load modules into our _ENV so they inherit -- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead). -- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV) local fn, err = loadfile(path, nil, _ENV)
if fn then return fn() if fn then return fn()
@@ -51,13 +52,14 @@ end
local log = dofile(_path("lib/log.lua")) local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua")) local ui = dofile(_path("lib/ui.lua"))
local itemDB = dofile(_path("lib/itemDB.lua"))
itemDB.init(_configPath(".item_names.db"))
------------------------------------------------- -------------------------------------------------
-- Load modules (factory pattern → shared context) -- Load modules (factory pattern → shared context)
------------------------------------------------- -------------------------------------------------
local cfg = dofile(_path("manager/config.lua"))(log, _path) local cfg = dofile(_path("manager/config.lua"))(log, _path)
cfg.loadConfig()
local state = dofile(_path("manager/state.lua"))() local state = dofile(_path("manager/state.lua"))()
-- Shared context table (Lua tables are by-reference, so all -- Shared context table (Lua tables are by-reference, so all
@@ -83,6 +85,7 @@ ctx.display = display
local craftEngine = dofile(_path("lib/craft.lua")) local craftEngine = dofile(_path("lib/craft.lua"))
craftEngine.init(cfg.recipeBook, ops.getItemTotal) craftEngine.init(cfg.recipeBook, ops.getItemTotal)
ctx.craftEngine = craftEngine ctx.craftEngine = craftEngine
ctx.itemDB = itemDB
-- Convenience aliases -- Convenience aliases
local cache = state.cache local cache = state.cache
@@ -148,12 +151,9 @@ local function broadcastState()
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly -- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
ctx.craftTurtleOk = payload.craftTurtleOk ctx.craftTurtleOk = payload.craftTurtleOk
-- Only include recipe tables when config has changed (they're large). payload.smeltable = cfg.SMELTABLE
if state.configDirty then payload.craftable = cfg.CRAFTABLE
payload.smeltable = cfg.SMELTABLE state.configDirty = false
payload.craftable = cfg.CRAFTABLE
state.configDirty = false
end
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload) ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
state.lastBroadcastVersion = state.stateVersion state.lastBroadcastVersion = state.stateVersion
@@ -195,14 +195,6 @@ local function main()
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE) log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
end 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 -- Find wired modem for client/turtle communication
for _, name in ipairs(peripheral.getNames()) do for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then if peripheral.getType(name) == "modem" then
@@ -358,40 +350,13 @@ local function main()
end end
print("") print("")
-----------------------------------------------
-- Resilient task wrapper: if a task crashes, it
-- restarts after a brief delay instead of killing
-- all other parallel tasks.
-----------------------------------------------
local function resilient(name, fn)
return function()
while true do
local ok, err = pcall(fn)
if not ok then
log.error("TASK", "%s crashed: %s", name, tostring(err))
sleep(5)
log.info("TASK", "%s restarting...", name)
else
-- Task returned normally (shouldn't happen)
log.warn("TASK", "%s exited unexpectedly, restarting...", name)
sleep(1)
end
end
end
end
----------------------------------------------- -----------------------------------------------
-- Parallel tasks -- 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( parallel.waitForAny(
-- Task 1: Background inventory scanner -- Task 1: Background inventory scanner
resilient("Scanner", function() function()
if cacheLoaded then if cacheLoaded then
pcall(ops.refreshCache) pcall(ops.refreshCache)
pcall(ops.checkAlerts) pcall(ops.checkAlerts)
@@ -403,22 +368,23 @@ local function main()
sleep(cfg.SCAN_INTERVAL) sleep(cfg.SCAN_INTERVAL)
pcall(ops.refreshCache) pcall(ops.refreshCache)
pcall(ops.checkAlerts) pcall(ops.checkAlerts)
pcall(function() itemDB.flush() end)
pcall(function() cfg.recipeBook.flush() end) pcall(function() cfg.recipeBook.flush() end)
state.needsRedraw = true state.needsRedraw = true
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
end end
end), end,
-- Task 2: Barrel auto-sort -- Task 2: Barrel auto-sort
resilient("Barrel-sort", function() function()
while true do while true do
pcall(ops.sortBarrel) pcall(ops.sortBarrel)
sleep(cfg.POLL_INTERVAL) sleep(cfg.POLL_INTERVAL)
end end
end), end,
-- Task 3: Auto-smelt -- Task 3: Auto-smelt
resilient("Auto-smelt", function() function()
while true do while true do
local ok, didWork = pcall(ops.autoSmelt) local ok, didWork = pcall(ops.autoSmelt)
if ok and didWork then if ok and didWork then
@@ -432,10 +398,10 @@ local function main()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
sleep(cfg.SMELT_INTERVAL) sleep(cfg.SMELT_INTERVAL)
end end
end), end,
-- Task 4: Defrag (consolidate partial stacks) -- Task 4: Defrag (consolidate partial stacks)
resilient("Defrag", function() function()
sleep(10) sleep(10)
while true do while true do
activity.defragging = true activity.defragging = true
@@ -445,10 +411,10 @@ local function main()
state.needsRedraw = true state.needsRedraw = true
sleep(cfg.DEFRAG_INTERVAL) sleep(cfg.DEFRAG_INTERVAL)
end end
end), end,
-- Task 5: Auto-compost -- Task 5: Auto-compost
resilient("Auto-compost", function() function()
while true do while true do
activity.composting = true activity.composting = true
state.needsRedraw = true state.needsRedraw = true
@@ -458,57 +424,10 @@ local function main()
pcall(ops.checkAlerts) pcall(ops.checkAlerts)
sleep(cfg.COMPOST_INTERVAL) sleep(cfg.COMPOST_INTERVAL)
end end
end), end,
-- Task 5b: Auto-discard excess stock
resilient("Auto-discard", function()
if #cfg.TRASH_DROPPERS == 0 then
while true do sleep(3600) end
end
sleep(8) -- let initial scan finish first
log.info("DISCARD", "Auto-discard active with %d trash dropper(s)", #cfg.TRASH_DROPPERS)
while true do
activity.discarding = true
state.needsRedraw = true
pcall(ops.discardExcess)
activity.discarding = false
state.needsRedraw = true
sleep(cfg.DISCARD_INTERVAL)
end
end),
-- Task 5c: Auto-craft excess items into target products
resilient("Auto-craft", function()
sleep(12) -- let initial scan + discard settle first
log.info("AUTOCRAFT", "Auto-craft active (%d explicit rule(s), smart=%s)",
#cfg.AUTO_CRAFT_RULES, tostring(cfg.AUTO_CRAFT_FROM_EXCESS))
while true do
if ctx.craftTurtleName then
activity.autocrafting = true
state.needsRedraw = true
pcall(ops.autoCraft)
activity.autocrafting = false
state.needsRedraw = true
end
sleep(cfg.AUTO_CRAFT_INTERVAL)
end
end),
-- Task 5d: Collection hopper emptying (egg spawner, etc.)
resilient("Hopper-collect", function()
if #cfg.COLLECTION_HOPPERS == 0 then
while true do sleep(3600) end
end
sleep(3)
log.info("COLLECT", "Hopper collection active for %d hopper(s)", #cfg.COLLECTION_HOPPERS)
while true do
pcall(ops.collectHoppers)
sleep(cfg.COLLECTION_INTERVAL)
end
end),
-- Task 6: Low-stock alert checker -- Task 6: Low-stock alert checker
resilient("Alert-checker", function() function()
sleep(5) sleep(5)
pcall(ops.checkAlerts) pcall(ops.checkAlerts)
state.needsRedraw = true state.needsRedraw = true
@@ -517,10 +436,10 @@ local function main()
pcall(ops.checkAlerts) pcall(ops.checkAlerts)
state.needsRedraw = true state.needsRedraw = true
end end
end), end,
-- Task 7: Main dashboard redraw (event-driven, polls 0.1s) -- Task 7: Main dashboard redraw (event-driven, polls 0.1s)
resilient("Dashboard", function() function()
state.needsRedraw = true state.needsRedraw = true
while true do while true do
if state.needsRedraw then if state.needsRedraw then
@@ -540,10 +459,10 @@ local function main()
end end
sleep(0.1) sleep(0.1)
end end
end), end,
-- Task 8: Smelter dashboard redraw -- Task 8: Smelter dashboard redraw
resilient("Smelter-dashboard", function() function()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
while true do while true do
if state.smelterNeedsRedraw then if state.smelterNeedsRedraw then
@@ -553,27 +472,10 @@ local function main()
end end
sleep(0.1) sleep(0.1)
end end
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) -- Task 9: Touch event listener (both monitors)
resilient("Touch-listener", function() function()
while true do while true do
local event, side, x, y = os.pullEvent("monitor_touch") local event, side, x, y = os.pullEvent("monitor_touch")
if display.smelterMonName and side == display.smelterMonName then if display.smelterMonName and side == display.smelterMonName then
@@ -584,20 +486,20 @@ local function main()
display.handleTouch(x, y) display.handleTouch(x, y)
end end
end end
end), end,
-- Task 10: Network state broadcast (skips if nothing changed) -- Task 10: Network state broadcast (skips if nothing changed)
resilient("Broadcast", function() function()
while true do while true do
if state.stateVersion ~= state.lastBroadcastVersion then if state.stateVersion ~= state.lastBroadcastVersion then
pcall(broadcastState) pcall(broadcastState)
end end
sleep(cfg.BROADCAST_INTERVAL) sleep(cfg.BROADCAST_INTERVAL)
end end
end), end,
-- Task 11: Peripheral detach handler -- Task 11: Peripheral detach handler
resilient("Detach-handler", function() function()
while true do while true do
local event, name = os.pullEvent("peripheral_detach") local event, name = os.pullEvent("peripheral_detach")
if name then if name then
@@ -610,10 +512,10 @@ local function main()
end end
end end
end end
end), end,
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle) -- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
resilient("Attach-handler", function() function()
while true do while true do
local event, name = os.pullEvent("peripheral_attach") local event, name = os.pullEvent("peripheral_attach")
if name and name:match("^turtle_") and not ctx.craftTurtleName then if name and name:match("^turtle_") and not ctx.craftTurtleName then
@@ -622,10 +524,10 @@ local function main()
pcall(broadcastState) pcall(broadcastState)
end end
end end
end), end,
-- Task 12: Supply chest (builder / manifest-based stocking) -- Task 12: Supply chest (builder / manifest-based stocking)
resilient("Supply-chest", function() function()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
while true do sleep(3600) end while true do sleep(3600) end
end end
@@ -634,55 +536,28 @@ local function main()
pcall(ops.supplyChest) pcall(ops.supplyChest)
sleep(cfg.SUPPLY_INTERVAL) sleep(cfg.SUPPLY_INTERVAL)
end end
end), end,
-- Task 13a: Network message capture (fast — never yields to peripheral calls) -- Task 13: Network order/command listener
-- This coroutine's filter is ALWAYS "modem_message", so it can never function()
-- miss events while other tasks yield for "task_complete" etc.
resilient("Network-capture", function()
if not ctx.networkModem then if not ctx.networkModem then
log.warn("NET-CAP", "No modem — capture task idle") log.warn("NET", "No modem — listener disabled")
while true do sleep(3600) end while true do sleep(3600) end
end end
log.info("NET-CAP", "Capture task started, listening on ch %d", cfg.ORDER_CHANNEL) log.info("NET", "Listener started on channel %d (modem: %s)", cfg.ORDER_CHANNEL, ctx.networkModemName or "?")
local cmdCount = 0
while true do while true do
log.info("NET", "Waiting for modem_message... (handled %d so far)", cmdCount)
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message") local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
log.info("NET", "Got modem_message: side=%s ch=%d reply=%d type=%s",
tostring(side), channel or -1, replyChannel or -1,
type(message) == "table" and tostring(message.type) or type(message))
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then 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 if isCommandDuplicate(message.commandId) then
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId)) 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 else
cmdCount = cmdCount + 1
log.info("NET", "Command #%d accepted: type=%s cmdId=%s", cmdCount, tostring(message.type), tostring(message.commandId))
recordCommandId(message.commandId) recordCommandId(message.commandId)
cleanupCommandIds() cleanupCommandIds()
local handlerOk, handlerErr = pcall(function() local handlerOk, handlerErr = pcall(function()
@@ -717,13 +592,6 @@ local function main()
state.needsRedraw = true state.needsRedraw = true
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
pcall(broadcastState) 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 elseif message.type == "toggle_pause" then
state.smeltingPaused = not state.smeltingPaused state.smeltingPaused = not state.smeltingPaused
@@ -733,13 +601,6 @@ local function main()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
state.needsRedraw = true state.needsRedraw = true
pcall(broadcastState) 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 elseif message.type == "toggle_recipe" and message.recipe then
if state.disabledRecipes[message.recipe] then if state.disabledRecipes[message.recipe] then
@@ -753,13 +614,6 @@ local function main()
state.bumpStateVersion() state.bumpStateVersion()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
pcall(broadcastState) 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 elseif message.type == "enable_all" then
state.disabledRecipes = {} state.disabledRecipes = {}
@@ -769,13 +623,6 @@ local function main()
state.bumpStateVersion() state.bumpStateVersion()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
pcall(broadcastState) 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 elseif message.type == "disable_all" then
for inputName in pairs(cfg.SMELTABLE) do for inputName in pairs(cfg.SMELTABLE) do
@@ -787,25 +634,11 @@ local function main()
state.bumpStateVersion() state.bumpStateVersion()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
pcall(broadcastState) 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 elseif message.type == "sort_barrel" and message.barrelName then
log.info("NET", "Sort barrel: %s", message.barrelName) log.info("NET", "Sort barrel: %s", message.barrelName)
pcall(ops.sortBarrel, message.barrelName) pcall(ops.sortBarrel, message.barrelName)
pcall(broadcastState) 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 elseif message.type == "register_droppers" and message.clientId and message.droppers then
local cid = tostring(message.clientId) local cid = tostring(message.clientId)
@@ -898,13 +731,6 @@ local function main()
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true
state.needsRedraw = true state.needsRedraw = true
pcall(broadcastState) 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 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) cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
@@ -914,13 +740,6 @@ local function main()
state.configDirty = true state.configDirty = true
state.bumpStateVersion() state.bumpStateVersion()
pcall(broadcastState) 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 elseif message.type == "learn_smelting_recipe" and message.input and message.result then
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces) cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
@@ -930,13 +749,6 @@ local function main()
state.configDirty = true state.configDirty = true
state.bumpStateVersion() state.bumpStateVersion()
pcall(broadcastState) 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 elseif message.type == "forget_recipe" and message.recipe then
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
@@ -949,13 +761,6 @@ local function main()
state.bumpStateVersion() state.bumpStateVersion()
end end
pcall(broadcastState) 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 elseif message.type == "find_item" and message.items then
-- Return chest+slot locations for the first matching item -- Return chest+slot locations for the first matching item
@@ -998,11 +803,15 @@ local function main()
end) -- pcall handler end) -- pcall handler
if not handlerOk then if not handlerOk then
log.error("NET", "Handler error: %s", tostring(handlerErr)) log.error("NET", "Handler error: %s", tostring(handlerErr))
else
log.info("NET", "Command #%d handled OK", cmdCount)
end end
end -- idempotency else end -- idempotency else
end -- queue loop else
log.info("NET", "Ignored: ch=%d (want %d) or not table", channel or -1, cfg.ORDER_CHANNEL)
end
end end
end) end
) )
end end

View File

@@ -3,51 +3,55 @@
-- Listens to the inventory master's broadcasts via modem and -- Listens to the inventory master's broadcasts via modem and
-- forwards state to the web server via HTTP. -- forwards state to the web server via HTTP.
-- Also polls the web server for commands and sends them to the master. -- 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 (via platform) -- 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
-------------------------------------------------
-- Load config from file if present
------------------------------------------------- -------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram()) local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
local config, configSource = WebBridge.loadConfig({ -- Persistent config path: survives Opus package updates
serverUrl = "http://localhost", local _PERSIST_DIR = "usr/config/inventory-manager"
pollInterval = 0.5, local function _configPath(rel)
stateInterval = 1, if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
apiKey = nil, if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
}, { return fs.combine(_PERSIST_DIR, rel)
"usr/config/inventory-manager/.webbridge_config", end
fs.combine(_baseDir, ".webbridge_config"), return _path(rel)
})
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 end
-- Channels from platform registry (matches inventoryManager.lua) local CONFIG_FILE = _configPath(".webbridge_config")
local BROADCAST_CHANNEL = Channels.get('inventory.broadcast') local API_KEY = nil -- optional API key for server auth
local ORDER_CHANNEL = Channels.get('inventory.order')
local BRIDGE_REPLY_CHANNEL = Channels.get('inventory.bridge') 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
------------------------------------------------- -------------------------------------------------
-- State -- State
@@ -58,42 +62,64 @@ local modem = nil
local modemName = nil local modemName = nil
local running = true local running = true
-- WebSocket state (real-time transport, with HTTP polling fallback) -------------------------------------------------
local ws = nil -- active WebSocket handle (nil when not connected) -- Find modem
local wsConnected = false -- gates WS vs HTTP transport selection -------------------------------------------------
local wsHasSynced = false -- true after first command_batch received from server
-- Reliable modem delivery: pending commands awaiting manager acknowledgment. local function findModem()
-- processCommand() inserts here; modemListener removes on result receipt. for _, name in ipairs(peripheral.getNames()) do
-- A retry task periodically re-transmits unacknowledged commands. if peripheral.getType(name) == "modem" then
local pendingModem = {} -- commandId -> { payload, channel, replyChannel, sent, retries } modem = peripheral.wrap(name)
local MODEM_RETRY_INTERVAL = 2 -- seconds between retry sweeps modemName = name
local MODEM_RETRY_MAX = 5 -- max retransmissions before giving up modem.open(BROADCAST_CHANNEL)
local MODEM_RETRY_DELAY = 2 -- seconds before first retry (per command) return true
end
end
return false
end
------------------------------------------------- -------------------------------------------------
-- HTTP helpers (thin wrappers around platform) -- HTTP helpers
------------------------------------------------- -------------------------------------------------
local function httpPost(path, body) local function httpPost(path, body)
local result, err = WebBridge.httpPost(SERVER_URL .. path, body, local url = SERVER_URL .. path
WebBridge.authHeaders(API_KEY)) local data = textutils.serialiseJSON(body)
if not result and err then local headers = { ["Content-Type"] = "application/json" }
print(string.format("[ERR] HTTP POST %s: %s", path, tostring(err))) 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
end end
return result return nil
end end
local function httpGet(path) local function httpGet(path)
local rawBody, err = WebBridge.httpGet(SERVER_URL .. path, local url = SERVER_URL .. path
WebBridge.authHeaders(API_KEY)) local headers = nil
if not rawBody then if API_KEY then headers = { ["Authorization"] = "Bearer " .. API_KEY } end
if err then local ok, result = pcall(function()
print(string.format("[ERR] HTTP GET %s: %s", path, tostring(err))) local response = http.get(url, headers)
if response then
local data = response.readAll()
response.close()
return textutils.unserialiseJSON(data)
end end
return nil end)
if ok then
return result
end end
return textutils.unserialiseJSON(rawBody) return nil
end end
------------------------------------------------- -------------------------------------------------
@@ -105,32 +131,6 @@ local function forwardState()
httpPost("/api/bridge/state", latestState) httpPost("/api/bridge/state", latestState)
end 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 -- Process commands from web server
------------------------------------------------- -------------------------------------------------
@@ -143,55 +143,60 @@ local function processCommand(cmd)
print(string.format("[CMD] %s", action)) print(string.format("[CMD] %s", action))
-- Build the modem payload from the server command
local payload
if action == "order" then if action == "order" then
payload = { modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "order", type = "order",
commandId = cmd.commandId, commandId = cmd.commandId,
itemName = cmd.itemName, itemName = cmd.itemName,
amount = cmd.amount, amount = cmd.amount,
dropperName = cmd.dropperName, dropperName = cmd.dropperName,
} })
elseif action == "scan" then elseif action == "scan" then
payload = { type = "scan", commandId = cmd.commandId } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "scan",
commandId = cmd.commandId,
})
elseif action == "toggle_pause" then elseif action == "toggle_pause" then
payload = { type = "toggle_pause", commandId = cmd.commandId } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "toggle_pause",
commandId = cmd.commandId,
})
elseif action == "toggle_recipe" then elseif action == "toggle_recipe" then
payload = { type = "toggle_recipe", commandId = cmd.commandId, recipe = cmd.recipe } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "toggle_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
elseif action == "enable_all" then elseif action == "enable_all" then
payload = { type = "enable_all", commandId = cmd.commandId } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "enable_all",
commandId = cmd.commandId,
})
elseif action == "disable_all" then elseif action == "disable_all" then
payload = { type = "disable_all", commandId = cmd.commandId } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "disable_all",
commandId = cmd.commandId,
})
elseif action == "sort_barrel" then elseif action == "sort_barrel" then
payload = { type = "sort_barrel", commandId = cmd.commandId, barrelName = cmd.barrelName } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "sort_barrel",
commandId = cmd.commandId,
barrelName = cmd.barrelName,
})
elseif action == "craft" then elseif action == "craft" then
payload = { type = "craft", commandId = cmd.commandId, recipeIdx = cmd.recipeIdx } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
elseif action == "recursive_craft" then type = "craft",
payload = { type = "recursive_craft", commandId = cmd.commandId, itemName = cmd.itemName, count = cmd.count } commandId = cmd.commandId,
elseif action == "learn_crafting_recipe" then recipeIdx = cmd.recipeIdx,
payload = { type = "learn_crafting_recipe", commandId = cmd.commandId, output = cmd.output, count = cmd.count, grid = cmd.grid } })
elseif action == "learn_smelting_recipe" then
payload = { type = "learn_smelting_recipe", commandId = cmd.commandId, input = cmd.input, result = cmd.result, furnaces = cmd.furnaces }
elseif action == "forget_recipe" then
payload = { type = "forget_recipe", commandId = cmd.commandId, recipe = cmd.recipe }
elseif action == "sync_disabled_recipes" then
payload = { type = "sync_disabled_recipes", commandId = cmd.commandId, disabledRecipes = cmd.disabledRecipes, smeltingPaused = cmd.smeltingPaused }
elseif action == "reboot" then elseif action == "reboot" then
payload = { type = "reboot", commandId = cmd.commandId, target = cmd.target or "all" } modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "reboot",
commandId = cmd.commandId,
target = cmd.target or "all",
})
else else
print("[CMD] Unknown action: " .. tostring(action)) 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
end end
@@ -207,95 +212,45 @@ local function modemListener()
if message.type == "state" then if message.type == "state" then
latestState = message latestState = message
end 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
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 end
end end
-- Task 2: Forward state to web server periodically -- Task 2: Forward state to web server periodically
-- Uses WebSocket if connected; falls back to HTTP POST.
local function stateForwarder() local function stateForwarder()
while running do while running do
if latestState then local ok, err = pcall(forwardState)
-- WS-first: send state directly via WebSocket if not ok then
if not wsSend(latestState) then -- Connection error, will retry
-- 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 end
sleep(STATE_INTERVAL) sleep(STATE_INTERVAL)
end end
end end
-- Task 3: Poll web server for commands (HTTP fallback) -- Task 3: Poll web server for commands
-- Active when WS is disconnected, or as safety net until first WS sync.
local lastProcessedId = 0 -- track highest processed command ID for dedup local lastProcessedId = 0 -- track highest processed command ID for dedup
local function commandPoller() local function commandPoller()
while running do while running do
-- HTTP polling is a fallback; also runs until WS initial sync completes local ok, err = pcall(function()
if not wsConnected or not wsHasSynced then local result = httpGet("/api/bridge/commands")
local ok, err = pcall(function() if result and result.commands and #result.commands > 0 then
local result = httpGet("/api/bridge/commands") local maxId = lastProcessedId
if result and result.commands and #result.commands > 0 then -- Process each command, skipping already-processed ones
local maxId = lastProcessedId for _, cmd in ipairs(result.commands) do
-- Process each command, skipping already-processed ones local cmdId = cmd.id or 0
for _, cmd in ipairs(result.commands) do if cmdId > lastProcessedId then
local cmdId = cmd.id or 0 pcall(processCommand, cmd)
if cmdId > lastProcessedId then if cmdId > maxId then maxId = cmdId end
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
end end
end) -- Acknowledge up to the highest processed ID
if not ok then if maxId > lastProcessedId then
print(string.format("[ERR] Command poll: %s", tostring(err))) lastProcessedId = maxId
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
end
end end
end end)
sleep(POLL_INTERVAL) sleep(POLL_INTERVAL)
end end
end end
@@ -320,91 +275,6 @@ local function heartbeat()
end end
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 -- Main
------------------------------------------------- -------------------------------------------------
@@ -415,16 +285,10 @@ local function main()
print("===================================") print("===================================")
print("") print("")
-- Config already loaded at require-time via WebBridge.loadConfig above loadConfig()
modem, modemName = WebBridge.findModem() if findModem() then
if modem then print("[OK] Modem: " .. modemName)
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 else
print("[WARN] No modem found! Bridge needs a modem.") print("[WARN] No modem found! Bridge needs a modem.")
print(" Attach a modem and restart.") print(" Attach a modem and restart.")
@@ -439,7 +303,6 @@ local function main()
else else
print("[WARN] No API key set (open access)") print("[WARN] No API key set (open access)")
end end
print("[OK] Transport: WebSocket (primary) + HTTP polling (fallback)")
print("") print("")
print("Bridge is running. Press Ctrl+T to stop.") print("Bridge is running. Press Ctrl+T to stop.")
print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL) print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL)
@@ -449,9 +312,7 @@ local function main()
modemListener, modemListener,
stateForwarder, stateForwarder,
commandPoller, commandPoller,
heartbeat, heartbeat
wsConnector,
modemRetry
) )
end end

View File

@@ -168,8 +168,7 @@ end
-- @param recipe crafting recipe table -- @param recipe crafting recipe table
-- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem -- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem
-- @return success, error_message -- @return success, error_message
function craft.executeSingleCraft(recipe, ctx, batches) function craft.executeSingleCraft(recipe, ctx)
batches = batches or 1
local ops = ctx.ops local ops = ctx.ops
local cfg = ctx.cfg local cfg = ctx.cfg
local st = ctx.state local st = ctx.state
@@ -178,14 +177,11 @@ function craft.executeSingleCraft(recipe, ctx, batches)
if not ctx.networkModem then return false, "No modem" end if not ctx.networkModem then return false, "No modem" end
if not peripheral.isPresent(ctx.craftTurtleName) then return false, "Turtle offline" end if not peripheral.isPresent(ctx.craftTurtleName) then return false, "Turtle offline" end
-- Clamp batches to 64 (max stack size per slot)
batches = math.min(batches, 64)
local chests = ops.getChests() local chests = ops.getChests()
local slotMap = {} local slotMap = {}
local reserved = {} local reserved = {}
-- Map each grid position to a chest slot, pulling `batches` items per slot -- Map each grid position to a chest slot
for gridPos = 1, 9 do for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos] local itemName = recipe.grid[gridPos]
if itemName then if itemName then
@@ -199,12 +195,11 @@ function craft.executeSingleCraft(recipe, ctx, batches)
for slot, si in pairs(chest.list()) do for slot, si in pairs(chest.list()) do
local key = src.chest .. ":" .. slot local key = src.chest .. ":" .. slot
if si.name == itemName and not reserved[key] then if si.name == itemName and not reserved[key] then
local pullCount = math.min(batches, si.count)
slotMap[tostring(tSlot)] = { slotMap[tostring(tSlot)] = {
chestName = src.chest, chestName = src.chest,
chestSlot = slot, chestSlot = slot,
itemName = itemName, itemName = itemName,
count = pullCount, count = 1,
} }
reserved[key] = true reserved[key] = true
found = true found = true
@@ -303,20 +298,16 @@ function craft.executeChain(targetItem, count, ctx)
ctx.state.needsRedraw = true ctx.state.needsRedraw = true
ctx.state.smelterNeedsRedraw = true ctx.state.smelterNeedsRedraw = true
-- Batch in chunks of 64 (max stack per turtle slot) for batch = 1, step.count do
local remaining = step.count local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx)
while remaining > 0 do
local chunk = math.min(remaining, 64)
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx, chunk)
if not ok then if not ok then
ctx.state.activity.crafting = false ctx.state.activity.crafting = false
ctx.state.needsRedraw = true ctx.state.needsRedraw = true
ctx.log.error("CRAFT", "Chain failed at step %d: %s", i, batchErr) ctx.log.error("CRAFT", "Chain failed at step %d batch %d: %s", i, batch, batchErr)
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr) return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
end end
remaining = remaining - chunk -- Brief pause between batches to let turtle finish
-- Brief pause between chunks to let turtle finish if batch < step.count then os.sleep(0.3) end
if remaining > 0 then os.sleep(0.3) end
end end
ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount) ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount)

View File

@@ -245,22 +245,4 @@ function recipeBook.count()
return cc, sc return cc, sc
end end
--- Find all crafting recipes that use a given item as an ingredient.
-- @param ingredientName string — the input item to search for
-- @return array of recipe tables
function recipeBook.findRecipesUsing(ingredientName)
local results = {}
for _, recipe in pairs(recipes.crafting) do
if recipe.grid then
for _, item in ipairs(recipe.grid) do
if item == ingredientName then
table.insert(results, recipe)
break
end
end
end
end
return results
end
return recipeBook return recipeBook

View File

@@ -34,9 +34,6 @@ C.COMPOST_INTERVAL = 3
C.ALERT_INTERVAL = 15 C.ALERT_INTERVAL = 15
C.CACHE_FILE = _configPath(".inventory_cache") C.CACHE_FILE = _configPath(".inventory_cache")
C.SMELTER_MONITOR_SIDE = "top" 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") C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
-- Network -- Network
@@ -56,26 +53,6 @@ C.COMPOST_RESERVE = 128
C.COMPOST_DROPPER = "minecraft:dropper_10" C.COMPOST_DROPPER = "minecraft:dropper_10"
C.COMPOST_HOPPER = "minecraft:hopper_0" C.COMPOST_HOPPER = "minecraft:hopper_0"
-- Collection hoppers: periodically emptied into storage (e.g. egg spawner, mob farm)
C.COLLECTION_HOPPERS = { "minecraft:hopper_1" }
C.COLLECTION_INTERVAL = 3 -- seconds between hopper collection
-- Stock limits / auto-discard (overridable via config file)
C.TRASH_DROPPERS = { -- droppers facing lava/void for destroying excess items
"minecraft:dropper_11",
"minecraft:dropper_12",
"minecraft:dropper_13",
"minecraft:dropper_14",
"minecraft:dropper_15",
"minecraft:dropper_16",
}
C.DISCARD_INTERVAL = 5 -- seconds between discard checks
-- Auto-craft (overridable via config file)
C.AUTO_CRAFT_INTERVAL = 10 -- seconds between auto-craft checks
C.AUTO_CRAFT_OUTPUT_CAP = 512 -- max items of any output before smart-craft stops crafting it
C.AUTO_CRAFT_FROM_EXCESS = true -- auto-discover recipes for over-stocked items
-- Peripheral -- Peripheral
C.PERIPHERAL_CACHE_TTL = 5 C.PERIPHERAL_CACHE_TTL = 5
@@ -120,10 +97,7 @@ function C.loadConfig()
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide 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.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end
@@ -139,18 +113,6 @@ function C.loadConfig()
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end
if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end
if cfg.collectionHoppers then C.COLLECTION_HOPPERS = cfg.collectionHoppers end
if cfg.collectionInterval then C.COLLECTION_INTERVAL = cfg.collectionInterval end
if cfg.trashDroppers then C.TRASH_DROPPERS = cfg.trashDroppers end
if cfg.discardInterval then C.DISCARD_INTERVAL = cfg.discardInterval end
if cfg.autoCraftInterval then C.AUTO_CRAFT_INTERVAL = cfg.autoCraftInterval end
if cfg.autoCraftOutputCap then C.AUTO_CRAFT_OUTPUT_CAP = cfg.autoCraftOutputCap end
if cfg.autoCraftFromExcess ~= nil then C.AUTO_CRAFT_FROM_EXCESS = cfg.autoCraftFromExcess end
if cfg.stockLimits then
for item, limit in pairs(cfg.stockLimits) do
C.STOCK_LIMITS[item] = limit -- merge / override per-item
end
end
if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end
if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
@@ -168,8 +130,6 @@ C.FUEL_LIST = dofile(_path("data/fuel.lua"))
local _compostData = dofile(_path("data/compostable.lua")) local _compostData = dofile(_path("data/compostable.lua"))
C.COMPOSTABLE = _compostData.items C.COMPOSTABLE = _compostData.items
C.COMPOST_TRASH = _compostData.trash C.COMPOST_TRASH = _compostData.trash
C.STOCK_LIMITS = dofile(_path("data/stock_limits.lua"))
C.AUTO_CRAFT_RULES = dofile(_path("data/auto_craft.lua"))
C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua")) C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
-- Recipe book: merges built-in recipes + user-learned recipes -- Recipe book: merges built-in recipes + user-learned recipes
@@ -224,10 +184,6 @@ for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
C.COMPOSTABLE_SET = {} C.COMPOSTABLE_SET = {}
for _, name in ipairs(C.COMPOSTABLE) do C.COMPOSTABLE_SET[name] = true end for _, name in ipairs(C.COMPOSTABLE) do C.COMPOSTABLE_SET[name] = true end
-- Build stock limits set for quick lookup
C.STOCK_LIMITS_SET = {}
for name, _ in pairs(C.STOCK_LIMITS) do C.STOCK_LIMITS_SET[name] = true end
return C return C
end end

View File

@@ -26,8 +26,6 @@ D.mon = nil
D.monName = nil D.monName = nil
D.smelterMon = nil D.smelterMon = nil
D.smelterMonName = nil D.smelterMonName = nil
D.billboardMon = nil
D.billboardMonName = nil
-- Opus UI devices and pages -- Opus UI devices and pages
local mainDevice = nil local mainDevice = nil
@@ -80,9 +78,7 @@ local function getActivityString()
if activity.smelting then table.insert(parts, "SMELTING") end if activity.smelting then table.insert(parts, "SMELTING") end
if activity.scanning then table.insert(parts, "SCANNING") end if activity.scanning then table.insert(parts, "SCANNING") end
if activity.defragging then table.insert(parts, "DEFRAG") end if activity.defragging then table.insert(parts, "DEFRAG") end
if activity.composting then table.insert(parts, "COMPOST") end if activity.composting then table.insert(parts, "COMPOST") end
if activity.discarding then table.insert(parts, "DISCARD") end
if activity.autocrafting then table.insert(parts, "AUTOCRAFT") end
if #parts > 0 then if #parts > 0 then
return table.concat(parts, " | ") return table.concat(parts, " | ")
end end
@@ -95,8 +91,6 @@ local function getBottomMessage()
elseif activity.sorting then return "SORTING BARREL..." elseif activity.sorting then return "SORTING BARREL..."
elseif activity.defragging then return "DEFRAGMENTING..." elseif activity.defragging then return "DEFRAGMENTING..."
elseif activity.composting then return "COMPOSTING..." elseif activity.composting then return "COMPOSTING..."
elseif activity.discarding then return "DISCARDING EXCESS..."
elseif activity.autocrafting then return "AUTO-CRAFTING..."
end end
return "Tap item to order" return "Tap item to order"
end end
@@ -120,142 +114,48 @@ end
-- Monitor setup -- Monitor setup
------------------------------------------------- -------------------------------------------------
--- Track peripheral names already assigned to a role. local function findMonitor(side, excludeSide)
-- A single physical monitor can appear under multiple names (e.g. "left" local mon = peripheral.wrap(side)
-- AND "monitor_0") when it is both side-attached and on a wired modem. local monName
-- We detect aliases by mutating text scale on one name and checking if mon and mon.setTextScale then
-- whether a known monitor's getSize() changes. monName = side
D._usedMonitorNames = {} -- set of names known to be taken else
mon = nil
--- Detect whether 'candidateName' is the same physical block as 'knownMon' end
-- (a wrapped peripheral table). We temporarily set the candidate's text if not mon then
-- scale to an extreme value and check whether the known monitor reports a for _, name in ipairs(peripheral.getNames()) do
-- size change. If it does, they share the same hardware. if peripheral.getType(name) == "monitor" and name ~= excludeSide then
local function isMonitorAlias(candidateName, knownMon) mon = peripheral.wrap(name)
if not candidateName or not knownMon then return false end monName = name
local refW, refH = knownMon.getSize() break
-- 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 end
end end
return mon, monName
end end
function D.setupMonitor() function D.setupMonitor()
local mon = peripheral.wrap(cfg.MONITOR_SIDE) D.mon, D.monName = findMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_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 if not D.mon then return false end
mainDevice = UI.Device({ mainDevice = UI.Device({
device = D.mon, device = D.mon,
textScale = 0.5, textScale = 0.5,
}) })
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.monName, D.mon)
return true return true
end end
function D.setupSmelterMonitor() function D.setupSmelterMonitor()
-- Try configured side first D.smelterMon, D.smelterMonName = findMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
local mon = peripheral.wrap(cfg.SMELTER_MONITOR_SIDE) if not D.smelterMon then return false end
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({ smelterDevice = UI.Device({
device = D.smelterMon, device = D.smelterMon,
textScale = 0.5, textScale = 0.5,
}) })
-- Register this monitor and all its aliases as taken
registerMonitorAliases(D.smelterMonName, D.smelterMon)
return true return true
end 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 -- Build main dashboard page
------------------------------------------------- -------------------------------------------------
@@ -1512,500 +1412,6 @@ function D.handleSmelterTouch(x, y)
end end
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 return D
end end

View File

@@ -765,279 +765,6 @@ function O.autoCompost()
return didWork return didWork
end end
-------------------------------------------------
-- Collection hopper emptying (egg spawner, mob farm, etc.)
-------------------------------------------------
function O.collectHoppers()
if #cfg.COLLECTION_HOPPERS == 0 then return false end
local didWork = false
local chests = O.getChestsByPriority()
local catalogue = cache.catalogue
for _, hopperName in ipairs(cfg.COLLECTION_HOPPERS) do
local hopper = O.wrapCached(hopperName)
if hopper then
local contents = hopper.list()
if contents and next(contents) then
for slot, item in pairs(contents) do
local moved = 0
local triedChests = {}
-- Prefer chests where item already exists
if catalogue[item.name] then
for _, entry in ipairs(catalogue[item.name]) do
triedChests[entry.chest] = true
local n = hopper.pushItems(entry.chest, slot)
if n and n > 0 then
moved = moved + n
state.adjustCache(item.name, entry.chest, n)
didWork = true
log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, entry.chest, hopperName)
end
if moved >= item.count then break end
end
end
-- Overflow to any chest with space
if moved < item.count then
for _, chest in ipairs(chests) do
if not triedChests[chest] then
local n = hopper.pushItems(chest, slot)
if n and n > 0 then
moved = moved + n
state.adjustCache(item.name, chest, n)
didWork = true
log.info("COLLECT", "%s x%d -> %s (from %s)", item.name, n, chest, hopperName)
end
if moved >= item.count then break end
end
end
end
end
end
end
end
return didWork
end
-------------------------------------------------
-- Auto-discard excess stock
-------------------------------------------------
function O.discardExcess()
if #cfg.TRASH_DROPPERS == 0 then return false end
local catalogue = cache.catalogue
local didWork = false
-- Build a flat list of dropper handles + free capacity
local droppers = {}
for _, dName in ipairs(cfg.TRASH_DROPPERS) do
local d = O.wrapCached(dName)
if d then
local used = 0
local contents = d.list()
if contents then
for _, item in pairs(contents) do used = used + item.count end
end
local free = (d.size() * 64) - used
if free > 0 then
table.insert(droppers, { name = dName, handle = d, free = free })
else
log.debug("DISCARD", "Dropper %s is full, skipping", dName)
end
end
end
if #droppers == 0 then
log.debug("DISCARD", "All trash droppers full or offline, cannot discard")
return false
end
-- Round-robin index across droppers
local dIdx = 1
for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do
if catalogue[itemName] then
local totalInStorage = 0
for _, src in ipairs(catalogue[itemName]) do
totalInStorage = totalInStorage + src.total
end
local excess = totalInStorage - maxCount
if excess > 0 then
log.info("DISCARD", "%s: %d in stock, limit %d, discarding %d",
itemName, totalInStorage, maxCount, excess)
local discarded = 0
local srcSnapshot = { table.unpack(catalogue[itemName]) }
for _, source in ipairs(srcSnapshot) do
if discarded >= excess then break end
if source.total > 0 then
local chest = O.wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
-- Find a dropper with free space (round-robin)
local startIdx = dIdx
local dropper = nil
repeat
if droppers[dIdx].free > 0 then
dropper = droppers[dIdx]
end
dIdx = (dIdx % #droppers) + 1
until dropper or dIdx == startIdx
if not dropper then break end -- all droppers full
local batch = math.min(slotItem.count, excess - discarded, dropper.free)
local n = chest.pushItems(dropper.name, slot, batch)
if n and n > 0 then
state.adjustCache(itemName, source.chest, -n)
dropper.free = dropper.free - n
discarded = discarded + n
didWork = true
end
if discarded >= excess then break end
end
end
end
end
end
if discarded > 0 then
log.info("DISCARD", "Discarded %s x%d", itemName, discarded)
end
end
end
end
return didWork
end
-------------------------------------------------
-- Auto-craft excess stock into target items
-------------------------------------------------
function O.autoCraft()
if not ctx.craftEngine then return false end
if not ctx.craftTurtleName then return false end
local catalogue = cache.catalogue
local didWork = false
-- Phase 1: Explicit rules from auto_craft.lua
for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do
local inputName = rule.input
local reserve = rule.reserve or 0
local outputName = rule.output
if catalogue[inputName] then
local totalInStorage = 0
for _, src in ipairs(catalogue[inputName]) do
totalInStorage = totalInStorage + src.total
end
local excess = totalInStorage - reserve
if excess > 0 then
local recipe = cfg.recipeBook.getCraftingRecipe(outputName)
if recipe then
local ingredients = cfg.recipeBook.getIngredients(recipe)
local inputPerCraft = ingredients[inputName] or 0
if inputPerCraft > 0 then
local batches = math.floor(excess / inputPerCraft)
batches = math.min(batches, 64)
if batches > 0 then
local craftCount = batches * recipe.count
log.info("AUTOCRAFT", "%s: %d excess (reserve %d), crafting %d x %s",
inputName, excess, reserve, craftCount, outputName)
local ok, err = O.recursiveCraft(outputName, craftCount)
if ok then
didWork = true
log.info("AUTOCRAFT", "Crafted %s x%d", outputName, craftCount)
else
log.warn("AUTOCRAFT", "Failed to craft %s: %s", outputName, tostring(err))
end
end
end
else
log.warn("AUTOCRAFT", "No recipe found for output: %s", outputName)
end
end
end
end
-- Phase 2: Smart excess-to-craft — auto-discover recipes for over-stocked items
if cfg.AUTO_CRAFT_FROM_EXCESS then
-- Track outputs we've already handled via explicit rules to avoid duplicates
local handledOutputs = {}
for _, rule in ipairs(cfg.AUTO_CRAFT_RULES) do
handledOutputs[rule.output] = true
end
for itemName, maxCount in pairs(cfg.STOCK_LIMITS) do
if catalogue[itemName] then
local totalInStorage = 0
for _, src in ipairs(catalogue[itemName]) do
totalInStorage = totalInStorage + src.total
end
local excess = totalInStorage - maxCount
if excess > 0 then
-- Find all crafting recipes that use this item
local usingRecipes = cfg.recipeBook.findRecipesUsing(itemName)
for _, recipe in ipairs(usingRecipes) do
if not handledOutputs[recipe.output] then
-- Check how much of the output we already have
local outputTotal = O.getItemTotal(recipe.output)
if outputTotal < cfg.AUTO_CRAFT_OUTPUT_CAP then
local ingredients = cfg.recipeBook.getIngredients(recipe)
local inputPerCraft = ingredients[itemName] or 0
if inputPerCraft > 0 then
-- Only craft up to the output cap
local outputRoom = cfg.AUTO_CRAFT_OUTPUT_CAP - outputTotal
local maxBatches = math.floor(excess / inputPerCraft)
local batchesByRoom = math.ceil(outputRoom / recipe.count)
local batches = math.min(maxBatches, batchesByRoom, 64)
if batches > 0 then
-- Check all other ingredients are available
local canCraft = true
for ingr, needed in pairs(ingredients) do
if ingr ~= itemName then
local have = O.getItemTotal(ingr)
if have < needed * batches then
canCraft = false
break
end
end
end
if canCraft then
local craftCount = batches * recipe.count
log.info("AUTOCRAFT", "Smart: %s over limit, crafting %d x %s",
itemName, craftCount, recipe.output)
local ok, err = O.recursiveCraft(recipe.output, craftCount)
if ok then
didWork = true
handledOutputs[recipe.output] = true
log.info("AUTOCRAFT", "Smart-crafted %s x%d", recipe.output, craftCount)
-- Recalculate excess after crafting
break
else
log.warn("AUTOCRAFT", "Smart craft failed %s: %s", recipe.output, tostring(err))
end
end
end
end
end
end
end
end
end
end
end
return didWork
end
------------------------------------------------- -------------------------------------------------
-- Low-stock alert checker -- Low-stock alert checker
------------------------------------------------- -------------------------------------------------
@@ -1225,8 +952,7 @@ function O.getMissingIngredients(recipe)
return ui.getMissingIngredients(recipe, O.getItemTotal) return ui.getMissingIngredients(recipe, O.getItemTotal)
end end
function O.craftItem(recipeIdx, batches) function O.craftItem(recipeIdx)
batches = batches or 1
local recipe = cfg.CRAFTABLE[recipeIdx] local recipe = cfg.CRAFTABLE[recipeIdx]
if not recipe then if not recipe then
log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx)) log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx))
@@ -1245,11 +971,7 @@ function O.craftItem(recipeIdx, batches)
return false, "Turtle offline" return false, "Turtle offline"
end end
-- Clamp batches to 64 (max stack size per slot) log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, ctx.craftTurtleName)
batches = math.min(batches, 64)
log.info("CRAFT", "Starting craft: %s x%d (%d batches, turtle: %s)",
recipe.output, batches * recipe.count, batches, ctx.craftTurtleName)
activity.crafting = true activity.crafting = true
state.needsRedraw = true state.needsRedraw = true
@@ -1259,27 +981,6 @@ function O.craftItem(recipeIdx, batches)
local slotMap = {} local slotMap = {}
local reservedSlots = {} local reservedSlots = {}
-- Sum how many of each ingredient we need total
local ingredientTotals = {}
for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos]
if itemName then
ingredientTotals[itemName] = (ingredientTotals[itemName] or 0) + batches
end
end
-- Check we have enough of each ingredient
for itemName, needed in pairs(ingredientTotals) do
local have = O.getItemTotal(itemName)
if have < needed then
log.error("CRAFT", "Not enough %s: have %d, need %d", itemName, have, needed)
activity.crafting = false
state.needsRedraw = true
state.smelterNeedsRedraw = true
return false, string.format("Need %d %s, have %d", needed, itemName, have)
end
end
for gridPos = 1, 9 do for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos] local itemName = recipe.grid[gridPos]
if itemName then if itemName then
@@ -1293,12 +994,11 @@ function O.craftItem(recipeIdx, batches)
for slot, slotItem in pairs(chest.list()) do for slot, slotItem in pairs(chest.list()) do
local key = source.chest .. ":" .. slot local key = source.chest .. ":" .. slot
if slotItem.name == itemName and not reservedSlots[key] then if slotItem.name == itemName and not reservedSlots[key] then
local pullCount = math.min(batches, slotItem.count)
slotMap[tostring(turtleSlot)] = { slotMap[tostring(turtleSlot)] = {
chestName = source.chest, chestName = source.chest,
chestSlot = slot, chestSlot = slot,
itemName = itemName, itemName = itemName,
count = pullCount, count = 1,
} }
reservedSlots[key] = true reservedSlots[key] = true
found = true found = true
@@ -1338,6 +1038,7 @@ function O.craftItem(recipeIdx, batches)
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT) log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
local deadline = os.clock() + cfg.CRAFT_TIMEOUT local deadline = os.clock() + cfg.CRAFT_TIMEOUT
local result = nil local result = nil
local bufferedMessages = {}
while os.clock() < deadline do while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock())) local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
@@ -1349,13 +1050,18 @@ function O.craftItem(recipeIdx, batches)
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
result = message result = message
break break
elseif channel == cfg.ORDER_CHANNEL then
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
end end
-- ORDER_CHANNEL messages are captured by the Network-capture task
elseif event == "timer" and p1 == timerId then elseif event == "timer" and p1 == timerId then
-- Timeout tick -- Timeout tick
end end
end end
for _, msg in ipairs(bufferedMessages) do
os.queueEvent(table.unpack(msg))
end
activity.crafting = false activity.crafting = false
state.needsRedraw = true state.needsRedraw = true
state.smelterNeedsRedraw = true state.smelterNeedsRedraw = true

View File

@@ -41,8 +41,6 @@ S.activity = {
defragging = false, defragging = false,
composting = false, composting = false,
crafting = false, crafting = false,
discarding = false,
autocrafting = false,
} }
------------------------------------------------- -------------------------------------------------
@@ -55,7 +53,6 @@ S.configDirty = true
function S.bumpStateVersion() function S.bumpStateVersion()
S.stateVersion = S.stateVersion + 1 S.stateVersion = S.stateVersion + 1
S.billboardNeedsRedraw = true
end end
------------------------------------------------- -------------------------------------------------
@@ -64,7 +61,6 @@ end
S.needsRedraw = true S.needsRedraw = true
S.smelterNeedsRedraw = true S.smelterNeedsRedraw = true
S.billboardNeedsRedraw = true
S.statusMessage = "" S.statusMessage = ""
S.statusColor = colors.white S.statusColor = colors.white
S.statusTimer = 0 S.statusTimer = 0

View File

@@ -12,16 +12,11 @@ local FILES = {
["manager/display.lua"] = "manager/display.lua", ["manager/display.lua"] = "manager/display.lua",
["lib/log.lua"] = "lib/log.lua", ["lib/log.lua"] = "lib/log.lua",
["lib/ui.lua"] = "lib/ui.lua", ["lib/ui.lua"] = "lib/ui.lua",
["lib/itemDB.lua"] = "lib/itemDB.lua",
["lib/craft.lua"] = "lib/craft.lua",
["lib/recipeBook.lua"] = "lib/recipeBook.lua",
["data/smeltable.lua"] = "data/smeltable.lua", ["data/smeltable.lua"] = "data/smeltable.lua",
["data/fuel.lua"] = "data/fuel.lua", ["data/fuel.lua"] = "data/fuel.lua",
["data/compostable.lua"] = "data/compostable.lua", ["data/compostable.lua"] = "data/compostable.lua",
["data/craftable.lua"] = "data/craftable.lua", ["data/craftable.lua"] = "data/craftable.lua",
["data/alerts.lua"] = "data/alerts.lua", ["data/alerts.lua"] = "data/alerts.lua",
["data/stock_limits.lua"] = "data/stock_limits.lua",
["data/auto_craft.lua"] = "data/auto_craft.lua",
} }
------------------------------------------------- -------------------------------------------------

View File

@@ -55,28 +55,4 @@ print("")
print("Starting miningTurtle...") print("Starting miningTurtle...")
sleep(1) sleep(1)
-- Reboot listener: reboots this turtle on remote command shell.run("miningTurtle.lua")
local SYSTEM_CHANNEL = 4205
local ROLE = "miner"
local function rebootListener()
local m = peripheral.find("modem")
if not m then return end
m.open(SYSTEM_CHANNEL)
while true do
local _, _, channel, _, message = os.pullEvent("modem_message")
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
local target = message.target or "all"
if target == "all" or target == ROLE or target == tostring(os.getComputerID()) then
print("[SYSTEM] Reboot command received. Rebooting...")
sleep(0.5)
os.reboot()
end
end
end
end
parallel.waitForAny(
function() shell.run("miningTurtle.lua") end,
rebootListener
)

View File

@@ -2,47 +2,274 @@ import React, { useState } from 'react';
import { getItemEmoji } from '../utils/itemUtils'; import { getItemEmoji } from '../utils/itemUtils';
import './ItemIcon.css'; import './ItemIcon.css';
// Server-side smart resolution endpoint — handles aliases, animated frames, // All textures are proxied & cached through our server
// carpet→wool, wood→log, derivatives, CC:Tweaked, Create, and prefix matching. const TEXTURE_PROXY_BASE = '/api/texture';
const RESOLVE_BASE = '/api/texture/resolve';
// Items rendered as 3D entities in-game — no flat texture exists. // Items whose texture file name differs from their registry name
// Skip to emoji immediately to avoid a wasted network request. const TEXTURE_ALIASES = {
const ENTITY_ONLY = new Set([ // Renamed items in 1.20+
'chest', 'ender_chest', 'trapped_chest', 'shield', 'conduit', grass: 'short_grass',
'bell', 'decorated_pot', 'trident', 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',
]); ]);
const DYE_COLORS = new Set([ // Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', // We use _front, _top, or _side variants for recognizable look
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue', const BLOCK_TEXTURE_SUFFIXES = {
'brown', 'green', 'red', 'black', 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
* Check if an item is known to have no flat texture (entity/model-only). const WOOD_TYPES = new Set([
*/ 'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
function isEntityOnly(name) { 'mangrove', 'cherry', 'bamboo', 'crimson', 'warped',
if (ENTITY_ONLY.has(name)) return true; ]);
if (name.endsWith('_bed') && DYE_COLORS.has(name.slice(0, -'_bed'.length))) return true; const STONE_ALIASES = {
if (name.endsWith('_banner') && DYE_COLORS.has(name.slice(0, -'_banner'.length))) return true; brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
if (name.endsWith('_skull') || name.endsWith('_head')) return true; nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
return false; 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;
} }
/** /**
* Build the single resolve URL for an item. * Generate texture URLs to try in order.
* The server handles all name resolution (aliases, suffixes, derivatives, etc.) * - 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
*/ */
function getTextureUrl(fullItemName) { function getTextureUrls(fullItemName) {
const colonIdx = (fullItemName || '').indexOf(':'); const colonIdx = (fullItemName || '').indexOf(':');
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft'; const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName; const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
const urls = [];
// Entity-only items → instant emoji, no network request // CC:Tweaked → use curated texture map
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null; 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;
}
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`; // 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;
} }
// Cache of resolved icon URLs (avoid re-fetching on every render) // Cache of resolved icon URLs (avoid re-fetching on every render)
@@ -50,20 +277,21 @@ const iconCache = new Map();
/** /**
* Renders a Minecraft item icon using the official game textures. * Renders a Minecraft item icon using the official game textures.
* The server's /api/texture/resolve endpoint does all the smart matching. * Cascading fallback: mod texture → item texture → block texture → emoji
* Falls back to emoji when no texture is found.
*/ */
function ItemIcon({ itemName, size = 32 }) { function ItemIcon({ itemName, size = 32 }) {
const [failed, setFailed] = useState(false); const [urlIndex, setUrlIndex] = useState(0);
const [allFailed, setAllFailed] = useState(false);
if (!itemName) { if (!itemName) {
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>; 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:/, ''); const cacheKey = itemName.replace(/^minecraft:/, '');
// Check if we already know this item has no texture // Check if we already know this item has no texture
if (iconCache.get(cacheKey) === 'none' || failed) { if (iconCache.get(cacheKey) === 'none' || allFailed) {
return ( return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}> <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
{getItemEmoji(itemName)} {getItemEmoji(itemName)}
@@ -86,9 +314,10 @@ function ItemIcon({ itemName, size = 32 }) {
); );
} }
const url = getTextureUrl(itemName); const urls = getTextureUrls(itemName);
const currentUrl = urls[urlIndex];
if (!url) { if (!currentUrl) {
iconCache.set(cacheKey, 'none'); iconCache.set(cacheKey, 'none');
return ( return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}> <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
@@ -100,17 +329,21 @@ function ItemIcon({ itemName, size = 32 }) {
return ( return (
<img <img
className="item-icon-img" className="item-icon-img"
src={url} src={currentUrl}
alt={cacheKey} alt={cacheKey}
width={size} width={size}
height={size} height={size}
loading="lazy" loading="lazy"
onLoad={() => { onLoad={() => {
iconCache.set(cacheKey, url); iconCache.set(cacheKey, currentUrl);
}} }}
onError={() => { onError={() => {
iconCache.set(cacheKey, 'none'); if (urlIndex + 1 < urls.length) {
setFailed(true); setUrlIndex(urlIndex + 1);
} else {
iconCache.set(cacheKey, 'none');
setAllFailed(true);
}
}} }}
/> />
); );

View File

@@ -1,12 +1,4 @@
# Stage 1: Fetch platform server package from git # Node.js backend
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 FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation # Build tools needed for better-sqlite3 native compilation
@@ -16,15 +8,8 @@ RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
COPY package*.json ./ 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 RUN npm install --omit=dev
# Remove build tools after install to keep image small # Remove build tools after install to keep image small

View File

@@ -8,16 +8,6 @@ mkdir -p /data
chown -R node:node /data chown -R node:node /data
echo "[entrypoint] /data permissions fixed" 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 # Drop privileges and exec the CMD
echo "[entrypoint] Dropping to user 'node', running: $*" echo "[entrypoint] Dropping to user 'node', running: $*"
exec su-exec node "$@" exec su-exec node "$@"

View File

@@ -1,213 +0,0 @@
#!/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,7 +8,6 @@
"name": "inventory-manager-server", "name": "inventory-manager-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@cc-platform/server": "file:../../../cc-platform-core/server",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
@@ -19,23 +18,6 @@
"vitest": "^3.2.1" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",

View File

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

View File

@@ -1,152 +1,91 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { import {
loadFullState, saveFullState, recordItemHistory, loadFullState, saveFullState, recordItemHistory,
saveItems, saveFurnaces, saveAlerts, saveState, loadState, saveItems, saveFurnaces, saveAlerts, saveState, loadState,
getHistory, getHistorySummary, closeDb, flushPendingSave, getHistory, getHistorySummary, closeDb, flushPendingSave,
} from './db.js'; } 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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// ========== Platform Server Setup ========== const app = express();
// Provides Express app, HTTP server, auth middleware, and health endpoint. const PORT = process.env.PORT || 3001;
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin. const HOST = process.env.HOST || '0.0.0.0';
const { app, server, auth, start, port } = createPlatformServer({
serviceName: 'inventory-manager',
cors: false,
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
healthExtras: () => ({
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
}),
});
const { requireAuth } = auth; // 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 ==========
// API key reference needed for WebSocket upgrade authentication
const API_KEY = process.env.API_KEY || ''; const API_KEY = process.env.API_KEY || '';
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints // Validate bearer token from Authorization header or ?key= query param
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 }); 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) => { app.use((req, res, next) => {
if (req.path.startsWith('/api/bridge/')) return next(); if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next(); // Read-only endpoints stay open
}
return requireAuth(req, res, next);
});
// ========== Rate Limiting (in-memory, no external dependencies) ==========
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();
};
}
// 30 mutating requests per minute per IP (excludes bridge state updates)
const commandLimiter = createRateLimiter(60_000, 30);
app.use((req, res, next) => {
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
return commandLimiter(req, res, 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 ========== // ========== State ==========
const webClients = new Set();
const bridgeClients = new Set();
// Load persisted state from SQLite on startup // Load persisted state from SQLite on startup
console.log('💾 Loading persisted state from database...'); console.log('💾 Loading persisted state from database...');
@@ -199,25 +138,31 @@ function recordCommand(commandId, result) {
// ========== Helpers ========== // ========== Helpers ==========
// Broadcasts data to all connected web dashboard clients.
// Delegates to platform WS manager (handles JSON serialization, error handling).
function broadcastToClients(data) { function broadcastToClients(data) {
wsManager.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);
}
}
});
} }
// 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) { function pushCommandToBridge(command) {
const sent = wsManager.sendToBridge(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);
}
}
}
if (!sent) { if (!sent) {
// Fallback: queue for HTTP polling with monotonic ID // Fallback: queue for HTTP polling with monotonic ID
const id = nextCommandId++; const id = nextCommandId++;
@@ -226,266 +171,27 @@ function pushCommandToBridge(command) {
} }
} }
// ========== HTTP Server ==========
const server = createServer(app);
// ========== Texture Proxy / Cache ========== // ========== Texture Proxy / Cache ==========
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-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 const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
// Upstream CDN mapping (Prominence Hasturian Era II modpack coverage) // Upstream CDN mapping
const TEXTURE_UPSTREAMS = { const TEXTURE_UPSTREAMS = {
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures', 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', 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', 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 // Ensure cache directory exists
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true }); 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) => { app.get('/api/texture/:namespace/*', async (req, res) => {
const { namespace } = req.params; const { namespace } = req.params;
const texturePath = req.params[0]; const texturePath = req.params[0]; // e.g. "item/diamond.png"
const upstream = TEXTURE_UPSTREAMS[namespace]; const upstream = TEXTURE_UPSTREAMS[namespace];
if (!upstream) { if (!upstream) {
return res.status(404).send('Unknown namespace'); return res.status(404).send('Unknown namespace');
@@ -576,7 +282,16 @@ app.delete('/api/texture-cache/negative', (req, res) => {
res.json({ cleared }); res.json({ cleared });
}); });
// Health endpoint provided by platform (createPlatformServer) with custom extras // Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
lastUpdate,
uptime: process.uptime(),
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
});
});
// Get current inventory state // Get current inventory state
app.get('/api/inventory', (req, res) => { app.get('/api/inventory', (req, res) => {
@@ -873,122 +588,6 @@ app.post('/api/craft', (req, res) => {
} }
}); });
// Recursive craft (multi-step crafting chain)
app.post('/api/recursive-craft', (req, res) => {
try {
const { itemName, count, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!itemName || typeof itemName !== 'string' || itemName.length > 200) {
return res.status(400).json({ error: 'Missing or invalid itemName' });
}
const parsedCount = parseInt(count);
if (!Number.isFinite(parsedCount) || parsedCount < 1 || parsedCount > 100000) {
return res.status(400).json({ error: 'Invalid count (1100000)' });
}
pushCommandToBridge({ type: 'command', action: 'recursive_craft', commandId, itemName, count: parsedCount });
const result = { success: true, commandId, message: `Recursive craft sent: ${itemName} x${count}` };
recordCommand(commandId, result);
console.log(`🔨 Recursive craft: ${itemName} x${count}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Learn a crafting recipe
app.post('/api/recipes/learn-crafting', (req, res) => {
try {
const { output, count, grid, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!output || typeof output !== 'string' || output.length > 200) {
return res.status(400).json({ error: 'Missing or invalid output' });
}
if (!grid || !Array.isArray(grid) || grid.length !== 9) {
return res.status(400).json({ error: 'grid must be an array of 9 items' });
}
pushCommandToBridge({ type: 'command', action: 'learn_crafting_recipe', commandId, output, count: count || 1, grid });
const result = { success: true, commandId, message: `Learned crafting recipe: ${output}` };
recordCommand(commandId, result);
console.log(`📖 Learn crafting recipe: ${output}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Learn a smelting recipe
app.post('/api/recipes/learn-smelting', (req, res) => {
try {
const { input, result: recipeResult, furnaces, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!input || typeof input !== 'string' || input.length > 200) {
return res.status(400).json({ error: 'Missing or invalid input' });
}
if (!recipeResult || typeof recipeResult !== 'string' || recipeResult.length > 200) {
return res.status(400).json({ error: 'Missing or invalid result' });
}
pushCommandToBridge({ type: 'command', action: 'learn_smelting_recipe', commandId, input, result: recipeResult, furnaces });
const apiResult = { success: true, commandId, message: `Learned smelting recipe: ${input}${recipeResult}` };
recordCommand(commandId, apiResult);
console.log(`📖 Learn smelting recipe: ${input}${recipeResult}`);
res.json(apiResult);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Forget a recipe
app.post('/api/recipes/forget', (req, res) => {
try {
const { recipe, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
return res.status(400).json({ error: 'Missing or invalid recipe name' });
}
pushCommandToBridge({ type: 'command', action: 'forget_recipe', commandId, recipe });
const result = { success: true, commandId, message: `Forget recipe: ${recipe}` };
recordCommand(commandId, result);
console.log(`🗑️ Forget recipe: ${recipe}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Sync disabled recipes state
app.post('/api/recipes/sync', (req, res) => {
try {
const { disabledRecipes, smeltingPaused, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
pushCommandToBridge({ type: 'command', action: 'sync_disabled_recipes', commandId, disabledRecipes, smeltingPaused });
const result = { success: true, commandId, message: 'Synced recipe state' };
recordCommand(commandId, result);
console.log('🔄 Sync disabled recipes');
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== Bridge Endpoints (for HTTP polling fallback) ========== // ========== Bridge Endpoints (for HTTP polling fallback) ==========
// Bridge sends inventory state // Bridge sends inventory state
@@ -1006,7 +605,11 @@ app.post('/api/bridge/state', (req, res) => {
// Bridge polls for pending commands (auth required — contains operational data) // Bridge polls for pending commands (auth required — contains operational data)
app.get('/api/bridge/commands', requireAuth, (req, res) => { app.get('/api/bridge/commands', requireAuth, (req, res) => {
try { try {
const commands = getPendingCommands(); const now = Date.now();
// Clear old commands (>30s)
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
const commands = [...pendingCommands];
res.json({ commands }); res.json({ commands });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1182,19 +785,187 @@ function updateStateFromBridge(data) {
} }
} }
// ========== Server Startup Info ========== // ========== WebSocket Server ==========
// WebSocket management is handled by createWebSocketManager() (initialized above).
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default) const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
console.log(`🚀 Inventory Manager Web Server starting...`); console.log(`🚀 Inventory Manager Web Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${port}`); console.log(`📡 HTTP Server: http://localhost:${PORT}`);
console.log(`🔌 WebSocket Server: ws://localhost:${port}/ws`); console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
if (API_KEY) { if (API_KEY) {
console.log('🔒 API key authentication enabled'); console.log('🔒 API key authentication enabled');
} else { } else {
console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)'); 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 ========== // ========== Cross-Project Integration API ==========
// These endpoints allow the RemoteTurtle system to query inventory state // These endpoints allow the RemoteTurtle system to query inventory state
@@ -1254,24 +1025,53 @@ app.get('/api/integration/low-stock', (req, res) => {
}); });
// Proxy to turtle server for combined dashboard info // Proxy to turtle server for combined dashboard info
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles'); 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}` });
}
});
// ========== Start Server ========== // ========== Start Server ==========
setupGracefulShutdown({ server.listen(PORT, HOST, () => {
serviceName: 'inventory-manager', console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
cleanup: [ console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`);
() => wsManager.close(), console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`);
() => server.close(), console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`);
() => { 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) { if (TURTLE_SERVER_URL) {
console.log(`🐢 Turtle server integration: ${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();
});