Compare commits

..

122 Commits

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

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

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

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

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

This fixes the ~80% message loss rate on dispense requests.
2026-03-28 23:01:14 -04:00
MayaTheShy
badde91336 fix(docker): clone platform from git instead of additional_contexts
The additional_contexts approach required cc-platform-core to exist on
the Docker host at a relative path. This fails on servers where the
repo layout differs. Instead, use a multi-stage build: stage 1 clones
cc-platform-core from Gitea (depth 1), stage 2 copies server/ into the
app and rewrites the file: path. Fully self-contained — no host deps.
2026-03-28 22:37:57 -04:00
MayaTheShy
c3344288a8 fix(docker): resolve @cc-platform/server file: dep in container build
Use additional_contexts to copy platform server package into the Docker
build context. Rewrites the file: dependency path and removes the
lockfile so npm install can resolve the local package correctly.
2026-03-28 22:35:39 -04:00
MayaTheShy
021b351248 feat: replace hand-rolled WebSocketServer with createWebSocketManager for improved connection handling and state updates 2026-03-26 16:00:58 -04:00
MayaTheShy
b782d5c8f9 feat: add @cc-platform/server dependency and update package-lock.json 2026-03-26 16:00:53 -04:00
MayaTheShy
45b264dbc4 feat: implement WebSocket support for real-time command handling and state updates 2026-03-26 15:52:05 -04:00
MayaTheShy
24ce637f6e feat: remove lava bucket from fuel items list 2026-03-26 15:22:59 -04:00
MayaTheShy
075d42ecab feat: update server log messages to use dynamic port variable 2026-03-26 15:20:01 -04:00
MayaTheShy
379d08b594 feat: refactor server setup to use createPlatformServer and streamline middleware 2026-03-26 15:19:27 -04:00
MayaTheShy
d54855bf19 feat: add missing dependency for cc-platform server in package.json 2026-03-26 15:19:22 -04:00
MayaTheShy
5eca4b7155 feat: refactor configuration loading and HTTP helpers to use WebBridge functions 2026-03-26 15:19:16 -04:00
MayaTheShy
e340368ef0 feat: add required platform field to package configuration 2026-03-26 15:18:59 -04:00
MayaTheShy
38ff3f61bd feat: enhance billboard rendering with pie chart and storage ring display 2026-03-26 14:44:51 -04:00
MayaTheShy
664741504f feat: update billboard monitor text scale configuration to use BILLBOARD_TEXT_SCALE 2026-03-26 14:27:31 -04:00
MayaTheShy
c830642dc8 feat: add BILLBOARD_TEXT_SCALE configuration option for billboard display size 2026-03-26 14:27:28 -04:00
MayaTheShy
29026175b7 feat: enhance billboard monitor setup with explicit configuration and auto-detection 2026-03-26 14:23:33 -04:00
MayaTheShy
fe6a6df6a3 feat: update BILLBOARD_MONITOR comment for clarity on auto-detection 2026-03-26 14:23:29 -04:00
MayaTheShy
3c4d76d4a9 feat: improve billboard monitor detection and logging 2026-03-26 14:23:24 -04:00
MayaTheShy
c7a1b7066a feat: rename BILLBOARD_MONITOR_SIDE to BILLBOARD_MONITOR for consistency 2026-03-26 14:21:15 -04:00
MayaTheShy
8a50bc586d feat: implement billboard monitor support and remove legacy code 2026-03-26 14:16:23 -04:00
MayaTheShy
c1b1713699 feat: add support for billboard role in startup script 2026-03-26 14:08:19 -04:00
MayaTheShy
c9d21bcfaa feat: add monitor billboard for displaying inventory goals and alerts 2026-03-26 14:08:14 -04:00
MayaTheShy
97983156c6 feat: expand upstream CDN mapping for additional modpack support 2026-03-26 13:43:50 -04:00
MayaTheShy
46f7b0c98a feat: expand supported mods in download-textures script for enhanced asset management 2026-03-26 13:43:47 -04:00
MayaTheShy
13b374d7f8 feat: implement smart texture resolution and indexing for improved asset management 2026-03-26 13:11:06 -04:00
MayaTheShy
981e561c2d feat: add download-textures script to package.json for texture management 2026-03-26 13:11:02 -04:00
MayaTheShy
a1ff2433e4 feat: refactor texture resolution logic to streamline item icon rendering 2026-03-26 13:10:58 -04:00
MayaTheShy
a50a6e9697 feat: add texture download logic to entrypoint script for initial setup 2026-03-26 13:10:53 -04:00
MayaTheShy
c74898049a feat: add download-textures script for bulk downloading Minecraft and Create textures 2026-03-26 13:10:49 -04:00
MayaTheShy
25623b735c feat: add aliased texture names for improved block texture handling 2026-03-25 23:06:03 -04:00
MayaTheShy
1050ed84f2 feat: enhance texture mapping with dynamic aliases and new Create mod textures 2026-03-25 23:05:05 -04:00
MayaTheShy
4cf1e550b7 feat: add error logging for HTTP requests and command processing in inventoryWebBridge 2026-03-25 22:42:52 -04:00
MayaTheShy
66ac81de65 feat: optimize broadcastState function to conditionally include recipe tables based on config changes 2026-03-25 22:42:45 -04:00
MayaTheShy
ea29136f25 feat: delegate recipe helper functions to shared lib/ui.lua for improved code organization 2026-03-25 22:42:39 -04:00
MayaTheShy
641a317873 feat: remove itemDB initialization and related flush calls for streamlined inventory management 2026-03-25 22:38:10 -04:00
MayaTheShy
5dd9bd9344 feat: add recursive crafting and recipe management endpoints for enhanced crafting capabilities 2026-03-25 22:37:08 -04:00
MayaTheShy
b7427aa973 feat: implement reboot listener for mining turtle to handle remote reboot commands 2026-03-25 22:37:03 -04:00
MayaTheShy
48ca088a2c feat: add new data files for enhanced inventory management and crafting capabilities 2026-03-25 22:36:57 -04:00
MayaTheShy
b9081f26a8 feat: add bridge reply channel and enhance command processing for improved communication 2026-03-25 22:36:52 -04:00
MayaTheShy
f10108bd48 feat: remove unused catalogue from state cache and update message handling for dropper data 2026-03-25 22:36:45 -04:00
MayaTheShy
f1e418ad83 feat: update installation instructions for Opus Package Manager and enhance startup script details 2026-03-25 22:24:05 -04:00
MayaTheShy
9a02b350c2 feat: add mining turtle and auto-updating startup scripts to enhance inventory management 2026-03-25 22:21:40 -04:00
MayaTheShy
c390b5291b feat: enhance item retrieval process in crafting command for improved efficiency 2026-03-25 22:00:44 -04:00
MayaTheShy
b2d55feb98 feat: add collection hopper emptying function to improve item management 2026-03-25 21:53:56 -04:00
MayaTheShy
54cad8b92b feat: add collection hoppers and interval configuration for improved item management 2026-03-25 21:53:51 -04:00
MayaTheShy
b3a69c6797 feat: implement resilient task wrapper for parallel tasks to enhance stability 2026-03-25 21:53:47 -04:00
MayaTheShy
62a9ab811d feat: enhance crafting function to support batch processing with ingredient validation 2026-03-25 21:45:39 -04:00
MayaTheShy
df436ff84d feat: enhance crafting execution to support batch processing with clamping to max stack size 2026-03-25 21:45:33 -04:00
MayaTheShy
1606d60a06 feat: add new crafting recipes for cobblestone and stone variants; enhance auto-crafting logic for excess items 2026-03-25 18:11:23 -04:00
MayaTheShy
f327f82677 feat: implement auto-crafting feature for excess stock management 2026-03-25 18:07:26 -04:00
MayaTheShy
2c99169ce9 feat: add crafting recipes for bamboo block and bamboo planks 2026-03-25 18:05:11 -04:00
MayaTheShy
9ca46dc29d feat: add stock limits configuration for item storage management 2026-03-25 17:48:59 -04:00
MayaTheShy
3f79645bb8 feat: add discarding activity flag to state management 2026-03-25 17:47:21 -04:00
MayaTheShy
8468134919 feat: implement auto-discard feature for excess stock management 2026-03-25 17:47:13 -04:00
MayaTheShy
d9638cdc69 feat: add discarding activity to activity string and bottom message 2026-03-25 17:47:05 -04:00
MayaTheShy
2d2b8835b1 feat: add configurable trash droppers and discard interval for stock management 2026-03-25 17:46:58 -04:00
MayaTheShy
22836dafb2 feat: implement auto-discard feature for excess stock management 2026-03-25 17:46:18 -04:00
MayaTheShy
1336590241 feat: notify manager to refresh cache after inventory dump 2026-03-22 23:00:07 -04:00
MayaTheShy
78e7c92893 Change turtle dashboard default URL to turtles.spatulaa.com 2026-03-22 22:52:57 -04:00
MayaTheShy
e59b6c1832 feat: upgrade Node.js version to 20-alpine in Dockerfiles and improve healthcheck command 2026-03-22 22:44:26 -04:00
MayaTheShy
e343ab8b4e feat: enhance smelter tab event handling and optimize stock lookup logic 2026-03-22 22:31:39 -04:00
MayaTheShy
b9b69a4966 feat: sync disabled recipes and smelting state from client, improve broadcast logic 2026-03-22 22:31:35 -04:00
MayaTheShy
bdc9b3f291 feat: sync full smelting state to master for persistence 2026-03-22 22:31:31 -04:00
MayaTheShy
8b8279878a Fix craft dispatch: add turtle attach/detach handlers, re-scan on craft, log failures
- Add peripheral_attach handler to auto-detect crafting turtle connecting after boot
- Update peripheral_detach handler to clear craftTurtleName when turtle disconnects
- Re-scan peripherals for turtle in display.lua craft handler if none known
- Add log.warn when craft turtle check fails (was silent notification only)
- Sync ctx.craftTurtleOk from broadcastState so display.lua has current value
2026-03-22 22:26:10 -04:00
MayaTheShy
f095e18e95 fix: simplify crafting turtle detection logic by removing unnecessary method checks 2026-03-22 22:19:08 -04:00
MayaTheShy
904d4ec7f7 feat: improve modem detection logic with fallback for wireless modems and ensure channels are open 2026-03-22 22:09:28 -04:00
MayaTheShy
baaa724595 Fix crafting turtle: remove self-wrap, use chest push/pull instead
Same fix as mining turtle — peripheral.wrap(selfName) returns nil
on some turtles. Replaced:
- selfInv.pushItems(chest, slot) → chest.pullItems(selfName, slot)
- selfInv.pullItems(chest, slot, n, dst) → chest.pushItems(selfName, slot, n, dst)
2026-03-22 22:05:07 -04:00
MayaTheShy
8ff4203152 feat: enhance auto-refuel functionality with manager integration and fallback mechanism 2026-03-22 21:43:02 -04:00
MayaTheShy
72c978ee75 feat: add find_item functionality to locate items in chests 2026-03-22 21:42:58 -04:00
MayaTheShy
d25f25ee52 Fix mining turtle: use pull instead of push to avoid self-wrap failure
peripheral.wrap(selfName) can return nil on some turtles even when
connected. Switch to wrapping the remote chest and calling
chest.pullItems(selfName, slot) instead.
2026-03-22 21:38:13 -04:00
MayaTheShy
2adfa7f7a3 Add mining turtle for infinite cobblestone generators
New miningTurtle.lua: sits on top of a cobble gen, continuously
digs down, and pushes items into networked chests via wired modem.
Features:
- Configurable mine interval, dump threshold, fuel slot
- Auto-dumps inventory when slots fill or timer expires
- Auto-refuels from designated fuel slot
- Remote reboot via SYSTEM_CHANNEL
- Stats display (mined, dumped, uptime, fuel)
- Persistent config in usr/config/inventory-manager/.miner_config

Also adds:
- startup/miner.lua: auto-update + launch script for standalone use
- .package: new 'Mining Turtle' role (option 4) in setup wizard
- autorun/startup.lua: detects .miner_config and launches miningTurtle
2026-03-22 21:32:42 -04:00
MayaTheShy
96afe8dcb7 Fix silent Docker crash: recursive chown, db error handling
- Entrypoint: chown -R /data (not just the directory) so existing
  volume files owned by root become writable by node user
- Entrypoint: add echo logging so startup progress is visible
- db.js: verify /data is writable before opening SQLite
- db.js: wrap Database() constructor in try-catch with clear error
  message instead of crashing silently at ESM import time
2026-03-22 21:22:30 -04:00
MayaTheShy
1b0b1c570d Fix health check: use wget -qO instead of --spider, increase start_period to 30s 2026-03-22 21:15:43 -04:00
MayaTheShy
73e157cc13 fix: remove healthcheck configuration from server service 2026-03-22 21:08:46 -04:00
MayaTheShy
18bdac03b1 Fix config.lua crash: move _configPath before first use
_configPath was called at lines 26/28 (CACHE_FILE, DISABLED_RECIPES_FILE)
but only defined at line 79. Moved the definition to the top of the
function body so it exists before the first call.
2026-03-22 20:55:33 -04:00
MayaTheShy
173a0a9f95 Persist config files across Opus package updates
The Opus package manager deletes the entire package directory on
update (fs.delete(packageDir)) before re-downloading files. This
wiped all config files (.manager_config, .client_config, etc.) that
were stored inside packages/inventory-manager/.

Fix: add _configPath() helper to every program that resolves config
file paths to usr/config/inventory-manager/ when running under Opus,
which lives outside the package directory and survives updates.
Falls back to the local _path() for standalone (non-Opus) use.

Updated files:
- inventoryManager.lua, manager/config.lua, inventoryClient.lua,
  inventoryWebBridge.lua, dropperController.lua, craftingTurtle.lua
- .package install script: saves configs to usr/config/inventory-manager/
- autorun/startup.lua: checks both persistent and package dirs
- startup/client.lua: uses persistent dir for .client_setup/.dropper_config
- web/server/Dockerfile: switch health check to wget (from prior fix)
2026-03-22 20:51:31 -04:00
MayaTheShy
01eef4eead fix: correct typo in HEALTHCHECK start-period option 2026-03-22 20:44:57 -04:00
MayaTheShy
a499446366 fix: use wget for Docker health check instead of node
Spawning a full Node process for health checks is slow on Alpine and
can exceed the 3s timeout, causing the container to be marked unhealthy.
Use wget (built into Alpine) instead, and increase start_period to 15s.
2026-03-22 20:42:37 -04:00
MayaTheShy
a6bf84d6b8 fix: update order popup background color to gray 2026-03-22 20:42:08 -04:00
MayaTheShy
aaaf25350c fix: correct item drawing in main page refresh 2026-03-22 20:29:51 -04:00
MayaTheShy
432a9feff9 Implemented the usage of the standarised ui library. 2026-03-22 20:23:06 -04:00
MayaTheShy
ee76a61240 Refactor turtle status check in smelter page
- Updated the turtle status check logic to use ctx.craftTurtleOk for determining if the crafting turtle is available.
- Adjusted the logic to fall back on checking ctx.craftTurtleName and its peripheral presence if ctx.craftTurtleOk is not set.
2026-03-22 20:20:07 -04:00
MayaTheShy
85228b134b feat: add order quantity popup for item ordering in dashboard 2026-03-22 20:16:01 -04:00
MayaTheShy
d97167b21c Add client_display.lua for network client dashboard using Opus UI
- Implemented a client dashboard mirroring manager/display.lua
- Integrated state management via master broadcasts (ctx.cache, ctx.activity)
- Enabled action commands to master through ctx.sendToMaster()
- Established UI components for inventory management and smelting operations
- Included item filtering, stock tracking, and crafting capabilities
- Designed responsive layouts for main and smelter dashboards
2026-03-22 20:15:53 -04:00
MayaTheShy
69e24e7d79 fix: update comments for clarity and organization in inventoryClient.lua 2026-03-22 20:10:41 -04:00
MayaTheShy
02248ccc38 fix: keyboard overlay hidden behind bottom bars due to z-order
Opus initChildren uses pairs() which has non-deterministic ordering.
The keyboard overlay occupies the same screen space as alertBar,
footerBar, and bottomBar. If those bars end up later in the children
array, they render on top and hide the keyboard.

Added raise() after enabling the keyboard to move it to the end
of the children array, ensuring it renders on top.
2026-03-22 20:04:51 -04:00
MayaTheShy
e67fded321 fix: client crash from barrel task early return in waitForAny
Same bug as the manager: CLIENT_BARREL_NAME == '' caused return
inside parallel.waitForAny, killing all tasks instantly.
Replace with infinite sleep when barrel is unconfigured.
2026-03-22 19:51:51 -04:00
MayaTheShy
621902b3e8 fix: correct reference to parent page in keyboard toggle event 2026-03-22 19:47:18 -04:00
MayaTheShy
380289d484 fix: improve error handling in crafting turtle script 2026-03-22 19:41:49 -04:00
MayaTheShy
d67a2fde88 feat: first-run setup wizard for client dropper config
- startup/client.lua now prompts on first run: asks if a dropper
  is next to the computer, and if so, which side it's on
- Saves config to .dropper_config (JSON with dropperSide,
  redstoneSide, enabled)
- Only launches dropperController if dropper is enabled
- dropperController.lua reads .dropper_config for its sides
  instead of hardcoding 'back'
- Delete .client_setup to re-run the setup wizard
2026-03-22 19:31:08 -04:00
MayaTheShy
215652d47c feat: reimplement on-screen keyboard for monitor search
UI.TextEntry requires keyboard input which monitors don't have.
Replaced it with the original touch-friendly approach:
- Search bar shows query text with toggle button (? / X)
- Tapping the search row opens an on-screen keyboard overlay
- 3-row QWERTY layout with Bksp, Done, Space, Clr keys
- Keyboard overlays the bottom status bars when active
- All key zones use touch hit-testing for monitor_touch events
2026-03-22 19:22:03 -04:00
MayaTheShy
b49574f39b fix: SQLite readonly error in Docker container
- Add entrypoint script that ensures /data is owned by node user
  before dropping privileges with su-exec
- Remove USER node from Dockerfile (entrypoint handles it)
- Change client depends_on to service_healthy so nginx waits for
  the server to pass its healthcheck before starting
2026-03-22 19:15:04 -04:00
MayaTheShy
d4a9441b54 fix: resize pages after re-parenting to monitor device
Page:postInit defaults parent to UI.term (small computer terminal).
Window:postInit then calls setParent() which computes all child
dimensions from that small terminal. When we later re-parent to
the monitor device, the children retain their small dimensions.
Adding resize() before setParent() forces all children to
recompute dimensions from the correct monitor size.
2026-03-22 19:09:49 -04:00
MayaTheShy
5518161adf fix: call enable() on pages after attaching to device
Opus Canvas only renders children with enabled=true. Without
calling page:enable(), all widgets were skipped during render,
resulting in a blank black monitor.
2026-03-22 19:04:20 -04:00
MayaTheShy
4c329bbfb3 debug: add raw monitor write test after sync + fix boot window visible 2026-03-22 19:01:10 -04:00
MayaTheShy
381951bd91 fix: clear boot window to prevent future monitor write interference 2026-03-22 19:00:39 -04:00
MayaTheShy
fdc3c36cd7 debug: add diagnostic logging to drawDashboard to trace rendering 2026-03-22 18:58:04 -04:00
MayaTheShy
64b3e4b069 debug: log draw errors instead of silent pcall swallowing 2026-03-22 18:52:23 -04:00
MayaTheShy
c4acc2159e fix: prevent early return in parallel tasks from killing waitForAny
Task 12 (supply chest) and Task 13 (network) returned immediately
when not configured, which caused parallel.waitForAny to exit and
the entire program to silently stop after cache build.
2026-03-22 18:49:43 -04:00
MayaTheShy
a0740b81f5 debug: xpcall entire program body, write crash log to .crash.log
Error is now saved to disk even if the window closes instantly.
Uses xpcall with debug.traceback for full stack trace.
2026-03-22 18:47:15 -04:00
MayaTheShy
82d74a01b5 debug: wrap main() in pcall to show crash error before window closes 2026-03-22 18:44:46 -04:00
MayaTheShy
bb139b4afd fix: override dofile to preserve Opus env (require was nil in sub-modules)
CC:Tweaked dofile loads files with _G as environment, but Opus
injects require into the program's sandbox _ENV. All dofile'd
modules (especially display.lua) could not see require.

Also add dropperController to apps.db.
2026-03-22 18:40:03 -04:00
MayaTheShy
40e6eab42d feat: shorten application titles in apps database and remove unused entries 2026-03-22 18:35:41 -04:00
MayaTheShy
d9b7bd32b7 feat: add exclusion for backup files and specific Lua script in package configuration 2026-03-22 18:35:33 -04:00
MayaTheShy
76772140aa feat: implement parallel inventory scanning and supply chest functionality 2026-03-22 18:15:50 -04:00
MayaTheShy
314fec5c47 feat: add parallel scanning and supply management configuration options 2026-03-22 18:15:43 -04:00
MayaTheShy
e261113134 feat: implement unified recipe database with learning support 2026-03-22 18:15:40 -04:00
MayaTheShy
7693265b62 feat: add item display name database with persistence and query functions 2026-03-22 18:15:36 -04:00
MayaTheShy
2d8d2b360f feat: implement recursive crafting engine with multi-step chain support 2026-03-22 18:15:31 -04:00
MayaTheShy
37e9b89057 feat: enhance inventory management with recursive crafting and supply chest functionality 2026-03-22 18:15:25 -04:00
35 changed files with 5085 additions and 2145 deletions

View File

@@ -1,13 +1,18 @@
{
title = "Inventory Manager",
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
required = {
'platform',
},
title = "Inventory Manager (Unstable)",
description = "UNSTABLE/DEV — Automated inventory management system for CC:Tweaked. Uses cc-platform-core. May have breaking changes. Install 'inventory-manager' for the stable version.",
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
exclude = {
"^web/", "^__tests__/", "^startup/",
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
"%.bak$", "^listDevicesByType%.lua$",
},
install = [[
local pkgDir = fs.combine("packages", "inventory-manager")
local cfgDir = "usr/config/inventory-manager"
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
local function ask(prompt, default)
if default and #default > 0 then
write(prompt .. " [" .. default .. "]: ")
@@ -26,9 +31,10 @@
print(" 1) Inventory Manager (main controller)")
print(" 2) Inventory Client (display-only)")
print(" 3) Web Bridge (HTTP forwarder)")
print(" 4) Skip setup")
print(" 4) Mining Turtle (cobble miner)")
print(" 5) Skip setup")
print("")
write("Choice (1/2/3/4): ")
write("Choice (1/2/3/4/5): ")
local choice = read()
if choice == "1" then
@@ -46,14 +52,14 @@
dropperName = dropperName,
barrelName = barrelName,
}
local f = fs.open(fs.combine(pkgDir, ".manager_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved manager config.")
if serverUrl and #serverUrl > 0 then
local bcfg = { serverUrl = serverUrl }
local bf = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
bf.write(textutils.serialiseJSON(bcfg))
bf.close()
print("Saved web bridge config.")
@@ -74,7 +80,7 @@
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
local f = fs.open(fs.combine(pkgDir, ".client_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved client config.")
@@ -85,11 +91,26 @@
local serverUrl = ask("Web server URL", "http://localhost")
local cfg = { serverUrl = serverUrl }
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved web bridge config.")
elseif choice == "4" then
print("")
print("-- Mining Turtle Configuration --")
local mineInterval = ask("Mine interval in seconds", "0.5")
local dumpThreshold = ask("Dump when N slots full", "14")
local cfg = {
mineInterval = tonumber(mineInterval) or 0.5,
dumpThreshold = tonumber(dumpThreshold) or 14,
}
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved miner config.")
else
print("Skipped — edit config files manually later.")
end

110
README.md
View File

@@ -47,7 +47,9 @@ 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. |
| `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. |
| `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. |
| `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)
@@ -62,54 +64,27 @@ A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc
### Prerequisites
- 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
- HTTP access enabled in the CC:Tweaked config (for the web bridge)
### 1. Install the Master Controller
### 1. Install via Opus Package Manager (Recommended)
On your main CC:Tweaked computer (with the monitor and wired modem), open the terminal and run:
On any CC:Tweaked computer running Opus, open the shell and run:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryManager.lua startup.lua
package install inventory-manager
```
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.
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.
### 2. Install a Display Client (Optional)
On a separate CC:Tweaked computer with a monitor:
To update later:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryClient.lua startup.lua
package update inventory-manager
```
### 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)
### 2. Start the Web Dashboard (Optional)
On the host machine, navigate to the `web/` directory and run:
@@ -119,14 +94,79 @@ docker compose up -d --build
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
| Channel | Purpose |
|---------|---------|
| 4200 | Master → Clients/Bridge (state broadcast) |
| 4201 | Clients/Bridge → Master (orders & commands) |
| 4202 | Master → Client (order/craft result replies) |
| 4203 | Master → Crafting Turtle (craft requests) |
| 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

View File

@@ -8,18 +8,26 @@ local peripheral = _G.peripheral
local shell = _ENV.shell
local BASE = 'packages/inventory-manager'
local CFG = 'usr/config/inventory-manager'
-------------------------------------------------
-- Determine role from config files written during install
-------------------------------------------------
local function cfgExists(name)
return fs.exists(fs.combine(CFG, name))
or fs.exists(fs.combine(BASE, name))
end
local role
if fs.exists(fs.combine(BASE, '.manager_config')) then
if cfgExists('.manager_config') then
role = 'manager'
elseif fs.exists(fs.combine(BASE, '.client_config')) then
elseif cfgExists('.client_config') then
role = 'client'
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
elseif cfgExists('.webbridge_config') then
role = 'bridge'
elseif cfgExists('.miner_config') then
role = 'miner'
elseif _G.turtle then
role = 'turtle'
end
@@ -63,6 +71,7 @@ local programs = {
client = 'inventoryClient.lua',
bridge = 'inventoryWebBridge.lua',
turtle = 'craftingTurtle.lua',
miner = 'miningTurtle.lua',
}
local program = fs.combine(BASE, programs[role])

View File

@@ -4,6 +4,15 @@
-- Pulls ingredients from chests, crafts, and pushes results back.
-- Requires a wired modem attached to the turtle.
local shell = _ENV.shell
local function fatal(msg)
printError(msg)
print("\nPress any key to exit...")
os.pullEvent("key")
return
end
-------------------------------------------------
-- Default configuration (overridden by .turtle_config)
-------------------------------------------------
@@ -22,7 +31,17 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
local TURTLE_CONFIG_FILE = _path(".turtle_config")
-- Persistent config path (survives Opus package updates)
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
local function loadConfig()
if not fs.exists(TURTLE_CONFIG_FILE) then return end
@@ -52,9 +71,7 @@ print("")
-- Verify this is a crafting turtle
if not turtle or not turtle.craft then
print("[ERR] No turtle.craft() available!")
print(" This must be a Crafting Turtle.")
return
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
end
-- Find wired modem and get our network name
@@ -78,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
end
if not modem or not selfName then
print("[ERR] No wired modem found!")
print(" Attach a wired modem to the turtle")
print(" and connect it to the network.")
return
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
end
print("[OK] Modem: " .. modemSide)
@@ -91,15 +105,6 @@ print("[OK] Network name: " .. selfName)
modem.open(CRAFT_CHANNEL)
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
-- Wrap our own inventory peripheral for pullItems/pushItems
local selfInv = peripheral.wrap(selfName)
if not selfInv then
print("[ERR] Cannot wrap own peripheral: " .. selfName)
print(" Make sure the wired modem is connected.")
return
end
print("[OK] Self-inventory peripheral ready")
print("")
print("Waiting for craft commands from master...")
print("")
@@ -109,15 +114,20 @@ print("")
-------------------------------------------------
local function clearInventory(chests)
-- Push all items from all 16 turtle slots back to chests
-- Pull all items from turtle slots into chests (using chest.pullItems)
local cleared = 0
for slot = 1, 16 do
if turtle.getItemCount(slot) > 0 then
for _, chestName in ipairs(chests) do
local ok, n = pcall(selfInv.pushItems, chestName, slot)
if ok and n and n > 0 then
cleared = cleared + n
break
local chest = peripheral.wrap(chestName)
if chest and chest.pullItems then
local ok, n = pcall(chest.pullItems, selfName, slot)
if ok and n and n > 0 then
cleared = cleared + n
if turtle.getItemCount(slot) == 0 then
break
end
end
end
end
end
@@ -170,24 +180,56 @@ local function handleCraftCommand(message)
for turtleSlotStr, info in pairs(slots) do
local turtleSlot = tonumber(turtleSlotStr)
local chestName = info.chestName
local chestSlot = info.chestSlot
local itemName = info.itemName
local count = info.count or 1
local placed = 0
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
itemName, chestName, chestSlot, turtleSlot))
local ok, n = pcall(selfInv.pullItems, chestName, chestSlot, count, turtleSlot)
if ok and n and n > 0 then
placedItems[turtleSlot] = itemName
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))
else
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))
-- Try the suggested chest+slot first
local chest = peripheral.wrap(info.chestName)
if chest then
local ok, n = pcall(chest.pushItems, selfName, info.chestSlot, count, turtleSlot)
if ok and n and n > 0 then
placed = n
end
end
-- If we didn't get enough, search ALL chests on the network for this item
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
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, placed, turtleSlot))
else
print(string.format("[CRAFT] Could not find %s anywhere on network!", itemName))
allPlaced = false
break
end
@@ -266,20 +308,26 @@ end
-- Main loop: listen for modem commands
-------------------------------------------------
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
local ok, err = pcall(function()
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == CRAFT_CHANNEL and type(message) == "table" then
if message.type == "craft_request" then
local result = handleCraftCommand(message)
-- Send result back to master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
elseif message.type == "ping" then
-- Health check from master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
type = "pong",
name = selfName,
})
if channel == CRAFT_CHANNEL and type(message) == "table" then
if message.type == "craft_request" then
local result = handleCraftCommand(message)
-- Send result back to master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
elseif message.type == "ping" then
-- Health check from master
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
type = "pong",
name = selfName,
})
end
end
end
end)
if not ok then
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
end

14
data/auto_craft.lua Normal file
View File

@@ -0,0 +1,14 @@
-- 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,6 +31,24 @@ return {
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",
count = 4,
@@ -105,6 +123,114 @@ return {
"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",
count = 3,

View File

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

20
data/stock_limits.lua Normal file
View File

@@ -0,0 +1,20 @@
-- 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

@@ -1,10 +1,41 @@
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
-- Watches the dropper and pulses redstone until it's empty.
-- Dropper Controller
-- Watches a dropper peripheral and pulses redstone until it's empty.
local REDSTONE_SIDE = "back" -- side facing dropper_0
local DROPPER_SIDE = "back" -- side where the dropper peripheral is
local REDSTONE_SIDE = "back" -- side facing dropper (for redstone output)
local PULSE_TIME = 0.3 -- redstone pulse duration in seconds
local POLL_INTERVAL = 0.5 -- how often to check the dropper
local DROPPER_SIDE = "back" -- side where the dropper is (as peripheral)
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return fs.combine(_baseDir, rel)
end
local CONFIG_FILE = _configPath(".dropper_config")
if fs.exists(CONFIG_FILE) then
local f = fs.open(CONFIG_FILE, "r")
local raw = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
if ok and cfg then
if cfg.dropperSide then DROPPER_SIDE = cfg.dropperSide end
if cfg.redstoneSide then REDSTONE_SIDE = cfg.redstoneSide end
if cfg.pulseTime then PULSE_TIME = cfg.pulseTime end
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
end
end
-------------------------------------------------
-- Helpers

View File

@@ -1,11 +1,11 @@
{
[ "im_inventory_manager" ] = {
title = "Inventory Manager",
title = "Inv Manager",
category = "Inventory",
run = "inventoryManager.lua",
},
[ "im_inventory_client" ] = {
title = "Inventory Display",
title = "Inv Display",
category = "Inventory",
run = "inventoryClient.lua",
},
@@ -13,21 +13,16 @@
title = "Crafting Turtle",
category = "Inventory",
run = "craftingTurtle.lua",
requires = { turtle = true },
},
[ "im_dropper_controller" ] = {
title = "Dropper Controller",
category = "Inventory",
run = "dropperController.lua",
requires = "turtle",
},
[ "im_web_bridge" ] = {
title = "Inventory Web Bridge",
title = "Inv Web Bridge",
category = "Inventory",
run = "inventoryWebBridge.lua",
},
[ "im_list_devices" ] = {
title = "List Devices",
[ "im_dropper" ] = {
title = "Dropper Ctrl",
category = "Inventory",
run = "listDevicesByType.lua",
run = "dropperController.lua",
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,18 +15,49 @@
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates by storing
-- user data in usr/config/inventory-manager/ instead of the package dir.
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
-- Write crash info to a file so we can always read it
local function _crashLog(err)
local f = fs.open(_path(".crash.log"), "w")
if f then
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
f.close()
end
end
local ok, err = xpcall(function()
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
else error(err, 2) end
end
-------------------------------------------------
-- Structured logging & shared UI helpers
-------------------------------------------------
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
-------------------------------------------------
-- Load modules (factory pattern → shared context)
-------------------------------------------------
local cfg = dofile(_path("manager/config.lua"))(log, _path)
cfg.loadConfig()
local state = dofile(_path("manager/state.lua"))()
-- Shared context table (Lua tables are by-reference, so all
@@ -48,6 +79,11 @@ ctx.ops = ops
local display = dofile(_path("manager/display.lua"))(ctx)
ctx.display = display
-- Recursive crafting engine
local craftEngine = dofile(_path("lib/craft.lua"))
craftEngine.init(cfg.recipeBook, ops.getItemTotal)
ctx.craftEngine = craftEngine
-- Convenience aliases
local cache = state.cache
local activity = state.activity
@@ -109,6 +145,10 @@ local function broadcastState()
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
}
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
ctx.craftTurtleOk = payload.craftTurtleOk
-- Only include recipe tables when config has changed (they're large).
if state.configDirty then
payload.smeltable = cfg.SMELTABLE
payload.craftable = cfg.CRAFTABLE
@@ -155,24 +195,47 @@ local function main()
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
end
-- Find modem for client communication
-- Billboard monitor (optional — set billboardMonitorSide in .manager_config)
-- Billboard monitor (auto-detects any 3rd monitor, or set billboardMonitor in .manager_config)
if display.setupBillboardMonitor() then
log.info("INIT", "Billboard monitor: %s", display.billboardMonName)
else
log.info("INIT", "No billboard monitor found (optional)")
end
-- Find wired modem for client/turtle communication
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
ctx.networkModem = peripheral.wrap(name)
ctx.networkModemName = name
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
break
local m = peripheral.wrap(name)
-- Prefer wired modem (has getNameLocal); skip wireless
if m.isWireless and not m.isWireless() then
ctx.networkModem = m
ctx.networkModemName = name
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
break
elseif not ctx.networkModem then
-- Fallback: use wireless if no wired available
ctx.networkModem = m
ctx.networkModemName = name
end
end
end
if ctx.networkModem then
log.info("INIT", "Network modem: %s", ctx.networkModemName)
-- Ensure channels are open even on fallback modem
ctx.networkModem.open(cfg.ORDER_CHANNEL)
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
log.info("INIT", "Network modem: %s (wired=%s)", ctx.networkModemName,
tostring(ctx.networkModem.isWireless and not ctx.networkModem.isWireless()))
else
log.warn("INIT", "No modem found for client sync")
end
-- Detect crafting turtle on network
-- The actual craft message goes on CRAFT_CHANNEL which only the crafting
-- turtle listens to, so we just need any turtle name for presence checks.
for _, name in ipairs(peripheral.getNames()) do
if name:match("^turtle_") then
ctx.craftTurtleName = name
@@ -196,7 +259,11 @@ local function main()
for k in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[k] then enabledCount = enabledCount + 1 end
end
log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount)
log.info("INIT", "%d/%d smelting recipes enabled", enabledCount, totalRecipeCount)
do
local cc, sc = cfg.recipeBook.count()
log.info("INIT", "Recipe book: %d crafting, %d smelting", cc, sc)
end
-- Compost peripherals
if peripheral.isPresent(cfg.COMPOST_DROPPER) then
@@ -279,6 +346,11 @@ local function main()
end
ops.refreshCache(drawBoot)
-- Destroy boot window so it doesn't intercept future monitor writes
buf.setVisible(true)
buf.clear()
buf = nil
else
ops.refreshCache()
end
@@ -286,13 +358,40 @@ local function main()
end
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
-----------------------------------------------
-- Shared queue: capture task writes here, processor task reads.
-- This ensures modem_message events are never lost while the
-- processor yields for peripheral calls (pushItems, list, etc).
local networkQueue = {}
parallel.waitForAny(
-- Task 1: Background inventory scanner
function()
resilient("Scanner", function()
if cacheLoaded then
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
@@ -304,21 +403,22 @@ local function main()
sleep(cfg.SCAN_INTERVAL)
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
pcall(function() cfg.recipeBook.flush() end)
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
end,
end),
-- Task 2: Barrel auto-sort
function()
resilient("Barrel-sort", function()
while true do
pcall(ops.sortBarrel)
sleep(cfg.POLL_INTERVAL)
end
end,
end),
-- Task 3: Auto-smelt
function()
resilient("Auto-smelt", function()
while true do
local ok, didWork = pcall(ops.autoSmelt)
if ok and didWork then
@@ -332,10 +432,10 @@ local function main()
state.smelterNeedsRedraw = true
sleep(cfg.SMELT_INTERVAL)
end
end,
end),
-- Task 4: Defrag (consolidate partial stacks)
function()
resilient("Defrag", function()
sleep(10)
while true do
activity.defragging = true
@@ -345,10 +445,10 @@ local function main()
state.needsRedraw = true
sleep(cfg.DEFRAG_INTERVAL)
end
end,
end),
-- Task 5: Auto-compost
function()
resilient("Auto-compost", function()
while true do
activity.composting = true
state.needsRedraw = true
@@ -358,10 +458,57 @@ local function main()
pcall(ops.checkAlerts)
sleep(cfg.COMPOST_INTERVAL)
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
function()
resilient("Alert-checker", function()
sleep(5)
pcall(ops.checkAlerts)
state.needsRedraw = true
@@ -370,15 +517,16 @@ local function main()
pcall(ops.checkAlerts)
state.needsRedraw = true
end
end,
end),
-- Task 7: Main dashboard redraw (event-driven, polls 0.1s)
function()
resilient("Dashboard", function()
state.needsRedraw = true
while true do
if state.needsRedraw then
state.needsRedraw = false
pcall(display.drawDashboard)
local dok, derr = pcall(display.drawDashboard)
if not dok then log.error("DRAW", "Dashboard: %s", tostring(derr)) end
end
if state.statusTimer > 0 then
state.statusTimer = state.statusTimer - 0.1
@@ -392,22 +540,40 @@ local function main()
end
sleep(0.1)
end
end,
end),
-- Task 8: Smelter dashboard redraw
function()
resilient("Smelter-dashboard", function()
state.smelterNeedsRedraw = true
while true do
if state.smelterNeedsRedraw then
state.smelterNeedsRedraw = false
pcall(display.drawSmelterDashboard)
local sok, serr = pcall(display.drawSmelterDashboard)
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
end
sleep(0.1)
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)
function()
resilient("Touch-listener", function()
while true do
local event, side, x, y = os.pullEvent("monitor_touch")
if display.smelterMonName and side == display.smelterMonName then
@@ -418,38 +584,104 @@ local function main()
display.handleTouch(x, y)
end
end
end,
end),
-- Task 10: Network state broadcast (skips if nothing changed)
function()
resilient("Broadcast", function()
while true do
if state.stateVersion ~= state.lastBroadcastVersion then
pcall(broadcastState)
end
sleep(cfg.BROADCAST_INTERVAL)
end
end,
end),
-- Task 11: Peripheral detach handler
function()
resilient("Detach-handler", function()
while true do
local event, name = os.pullEvent("peripheral_detach")
if name then
ops.invalidateWrapCache(name)
ops.invalidatePeripheralCaches()
log.info("DETACH", "%s", name)
if name == ctx.craftTurtleName then
ctx.craftTurtleName = nil
log.warn("DETACH", "Crafting turtle disconnected")
end
end
end
end,
end),
-- Task 12: Network order/command listener
function()
if not ctx.networkModem then return end
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
resilient("Attach-handler", function()
while true do
local event, name = os.pullEvent("peripheral_attach")
if name and name:match("^turtle_") and not ctx.craftTurtleName then
ctx.craftTurtleName = name
log.info("ATTACH", "Crafting turtle detected: %s", name)
pcall(broadcastState)
end
end
end),
-- Task 12: Supply chest (builder / manifest-based stocking)
resilient("Supply-chest", function()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
while true do sleep(3600) end
end
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
while true do
pcall(ops.supplyChest)
sleep(cfg.SUPPLY_INTERVAL)
end
end),
-- Task 13a: Network message capture (fast — never yields to peripheral calls)
-- This coroutine's filter is ALWAYS "modem_message", so it can never
-- miss events while other tasks yield for "task_complete" etc.
resilient("Network-capture", function()
if not ctx.networkModem then
log.warn("NET-CAP", "No modem — capture task idle")
while true do sleep(3600) end
end
log.info("NET-CAP", "Capture task started, listening on ch %d", cfg.ORDER_CHANNEL)
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
table.insert(networkQueue, { replyChannel = replyChannel, message = message })
log.debug("NET-CAP", "Queued: type=%s queue=%d", tostring(message.type), #networkQueue)
end
end
end),
-- Task 13b: Network message processor (drains queue — safe to yield)
resilient("Network-processor", function()
if not ctx.networkModem then
log.warn("NET-PROC", "No modem — processor task idle")
while true do sleep(3600) end
end
log.info("NET-PROC", "Processor task started")
while true do
if #networkQueue == 0 then
os.pullEvent()
end
while #networkQueue > 0 do
local entry = table.remove(networkQueue, 1)
local message = entry.message
local replyChannel = entry.replyChannel
log.debug("NET-PROC", "Processing: type=%s id=%s queue=%d",
tostring(message.type), tostring(message.commandId), #networkQueue)
if isCommandDuplicate(message.commandId) then
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
-- Still ACK so the sender stops retrying
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
else
recordCommandId(message.commandId)
cleanupCommandIds()
@@ -485,6 +717,13 @@ local function main()
state.needsRedraw = true
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "toggle_pause" then
state.smeltingPaused = not state.smeltingPaused
@@ -494,6 +733,13 @@ local function main()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "toggle_recipe" and message.recipe then
if state.disabledRecipes[message.recipe] then
@@ -507,6 +753,13 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "enable_all" then
state.disabledRecipes = {}
@@ -516,6 +769,13 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "disable_all" then
for inputName in pairs(cfg.SMELTABLE) do
@@ -527,11 +787,25 @@ local function main()
state.bumpStateVersion()
state.smelterNeedsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "sort_barrel" and message.barrelName then
log.info("NET", "Sort barrel: %s", message.barrelName)
pcall(ops.sortBarrel, message.barrelName)
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "register_droppers" and message.clientId and message.droppers then
local cid = tostring(message.clientId)
@@ -589,6 +863,136 @@ local function main()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "recursive_craft" and message.itemName and message.count then
log.info("NET", "Recursive craft: %s x%d", message.itemName, message.count)
local pok, ok, craftErr = pcall(ops.recursiveCraft, message.itemName, message.count)
if not pok then
log.error("NET", "recursiveCraft crashed: %s", tostring(ok))
craftErr = tostring(ok)
ok = false
end
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "recursive_craft_result",
commandId = message.commandId,
success = ok,
error = craftErr,
})
end)
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "sync_disabled_recipes" then
if message.disabledRecipes then
state.disabledRecipes = message.disabledRecipes
end
if message.smeltingPaused ~= nil then
state.smeltingPaused = message.smeltingPaused
end
ops.saveDisabledRecipes()
log.info("NET", "Synced smelting state from client")
state.configDirty = true
state.bumpStateVersion()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Learned crafting recipe: %s", message.output)
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Learned smelting recipe: %s -> %s", message.input, message.result)
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "forget_recipe" and message.recipe then
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
cfg.recipeBook.forgetSmeltingRecipe(message.recipe)
if forgot then
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Forgot recipe: %s", message.recipe)
state.configDirty = true
state.bumpStateVersion()
end
pcall(broadcastState)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "command_ack",
commandId = message.commandId,
success = true,
})
end)
elseif message.type == "find_item" and message.items then
-- Return chest+slot locations for the first matching item
-- message.items = list of item names to search (in priority order)
-- message.limit = max items to return info for (default 64)
local limit = message.limit or 64
local results = {}
for _, itemName in ipairs(message.items) do
if cache.catalogue[itemName] then
for _, source in ipairs(cache.catalogue[itemName]) do
local chest = ops.wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == itemName then
table.insert(results, {
chest = source.chest,
slot = slot,
name = itemName,
count = slotItem.count,
})
if #results >= limit then break end
end
end
end
if #results >= limit then break end
end
end
if #results > 0 then break end -- found fuel, stop searching
end
log.info("NET", "find_item: found %d source(s)", #results)
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "find_item_result",
commandId = message.commandId,
results = results,
})
end)
end
end) -- pcall handler
@@ -596,10 +1000,22 @@ local function main()
log.error("NET", "Handler error: %s", tostring(handlerErr))
end
end -- idempotency else
end
end -- queue loop
end
end
end)
)
end
main()
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
if not ok then
_crashLog(err)
printError(tostring(err))
print("")
print("Crash log saved to: " .. _path(".crash.log"))
print("")
print("Press any key to exit...")
os.pullEvent("char")
end

View File

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

334
lib/craft.lua Normal file
View File

@@ -0,0 +1,334 @@
-- lib/craft.lua — Recursive crafting engine
-- Adapted from opus-apps milo/apis/craft2.lua for Inventory Manager.
-- Supports multi-step crafting chains with cycle detection.
--
-- Usage:
-- local craft = dofile("lib/craft.lua")
-- craft.init(recipeBook, ops.getItemTotal)
-- local ok, err = craft.executeChain("minecraft:chest", 1, ctx)
local craft = {}
local recipeBook = nil -- lib/recipeBook instance
local getItemTotal = nil -- function(itemName) -> number
--- Initialize the crafting engine.
-- @param rb recipeBook instance (lib/recipeBook.lua)
-- @param getTotal function(itemName) returning stock count
function craft.init(rb, getTotal)
recipeBook = rb
getItemTotal = getTotal
end
-------------------------------------------------
-- Ingredient analysis
-------------------------------------------------
--- Sum ingredients across a recipe grid.
-- @return { [itemName] = count }
function craft.sumIngredients(recipe)
return recipeBook.getIngredients(recipe)
end
--- Recursively determine how many items can be crafted.
-- Follows sub-recipe chains with cycle detection.
-- @param recipe crafting recipe table
-- @param count desired output quantity
-- @return craftable amount (may be less than count)
function craft.getCraftableAmount(recipe, count)
local path = { [recipe.output] = true }
local function check(r, summed, cnt, p)
local can = 0
for _ = 1, cnt do
for ingr, needed in pairs(craft.sumIngredients(r)) do
local avail = summed[ingr] or getItemTotal(ingr)
-- Try sub-crafting if we don't have enough
if avail < needed then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub and not p[ingr] then
local sp = {}
for k, v in pairs(p) do sp[k] = v end
sp[sub.output] = true
avail = avail + check(sub, summed, needed - avail, sp)
end
end
if avail < needed then return can end
summed[ingr] = avail - needed
end
can = can + r.count
end
return can
end
return check(recipe, {}, math.ceil(count / recipe.count), path)
end
--- Build a full resource list for a recipe (what's needed, available, missing).
-- Recursively expands sub-recipes.
-- @param recipe crafting recipe table
-- @param count desired output quantity
-- @return { [itemName] = { name, have, total, used, need, craftable } }
function craft.getResourceList(recipe, count)
local summed = {}
local function sum(r, cnt, path)
for ingr, per in pairs(craft.sumIngredients(r)) do
local need = per * cnt
if not summed[ingr] then
summed[ingr] = {
name = ingr,
have = getItemTotal(ingr),
total = 0,
used = 0,
need = 0,
craftable = recipeBook.isCraftable(ingr),
}
end
local e = summed[ingr]
e.total = e.total + need
local canUse = math.min(e.have, need)
e.used = e.used + canUse
e.have = e.have - canUse
local short = need - canUse
if short > 0 then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub and not path[ingr] then
local p = {}
for k, v in pairs(path) do p[k] = v end
p[sub.output] = true
sum(sub, math.ceil(short / sub.count), p)
else
e.need = e.need + short
end
end
end
end
sum(recipe, math.ceil(count / recipe.count), { [recipe.output] = true })
return summed
end
-------------------------------------------------
-- Crafting chain planning
-------------------------------------------------
--- Plan a crafting chain (bottom-up order: sub-ingredients first).
-- @param targetItem string — output item name
-- @param count number — desired quantity
-- @return array of steps { recipe, count (batches), output, outputCount } or nil, error
function craft.planChain(targetItem, count)
local recipe = recipeBook.getCraftingRecipe(targetItem)
if not recipe then return nil, "No recipe for " .. targetItem end
local steps = {}
local visited = {}
local function plan(r, cnt, depth)
if depth > 20 then return false, "Recipe chain too deep (cycle?)" end
local batches = math.ceil(cnt / r.count)
-- Plan sub-ingredients first (depth-first)
for ingr, per in pairs(craft.sumIngredients(r)) do
local need = per * batches
local have = getItemTotal(ingr)
if have < need and not visited[ingr] then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub then
local ok, err = plan(sub, need - have, depth + 1)
if not ok then return false, err end
end
end
end
-- Add this step after all sub-steps
if not visited[r.output] then
visited[r.output] = true
table.insert(steps, {
recipe = r,
count = batches,
output = r.output,
outputCount = batches * r.count,
})
end
return true
end
local ok, err = plan(recipe, count, 0)
if not ok then return nil, err end
return steps
end
-------------------------------------------------
-- Craft execution (remote turtle protocol)
-------------------------------------------------
--- Execute a single craft batch via remote turtle.
-- Uses the existing IM craft_request/craft_result modem protocol.
-- @param recipe crafting recipe table
-- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem
-- @return success, error_message
function craft.executeSingleCraft(recipe, ctx, batches)
batches = batches or 1
local ops = ctx.ops
local cfg = ctx.cfg
local st = ctx.state
if not ctx.craftTurtleName then return false, "No turtle" end
if not ctx.networkModem then return false, "No modem" 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 slotMap = {}
local reserved = {}
-- Map each grid position to a chest slot, pulling `batches` items per slot
for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos]
if itemName then
local tSlot = cfg.GRID_TO_SLOT[gridPos]
local found = false
if st.cache.catalogue[itemName] then
for _, src in ipairs(st.cache.catalogue[itemName]) do
local chest = ops.wrapCached(src.chest)
if chest then
for slot, si in pairs(chest.list()) do
local key = src.chest .. ":" .. slot
if si.name == itemName and not reserved[key] then
local pullCount = math.min(batches, si.count)
slotMap[tostring(tSlot)] = {
chestName = src.chest,
chestSlot = slot,
itemName = itemName,
count = pullCount,
}
reserved[key] = true
found = true
break
end
end
end
if found then break end
end
end
if not found then
return false, "Missing ingredient: " .. itemName
end
end
end
-- Send craft request to turtle
ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, {
type = "craft_request",
output = recipe.output,
slots = slotMap,
returnChests = chests,
})
-- Optimistically update cache
for _, info in pairs(slotMap) do
st.adjustCache(info.itemName, info.chestName, -info.count)
end
-- Wait for turtle reply
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
local result
local buffered = {}
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
local event, p1, p2, p3, p4, p5 = os.pullEvent()
if event == "modem_message" then
if p2 == cfg.CRAFT_REPLY_CHANNEL and type(p4) == "table" and p4.type == "craft_result" then
result = p4
break
elseif p2 == cfg.ORDER_CHANNEL then
table.insert(buffered, { event, p1, p2, p3, p4, p5 })
end
end
end
-- Re-queue any buffered messages
for _, msg in ipairs(buffered) do
os.queueEvent(table.unpack(msg))
end
if not result then
return false, "Turtle timeout"
end
if result.success then
local totalOutput = result.totalOutput or recipe.count
if result.results then
for _, r in ipairs(result.results) do
if #chests > 0 then
st.adjustCache(r.name, chests[1], r.count)
end
end
elseif #chests > 0 then
st.adjustCache(recipe.output, chests[1], totalOutput)
end
return true
else
-- Restore cache on failure
for _, info in pairs(slotMap) do
st.adjustCache(info.itemName, info.chestName, info.count)
end
return false, result.error or "Craft failed"
end
end
--- Execute a full crafting chain (all steps, bottom-up).
-- @param targetItem string — desired output item name
-- @param count number — desired quantity
-- @param ctx context table
-- @return success, error_message
function craft.executeChain(targetItem, count, ctx)
local steps, err = craft.planChain(targetItem, count)
if not steps then return false, err end
ctx.log.info("CRAFT", "Chain: %d steps for %s x%d", #steps, targetItem, count)
for i, step in ipairs(steps) do
ctx.log.info("CRAFT", "Step %d/%d: %s x%d (%d batches)",
i, #steps, step.output, step.outputCount, step.count)
ctx.state.activity.crafting = true
ctx.state.needsRedraw = true
ctx.state.smelterNeedsRedraw = true
-- Batch in chunks of 64 (max stack per turtle slot)
local remaining = step.count
while remaining > 0 do
local chunk = math.min(remaining, 64)
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx, chunk)
if not ok then
ctx.state.activity.crafting = false
ctx.state.needsRedraw = true
ctx.log.error("CRAFT", "Chain failed at step %d: %s", i, batchErr)
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
end
remaining = remaining - chunk
-- Brief pause between chunks to let turtle finish
if remaining > 0 then os.sleep(0.3) end
end
ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount)
-- Pause between steps to let items settle in storage
if i < #steps then os.sleep(0.5) end
end
ctx.state.activity.crafting = false
ctx.state.needsRedraw = true
ctx.state.smelterNeedsRedraw = true
ctx.log.info("CRAFT", "Chain complete: %s x%d", targetItem, count)
return true
end
return craft

114
lib/itemDB.lua Normal file
View File

@@ -0,0 +1,114 @@
-- lib/itemDB.lua — Item display name database
-- Learns display names from getItemDetail() and persists to disk.
-- Adapted from opus-apps core.itemDB for Inventory Manager.
--
-- Usage:
-- local itemDB = dofile("lib/itemDB.lua")
-- itemDB.init(".item_names.db")
-- itemDB.learn("minecraft:diamond", "Diamond")
-- print(itemDB.getName("minecraft:diamond")) --> "Diamond"
local itemDB = {}
local nameData = {}
local DATA_FILE = nil
local dirty = false
-------------------------------------------------
-- Initialization and persistence
-------------------------------------------------
function itemDB.init(dataFile)
DATA_FILE = dataFile
itemDB.load()
end
function itemDB.load()
if not DATA_FILE or not fs.exists(DATA_FILE) then return end
pcall(function()
local f = fs.open(DATA_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if type(data) == "table" then
nameData = data
end
end)
end
function itemDB.flush()
if not dirty or not DATA_FILE then return end
pcall(function()
local f = fs.open(DATA_FILE, "w")
f.write(textutils.serialise(nameData))
f.close()
end)
dirty = false
end
-------------------------------------------------
-- Name resolution
-------------------------------------------------
--- Get a human-readable display name for an item.
-- Falls back to generating a name from the item ID.
-- @param itemName string or table with .name field
-- @return string
function itemDB.getName(itemName)
if type(itemName) == "table" then itemName = itemName.name end
if not itemName then return "?" end
if nameData[itemName] then return nameData[itemName] end
-- Generate readable name from item ID (e.g. "minecraft:iron_ingot" -> "Iron Ingot")
local short = itemName:gsub("^[%w_]+:", "")
return short:gsub("_", " "):gsub("(%a)([%w_']*)", function(first, rest)
return first:upper() .. rest:lower()
end)
end
--- Learn a display name for an item.
-- @param itemName string or table with .name and optional .displayName
-- @param displayName string (optional if itemName is a table)
function itemDB.learn(itemName, displayName)
if type(itemName) == "table" then
displayName = displayName or itemName.displayName
itemName = itemName.name
end
if itemName and displayName and displayName ~= "" then
if nameData[itemName] ~= displayName then
nameData[itemName] = displayName
dirty = true
end
end
end
--- Learn from a getItemDetail() result table.
-- @param detail table with .name and .displayName fields
function itemDB.learnFromDetail(detail)
if detail and detail.name and detail.displayName then
itemDB.learn(detail.name, detail.displayName)
end
end
-------------------------------------------------
-- Queries
-------------------------------------------------
--- Check if an item's display name has been learned.
function itemDB.isKnown(itemName)
if type(itemName) == "table" then itemName = itemName.name end
return nameData[itemName] ~= nil
end
--- Get all known name mappings.
function itemDB.getAllNames()
return nameData
end
--- Get count of known items.
function itemDB.count()
local n = 0
for _ in pairs(nameData) do n = n + 1 end
return n
end
return itemDB

266
lib/recipeBook.lua Normal file
View File

@@ -0,0 +1,266 @@
-- lib/recipeBook.lua — Unified recipe database with learning support
-- Loads built-in recipes from data files + user-learned recipes from disk.
-- Compatible with Prominence II Hasturian Era modpack (or any modded setup).
--
-- Usage:
-- local recipeBook = dofile("lib/recipeBook.lua")
-- recipeBook.init(".recipes.db")
-- recipeBook.loadLegacyCrafting(dofile("data/craftable.lua"))
-- recipeBook.loadLegacySmelting(dofile("data/smeltable.lua"))
-- recipeBook.learnCraftingRecipe("mod:item", 4, { ... })
-- recipeBook.flush()
local recipeBook = {}
local recipes = {
crafting = {}, -- keyed by output item name
smelting = {}, -- keyed by input item name
}
local RECIPE_FILE = nil
local dirty = false
-------------------------------------------------
-- Initialization and persistence
-------------------------------------------------
function recipeBook.init(recipeFile)
RECIPE_FILE = recipeFile
recipeBook.loadUserRecipes()
end
--- Load legacy crafting recipes from data/craftable.lua format.
-- Does not overwrite recipes already loaded (user-learned take priority).
function recipeBook.loadLegacyCrafting(craftableArray)
for _, recipe in ipairs(craftableArray) do
if not recipes.crafting[recipe.output] then
recipes.crafting[recipe.output] = {
output = recipe.output,
count = recipe.count,
grid = recipe.grid,
source = "builtin",
}
end
end
end
--- Load legacy smelting recipes from data/smeltable.lua format.
-- Does not overwrite recipes already loaded (user-learned take priority).
function recipeBook.loadLegacySmelting(smeltableTable)
for input, recipe in pairs(smeltableTable) do
if not recipes.smelting[input] then
recipes.smelting[input] = {
input = input,
result = recipe.result,
furnaces = recipe.furnaces,
source = "builtin",
}
end
end
end
--- Load user-learned recipes from disk.
function recipeBook.loadUserRecipes()
if not RECIPE_FILE or not fs.exists(RECIPE_FILE) then return end
pcall(function()
local f = fs.open(RECIPE_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if type(data) == "table" then
for output, recipe in pairs(data.crafting or {}) do
recipe.source = "learned"
recipe.output = output
recipes.crafting[output] = recipe
end
for input, recipe in pairs(data.smelting or {}) do
recipe.source = "learned"
recipe.input = input
recipes.smelting[input] = recipe
end
end
end)
end
--- Save user-learned recipes to disk.
function recipeBook.flush()
if not dirty or not RECIPE_FILE then return end
pcall(function()
local uc, us = {}, {}
for output, r in pairs(recipes.crafting) do
if r.source == "learned" then
uc[output] = { output = r.output, count = r.count, grid = r.grid }
end
end
for input, r in pairs(recipes.smelting) do
if r.source == "learned" then
us[input] = { result = r.result, furnaces = r.furnaces }
end
end
local f = fs.open(RECIPE_FILE, "w")
f.write(textutils.serialise({ crafting = uc, smelting = us }))
f.close()
end)
dirty = false
end
-------------------------------------------------
-- Learning / forgetting recipes
-------------------------------------------------
--- Learn a new crafting recipe (or overwrite an existing one).
-- @param output string — output item name (e.g. "minecraft:stick")
-- @param count number — items produced per craft
-- @param grid table — 9-entry grid array
function recipeBook.learnCraftingRecipe(output, count, grid)
recipes.crafting[output] = {
output = output,
count = count,
grid = grid,
source = "learned",
}
dirty = true
end
--- Learn a new smelting recipe.
-- @param input string — input item name
-- @param result string — output item name
-- @param furnaces table — array of furnace type strings (default: {"minecraft:furnace"})
function recipeBook.learnSmeltingRecipe(input, result, furnaces)
recipes.smelting[input] = {
input = input,
result = result,
furnaces = furnaces or { "minecraft:furnace" },
source = "learned",
}
dirty = true
end
--- Forget a learned crafting recipe (built-in recipes cannot be forgotten).
-- @return true if removed, false if recipe was built-in or not found
function recipeBook.forgetCraftingRecipe(output)
if recipes.crafting[output] and recipes.crafting[output].source == "learned" then
recipes.crafting[output] = nil
dirty = true
return true
end
return false
end
--- Forget a learned smelting recipe.
function recipeBook.forgetSmeltingRecipe(input)
if recipes.smelting[input] and recipes.smelting[input].source == "learned" then
recipes.smelting[input] = nil
dirty = true
return true
end
return false
end
-------------------------------------------------
-- Lookups
-------------------------------------------------
--- Get a crafting recipe by output item name.
function recipeBook.getCraftingRecipe(output)
return recipes.crafting[output]
end
--- Get a smelting recipe by input item name.
function recipeBook.getSmeltingRecipe(input)
return recipes.smelting[input]
end
--- Find any recipe (crafting or smelting) that produces a given item.
-- @return recipe, recipeType ("crafting" or "smelting") or nil
function recipeBook.findRecipeFor(itemName)
if recipes.crafting[itemName] then
return recipes.crafting[itemName], "crafting"
end
for input, recipe in pairs(recipes.smelting) do
if recipe.result == itemName then
return recipe, "smelting"
end
end
return nil
end
-------------------------------------------------
-- Backward-compatible accessors
-------------------------------------------------
--- Get all crafting recipes as an indexed array (compat with cfg.CRAFTABLE).
-- Sorted by output name.
function recipeBook.getCraftingList()
local list = {}
for _, recipe in pairs(recipes.crafting) do
table.insert(list, recipe)
end
table.sort(list, function(a, b) return a.output < b.output end)
return list
end
--- Get all smelting recipes as a keyed table (compat with cfg.SMELTABLE).
function recipeBook.getSmeltingTable()
local result = {}
for input, recipe in pairs(recipes.smelting) do
result[input] = recipe
end
return result
end
-------------------------------------------------
-- Utilities
-------------------------------------------------
--- Get summed ingredients for a crafting recipe.
-- @return table { [itemName] = count }
function recipeBook.getIngredients(recipe)
local ingredients = {}
if recipe and recipe.grid then
for _, item in ipairs(recipe.grid) do
if item then
ingredients[item] = (ingredients[item] or 0) + 1
end
end
end
return ingredients
end
--- Quick craftability check.
function recipeBook.isCraftable(itemName)
return recipes.crafting[itemName] ~= nil
end
--- Quick smeltability check.
function recipeBook.isSmeltable(itemName)
return recipes.smelting[itemName] ~= nil
end
--- Count total recipes.
-- @return craftCount, smeltCount
function recipeBook.count()
local cc, sc = 0, 0
for _ in pairs(recipes.crafting) do cc = cc + 1 end
for _ in pairs(recipes.smelting) do sc = sc + 1 end
return cc, sc
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

View File

@@ -6,6 +6,16 @@ return function(log, _path)
-- Fall back to CWD-relative if _path not provided (standalone use)
if not _path then _path = function(p) return p end end
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local C = {}
-------------------------------------------------
@@ -22,9 +32,12 @@ C.SMELT_RESERVE = 128
C.DEFRAG_INTERVAL = 600
C.COMPOST_INTERVAL = 3
C.ALERT_INTERVAL = 15
C.CACHE_FILE = _path(".inventory_cache")
C.CACHE_FILE = _configPath(".inventory_cache")
C.SMELTER_MONITOR_SIDE = "top"
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
C.BILLBOARD_MONITOR = "" -- network name e.g. "monitor_0"; auto-detects if empty
C.BILLBOARD_TOP_ITEMS = 20 -- max items in billboard bar chart
C.BILLBOARD_TEXT_SCALE = 1 -- 0.5 = tiny, 1 = normal, 2 = large, 5 = huge
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
-- Network
C.BROADCAST_CHANNEL = 4200
@@ -43,9 +56,40 @@ C.COMPOST_RESERVE = 128
C.COMPOST_DROPPER = "minecraft:dropper_10"
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
C.PERIPHERAL_CACHE_TTL = 5
-- Parallel scanning
C.PARALLEL_SCAN_CHUNKS = 8
-- Storage priority (higher = preferred; keyed by chest peripheral name)
C.CHEST_PRIORITY = {}
-- Builder / supply manifest
C.SUPPLY_CHEST = "" -- peripheral name of supply chest (empty = disabled)
C.SUPPLY_INTERVAL = 10 -- seconds between supply checks
C.SUPPLY_MANIFEST = {} -- { { name = "mod:item", count = N }, ... }
-- Furnace types
C.FURNACE_TYPES = {
"minecraft:furnace",
@@ -60,7 +104,7 @@ C.SLOT_OUTPUT = 3
-- Config file loader
-------------------------------------------------
local CONFIG_FILE = _path(".manager_config")
local CONFIG_FILE = _configPath(".manager_config")
function C.loadConfig()
if not fs.exists(CONFIG_FILE) then return end
@@ -76,7 +120,10 @@ function C.loadConfig()
if cfg.dropperName then C.DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then C.BARREL_NAME = cfg.barrelName end
if cfg.monitorSide then C.MONITOR_SIDE = cfg.monitorSide end
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.smelterMonitorSide then C.SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.billboardMonitor then C.BILLBOARD_MONITOR = cfg.billboardMonitor end
if cfg.billboardTopItems then C.BILLBOARD_TOP_ITEMS = cfg.billboardTopItems end
if cfg.billboardTextScale then C.BILLBOARD_TEXT_SCALE = cfg.billboardTextScale end
if cfg.pollInterval then C.POLL_INTERVAL = cfg.pollInterval end
if cfg.scanInterval then C.SCAN_INTERVAL = cfg.scanInterval end
if cfg.smeltInterval then C.SMELT_INTERVAL = cfg.smeltInterval end
@@ -92,6 +139,23 @@ function C.loadConfig()
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper 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.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
if cfg.supplyInterval then C.SUPPLY_INTERVAL = cfg.supplyInterval end
if cfg.supplyManifest then C.SUPPLY_MANIFEST = cfg.supplyManifest end
if cfg.logLevel then log.setLevel(cfg.logLevel) end
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
end
@@ -100,31 +164,38 @@ end
-- Data tables
-------------------------------------------------
C.SMELTABLE = dofile(_path("data/smeltable.lua"))
C.FUEL_LIST = dofile(_path("data/fuel.lua"))
local _compostData = dofile(_path("data/compostable.lua"))
C.COMPOSTABLE = _compostData.items
C.COMPOST_TRASH = _compostData.trash
C.CRAFTABLE = dofile(_path("data/craftable.lua"))
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"))
-- Pre-build furnace compatibility sets for O(1) lookup
for _, recipe in pairs(C.SMELTABLE) do
recipe.furnaceSet = {}
for _, ft in ipairs(recipe.furnaces) do
recipe.furnaceSet[ft] = true
end
end
-- Recipe book: merges built-in recipes + user-learned recipes
local recipeBook = dofile(_path("lib/recipeBook.lua"))
recipeBook.init(_configPath(".recipes.db"))
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
C.recipeBook = recipeBook
C.SMELTABLE = recipeBook.getSmeltingTable()
C.CRAFTABLE = recipeBook.getCraftingList()
-- Pre-built smelt candidate lists per furnace type
C.smeltCandidatesByType = {}
do
-- Rebuild furnace/smelt indices from current recipe data
function C.rebuildIndices()
for _, recipe in pairs(C.SMELTABLE) do
recipe.furnaceSet = {}
for _, ft in ipairs(recipe.furnaces) do
recipe.furnaceSet[ft] = true
end
end
C.smeltCandidatesByType = {}
for _, ftype in ipairs(C.FURNACE_TYPES) do
C.smeltCandidatesByType[ftype] = {}
end
for itemName, recipe in pairs(C.SMELTABLE) do
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
for ft, _ in pairs(recipe.furnaceSet) do
for ft in pairs(recipe.furnaceSet) do
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
end
end
@@ -136,6 +207,15 @@ do
end
end
-- Refresh recipes from recipeBook and rebuild all indices
function C.refreshRecipes()
C.SMELTABLE = C.recipeBook.getSmeltingTable()
C.CRAFTABLE = C.recipeBook.getCraftingList()
C.rebuildIndices()
end
C.rebuildIndices()
-- Build fuel set for quick lookup
C.FUEL_SET = {}
for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
@@ -144,6 +224,10 @@ for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end
C.COMPOSTABLE_SET = {}
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
end

File diff suppressed because it is too large Load Diff

View File

@@ -148,13 +148,39 @@ function O.refreshCache(onProgress)
local totalSlots = 0
local usedSlots = 0
for ci, chest in ipairs(chests) do
if onProgress then onProgress(ci, #chests, chest) end
local inv = O.wrapCached(chest)
if inv then
totalSlots = totalSlots + inv.size()
local contents = inv.list()
for slot, item in pairs(contents) do
-- Parallel inventory scanning (chunked)
local CHUNK = cfg.PARALLEL_SCAN_CHUNKS or 8
local scanData = {}
local scanFns = {}
for _, chest in ipairs(chests) do
table.insert(scanFns, function()
local inv = O.wrapCached(chest)
if inv then
scanData[chest] = {
size = inv.size(),
contents = inv.list(),
}
end
end)
end
for i = 1, #scanFns, CHUNK do
local chunk = {}
local chunkEnd = math.min(i + CHUNK - 1, #scanFns)
for j = i, chunkEnd do
chunk[#chunk + 1] = scanFns[j]
end
if onProgress then
onProgress(chunkEnd, #chests, chests[chunkEnd] or "scanning...")
end
parallel.waitForAll(table.unpack(chunk))
end
for _, chest in ipairs(chests) do
local data = scanData[chest]
if data then
totalSlots = totalSlots + data.size
for slot, item in pairs(data.contents) do
usedSlots = usedSlots + 1
if not catalogue[item.name] then
catalogue[item.name] = {}
@@ -293,6 +319,22 @@ end
-- Barrel auto-sort
-------------------------------------------------
--- Get chests sorted by priority (highest first).
-- Falls back to name order if no priority is configured.
function O.getChestsByPriority()
local chests = O.getChests()
local priority = cfg.CHEST_PRIORITY
if not priority or not next(priority) then return chests end
local sorted = { table.unpack(chests) }
table.sort(sorted, function(a, b)
local pa = priority[a] or 0
local pb = priority[b] or 0
if pa ~= pb then return pa > pb end
return a < b
end)
return sorted
end
function O.sortBarrel(barrelOverride)
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME
local barrel = O.wrapCached(barrelTarget)
@@ -305,7 +347,7 @@ function O.sortBarrel(barrelOverride)
state.needsRedraw = true
local catalogue = cache.catalogue
local chests = O.getChests()
local chests = O.getChestsByPriority()
for slot, item in pairs(contents) do
local moved = 0
@@ -723,6 +765,279 @@ function O.autoCompost()
return didWork
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
-------------------------------------------------
@@ -751,6 +1066,63 @@ function O.checkAlerts()
end
end
-------------------------------------------------
-- Supply chest (builder / manifest-based stocking)
-------------------------------------------------
function O.supplyChest()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return false end
local supply = O.wrapCached(cfg.SUPPLY_CHEST)
if not supply then return false end
-- Scan what's already in the supply chest
local existing = {}
local contents = supply.list()
if contents then
for _, item in pairs(contents) do
existing[item.name] = (existing[item.name] or 0) + item.count
end
end
local catalogue = cache.catalogue
local didWork = false
for _, manifest in ipairs(cfg.SUPPLY_MANIFEST) do
local have = existing[manifest.name] or 0
local want = manifest.count or 0
local deficit = want - have
if deficit > 0 and catalogue[manifest.name] then
local remaining = deficit
local srcSnapshot = { table.unpack(catalogue[manifest.name]) }
for _, source in ipairs(srcSnapshot) do
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 == manifest.name then
local toMove = math.min(slotItem.count, remaining)
local n = chest.pushItems(cfg.SUPPLY_CHEST, slot, toMove)
if n and n > 0 then
state.adjustCache(manifest.name, source.chest, -n)
remaining = remaining - n
didWork = true
log.info("SUPPLY", "%s x%d -> %s", manifest.name, n, cfg.SUPPLY_CHEST)
if remaining <= 0 then break end
end
end
end
end
end
if remaining <= 0 then break end
end
end
end
return didWork
end
-------------------------------------------------
-- Order / Dispense
-------------------------------------------------
@@ -853,7 +1225,8 @@ function O.getMissingIngredients(recipe)
return ui.getMissingIngredients(recipe, O.getItemTotal)
end
function O.craftItem(recipeIdx)
function O.craftItem(recipeIdx, batches)
batches = batches or 1
local recipe = cfg.CRAFTABLE[recipeIdx]
if not recipe then
log.error("CRAFT", "Invalid recipe index: %s", tostring(recipeIdx))
@@ -872,7 +1245,11 @@ function O.craftItem(recipeIdx)
return false, "Turtle offline"
end
log.info("CRAFT", "Starting craft: %s (turtle: %s)", recipe.output, ctx.craftTurtleName)
-- Clamp batches to 64 (max stack size per slot)
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
state.needsRedraw = true
@@ -882,6 +1259,27 @@ function O.craftItem(recipeIdx)
local slotMap = {}
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
local itemName = recipe.grid[gridPos]
if itemName then
@@ -895,11 +1293,12 @@ function O.craftItem(recipeIdx)
for slot, slotItem in pairs(chest.list()) do
local key = source.chest .. ":" .. slot
if slotItem.name == itemName and not reservedSlots[key] then
local pullCount = math.min(batches, slotItem.count)
slotMap[tostring(turtleSlot)] = {
chestName = source.chest,
chestSlot = slot,
itemName = itemName,
count = 1,
count = pullCount,
}
reservedSlots[key] = true
found = true
@@ -939,7 +1338,6 @@ function O.craftItem(recipeIdx)
log.info("CRAFT", "Waiting for turtle reply (timeout: %ds)...", cfg.CRAFT_TIMEOUT)
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
local result = nil
local bufferedMessages = {}
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
@@ -951,18 +1349,13 @@ function O.craftItem(recipeIdx)
if channel == cfg.CRAFT_REPLY_CHANNEL and type(message) == "table" and message.type == "craft_result" then
result = message
break
elseif channel == cfg.ORDER_CHANNEL then
table.insert(bufferedMessages, {event, p1, p2, p3, p4, p5})
end
-- ORDER_CHANNEL messages are captured by the Network-capture task
elseif event == "timer" and p1 == timerId then
-- Timeout tick
end
end
for _, msg in ipairs(bufferedMessages) do
os.queueEvent(table.unpack(msg))
end
activity.crafting = false
state.needsRedraw = true
state.smelterNeedsRedraw = true
@@ -1012,6 +1405,24 @@ function O.craftItem(recipeIdx)
end
end
-------------------------------------------------
-- Recursive crafting (multi-step chains)
-------------------------------------------------
function O.recursiveCraft(outputName, count)
if not ctx.craftEngine then
return false, "Craft engine not initialized"
end
activity.crafting = true
state.needsRedraw = true
state.smelterNeedsRedraw = true
local ok, err = ctx.craftEngine.executeChain(outputName, count, ctx)
activity.crafting = false
state.needsRedraw = true
state.smelterNeedsRedraw = true
return ok, err
end
-------------------------------------------------
-- Smelting recipe persistence
-------------------------------------------------

View File

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

414
miningTurtle.lua Normal file
View File

@@ -0,0 +1,414 @@
-- Mining Turtle Script
-- Sits on top of an infinite cobblestone generator.
-- Continuously mines the block below and pushes items into
-- networked storage via wired modem.
-- Requires a wired modem attached to the turtle.
local shell = _ENV.shell
local function fatal(msg)
printError(msg)
print("\nPress any key to exit...")
os.pullEvent("key")
return
end
-------------------------------------------------
-- Default configuration (overridden by .miner_config)
-------------------------------------------------
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
local ORDER_CHANNEL = 4201 -- channel to talk to manager
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
-- Fuel items the turtle will look for in storage (best first)
local FUEL_ITEMS = {
"minecraft:coal",
"minecraft:charcoal",
"minecraft:coal_block",
"minecraft:blaze_rod",
"minecraft:dried_kelp_block",
}
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path (survives Opus package updates)
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
local CONFIG_FILE = _configPath(".miner_config")
local function loadConfig()
if not fs.exists(CONFIG_FILE) then return end
local f = fs.open(CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if not ok or not cfg then
print("[WARN] Failed to parse " .. CONFIG_FILE)
return
end
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
print("[CONFIG] Loaded from " .. CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- Setup
-------------------------------------------------
print("=================================")
print(" Mining Turtle (Cobble Miner)")
print("=================================")
print("")
if not turtle then
return fatal("[ERR] Not a turtle!")
end
-- Find wired modem and get our network name
local modem = nil
local modemSide = nil
local selfName = nil
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
if peripheral.getType(side) == "modem" then
local m = peripheral.wrap(side)
if m.getNameLocal then
local name = m.getNameLocal()
if name then
modem = m
modemSide = side
selfName = name
break
end
end
end
end
if not modem or not selfName then
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
end
print("[OK] Modem: " .. modemSide)
print("[OK] Network name: " .. selfName)
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
print("")
-------------------------------------------------
-- Find chests on the network to dump into
-------------------------------------------------
local function getChests()
local chests = {}
for _, name in ipairs(peripheral.getNames()) do
local ptype = peripheral.getType(name)
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
ptype == "minecraft:shulker_box" then
table.insert(chests, name)
end
end
return chests
end
-------------------------------------------------
-- Dump inventory into networked storage
-- Uses pull approach: wrap the remote chest and call
-- chest.pullItems(selfName, slot) — avoids needing
-- to wrap the turtle's own peripheral.
-------------------------------------------------
local function dumpInventory()
local chests = getChests()
if #chests == 0 then
print("[DUMP] No chests on network!")
return 0
end
local totalPushed = 0
for slot = 1, 16 do
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
for _, chestName in ipairs(chests) do
local chest = peripheral.wrap(chestName)
if chest and chest.pullItems then
local ok, n = pcall(chest.pullItems, selfName, slot)
if ok and n and n > 0 then
totalPushed = totalPushed + n
if turtle.getItemCount(slot) == 0 then
break -- slot empty, move to next
end
end
end
end
end
end
-- Notify manager to refresh its cache so counts update immediately
if totalPushed > 0 then
pcall(function()
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
end)
end
return totalPushed
end
-------------------------------------------------
-- Count occupied inventory slots
-------------------------------------------------
local function occupiedSlots()
local count = 0
for slot = 1, 16 do
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
count = count + 1
end
end
return count
end
-------------------------------------------------
-- Auto-refuel: ask manager for fuel, pull directly
-------------------------------------------------
--- Try to burn whatever is already in FUEL_SLOT
local function burnFuelSlot()
if FUEL_SLOT <= 0 then return false end
local prev = turtle.getSelectedSlot()
turtle.select(FUEL_SLOT)
if turtle.getItemCount() > 0 then
local ok = turtle.refuel()
turtle.select(prev)
return ok
end
turtle.select(prev)
return false
end
--- Ask the inventory manager where fuel items are
local function askManagerForFuel()
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
modem.open(replyChannel)
modem.transmit(ORDER_CHANNEL, replyChannel, {
type = "find_item",
items = FUEL_ITEMS,
limit = 1,
})
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
local result = nil
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
local event, p1, p2, p3, p4 = os.pullEvent()
if event == "modem_message" and p2 == replyChannel then
if type(p4) == "table" and p4.type == "find_item_result" then
result = p4.results
break
end
elseif event == "timer" and p1 == timerId then
break
end
end
modem.close(replyChannel)
return result
end
--- Pull fuel from a specific chest+slot (told by manager)
local function pullFuelFromSource(source)
if FUEL_SLOT <= 0 then return false end
local chest = peripheral.wrap(source.chest)
if not chest then return false end
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
if ok and n and n > 0 then
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
return true
end
return false
end
local function pullFuelFromStorage()
-- Ask manager first (it knows exactly where fuel is)
local sources = askManagerForFuel()
if sources and #sources > 0 then
for _, source in ipairs(sources) do
if pullFuelFromSource(source) then return true end
end
end
-- Fallback: scan chests directly (in case manager is offline)
local fuelSet = {}
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
local chests = getChests()
for _, chestName in ipairs(chests) do
local chest = peripheral.wrap(chestName)
if chest and chest.list then
for slot, item in pairs(chest.list() or {}) do
if fuelSet[item.name] then
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
if ok and n and n > 0 then
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
return true
end
end
end
end
end
return false
end
local function autoRefuel()
if turtle.getFuelLevel() == "unlimited" then return end
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
-- First try burning whatever is already in the fuel slot
if burnFuelSlot() then
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
return
end
-- Pull fresh fuel from networked storage
if pullFuelFromStorage() then
burnFuelSlot()
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
end
end
-------------------------------------------------
-- Stats
-------------------------------------------------
local totalMined = 0
local totalDumped = 0
local startTime = os.clock()
local function printStats()
local elapsed = os.clock() - startTime
local mins = math.floor(elapsed / 60)
local secs = math.floor(elapsed % 60)
term.clear()
term.setCursorPos(1, 1)
print("=================================")
print(" Mining Turtle - Running")
print("=================================")
print(string.format(" Mined: %d blocks", totalMined))
print(string.format(" Dumped: %d items", totalDumped))
print(string.format(" Uptime: %dm %ds", mins, secs))
if turtle.getFuelLevel() ~= "unlimited" then
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
end
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
print("=================================")
end
-------------------------------------------------
-- Main mining loop
-------------------------------------------------
local function mineLoop()
local lastDump = os.clock()
while true do
-- Auto-refuel if low
autoRefuel()
-- Check fuel — keep trying to pull from storage
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
print("[WARN] Out of fuel! Searching storage...")
while turtle.getFuelLevel() < 1 do
if pullFuelFromStorage() then
burnFuelSlot()
end
if turtle.getFuelLevel() < 1 then
printStats()
sleep(10)
end
end
end
-- Mine the block below
if turtle.detectDown() then
local ok, err = turtle.digDown()
if ok then
totalMined = totalMined + 1
end
end
-- Dump inventory if threshold reached or timer expired
local now = os.clock()
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
if occupiedSlots() > 0 then
local pushed = dumpInventory()
totalDumped = totalDumped + pushed
if pushed > 0 then
print(string.format("[DUMP] Pushed %d items to storage", pushed))
end
end
lastDump = os.clock()
end
-- Refresh display periodically
printStats()
sleep(MINE_INTERVAL)
end
end
-------------------------------------------------
-- Reboot listener (remote reboot support)
-------------------------------------------------
local function rebootListener()
modem.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 == "miner" or target == tostring(os.getComputerID()) then
print("[SYSTEM] Reboot command received.")
sleep(0.5)
os.reboot()
end
end
end
end
-------------------------------------------------
-- Entry point
-------------------------------------------------
print("Starting mining loop...")
print("Press Ctrl+T to stop.")
print("")
sleep(1)
local ok, err = pcall(function()
parallel.waitForAny(mineLoop, rebootListener)
end)
if not ok then
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
end

View File

@@ -3,6 +3,13 @@
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
-- Persistent config directory (survives Opus package updates)
local PERSIST_DIR = "usr/config/inventory-manager"
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
-- Files to download (destination -> repo path)
local FILES = {
["inventoryClient.lua"] = "inventoryClient.lua",
@@ -65,8 +72,103 @@ else
print(string.format("All %d files up to date.", updated))
end
-------------------------------------------------
-- First-run setup
-------------------------------------------------
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
local function isValidSide(s)
for _, v in ipairs(VALID_SIDES) do
if v == s then return true end
end
return false
end
local function firstRunSetup()
if fs.exists(SETUP_FILE) then return end
print("")
print("========== First-Run Setup ==========")
print("")
write("Is there a dropper next to this computer? (y/n): ")
local answer = read():lower():sub(1, 1)
if answer == "y" then
-- Ask which side the dropper is on
local side
while true do
print("")
print("Valid sides: top, bottom, left, right, front, back")
write("Which side is the dropper on? ")
side = read():lower():gsub("%s+", "")
if isValidSide(side) then
break
end
print("Invalid side. Please try again.")
end
-- Ask which side to pulse redstone from (defaults to same side)
print("")
write("Redstone output side [" .. side .. "]: ")
local rsSide = read():lower():gsub("%s+", "")
if rsSide == "" then rsSide = side end
if not isValidSide(rsSide) then
print("Invalid side, defaulting to " .. side)
rsSide = side
end
-- Save dropper config
local cfg = textutils.serialiseJSON({
dropperSide = side,
redstoneSide = rsSide,
enabled = true,
})
local f = fs.open(DROPPER_CONFIG, "w")
f.write(cfg)
f.close()
print("")
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
else
-- No dropper - save config with enabled=false
local f = fs.open(DROPPER_CONFIG, "w")
f.write(textutils.serialiseJSON({ enabled = false }))
f.close()
print("No dropper configured.")
end
-- Mark setup as complete
local f = fs.open(SETUP_FILE, "w")
f.write("done")
f.close()
print("")
print("Setup complete!")
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
sleep(2)
end
firstRunSetup()
-------------------------------------------------
-- Determine if dropper controller should run
-------------------------------------------------
local dropperEnabled = false
if fs.exists(DROPPER_CONFIG) then
local f = fs.open(DROPPER_CONFIG, "r")
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
f.close()
if ok and cfg and cfg.enabled then
dropperEnabled = true
end
end
print("")
print("Starting inventoryClient + dropperController...")
if dropperEnabled then
print("Starting inventoryClient + dropperController...")
else
print("Starting inventoryClient (no dropper)...")
end
sleep(1)
-- Reboot listener: reboots this computer on remote command
@@ -90,8 +192,12 @@ local function rebootListener()
end
end
parallel.waitForAny(
local tasks = {
function() shell.run("inventoryClient.lua") end,
function() shell.run("dropperController.lua") end,
rebootListener
)
rebootListener,
}
if dropperEnabled then
table.insert(tasks, function() shell.run("dropperController.lua") end)
end
parallel.waitForAny(table.unpack(tasks))

View File

@@ -12,11 +12,16 @@ local FILES = {
["manager/display.lua"] = "manager/display.lua",
["lib/log.lua"] = "lib/log.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/fuel.lua"] = "data/fuel.lua",
["data/compostable.lua"] = "data/compostable.lua",
["data/craftable.lua"] = "data/craftable.lua",
["data/alerts.lua"] = "data/alerts.lua",
["data/stock_limits.lua"] = "data/stock_limits.lua",
["data/auto_craft.lua"] = "data/auto_craft.lua",
}
-------------------------------------------------

82
startup/miner.lua Normal file
View File

@@ -0,0 +1,82 @@
-- startup.lua for Mining Turtle
-- Auto-updates from git then launches miningTurtle.lua
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
local FILES = {
["miningTurtle.lua"] = "miningTurtle.lua",
}
-------------------------------------------------
local function download(remotePath, localPath)
local url = REPO_RAW .. "/" .. remotePath
local response = http.get(url)
if response then
local f = fs.open(localPath, "w")
f.write(response.readAll())
f.close()
response.close()
return true
end
return false
end
-------------------------------------------------
term.clear()
term.setCursorPos(1, 1)
print("==================================")
print(" Mining Turtle - Startup")
print(" Computer ID: " .. os.getComputerID())
print("==================================")
print("")
local updated, failed = 0, 0
for localPath, remotePath in pairs(FILES) do
write(" " .. localPath .. " ... ")
if download(remotePath, localPath) then
print("OK")
updated = updated + 1
else
print("FAIL")
failed = failed + 1
end
end
print("")
if failed > 0 then
print(string.format("Updated %d files, %d failed.", updated, failed))
else
print(string.format("All %d files up to date.", updated))
end
print("")
print("Starting miningTurtle...")
sleep(1)
-- Reboot listener: reboots this turtle on remote command
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

@@ -1,5 +1,5 @@
# Stage 1: Build the React app
FROM node:18-alpine AS build
FROM node:20-alpine AS build
WORKDIR /app

View File

@@ -22,7 +22,7 @@ function App() {
const [showSettings, setShowSettings] = useState(false);
const [, forceRender] = useState(0);
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}:4444`;
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
useEffect(() => {
connect();

View File

@@ -2,274 +2,47 @@ import React, { useState } from 'react';
import { getItemEmoji } from '../utils/itemUtils';
import './ItemIcon.css';
// All textures are proxied & cached through our server
const TEXTURE_PROXY_BASE = '/api/texture';
// Server-side smart resolution endpoint — handles aliases, animated frames,
// carpet→wool, wood→log, derivatives, CC:Tweaked, Create, and prefix matching.
const RESOLVE_BASE = '/api/texture/resolve';
// Items whose texture file name differs from their registry name
const TEXTURE_ALIASES = {
// Renamed items in 1.20+
grass: 'short_grass',
scute: 'turtle_scute',
};
// CC:Tweaked texture paths (registry name → actual file in the CC repo)
const CC_TEXTURE_MAP = {
turtle_normal: 'block/turtle_normal_front',
turtle_advanced: 'block/turtle_advanced_front',
computer_normal: 'block/computer_normal_front',
computer_advanced: 'block/computer_advanced_front',
computer_command: 'block/computer_command_front',
monitor_normal: 'block/monitor_normal_0',
monitor_advanced: 'block/monitor_advanced_0',
wired_modem: 'block/wired_modem_face',
wired_modem_full: 'block/wired_modem_face',
wireless_modem_normal: 'block/wireless_modem_normal_face',
wireless_modem_advanced: 'block/wireless_modem_advanced_face',
speaker: 'block/speaker_front',
disk_drive: 'block/disk_drive_front',
printer: 'block/printer_front_empty',
cable: 'block/cable_core',
// CC item textures
pocket_computer_normal: 'item/pocket_computer_normal',
pocket_computer_advanced: 'item/pocket_computer_advanced',
disk: 'item/disk_frame',
printed_book: 'item/printed_book',
printed_page: 'item/printed_page',
printed_pages: 'item/printed_pages',
};
// Items whose texture lives in the block/ folder instead of item/
const BLOCK_TEXTURES = new Set([
'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite',
'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks',
'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks',
'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log',
'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block',
'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log',
'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log',
'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud',
'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian',
'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt',
'glowstone', 'glass', 'tinted_glass',
'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks',
'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks',
'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon',
'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine',
'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice',
'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block',
'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block',
'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore',
'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris',
'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore',
'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore',
'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block',
'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block',
'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block',
'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar',
'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block',
'slime_block', 'honey_block', 'note_block', 'jukebox',
'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool',
'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool',
'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool',
'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete',
'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete',
'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete',
'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete',
'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta',
'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta',
'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta',
'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta',
'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta',
'light_blue_glazed_terracotta', 'yellow_glazed_terracotta', 'lime_glazed_terracotta',
'pink_glazed_terracotta', 'gray_glazed_terracotta', 'light_gray_glazed_terracotta',
'cyan_glazed_terracotta', 'purple_glazed_terracotta', 'blue_glazed_terracotta',
'brown_glazed_terracotta', 'green_glazed_terracotta', 'red_glazed_terracotta',
'black_glazed_terracotta',
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
'light_blue_stained_glass', 'yellow_stained_glass', 'lime_stained_glass',
'pink_stained_glass', 'gray_stained_glass', 'light_gray_stained_glass',
'cyan_stained_glass', 'purple_stained_glass', 'blue_stained_glass',
'brown_stained_glass', 'green_stained_glass', 'red_stained_glass',
'black_stained_glass',
'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table',
'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone',
'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table',
'brewing_stand', 'cauldron', 'composter', 'barrel',
'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
'beacon', 'conduit', 'lodestone', 'respawn_anchor',
'cactus', 'sugar_cane', 'bamboo',
'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block',
'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves',
'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves',
// Additional blocks commonly seen as items
'smooth_stone', 'smooth_sandstone', 'smooth_red_sandstone', 'smooth_quartz',
'chiseled_sandstone', 'cut_sandstone', 'chiseled_red_sandstone', 'cut_red_sandstone',
'quartz_pillar', 'chiseled_quartz_block', 'quartz_bricks',
'ladder', 'cobweb', 'torch', 'soul_torch', 'lantern', 'soul_lantern',
'redstone_torch', 'chain',
'rail', 'powered_rail', 'detector_rail', 'activator_rail',
'brown_mushroom', 'red_mushroom',
'oak_sapling', 'spruce_sapling', 'birch_sapling', 'jungle_sapling',
'acacia_sapling', 'dark_oak_sapling', 'cherry_sapling',
'dandelion', 'poppy', 'blue_orchid', 'allium', 'azure_bluet',
'red_tulip', 'orange_tulip', 'white_tulip', 'pink_tulip',
'oxeye_daisy', 'cornflower', 'lily_of_the_valley', 'sunflower',
'lilac', 'rose_bush', 'peony', 'wither_rose', 'torchflower',
'dead_bush', 'fern', 'short_grass', 'tall_grass', 'large_fern',
'vine', 'lily_pad', 'seagrass', 'kelp', 'hanging_roots', 'spore_blossom',
'tube_coral', 'brain_coral', 'bubble_coral', 'fire_coral', 'horn_coral',
'tube_coral_block', 'brain_coral_block', 'bubble_coral_block', 'fire_coral_block', 'horn_coral_block',
'tube_coral_fan', 'brain_coral_fan', 'bubble_coral_fan', 'fire_coral_fan', 'horn_coral_fan',
'white_concrete_powder', 'orange_concrete_powder', 'magenta_concrete_powder',
'light_blue_concrete_powder', 'yellow_concrete_powder', 'lime_concrete_powder',
'pink_concrete_powder', 'gray_concrete_powder', 'light_gray_concrete_powder',
'cyan_concrete_powder', 'purple_concrete_powder', 'blue_concrete_powder',
'brown_concrete_powder', 'green_concrete_powder', 'red_concrete_powder', 'black_concrete_powder',
'sculk', 'sculk_catalyst', 'sculk_shrieker', 'sculk_sensor', 'sculk_vein',
'mud_bricks', 'packed_mud', 'muddy_mangrove_roots',
'crimson_stem', 'warped_stem', 'stripped_crimson_stem', 'stripped_warped_stem',
'crimson_nylium', 'warped_nylium', 'shroomlight', 'nether_wart_block', 'warped_wart_block',
'crying_obsidian', 'blackstone', 'polished_blackstone', 'polished_blackstone_bricks',
'chiseled_polished_blackstone', 'gilded_blackstone', 'cracked_polished_blackstone_bricks',
'lodestone', 'respawn_anchor',
'pointed_dripstone', 'moss_carpet', 'azalea', 'flowering_azalea',
'powder_snow', 'mangrove_roots',
'copper_block', 'exposed_copper', 'weathered_copper', 'oxidized_copper',
'cut_copper', 'exposed_cut_copper', 'weathered_cut_copper', 'oxidized_cut_copper',
'waxed_copper_block', 'waxed_exposed_copper', 'waxed_weathered_copper', 'waxed_oxidized_copper',
// Items rendered as 3D entities in-game — no flat texture exists.
// Skip to emoji immediately to avoid a wasted network request.
const ENTITY_ONLY = new Set([
'chest', 'ender_chest', 'trapped_chest', 'shield', 'conduit',
'bell', 'decorated_pot', 'trident',
]);
// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
// We use _front, _top, or _side variants for recognizable look
const BLOCK_TEXTURE_SUFFIXES = {
furnace: '_front',
blast_furnace: '_front',
smoker: '_front',
dispenser: '_front',
dropper: '_front',
observer: '_front',
piston: '_top',
sticky_piston: '_top',
barrel: '_top',
crafting_table: '_top',
cartography_table: '_top',
fletching_table: '_top',
smithing_table: '_top',
grass_block: '_top',
mycelium: '_top',
podzol: '_top',
pumpkin: '_side',
carved_pumpkin: '_front',
jack_o_lantern: '_front',
jukebox: '_top',
loom: '_front',
bee_nest: '_front',
beehive: '_front',
respawn_anchor: '_top',
bone_block: '_side',
basalt: '_side',
polished_basalt: '_side',
quartz_pillar: '_side',
purpur_pillar: '_side',
tnt: '_side',
composter: '_side',
enchanting_table: '_top',
};
// Resolve derivative blocks (stairs, slabs, fences, walls, buttons) to parent block texture
const WOOD_TYPES = new Set([
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped',
const DYE_COLORS = new Set([
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
'brown', 'green', 'red', 'black',
]);
const STONE_ALIASES = {
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
smooth_stone: 'smooth_stone',
};
function resolveDerivativeTexture(name) {
const suffixes = ['_stairs', '_slab', '_fence_gate', '_fence', '_wall', '_button', '_pressure_plate'];
for (const suffix of suffixes) {
if (!name.endsWith(suffix)) continue;
const base = name.slice(0, -suffix.length);
if (BLOCK_TEXTURES.has(base)) return base;
if (WOOD_TYPES.has(base)) return `${base}_planks`;
if (STONE_ALIASES[base]) return STONE_ALIASES[base];
return null;
}
return null;
/**
* Check if an item is known to have no flat texture (entity/model-only).
*/
function isEntityOnly(name) {
if (ENTITY_ONLY.has(name)) return true;
if (name.endsWith('_bed') && DYE_COLORS.has(name.slice(0, -'_bed'.length))) return true;
if (name.endsWith('_banner') && DYE_COLORS.has(name.slice(0, -'_banner'.length))) return true;
if (name.endsWith('_skull') || name.endsWith('_head')) return true;
return false;
}
/**
* Generate texture URLs to try in order.
* - CC:Tweaked → curated texture map (1 request)
* - Create → item/ then block/ (2 requests max)
* - Unknown mods → empty (instant emoji, no wasted requests)
* - Vanilla derivatives → parent block texture (1 request)
* - Vanilla → block/ or item/ with fallback
* Build the single resolve URL for an item.
* The server handles all name resolution (aliases, suffixes, derivatives, etc.)
*/
function getTextureUrls(fullItemName) {
function getTextureUrl(fullItemName) {
const colonIdx = (fullItemName || '').indexOf(':');
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
const urls = [];
// CC:Tweaked → use curated texture map
if (namespace === 'computercraft') {
const mapped = CC_TEXTURE_MAP[shortName];
if (mapped) {
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/${mapped}.png`);
} else {
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/item/${shortName}.png`);
urls.push(`${TEXTURE_PROXY_BASE}/computercraft/block/${shortName}.png`);
}
return urls;
}
// Entity-only items → instant emoji, no network request
if (namespace === 'minecraft' && isEntityOnly(shortName)) return null;
// Create mod → item/ then block/
if (namespace === 'create') {
urls.push(`${TEXTURE_PROXY_BASE}/create/item/${shortName}.png`);
urls.push(`${TEXTURE_PROXY_BASE}/create/block/${shortName}.png`);
return urls;
}
// Unknown mod namespace → no textures available, instant emoji fallback
if (namespace !== 'minecraft') {
return urls;
}
// === Vanilla (minecraft) ===
const alias = TEXTURE_ALIASES[shortName] || shortName;
// Derivative blocks (stairs, slabs, fences, walls, buttons) → parent texture
const parent = resolveDerivativeTexture(alias);
if (parent) {
const suffix = BLOCK_TEXTURE_SUFFIXES[parent] || '';
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${parent}${suffix}.png`);
return urls;
}
// Known block → try block/ first (with suffix if applicable)
if (BLOCK_TEXTURES.has(alias)) {
const suffix = BLOCK_TEXTURE_SUFFIXES[alias] || '';
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}${suffix}.png`);
if (suffix) urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
}
// Try item/ texture
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/item/${alias}.png`);
// If not a known block, also try block/ as last resort
if (!BLOCK_TEXTURES.has(alias)) {
urls.push(`${TEXTURE_PROXY_BASE}/minecraft/block/${alias}.png`);
}
return urls;
return `${RESOLVE_BASE}/${namespace}/${shortName}.png`;
}
// Cache of resolved icon URLs (avoid re-fetching on every render)
@@ -277,21 +50,20 @@ const iconCache = new Map();
/**
* Renders a Minecraft item icon using the official game textures.
* Cascading fallback: mod texture → item texture → block texture → emoji
* The server's /api/texture/resolve endpoint does all the smart matching.
* Falls back to emoji when no texture is found.
*/
function ItemIcon({ itemName, size = 32 }) {
const [urlIndex, setUrlIndex] = useState(0);
const [allFailed, setAllFailed] = useState(false);
const [failed, setFailed] = useState(false);
if (!itemName) {
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
}
// Use full item name as cache key (handles mod namespaces correctly)
const cacheKey = itemName.replace(/^minecraft:/, '');
// Check if we already know this item has no texture
if (iconCache.get(cacheKey) === 'none' || allFailed) {
if (iconCache.get(cacheKey) === 'none' || failed) {
return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
{getItemEmoji(itemName)}
@@ -314,10 +86,9 @@ function ItemIcon({ itemName, size = 32 }) {
);
}
const urls = getTextureUrls(itemName);
const currentUrl = urls[urlIndex];
const url = getTextureUrl(itemName);
if (!currentUrl) {
if (!url) {
iconCache.set(cacheKey, 'none');
return (
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
@@ -329,21 +100,17 @@ function ItemIcon({ itemName, size = 32 }) {
return (
<img
className="item-icon-img"
src={currentUrl}
src={url}
alt={cacheKey}
width={size}
height={size}
loading="lazy"
onLoad={() => {
iconCache.set(cacheKey, currentUrl);
iconCache.set(cacheKey, url);
}}
onError={() => {
if (urlIndex + 1 < urls.length) {
setUrlIndex(urlIndex + 1);
} else {
iconCache.set(cacheKey, 'none');
setAllFailed(true);
}
iconCache.set(cacheKey, 'none');
setFailed(true);
}}
/>
);

View File

@@ -9,12 +9,6 @@ services:
- API_KEY=${API_KEY:-}
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
interval: 10s
timeout: 3s
start_period: 5s
retries: 3
client:
build:
@@ -27,7 +21,7 @@ services:
- inventory-network
depends_on:
server:
condition: service_started
condition: service_healthy
restart: unless-stopped
networks:

View File

@@ -1,30 +1,50 @@
# Node.js backend
FROM node:18-alpine
# Stage 1: Fetch platform server package from git
FROM alpine:3.20 AS platform
RUN apk add --no-cache git
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
ARG PLATFORM_BRANCH=master
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
&& rm -rf /src/server/node_modules /src/.git
# Stage 2: Node.js backend
FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation
RUN apk add --no-cache python3 make g++
# su-exec for dropping privileges in entrypoint
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
RUN npm install --omit=dev
# Remove build tools after install to keep image small
# libstdc++ and su-exec are kept for runtime
RUN apk del python3 make g++
COPY . .
# Create data directory for SQLite with proper ownership
RUN mkdir -p /data && chown node:node /data
# Create data directory for SQLite
RUN mkdir -p /data
VOLUME /data
# Run as non-root user for security
USER node
# Entrypoint fixes /data permissions then drops to node user
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

@@ -1,16 +1,32 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
// Ensure the directory exists
const dbDir = dirname(DB_PATH);
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
const db = new Database(DB_PATH);
// Verify directory is writable before opening SQLite
try {
accessSync(dbDir, fsConstants.W_OK);
} catch {
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
process.exit(1);
}
let db;
try {
db = new Database(DB_PATH);
} catch (err) {
console.error(`[db] FATAL: failed to open database: ${err.message}`);
process.exit(1);
}
// Performance pragmas
db.pragma('journal_mode = WAL');
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
db.pragma('cache_size = -8000'); // 8MB cache
db.pragma('temp_store = MEMORY');
console.log('[db] Database ready');
// ========== Schema ==========
db.exec(`

23
web/server/docker-entrypoint.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,91 +1,152 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
loadFullState, saveFullState, recordItemHistory,
saveItems, saveFurnaces, saveAlerts, saveState, loadState,
getHistory, getHistorySummary, closeDb, flushPendingSave,
} from './db.js';
const require = createRequire(import.meta.url);
const {
createPlatformServer,
createWebSocketManager,
setupGracefulShutdown,
createRateLimiter,
createProxyEndpoint,
} = require('@cc-platform/server');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin.
// If you need direct server access during dev, add: app.use(require('cors')())
app.disable('x-powered-by');
app.use(express.json({ limit: '5mb' }));
// ========== API Key Authentication ==========
const API_KEY = process.env.API_KEY || '';
// Validate bearer token from Authorization header or ?key= query param
function extractApiKey(req) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) return auth.slice(7);
return req.query.key || '';
}
// Middleware: require API key on mutating endpoints
function requireAuth(req, res, next) {
if (!API_KEY) return next(); // Auth disabled when no key configured
const token = extractApiKey(req);
if (token === API_KEY) return next();
return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' });
}
// Apply auth to all POST/PUT/DELETE routes
app.use((req, res, next) => {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next(); // Read-only endpoints stay open
}
return requireAuth(req, res, next);
// ========== Platform Server Setup ==========
// Provides Express app, HTTP server, auth middleware, and health endpoint.
// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin.
const { app, server, auth, start, port } = createPlatformServer({
serviceName: 'inventory-manager',
cors: false,
rateLimit: false, // custom rate limiting below (excludes bridge endpoints)
healthExtras: () => ({
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
}),
});
// ========== Rate Limiting (in-memory, no external dependencies) ==========
const { requireAuth } = auth;
function createRateLimiter(windowMs, max) {
const hits = new Map();
setInterval(() => {
const cutoff = Date.now() - windowMs;
for (const [key, entry] of hits) {
if (entry.start < cutoff) hits.delete(key);
}
}, windowMs);
return (req, res, next) => {
const key = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
hits.set(key, { start: now, count: 1 });
return next();
}
entry.count++;
if (entry.count > max) {
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
return res.status(429).json({ error: 'Too many requests — try again later' });
}
return next();
};
}
// API key reference needed for WebSocket upgrade authentication
const API_KEY = process.env.API_KEY || '';
// 30 mutating requests per minute per IP (excludes bridge state updates)
const commandLimiter = createRateLimiter(60_000, 30);
// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints
const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 });
app.use((req, res, next) => {
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
if (req.path.startsWith('/api/bridge/')) return next();
return commandLimiter(req, res, next);
});
// ========== WebSocket Manager (platform-managed) ==========
// Replaces hand-rolled WebSocketServer with createWebSocketManager() from
// @cc-platform/server. Handles bridge/client URL routing, API key auth on
// upgrade, ping/pong keepalive with stale-connection cleanup.
//
// Bridge path: /ws/bridge — receives state/results, pushes commands real-time
// Client path: /ws — receives initial_state on connect, sends commands
//
// HTTP polling fallback preserved via /api/bridge/* endpoints until WS
// adoption is fully verified.
const wsManager = createWebSocketManager(server, {
apiKey: API_KEY,
// ---- Bridge lifecycle ----
onBridgeConnect: (ws) => {
console.log('\u{1F309} CC:Tweaked bridge connected via WebSocket');
wsManager.broadcastToClients({ type: 'state_update', bridgeConnected: true });
// Flush any pending commands queued while no WS bridge was connected.
// Replaces the implicit sync that HTTP polling previously provided.
const pending = getPendingCommands();
if (pending.length > 0) {
try {
ws.send(JSON.stringify({ type: 'command_batch', commands: pending }));
console.log(`[Bridge] Flushed ${pending.length} pending command(s) via WS`);
} catch (e) {
console.error('[Bridge] Failed to flush pending commands:', e.message);
}
}
},
onBridgeMessage: (ws, data) => {
if (data.type === 'state') {
// Full state update from bridge (same path as POST /api/bridge/state)
updateStateFromBridge(data);
} else if (data.type === 'command_result') {
// Command result from bridge — forward to all web clients
wsManager.broadcastToClients({
type: 'command_result',
commandId: data.commandId,
action: data.action,
success: data.success,
message: data.message,
error: data.error,
});
}
},
onBridgeDisconnect: () => {
console.log('\u{1F309} CC:Tweaked bridge disconnected');
wsManager.broadcastToClients({
type: 'state_update',
bridgeConnected: wsManager.bridgeClients.size > 0,
});
},
// ---- Web client lifecycle ----
onClientConnect: (ws) => {
console.log('\u{1F310} New web client connected');
// Send full current state to newly connected dashboard
ws.send(JSON.stringify({
type: 'initial_state',
inventory: inventoryState,
activity: activityState,
alerts: alertsState,
smeltingPaused,
disabledRecipes,
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: wsManager.bridgeClients.size > 0,
dropperNicknames,
}));
},
onClientMessage: (ws, data) => {
if (data.type === 'command') {
// Idempotency check for WS commands
const cached = checkIdempotent(data.commandId);
if (cached) {
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
return;
}
// Forward command to bridge (WS push or HTTP poll queue)
pushCommandToBridge(data);
if (data.commandId) {
recordCommand(data.commandId, { success: true, commandId: data.commandId });
}
}
},
onClientDisconnect: () => {
console.log('\u{1F44B} Web client disconnected');
},
});
// Aliases — backward compatibility for code referencing these Sets directly
const { bridgeClients, webClients } = wsManager;
// ========== State ==========
const webClients = new Set();
const bridgeClients = new Set();
// Load persisted state from SQLite on startup
console.log('💾 Loading persisted state from database...');
@@ -138,31 +199,25 @@ function recordCommand(commandId, result) {
// ========== Helpers ==========
// Broadcasts data to all connected web dashboard clients.
// Delegates to platform WS manager (handles JSON serialization, error handling).
function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) {
try {
client.send(message);
} catch (err) {
console.error('❌ WS send error (client):', err.message);
}
}
});
wsManager.broadcastToClients(data);
}
// Returns a filtered copy of pending commands (clears expired entries).
// Used by both the HTTP polling endpoint and WS initial sync.
function getPendingCommands() {
const now = Date.now();
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
return [...pendingCommands];
}
// Pushes a command to the CC:Tweaked bridge.
// Primary: real-time push via WebSocket (wsManager.sendToBridge).
// Fallback: queues for HTTP polling if no WS bridge is connected.
function pushCommandToBridge(command) {
let sent = false;
for (const bridge of bridgeClients) {
if (bridge.readyState === 1) {
try {
bridge.send(JSON.stringify(command));
sent = true;
} catch (err) {
console.error('❌ WS send error (bridge):', err.message);
}
}
}
const sent = wsManager.sendToBridge(command);
if (!sent) {
// Fallback: queue for HTTP polling with monotonic ID
const id = nextCommandId++;
@@ -171,27 +226,266 @@ function pushCommandToBridge(command) {
}
}
// ========== HTTP Server ==========
const server = createServer(app);
// ========== Texture Proxy / Cache ==========
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
// Upstream CDN mapping
// Upstream CDN mapping (Prominence Hasturian Era II modpack coverage)
const TEXTURE_UPSTREAMS = {
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
mythicmetals: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20/src/main/resources/assets/mythicmetals/textures',
farmersdelight: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20/src/main/resources/assets/farmersdelight/textures',
ad_astra: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1/common/src/main/resources/assets/ad_astra/textures',
betterend: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20/src/main/resources/assets/betterend/textures',
ae2: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1/src/main/resources/assets/ae2/textures',
twilightforest: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1/src/main/resources/assets/twilightforest/textures',
};
// Ensure cache directory exists
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
// ── Texture index: maps base_name → relative path for smart resolution ────
// Built on startup by scanning the texture cache. Key = short name (no ext),
// Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"])
const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } }
function buildTextureIndex() {
for (const ns of Object.keys(TEXTURE_UPSTREAMS)) {
textureIndex[ns] = {};
const nsDir = path.join(TEXTURE_CACHE_DIR, ns);
if (!fs.existsSync(nsDir)) continue;
(function walk(dir, rel) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel);
else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) {
const baseName = entry.name.slice(0, -4); // strip .png
if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = [];
textureIndex[ns][baseName].push(childRel);
}
}
} catch (_) { /* ignore unreadable dirs */ }
})(nsDir, '');
}
const total = Object.values(textureIndex).reduce(
(sum, idx) => sum + Object.keys(idx).length, 0);
console.log(`[Texture Index] Indexed ${total} unique texture names`);
}
buildTextureIndex();
// ── Smart texture resolution ──────────────────────────────────────────────
// Given a Minecraft item/block registry name, find the best matching texture.
// Returns the relative cache path or null.
// Patterns for items whose textures use numbered animation frames
const ANIMATED_ITEMS = new Set([
'compass', 'recovery_compass', 'clock',
'crossbow', // crossbow_standby, crossbow_pulling_0, etc.
'light', // light_0 .. light_15
]);
// Maps a registry short name to the actual texture base name
const SERVER_ALIASES = {
// Registry name differs from texture filename
magma_block: 'magma',
grass: 'short_grass',
scute: 'turtle_scute',
enchanted_golden_apple: 'golden_apple',
};
// Preferred animation frame for animated items
const ANIMATED_FRAME = {
compass: 'compass_16',
recovery_compass: 'recovery_compass_16',
clock: 'clock_22',
crossbow: 'crossbow_standby',
light: 'light_15',
};
// Dye color set for carpet/bed/banner resolution
const DYE_COLORS = new Set([
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
'brown', 'green', 'red', 'black',
]);
const WOOD_TYPES = new Set([
'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak',
'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak',
]);
// Blocks whose texture uses a suffix: name_front, name_top, etc.
const BLOCK_SUFFIXES = {
furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on',
dispenser: '_front', dropper: '_front', observer: '_front',
barrel: '_top', crafting_table: '_top', grass_block: '_top',
mycelium: '_top', podzol: '_top', dirt_path: '_top',
quartz_block: '_side',
pumpkin: '_side', carved_pumpkin: '_front', jack_o_lantern: '_front',
jukebox: '_top', loom: '_front', bee_nest: '_front', beehive: '_front',
respawn_anchor: '_top', bone_block: '_side',
basalt: '_side', polished_basalt: '_side',
tnt: '_side', composter: '_side', enchanting_table: '_top',
cartography_table: '_top', fletching_table: '_top', smithing_table: '_top',
};
// Derivative block suffixes → resolve to parent block
const DERIVATIVE_SUFFIXES = [
'_stairs', '_slab', '_fence_gate', '_fence', '_wall',
'_button', '_pressure_plate',
];
const STONE_ALIASES = {
brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks',
nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks',
end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks',
deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks',
mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block',
smooth_stone: 'smooth_stone',
};
// "smooth_" blocks use the parent's top/bottom face
const SMOOTH_ALIASES = {
smooth_sandstone: 'sandstone_top',
smooth_red_sandstone: 'red_sandstone_top',
smooth_quartz: 'quartz_block_bottom',
};
function resolveTexturePath(ns, itemName) {
const idx = textureIndex[ns];
if (!idx) return null;
// Helper: check if a baseName exists and return preferred path
function find(baseName, preferFolder) {
const paths = idx[baseName];
if (!paths || paths.length === 0) return null;
if (preferFolder) {
const match = paths.find(p => p.startsWith(preferFolder + '/'));
if (match) return match;
}
return paths[0];
}
// Helper: check if a baseName with a suffix exists in block/
function findBlock(baseName) {
const suffix = BLOCK_SUFFIXES[baseName];
if (suffix) {
const hit = find(baseName + suffix, 'block') || find(baseName, 'block');
if (hit) return hit;
}
return find(baseName, 'block');
}
// 1. Direct lookup — try item/ then block/
let hit = find(itemName, 'item') || findBlock(itemName);
if (hit) return hit;
// 2. Server-side alias
if (SERVER_ALIASES[itemName]) {
const alias = SERVER_ALIASES[itemName];
hit = find(alias, 'item') || findBlock(alias);
if (hit) return hit;
}
// 3. Smooth blocks
if (SMOOTH_ALIASES[itemName]) {
hit = find(SMOOTH_ALIASES[itemName], 'block');
if (hit) return hit;
}
// 4. Animated items — pick a representative frame
if (ANIMATED_FRAME[itemName]) {
hit = find(ANIMATED_FRAME[itemName], 'item');
if (hit) return hit;
}
// 5. Carpets → wool texture
if (itemName.endsWith('_carpet')) {
const color = itemName.slice(0, -'_carpet'.length);
if (DYE_COLORS.has(color)) {
hit = find(color + '_wool', 'block');
if (hit) return hit;
}
}
// 6. Wood → log texture
if (itemName.endsWith('_wood')) {
const base = itemName.slice(0, -'_wood'.length);
const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null;
const woodType = stripped || base;
if (WOOD_TYPES.has(woodType)) {
const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`;
hit = find(logName, 'block');
if (hit) return hit;
}
}
// 7. Derivative blocks (stairs, slabs, fences, walls…) → parent block
for (const suffix of DERIVATIVE_SUFFIXES) {
if (!itemName.endsWith(suffix)) continue;
const base = itemName.slice(0, -suffix.length);
// Try direct parent
hit = findBlock(base);
if (hit) return hit;
// Wood → planks
if (WOOD_TYPES.has(base)) {
hit = findBlock(base + '_planks');
if (hit) return hit;
}
// Stone aliases (brick → bricks, etc.)
if (STONE_ALIASES[base]) {
hit = findBlock(STONE_ALIASES[base]);
if (hit) return hit;
}
break; // only one derivative suffix can match
}
// 8. Prefix match — for items like "compass" try "compass_*"
const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_'));
if (prefixMatches.length > 0) {
// Sort for deterministic result, prefer item/ folder
prefixMatches.sort();
for (const m of prefixMatches) {
hit = find(m, 'item');
if (hit) return hit;
}
hit = find(prefixMatches[0]);
if (hit) return hit;
}
return null;
}
// ── Smart resolution endpoint ─────────────────────────────────────────────
// GET /api/texture/resolve/:namespace/:itemName
// Returns the texture PNG for a Minecraft registry item name, using smart
// matching (aliases, animation frames, carpet→wool, etc.)
app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => {
const { namespace, itemName } = req.params;
const name = itemName.replace(/\.png$/i, '');
const resolved = resolveTexturePath(namespace, name);
if (!resolved) {
return res.status(404).send('No texture found');
}
const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved);
if (!fs.existsSync(fullPath)) {
return res.status(404).send('Texture file missing');
}
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=604800');
res.set('X-Texture-Resolved', resolved);
return res.sendFile(fullPath);
});
// ── Legacy texture proxy (fallback for cache misses) ──────────────────────
app.get('/api/texture/:namespace/*', async (req, res) => {
const { namespace } = req.params;
const texturePath = req.params[0]; // e.g. "item/diamond.png"
const texturePath = req.params[0];
const upstream = TEXTURE_UPSTREAMS[namespace];
if (!upstream) {
return res.status(404).send('Unknown namespace');
@@ -282,16 +576,7 @@ app.delete('/api/texture-cache/negative', (req, res) => {
res.json({ cleared });
});
// Health check
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
lastUpdate,
uptime: process.uptime(),
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
});
});
// Health endpoint provided by platform (createPlatformServer) with custom extras
// Get current inventory state
app.get('/api/inventory', (req, res) => {
@@ -588,6 +873,122 @@ 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 sends inventory state
@@ -605,11 +1006,7 @@ app.post('/api/bridge/state', (req, res) => {
// Bridge polls for pending commands (auth required — contains operational data)
app.get('/api/bridge/commands', requireAuth, (req, res) => {
try {
const now = Date.now();
// Clear old commands (>30s)
pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000);
const commands = [...pendingCommands];
const commands = getPendingCommands();
res.json({ commands });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -785,187 +1182,19 @@ function updateStateFromBridge(data) {
}
}
// ========== WebSocket Server ==========
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
// ========== Server Startup Info ==========
// WebSocket management is handled by createWebSocketManager() (initialized above).
// Bridge: /ws/bridge | Client: /ws | Keepalive: 25s ping/pong (platform default)
console.log(`🚀 Inventory Manager Web Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
console.log(`🔌 WebSocket Server: ws://localhost:${PORT}/ws`);
console.log(`📡 HTTP Server: http://localhost:${port}`);
console.log(`🔌 WebSocket Server: ws://localhost:${port}/ws`);
if (API_KEY) {
console.log('🔒 API key authentication enabled');
} else {
console.log('⚠️ No API_KEY set \u2014 authentication disabled (open access)');
}
// Authenticate WebSocket upgrades
server.on('upgrade', (req, socket, head) => {
if (API_KEY) {
// Extract key from query string: /ws?key=... or /ws/bridge?key=...
const urlObj = new URL(req.url, `http://${req.headers.host}`);
const token = urlObj.searchParams.get('key') || '';
if (token !== API_KEY) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
const url = req.url || '';
// ---- Bridge WebSocket connection ----
if (url.startsWith('/ws/bridge')) {
console.log('🌉 CC:Tweaked bridge connected via WebSocket');
bridgeClients.add(ws);
ws.isAlive = true;
// Notify web clients that the bridge is now connected
broadcastToClients({
type: 'state_update',
bridgeConnected: true,
});
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw);
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (data.type === 'state') {
// Full state update from bridge
updateStateFromBridge(data);
} else if (data.type === 'command_result') {
// Command result from bridge — include commandId
broadcastToClients({
type: 'command_result',
commandId: data.commandId,
action: data.action,
success: data.success,
message: data.message,
error: data.error,
});
}
} catch (error) {
console.error('❌ Bridge WS message error:', error.message);
}
});
ws.on('close', () => {
console.log('🌉 CC:Tweaked bridge disconnected');
bridgeClients.delete(ws);
// Notify web clients that the bridge may be disconnected
broadcastToClients({
type: 'state_update',
bridgeConnected: bridgeClients.size > 0,
});
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('error', (error) => {
console.error('❌ Bridge WS error:', error);
bridgeClients.delete(ws);
broadcastToClients({
type: 'state_update',
bridgeConnected: bridgeClients.size > 0,
});
});
return;
}
// ---- Web client WebSocket connection ----
console.log('🌐 New web client connected');
webClients.add(ws);
ws.isAlive = true;
// Send current state to new client
ws.send(JSON.stringify({
type: 'initial_state',
inventory: inventoryState,
activity: activityState,
alerts: alertsState,
smeltingPaused,
disabledRecipes,
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
dropperNicknames,
}));
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'command') {
// Idempotency check for WS commands
const cached = checkIdempotent(data.commandId);
if (cached) {
ws.send(JSON.stringify({ type: 'command_result', commandId: data.commandId, ...cached }));
return;
}
// Forward command to bridge
pushCommandToBridge(data);
if (data.commandId) {
recordCommand(data.commandId, { success: true, commandId: data.commandId });
}
}
} catch (error) {
console.error('❌ Error processing web client message:', error);
}
});
ws.on('close', () => {
console.log('👋 Web client disconnected');
webClients.delete(ws);
});
ws.on('error', (error) => {
console.error('❌ WebSocket error:', error);
});
});
// ========== WebSocket Keep-Alive ==========
// Ping all web clients and bridge connections every 25s to keep connections alive
const WS_PING_INTERVAL = setInterval(() => {
webClients.forEach((ws) => {
if (!ws.isAlive) {
webClients.delete(ws);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
bridgeClients.forEach((ws) => {
if (!ws.isAlive) {
console.log('🌉 Bridge connection stale — terminating');
bridgeClients.delete(ws);
broadcastToClients({ type: 'state_update', bridgeConnected: bridgeClients.size > 0 });
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 25000);
wss.on('close', () => {
clearInterval(WS_PING_INTERVAL);
});
// ========== Cross-Project Integration API ==========
// These endpoints allow the RemoteTurtle system to query inventory state
@@ -1025,53 +1254,24 @@ app.get('/api/integration/low-stock', (req, res) => {
});
// Proxy to turtle server for combined dashboard info
app.get('/api/integration/turtle-status', async (req, res) => {
if (!TURTLE_SERVER_URL) {
return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' });
}
try {
const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` });
}
});
createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles');
// ========== Start Server ==========
server.listen(PORT, HOST, () => {
console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`);
console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`);
console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`);
console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`);
setupGracefulShutdown({
serviceName: 'inventory-manager',
cleanup: [
() => wsManager.close(),
() => server.close(),
() => { closeDb(); console.log('💾 Database closed'); },
],
});
start(() => {
console.log(`\nBridge HTTP endpoint: http://localhost:${port}/api/bridge/state`);
console.log(`Bridge WebSocket: ws://localhost:${port}/ws/bridge`);
console.log(`Web client WebSocket: ws://localhost:${port}/ws`);
if (TURTLE_SERVER_URL) {
console.log(`🐢 Turtle server integration: ${TURTLE_SERVER_URL}`);
}
});
// Graceful shutdown
function shutdown() {
console.log('\n🛑 Shutting down server...');
try {
wss.close();
server.close();
closeDb();
console.log('💾 Database closed');
} catch (err) {
console.error('❌ Error during shutdown:', err.message);
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Catch unhandled errors to prevent silent crashes
process.on('unhandledRejection', (reason) => {
console.error('❌ Unhandled rejection:', reason);
});
process.on('uncaughtException', (err) => {
console.error('❌ Uncaught exception:', err);
shutdown();
});