Compare commits

...

121 Commits

Author SHA1 Message Date
MayaTheShy
9835556e6f diag: add verbose network listener logging to trace command delivery
Manager logs: listener start, every modem_message received, channel
match/mismatch, command acceptance count, handler result.
Client logs: every sendToMaster call with type and commandId.
2026-03-29 16:39:29 -04:00
MayaTheShy
033da0933c fix: point package repository to stable branch 2026-03-29 16:22:25 -04:00
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
MayaTheShy
467be0493c chore: add *.bak to gitignore 2026-03-22 17:18:33 -04:00
MayaTheShy
6f3a650e69 feat: add Opus autorun script and grid single-click for touch
- Add autorun/startup.lua for Opus package integration:
  - Detects role from config files (.manager_config, .client_config, etc.)
  - Registers reboot listener as kernel hook
  - Launches program via shell.openForegroundTab()
  - Client role also opens dropperController in separate tab

- Override grid eventHandler on actionable grids (itemGrid, smeltTab,
  craftTab) to emit grid_select on single mouse_click, enabling
  monitor touch to trigger actions immediately instead of requiring
  double-click
2026-03-22 17:17:56 -04:00
MayaTheShy
38727c5eeb fix: update selected amount assignment in main page event handling 2026-03-22 17:04:20 -04:00
MayaTheShy
d4a3b1cce9 Add dashboard rendering and touch handlers for inventory management
- Implemented main dashboard UI with item display, status bar, and pagination.
- Added touch zones for item ordering, quantity selection, and search functionality.
- Created smelter dashboard with tabs for status, smelting, crafting, and missing recipes.
- Integrated keyboard input for search queries and item management.
- Enhanced drawing functions for better UI representation and interaction.
2026-03-22 16:56:50 -04:00
MayaTheShy
356e84b262 feat: add interactive setup for Inventory Manager roles and configurations 2026-03-22 16:11:28 -04:00
MayaTheShy
57fd3e88bc fix: resolve all paths relative to script directory for Opus package compatibility
- Add _baseDir/_path() resolution using shell.getRunningProgram() in all
  entry-point scripts (inventoryManager, inventoryClient, craftingTurtle,
  inventoryWebBridge)
- Pass _path function through to manager/config.lua for data/ and config files
- Config files (.manager_config, .inventory_cache, etc.) now resolve relative
  to the package directory rather than CWD
- Exclude startup/ auto-updater scripts from Opus .package manifest
- All dofile() calls now use _path() for portable path resolution
2026-03-22 16:07:43 -04:00
MayaTheShy
4d8229ab46 Refactor config file path to use dynamic base directory 2026-03-22 16:07:39 -04:00
MayaTheShy
d43662b2dc Refactor module loading to use dynamic base directory path 2026-03-22 16:07:33 -04:00
MayaTheShy
3095fc7387 Refactor configuration loading to use dynamic base directory path 2026-03-22 16:07:25 -04:00
MayaTheShy
63e28d9126 Refactor turtle configuration loading to use dynamic base directory path 2026-03-22 16:07:20 -04:00
MayaTheShy
761127650f Update exclude patterns in package configuration for improved clarity 2026-03-22 16:07:15 -04:00
MayaTheShy
67273d6a80 Add apps.db configuration for inventory management applications 2026-03-22 15:56:06 -04:00
MayaTheShy
15362edc87 Add initial package configuration for Inventory Manager 2026-03-22 15:56:01 -04:00
MayaTheShy
40df13e756 Refactor database test setup to use DB_PATH from vitest.config.js 2026-03-22 11:54:57 -04:00
MayaTheShy
f2634328ec Add Vitest configuration for testing environment setup 2026-03-22 11:54:51 -04:00
MayaTheShy
53863657d7 Refactor code structure for improved readability and maintainability 2026-03-22 11:54:07 -04:00
MayaTheShy
ba0cbfbbb6 Add unit tests for server logic including rate limiter, normalization helpers, and input validation 2026-03-22 11:52:33 -04:00
MayaTheShy
a8513b339c Add comprehensive tests for the database layer in db.test.js 2026-03-22 11:51:47 -04:00
MayaTheShy
21f12501ca Add testing scripts and update devDependencies in package.json 2026-03-22 11:46:57 -04:00
MayaTheShy
6760ca5e5d Refactor code structure for improved readability and maintainability 2026-03-22 11:38:41 -04:00
MayaTheShy
82ab5cd9f5 Refactor file download paths for improved organization in manager.lua 2026-03-22 11:38:32 -04:00
MayaTheShy
5a99543fd6 Add display module for dashboard rendering and touch handling
- Implemented main dashboard UI with item selection, search functionality, and pagination.
- Added smelter dashboard with furnace status, crafting options, and recipe management.
- Introduced touch handlers for user interactions including item ordering and keyboard input.
- Integrated drawing helpers for UI elements and status messages.
- Established state management for UI updates and interactions.
2026-03-22 11:33:26 -04:00
MayaTheShy
79fdee1e29 Add shared mutable state management and cache implementation in state.lua 2026-03-22 11:31:03 -04:00
MayaTheShy
acfaad67f7 Add peripheral helpers and inventory operations in operations.lua 2026-03-22 11:30:57 -04:00
MayaTheShy
e664102807 Add configuration constants and data structures for inventory management 2026-03-22 11:30:38 -04:00
MayaTheShy
026d0c8d6b Add Cross-Project Integration API for RemoteTurtle system 2026-03-22 04:10:11 -04:00
MayaTheShy
33845c70d7 Add cross-link to Turtle Dashboard in App component 2026-03-22 04:10:06 -04:00
MayaTheShy
fa18c72cf7 Add styles for cross-link button in App.css 2026-03-22 04:10:02 -04:00
MayaTheShy
c5aa4b5332 Add TURTLE_SERVER_URL environment variable to server configuration 2026-03-22 04:09:56 -04:00
MayaTheShy
6adbd88b22 Add remote reboot functionality to SettingsPanel 2026-03-22 03:16:24 -04:00
MayaTheShy
c65fd67b8e Add styles for remote reboot section in SettingsPanel 2026-03-22 03:16:20 -04:00
MayaTheShy
62f67b7893 Add reboot listener to handle remote reboot commands 2026-03-22 03:16:16 -04:00
MayaTheShy
58ff05c5e6 Add reboot listener to handle incoming reboot commands 2026-03-22 03:16:09 -04:00
MayaTheShy
899606b5c0 Add reboot listener to handle remote reboot commands 2026-03-22 03:16:04 -04:00
MayaTheShy
67a1148e85 Add reboot listener to handle remote reboot commands 2026-03-22 03:15:58 -04:00
MayaTheShy
00206ef794 Add reboot command handling in processCommand function 2026-03-22 03:15:52 -04:00
MayaTheShy
e1186ab532 Add remote reboot functionality via system channel 2026-03-22 03:15:47 -04:00
MayaTheShy
01ca8ca127 Add rebootComputers function to initiate remote reboot via API 2026-03-22 03:15:42 -04:00
MayaTheShy
2ac11350e6 Add API endpoint for remote reboot of CC:Tweaked computers 2026-03-22 03:15:36 -04:00
MayaTheShy
891fb2a10c Add startup script for Crafting Turtle with auto-update functionality 2026-03-22 03:09:11 -04:00
MayaTheShy
8da6d8bc0e Add startup script for Inventory Client with auto-update functionality 2026-03-22 03:09:04 -04:00
MayaTheShy
8c98546fbf Add startup script for Web Bridge with auto-update functionality 2026-03-22 03:08:59 -04:00
MayaTheShy
9a7d4b3175 Add startup script for automatic file updates and launching inventoryManager 2026-03-22 03:08:51 -04:00
MayaTheShy
479e1918f5 Implement rate limiting and enhance input validation for API endpoints 2026-03-22 02:57:21 -04:00
MayaTheShy
9d5fceee5c Enhance Dockerfile to create SQLite data directory with proper ownership 2026-03-22 02:57:18 -04:00
MayaTheShy
efc3a88052 Add security headers to nginx configuration for enhanced protection 2026-03-22 02:57:13 -04:00
MayaTheShy
deeb70ba0d Buffer ORDER_CHANNEL messages in craftItem function to avoid re-queue bounce 2026-03-22 02:52:30 -04:00
MayaTheShy
187b0276d1 Improve cache update logic to handle nil values in main function 2026-03-22 02:52:22 -04:00
MayaTheShy
48d34a5eeb Refactor nginx configuration to remove duplicate texture proxy cache zone definition 2026-03-22 02:42:12 -04:00
MayaTheShy
78674714b1 Enhance WebSocket keep-alive mechanism to include bridge connections and handle stale connections 2026-03-22 02:41:52 -04:00
MayaTheShy
1a6d32c16b Add texture proxy cache configuration to nginx 2026-03-22 02:41:45 -04:00
MayaTheShy
7a277a86eb Enhance event handling in craftItem function and improve broadcastState function to include stateVersion 2026-03-22 02:41:36 -04:00
MayaTheShy
8c02dc15c0 Fix lastUpdate assignment to handle null values in _applyStateData function 2026-03-22 02:38:40 -04:00
MayaTheShy
4045def0a7 Add reconnectDelay variable for WebSocket reconnection logic 2026-03-22 02:38:35 -04:00
MayaTheShy
460cf34252 Enhance WebSocket error handling and implement command idempotency for dropper nickname and recipe management endpoints 2026-03-22 02:38:30 -04:00
MayaTheShy
ea75c1eabc Implement exponential backoff for WebSocket reconnection and enhance error handling in recipe management functions 2026-03-22 02:38:21 -04:00
MayaTheShy
0fb57d7c94 Ensure database is only closed if it is open in closeDb function 2026-03-22 02:38:17 -04:00
MayaTheShy
edcc19e5a4 Add command ID to dropper nickname request for idempotency 2026-03-22 02:38:12 -04:00
MayaTheShy
8ad05cdeb9 Refactor autoSmelt and autoCompost functions to use snapshots for catalogue iteration and improve command return values 2026-03-22 02:38:00 -04:00
MayaTheShy
e7e605ea00 Add command ID generation for message idempotency in sendToMaster 2026-03-22 02:37:46 -04:00
MayaTheShy
6fa7ae5e8f Refactor shared UI helpers to load earlier in inventoryClient 2026-03-22 02:35:12 -04:00
MayaTheShy
b3c3faa06d Add optional API key support for server authentication in inventoryWebBridge 2026-03-22 02:35:06 -04:00
MayaTheShy
5162a71be4 Add support for API key at build time in Dockerfile for Vite 2026-03-22 02:15:13 -04:00
MayaTheShy
0d3de9dc48 Refactor docker-compose to include API key in environment variables for server and client 2026-03-22 02:15:08 -04:00
MayaTheShy
2a68ffcb90 Add API key support for server authentication in configuration 2026-03-22 02:15:05 -04:00
MayaTheShy
10dc27a2c4 Enhance API request handling with authentication headers and API key support 2026-03-22 02:14:15 -04:00
41 changed files with 12862 additions and 3916 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules/
dist/
bin/
.env
*.bak

115
.package Normal file
View File

@@ -0,0 +1,115 @@
{
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.",
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/stable/",
exclude = {
"^web/", "^__tests__/", "^startup/",
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
"%.bak$", "^listDevicesByType%.lua$",
},
install = [[
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 .. "]: ")
else
write(prompt .. ": ")
end
local val = read()
if not val or #val == 0 then return default end
return val
end
print("")
print("-- Inventory Manager Setup --")
print("")
print("Which role(s) will this computer run?")
print(" 1) Inventory Manager (main controller)")
print(" 2) Inventory Client (display-only)")
print(" 3) Web Bridge (HTTP forwarder)")
print(" 4) Mining Turtle (cobble miner)")
print(" 5) Skip setup")
print("")
write("Choice (1/2/3/4/5): ")
local choice = read()
if choice == "1" then
print("")
print("-- Manager Configuration --")
local monitorSide = ask("Monitor side", "left")
local smelterMonitorSide = ask("Smelter monitor side", "top")
local dropperName = ask("Dropper peripheral name", "minecraft:dropper_9")
local barrelName = ask("Barrel peripheral name", "minecraft:barrel_0")
local serverUrl = ask("Web server URL (blank to skip)", "")
local cfg = {
monitorSide = monitorSide,
smelterMonitorSide = smelterMonitorSide,
dropperName = dropperName,
barrelName = barrelName,
}
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(cfgDir, ".webbridge_config"), "w")
bf.write(textutils.serialiseJSON(bcfg))
bf.close()
print("Saved web bridge config.")
end
elseif choice == "2" then
print("")
print("-- Client Configuration --")
local monitorSide = ask("Monitor side", "left")
local smelterMonitorSide = ask("Smelter monitor side", "top")
local dropperName = ask("Dropper peripheral name (blank if none)", "")
local barrelName = ask("Barrel peripheral name (blank if none)", "")
local cfg = {
monitorSide = monitorSide,
smelterMonitorSide = smelterMonitorSide,
}
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(cfgDir, ".client_config"), "w")
f.write(textutils.serialiseJSON(cfg))
f.close()
print("Saved client config.")
elseif choice == "3" then
print("")
print("-- Web Bridge Configuration --")
local serverUrl = ask("Web server URL", "http://localhost")
local cfg = { serverUrl = serverUrl }
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
]],
}

92
autorun/startup.lua Normal file
View File

@@ -0,0 +1,92 @@
-- Inventory Manager - Opus autorun script
-- Detects configured role and launches the appropriate program at boot
local fs = _G.fs
local kernel = _G.kernel
local os = _G.os
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 cfgExists('.manager_config') then
role = 'manager'
elseif cfgExists('.client_config') then
role = 'client'
elseif cfgExists('.webbridge_config') then
role = 'bridge'
elseif cfgExists('.miner_config') then
role = 'miner'
elseif _G.turtle then
role = 'turtle'
end
if not role then return end
-------------------------------------------------
-- Register reboot listener as a kernel hook
-- Allows remote reboot via modem (channel 4205)
-------------------------------------------------
local SYSTEM_CHANNEL = 4205
local modem = peripheral.find('modem')
if modem then
modem.open(SYSTEM_CHANNEL)
kernel.hook('modem_message', function(_, data)
-- data = { side, channel, replyChannel, message, distance }
if data[2] == SYSTEM_CHANNEL
and type(data[4]) == 'table'
and data[4].type == 'reboot'
then
local target = data[4].target or 'all'
if target == 'all'
or target == role
or target == tostring(os.getComputerID())
then
os.reboot()
end
end
end)
end
-------------------------------------------------
-- Launch the program for this role
-------------------------------------------------
local programs = {
manager = 'inventoryManager.lua',
client = 'inventoryClient.lua',
bridge = 'inventoryWebBridge.lua',
turtle = 'craftingTurtle.lua',
miner = 'miningTurtle.lua',
}
local program = fs.combine(BASE, programs[role])
if shell.openForegroundTab then
shell.openForegroundTab(program)
-- Client role also runs the dropper controller
if role == 'client' then
local dropper = fs.combine(BASE, 'dropperController.lua')
if fs.exists(dropper) then
shell.openTab(dropper)
end
end
else
-- No multishell — run directly (blocks boot)
shell.run(program)
end

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)
-------------------------------------------------
@@ -19,7 +28,20 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
-- Load config from file if present
-------------------------------------------------
local TURTLE_CONFIG_FILE = ".turtle_config"
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 TURTLE_CONFIG_FILE = _configPath(".turtle_config")
local function loadConfig()
if not fs.exists(TURTLE_CONFIG_FILE) then return end
@@ -49,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
@@ -75,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)
@@ -88,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("")
@@ -106,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
@@ -175,7 +188,13 @@ local function handleCraftCommand(message)
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)
local chest = peripheral.wrap(chestName)
if not chest then
print(string.format("[CRAFT] Cannot wrap chest: %s", chestName))
allPlaced = false
break
end
local ok, n = pcall(chest.pushItems, selfName, 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))
@@ -263,20 +282,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

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

28
etc/apps.db Normal file
View File

@@ -0,0 +1,28 @@
{
[ "im_inventory_manager" ] = {
title = "Inv Manager",
category = "Inventory",
run = "inventoryManager.lua",
},
[ "im_inventory_client" ] = {
title = "Inv Display",
category = "Inventory",
run = "inventoryClient.lua",
},
[ "im_crafting_turtle" ] = {
title = "Crafting Turtle",
category = "Inventory",
run = "craftingTurtle.lua",
requires = "turtle",
},
[ "im_web_bridge" ] = {
title = "Inv Web Bridge",
category = "Inventory",
run = "inventoryWebBridge.lua",
},
[ "im_dropper" ] = {
title = "Dropper Ctrl",
category = "Inventory",
run = "dropperController.lua",
},
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3019
inventoryManager.lua.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,21 @@ local ORDER_CHANNEL = 4201
-- Load config from file if present
-------------------------------------------------
local CONFIG_FILE = ".webbridge_config"
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(".webbridge_config")
local API_KEY = nil -- optional API key for server auth
local function loadConfig()
if fs.exists(CONFIG_FILE) then
@@ -33,6 +47,7 @@ local function loadConfig()
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
@@ -71,8 +86,9 @@ 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, err = pcall(function()
local ok, result = pcall(function()
local response = http.post(url, data, headers)
if response then
local responseData = response.readAll()
@@ -81,16 +97,18 @@ local function httpPost(path, body)
end
end)
if not ok then
-- Silent fail, will retry
return nil
if ok then
return result
end
return nil
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)
local response = http.get(url, headers)
if response then
local data = response.readAll()
response.close()
@@ -171,6 +189,12 @@ local function processCommand(cmd)
commandId = cmd.commandId,
recipeIdx = cmd.recipeIdx,
})
elseif action == "reboot" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "reboot",
commandId = cmd.commandId,
target = cmd.target or "all",
})
else
print("[CMD] Unknown action: " .. tostring(action))
end
@@ -274,6 +298,11 @@ local function main()
print("[OK] Server URL: " .. SERVER_URL)
print("[OK] Poll interval: " .. POLL_INTERVAL .. "s")
print("[OK] State interval: " .. STATE_INTERVAL .. "s")
if API_KEY then
print("[OK] API key configured")
else
print("[WARN] No API key set (open access)")
end
print("")
print("Bridge is running. Press Ctrl+T to stop.")
print("Listening for master broadcasts on ch " .. BROADCAST_CHANNEL)

325
lib/craft.lua Normal file
View File

@@ -0,0 +1,325 @@
-- 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)
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
local chests = ops.getChests()
local slotMap = {}
local reserved = {}
-- Map each grid position to a chest 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
slotMap[tostring(tSlot)] = {
chestName = src.chest,
chestSlot = slot,
itemName = itemName,
count = 1,
}
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
for batch = 1, step.count do
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx)
if not ok then
ctx.state.activity.crafting = false
ctx.state.needsRedraw = true
ctx.log.error("CRAFT", "Chain failed at step %d batch %d: %s", i, batch, batchErr)
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
end
-- Brief pause between batches to let turtle finish
if batch < step.count 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

248
lib/recipeBook.lua Normal file
View File

@@ -0,0 +1,248 @@
-- 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
return recipeBook

189
manager/config.lua Normal file
View File

@@ -0,0 +1,189 @@
-- manager/config.lua — Configuration constants, data tables, and lookup structures
-- Usage: local cfg = dofile(_path("manager/config.lua"))(log, _path)
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 = {}
-------------------------------------------------
-- Default configuration (overridden by .manager_config)
-------------------------------------------------
C.DROPPER_NAME = "minecraft:dropper_9"
C.BARREL_NAME = "minecraft:barrel_0"
C.POLL_INTERVAL = 2
C.MONITOR_SIDE = "left"
C.SCAN_INTERVAL = 120
C.SMELT_INTERVAL = 3
C.SMELT_RESERVE = 128
C.DEFRAG_INTERVAL = 600
C.COMPOST_INTERVAL = 3
C.ALERT_INTERVAL = 15
C.CACHE_FILE = _configPath(".inventory_cache")
C.SMELTER_MONITOR_SIDE = "top"
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
-- Network
C.BROADCAST_CHANNEL = 4200
C.ORDER_CHANNEL = 4201
C.BROADCAST_INTERVAL = 1
C.CRAFT_CHANNEL = 4203
C.CRAFT_REPLY_CHANNEL = 4204
C.SYSTEM_CHANNEL = 4205
-- Crafting
C.CRAFT_TIMEOUT = 15
C.GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11}
-- Compost (overridable via config file)
C.COMPOST_RESERVE = 128
C.COMPOST_DROPPER = "minecraft:dropper_10"
C.COMPOST_HOPPER = "minecraft:hopper_0"
-- 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",
"minecraft:smoker",
"minecraft:blast_furnace",
}
C.SLOT_INPUT = 1
C.SLOT_FUEL = 2
C.SLOT_OUTPUT = 3
-------------------------------------------------
-- Config file loader
-------------------------------------------------
local CONFIG_FILE = _configPath(".manager_config")
function C.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
log.warn("CONFIG", "Failed to parse %s", CONFIG_FILE)
return
end
C._raw = cfg
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.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
if cfg.defragInterval then C.DEFRAG_INTERVAL = cfg.defragInterval end
if cfg.compostInterval then C.COMPOST_INTERVAL = cfg.compostInterval end
if cfg.alertInterval then C.ALERT_INTERVAL = cfg.alertInterval end
if cfg.broadcastInterval then C.BROADCAST_INTERVAL = cfg.broadcastInterval end
if cfg.smeltReserve then C.SMELT_RESERVE = cfg.smeltReserve end
if cfg.broadcastChannel then C.BROADCAST_CHANNEL = cfg.broadcastChannel end
if cfg.orderChannel then C.ORDER_CHANNEL = cfg.orderChannel end
if cfg.craftChannel then C.CRAFT_CHANNEL = cfg.craftChannel end
if cfg.craftReplyChannel then C.CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
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.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
-------------------------------------------------
-- Data tables
-------------------------------------------------
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.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
-- 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()
-- 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
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
end
end
for _, list in pairs(C.smeltCandidatesByType) do
table.sort(list, function(a, b)
if a.food ~= b.food then return a.food end
return a.name < b.name
end)
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
-- Build compostable set for quick lookup
C.COMPOSTABLE_SET = {}
for _, name in ipairs(C.COMPOSTABLE) do C.COMPOSTABLE_SET[name] = true end
return C
end

1417
manager/display.lua Normal file

File diff suppressed because it is too large Load Diff

998
manager/display.lua.bak Normal file
View File

@@ -0,0 +1,998 @@
-- manager/display.lua — Dashboard rendering and touch handlers
-- Usage: local display = dofile("manager/display.lua")(ctx)
return function(ctx)
local cfg = ctx.cfg
local state = ctx.state
local log = ctx.log
local ui = ctx.ui
local ops = ctx.ops
local cache = state.cache
local activity = state.activity
local D = {}
-------------------------------------------------
-- Monitor handles (set during init)
-------------------------------------------------
D.mon = nil
D.monName = nil
D.smelterMon = nil
D.smelterMonName = nil
function D.setupMonitor()
D.mon, D.monName = ui.setupMonitor(cfg.MONITOR_SIDE, cfg.SMELTER_MONITOR_SIDE)
return D.mon ~= nil
end
function D.setupSmelterMonitor()
D.smelterMon, D.smelterMonName = ui.setupSmelterMonitor(cfg.SMELTER_MONITOR_SIDE, D.monName)
return D.smelterMon ~= nil
end
-------------------------------------------------
-- Main dashboard UI state
-------------------------------------------------
local selectedAmount = 1
local amountOptions = {1, 4, 8, 16, 32, 64}
local touchZones = {}
local pendingZones = {}
local currentPage = 1
local totalPages = 1
local searchQuery = ""
local showKeyboard = false
local kbRows = {
{"Q","W","E","R","T","Y","U","I","O","P"},
{"A","S","D","F","G","H","J","K","L"},
{"Z","X","C","V","B","N","M"},
}
-------------------------------------------------
-- Smelter dashboard UI state
-------------------------------------------------
local smelterView = "status"
local smelterPage = 1
local smelterTotalPages = 1
local smelterTouchZones = {}
local smelterPendingZones = {}
-------------------------------------------------
-- Drawing helpers (delegated to shared ui module)
-------------------------------------------------
local draw = nil
local function setDrawTarget(target)
draw = target
ui.draw = target
end
local function monWrite(x, y, text, fg, bg) ui.monWrite(x, y, text, fg, bg) end
local function monFill(y, color) ui.monFill(y, color) end
local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end
local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end
local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end
local function addZone(x1, y1, x2, y2, action, data)
ui.addZone(pendingZones, x1, y1, x2, y2, action, data)
end
local function hitTest(x, y)
return ui.hitTest(touchZones, x, y)
end
local function addSmelterZone(x1, y1, x2, y2, action, data)
ui.addZone(smelterPendingZones, x1, y1, x2, y2, action, data)
end
local function smelterHitTest(x, y)
return ui.hitTest(smelterTouchZones, x, y)
end
local function getFilteredItems()
state.ensureItemList()
return ui.getFilteredItems(cache.itemList, searchQuery)
end
-------------------------------------------------
-- Main dashboard drawing
-------------------------------------------------
function D.drawDashboard()
if not D.mon then return end
local w, h = D.mon.getSize()
pendingZones = {}
setDrawTarget(window.create(D.mon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
-- Title bar
monFill(1, colors.blue)
monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue)
-- Status bar
monFill(2, colors.gray)
local statusParts = {}
table.insert(statusParts, string.format(" Chests: %d", cache.chestCount))
table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
if cache.furnaceCount and cache.furnaceCount > 0 then
table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount))
end
local actParts = {}
if activity.sorting then table.insert(actParts, "SORTING") end
if activity.dispensing then table.insert(actParts, "DISPENSING") end
if activity.smelting then table.insert(actParts, "SMELTING") end
if activity.scanning then table.insert(actParts, "SCANNING") end
if activity.defragging then table.insert(actParts, "DEFRAG") end
if activity.composting then table.insert(actParts, "COMPOST") end
monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray)
if #actParts > 0 then
local actStr = " " .. table.concat(actParts, " | ") .. " "
monWrite(w - #actStr, 2, actStr, colors.white, colors.orange)
end
-- Divider
monFill(3, colors.lightBlue)
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue)
-- Storage capacity
monFill(4, colors.black)
local capLabel = string.format(" Storage: %d/%d slots (%d free)",
cache.usedSlots, cache.totalSlots, cache.freeSlots)
monWrite(2, 4, capLabel, colors.lightGray, colors.black)
local barStart = #capLabel + 4
local barWidth = w - barStart - 2
if barWidth > 4 then
local barColor = colors.lime
if cache.usedRatio > 0.9 then barColor = colors.red
elseif cache.usedRatio > 0.7 then barColor = colors.orange
elseif cache.usedRatio > 0.5 then barColor = colors.yellow
end
monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray)
local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100))
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
monWrite(pctX, 4, pctStr, colors.white, barColor)
end
-- Amount selector (row 5)
monFill(5, colors.black)
monWrite(2, 5, "Qty:", colors.lightGray, colors.black)
local btnX = 7
for _, amt in ipairs(amountOptions) do
local label = tostring(amt)
local bg = (amt == selectedAmount) and colors.cyan or colors.gray
local fg = (amt == selectedAmount) and colors.white or colors.lightGray
local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg)
addZone(x1, y1, x2, y2, "amount", amt)
btnX = x2 + 2
end
local refreshBg = activity.scanning and colors.yellow or colors.green
local refreshFg = activity.scanning and colors.black or colors.white
local refreshTxt = activity.scanning and "Scanning" or "Refresh"
local scanX = w - #refreshTxt - 3
local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1)
addZone(sx1, sy1, sx2, sy2, "scan", nil)
-- Search bar + Pagination (row 6)
monFill(6, colors.black)
local kbLabel = showKeyboard and " X " or " ? "
local kbBg = showKeyboard and colors.red or colors.purple
monWrite(2, 6, kbLabel, colors.white, kbBg)
addZone(2, 6, 4, 6, "kb_toggle", nil)
local queryDisplay = searchQuery
if showKeyboard then
queryDisplay = queryDisplay .. "|"
elseif queryDisplay == "" then
queryDisplay = "search..."
end
local fieldW = math.floor(w * 0.4)
if fieldW < 10 then fieldW = 10 end
local displayText = queryDisplay:sub(1, fieldW)
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
monWrite(6, 6, displayText,
(searchQuery == "" and not showKeyboard) and colors.gray or colors.white,
colors.black)
addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil)
local filteredItems = getFilteredItems()
local maxRows = h - 10
if maxRows < 1 then maxRows = 1 end
totalPages = math.max(1, math.ceil(#filteredItems / maxRows))
if currentPage > totalPages then currentPage = totalPages end
if currentPage < 1 then currentPage = 1 end
local pageStr = string.format("Pg %d/%d", currentPage, totalPages)
local navW = 3 + 1 + #pageStr + 1 + 3
local navX = w - navW
if currentPage > 1 then
monWrite(navX, 6, " < ", colors.white, colors.gray)
addZone(navX, 6, navX + 2, 6, "page_prev", nil)
else
monWrite(navX, 6, " < ", colors.lightGray, colors.black)
end
monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black)
local nextX = navX + 4 + #pageStr + 1
if currentPage < totalPages then
monWrite(nextX, 6, " > ", colors.white, colors.gray)
addZone(nextX, 6, nextX + 2, 6, "page_next", nil)
else
monWrite(nextX, 6, " > ", colors.lightGray, colors.black)
end
-- Column headers (row 7)
local row = 7
monFill(row, colors.gray)
monWrite(2, row, "#", colors.lightGray, colors.gray)
monWrite(5, row, "Item", colors.lightGray, colors.gray)
monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray)
monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray)
monWrite(w - 1, row, ">", colors.lightGray, colors.gray)
row = row + 1
-- Item rows
local maxCount = 0
for _, item in ipairs(filteredItems) do
if item.total > maxCount then maxCount = item.total end
end
if maxCount == 0 then maxCount = 1 end
local startIdx = (currentPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #filteredItems)
if #filteredItems == 0 then
monFill(8, colors.black)
monFill(9, colors.black)
if searchQuery ~= "" then
monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black)
else
monCenter(9, "No items in storage", colors.gray, colors.black)
end
row = 10
else
for i = startIdx, endIdx do
local item = filteredItems[i]
local y = row
local short = item.name:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local maxNameLen = w - 30
if #short > maxNameLen then
short = short:sub(1, maxNameLen - 2) .. ".."
end
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
monWrite(5, y, short, colors.white, rowBg)
monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg)
local ratio = item.total / maxCount
local barColor = colors.lime
if ratio < 0.25 then barColor = colors.red
elseif ratio < 0.5 then barColor = colors.orange
end
monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray)
monWrite(w - 1, y, ">", colors.orange, rowBg)
addZone(1, y, w, y, "order", item.name)
row = row + 1
end
end
local lastItemRow = h - 3
while row <= lastItemRow do
monFill(row, colors.black)
row = row + 1
end
if showKeyboard then
local keyW = 3
local keyGap = 1
local kbDefs = {
{ keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} },
{ keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} },
{ keys = kbRows[3], specials = {
{ label = " Space ", action = "kb_space", bg = colors.lightGray },
{ label = " Clr ", action = "kb_clear", bg = colors.orange },
}},
}
for rowIdx, def in ipairs(kbDefs) do
local y = h - 3 + rowIdx
monFill(y, colors.black)
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
local specialsW = 0
for _, sp in ipairs(def.specials) do
specialsW = specialsW + keyGap + #sp.label
end
local rowW = keysW + specialsW
local x = math.floor((w - rowW) / 2) + 1
for ki, key in ipairs(def.keys) do
monWrite(x, y, " " .. key .. " ", colors.white, colors.gray)
addZone(x, y, x + keyW - 1, y, "kb_key", key:lower())
x = x + keyW
if ki < #def.keys then x = x + keyGap end
end
for _, sp in ipairs(def.specials) do
x = x + keyGap
monWrite(x, y, sp.label, colors.white, sp.bg)
addZone(x, y, x + #sp.label - 1, y, sp.action, nil)
x = x + #sp.label
end
end
else
-- Status message
monFill(h - 2, colors.black)
if #state.activeAlerts > 0 then
local alertIdx = math.floor(os.epoch("utc") / 2000) % #state.activeAlerts + 1
local a = state.activeAlerts[alertIdx]
local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
monCenter(h - 2, alertMsg, colors.white, colors.red)
elseif state.statusTimer > 0 and #state.statusMessage > 0 then
monCenter(h - 2, state.statusMessage, state.statusColor, colors.black)
end
-- Footer
state.ensureItemList()
monFill(h - 1, colors.gray)
local footerLeft = string.format(" Total: %d items | %d types ",
cache.grandTotal, #cache.itemList)
monWrite(2, h - 1, footerLeft, colors.white, colors.gray)
if searchQuery ~= "" then
local filterNote = string.format("| Showing %d ", #filteredItems)
monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray)
end
local timeStr = textutils.formatTime(os.time(), true)
monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray)
-- Bottom accent
monFill(h, colors.blue)
local bottomMsg = " Tap item to order "
if activity.dispensing then
bottomMsg = " DISPENSING... "
elseif activity.smelting then
bottomMsg = " SMELTING... "
elseif activity.sorting then
bottomMsg = " SORTING BARREL... "
elseif activity.defragging then
bottomMsg = " DEFRAGMENTING... "
elseif activity.composting then
bottomMsg = " COMPOSTING... "
end
monCenter(h, bottomMsg, colors.lightBlue, colors.blue)
end
draw.setVisible(true)
touchZones = pendingZones
end
-------------------------------------------------
-- Smelter dashboard drawing
-------------------------------------------------
function D.drawSmelterDashboard()
if not D.smelterMon then return end
local w, h = D.smelterMon.getSize()
smelterPendingZones = {}
setDrawTarget(window.create(D.smelterMon, 1, 1, w, h, false))
draw.setBackgroundColor(colors.black)
draw.clear()
-- Title bar
monFill(1, colors.purple)
monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple)
-- Status bar
monFill(2, colors.gray)
local activeCount = 0
for _, fs in ipairs(cache.furnaceStatus or {}) do
if fs.active then activeCount = activeCount + 1 end
end
local statusStr = string.format(" Furnaces: %d Active: %d",
cache.furnaceCount or 0, activeCount)
monWrite(2, 2, statusStr, colors.white, colors.gray)
local pauseLabel = state.smeltingPaused and " PAUSED " or " ACTIVE "
local pauseBg = state.smeltingPaused and colors.red or colors.lime
local pauseFg = state.smeltingPaused and colors.white or colors.black
monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg)
addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil)
-- Divider
monFill(3, colors.magenta)
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta)
-- Tab row
monFill(4, colors.black)
local tabStatusBg = smelterView == "status" and colors.purple or colors.gray
local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray
local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray
local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray
local bx1, by1, bx2, by2
bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "status")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "craft")
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg)
addSmelterZone(bx1, by1, bx2, by2, "tab", "missing")
local craftAvailCount = nil
if smelterView == "status" then
-- Furnace Status View
monFill(5, colors.gray)
local outCol = math.floor(w * 0.40)
local fuelCol = math.floor(w * 0.65)
local statCol = w - 6
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "T", colors.lightGray, colors.gray)
monWrite(6, 5, "Input", colors.lightGray, colors.gray)
monWrite(outCol, 5, "Output", colors.lightGray, colors.gray)
monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray)
monWrite(statCol, 5, "State", colors.lightGray, colors.gray)
local furnaceList = cache.furnaceStatus or {}
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #furnaceList)
local row = 6
if #furnaceList == 0 then
monFill(7, colors.black)
monCenter(7, "No furnaces found on network", colors.gray, colors.black)
row = 8
else
for i = startIdx, endIdx do
local fs = furnaceList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
local typeAbbr = "F"
local typeColor = colors.orange
if fs.type == "minecraft:smoker" then
typeAbbr = "S"
typeColor = colors.green
elseif fs.type == "minecraft:blast_furnace" then
typeAbbr = "B"
typeColor = colors.cyan
end
monWrite(4, y, typeAbbr, typeColor, rowBg)
if fs.input then
local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxIn = outCol - 8
if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end
monWrite(6, y, inName, colors.white, rowBg)
monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg)
else
monWrite(6, y, "(empty)", colors.lightGray, rowBg)
end
if fs.output then
local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxOut = fuelCol - outCol - 5
if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end
monWrite(outCol, y, outName, colors.white, rowBg)
monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg)
else
monWrite(outCol, y, "-", colors.lightGray, rowBg)
end
if fs.fuel then
local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ")
local maxFuel = statCol - fuelCol - 4
if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end
monWrite(fuelCol, y, fuelName, colors.white, rowBg)
monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg)
else
monWrite(fuelCol, y, "-", colors.lightGray, rowBg)
end
if state.smeltingPaused then
monWrite(statCol, y, "PAUSE", colors.red, rowBg)
elseif fs.active then
monWrite(statCol, y, " COOK", colors.lime, rowBg)
elseif fs.input and not fs.fuel then
monWrite(statCol, y, "FUEL?", colors.orange, rowBg)
else
monWrite(statCol, y, " IDLE", colors.lightGray, rowBg)
end
row = row + 1
end
end
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
elseif smelterView == "smelt" then
-- Smelt Recipe Manager View
local recipeList = {}
for inputName, recipe in pairs(cfg.SMELTABLE) do
local short = inputName:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ")
resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2)
local types = ""
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
local enabled = not state.disabledRecipes[inputName]
local inStorage = 0
if cache.catalogue[inputName] then
for _, s in ipairs(cache.catalogue[inputName]) do
inStorage = inStorage + s.total
end
end
table.insert(recipeList, {
inputName = inputName,
inputShort = short,
resultShort = resultShort,
types = types,
enabled = enabled,
inStorage = inStorage,
})
end
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
local arrowCol = math.floor(w * 0.30)
local typeCol = math.floor(w * 0.60)
local stockCol = math.floor(w * 0.72)
local toggleCol = w - 5
monFill(5, colors.gray)
monWrite(2, 5, "Input", colors.lightGray, colors.gray)
monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray)
monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray)
monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray)
monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray)
local bulkX = w - 22
bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green)
addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil)
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red)
addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #recipeList)
local row = 6
for i = startIdx, endIdx do
local r = recipeList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
local maxInputLen = arrowCol - 3
local inputDisplay = r.inputShort
if #inputDisplay > maxInputLen then
inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".."
end
monWrite(2, y, inputDisplay, colors.white, rowBg)
local maxOutLen = typeCol - arrowCol - 2
local outDisplay = r.resultShort
if #outDisplay > maxOutLen then
outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".."
end
monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg)
monWrite(typeCol, y, r.types, colors.orange, rowBg)
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
if r.enabled then
monWrite(toggleCol, y, " ON ", colors.white, colors.green)
else
monWrite(toggleCol, y, " OFF", colors.white, colors.red)
end
addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName)
row = row + 1
end
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
elseif smelterView == "craft" then
-- Available Crafting Recipes
local turtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName)
local tLabel = turtleOk and " Turtle OK " or " No Turtle "
local tBg = turtleOk and colors.lime or colors.red
local tFg = turtleOk and colors.black or colors.white
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
local availList = {}
for idx, recipe in ipairs(cfg.CRAFTABLE) do
if ops.canCraftRecipe(recipe) then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local batches = ops.maxCraftBatches(recipe)
table.insert(availList, {
idx = idx,
short = short,
count = recipe.count,
batches = batches,
})
end
end
craftAvailCount = #availList
monFill(5, colors.gray)
local makeCol = w - 6
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray)
monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#availList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #availList)
local row = 6
if #availList == 0 then
monFill(7, colors.black)
monCenter(7, "No recipes available to craft", colors.gray, colors.black)
row = 8
else
for i = startIdx, endIdx do
local r = availList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
local maxNameLen = math.floor(w * 0.40)
local nameDisplay = r.short
if #nameDisplay > maxNameLen then
nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".."
end
monWrite(4, y, nameDisplay, colors.white, rowBg)
monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg)
monWrite(math.floor(w * 0.60), y,
string.format("x%d", r.batches), colors.lime, rowBg)
if turtleOk then
monWrite(makeCol, y, " MAKE ", colors.white, colors.green)
addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx)
else
monWrite(makeCol, y, " ---- ", colors.gray, colors.black)
end
row = row + 1
end
end
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
elseif smelterView == "missing" then
-- Unavailable Crafting Recipes
local missList = {}
for idx, recipe in ipairs(cfg.CRAFTABLE) do
if not ops.canCraftRecipe(recipe) then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
short = short:sub(1,1):upper() .. short:sub(2)
local missing = ops.getMissingIngredients(recipe)
local parts = {}
for _, m in ipairs(missing) do
local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ")
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
end
table.insert(missList, {
idx = idx,
short = short,
count = recipe.count,
summary = table.concat(parts, ", "),
})
end
end
craftAvailCount = #cfg.CRAFTABLE - #missList
monFill(5, colors.gray)
monWrite(2, 5, "#", colors.lightGray, colors.gray)
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray)
local maxRows = h - 8
if maxRows < 1 then maxRows = 1 end
smelterTotalPages = math.max(1, math.ceil(#missList / maxRows))
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
if smelterPage < 1 then smelterPage = 1 end
local startIdx = (smelterPage - 1) * maxRows + 1
local endIdx = math.min(startIdx + maxRows - 1, #missList)
local row = 6
if #missList == 0 then
monFill(7, colors.black)
monCenter(7, "All recipes can be crafted!", colors.lime, colors.black)
row = 8
else
for i = startIdx, endIdx do
local r = missList[i]
local y = row
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
monFill(y, rowBg)
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
local nameCol = math.floor(w * 0.35) - 5
local nameDisplay = r.short .. " x" .. r.count
if #nameDisplay > nameCol then
nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".."
end
monWrite(4, y, nameDisplay, colors.white, rowBg)
local missCol = math.floor(w * 0.35)
local missW = w - missCol - 1
local summaryDisplay = r.summary
if #summaryDisplay > missW then
summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".."
end
monWrite(missCol, y, summaryDisplay, colors.red, rowBg)
row = row + 1
end
end
while row <= h - 2 do monFill(row, colors.black); row = row + 1 end
end
-- Pagination (h - 1)
monFill(h - 1, colors.gray)
local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages)
monCenter(h - 1, pageStr, colors.white, colors.gray)
if smelterPage > 1 then
monWrite(2, h - 1, " < ", colors.white, colors.lightGray)
addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil)
end
if smelterPage < smelterTotalPages then
monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray)
addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil)
end
-- Bottom accent
monFill(h, colors.purple)
local bottomMsg = ""
if smelterView == "status" or smelterView == "smelt" then
local enabledCount = 0
local totalRecipes = 0
for _ in pairs(cfg.SMELTABLE) do totalRecipes = totalRecipes + 1 end
for inputName in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[inputName] then enabledCount = enabledCount + 1 end
end
bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes)
if activity.smelting then bottomMsg = " SMELTING... " end
elseif smelterView == "craft" then
bottomMsg = " Tap MAKE to craft "
if activity.crafting then bottomMsg = " CRAFTING... " end
elseif smelterView == "missing" then
local availC = craftAvailCount or 0
bottomMsg = string.format(" Available: %d/%d recipes ", availC, #cfg.CRAFTABLE)
end
monCenter(h, bottomMsg, colors.pink, colors.purple)
draw.setVisible(true)
smelterTouchZones = smelterPendingZones
end
-------------------------------------------------
-- Touch handlers
-------------------------------------------------
function D.handleTouch(x, y)
local action, data = hitTest(x, y)
if not action then
log.debug("TOUCH", "No zone hit")
return
end
if action == "amount" then
selectedAmount = data
log.debug("UI", "Amount set to %s", data)
state.needsRedraw = true
elseif action == "order" then
local itemName = data
if itemName then
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
state.statusColor = colors.cyan
state.statusTimer = 10
activity.dispensing = true
state.needsRedraw = true
ops.orderItem(itemName, selectedAmount)
end
elseif action == "scan" then
state.statusMessage = "Refreshing..."
state.statusColor = colors.cyan
state.statusTimer = 3
state.needsRedraw = true
log.debug("UI", "Manual refresh")
elseif action == "kb_toggle" then
showKeyboard = not showKeyboard
log.debug("UI", "Keyboard %s", showKeyboard and "open" or "closed")
state.needsRedraw = true
elseif action == "kb_key" then
if #searchQuery < 30 then
searchQuery = searchQuery .. data
end
currentPage = 1
state.needsRedraw = true
elseif action == "kb_bksp" then
if #searchQuery > 0 then
searchQuery = searchQuery:sub(1, -2)
end
currentPage = 1
state.needsRedraw = true
elseif action == "kb_space" then
if #searchQuery < 30 then
searchQuery = searchQuery .. " "
end
currentPage = 1
state.needsRedraw = true
elseif action == "kb_done" then
showKeyboard = false
log.debug("UI", "Keyboard closed")
state.needsRedraw = true
elseif action == "kb_clear" then
searchQuery = ""
currentPage = 1
log.debug("UI", "Search cleared")
state.needsRedraw = true
elseif action == "page_prev" then
if currentPage > 1 then
currentPage = currentPage - 1
log.debug("UI", "Page %d", currentPage)
end
state.needsRedraw = true
elseif action == "page_next" then
if currentPage < totalPages then
currentPage = currentPage + 1
log.debug("UI", "Page %d", currentPage)
end
state.needsRedraw = true
end
end
function D.handleSmelterTouch(x, y)
local action, data = smelterHitTest(x, y)
if not action then return end
if action == "tab" then
smelterView = data
smelterPage = 1
log.debug("UI", "Tab: %s", data)
state.smelterNeedsRedraw = true
elseif action == "toggle_pause" then
state.smeltingPaused = not state.smeltingPaused
log.debug("UI", "Smelting %s", state.smeltingPaused and "PAUSED" or "RESUMED")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
state.needsRedraw = true
elseif action == "toggle_recipe" then
if state.disabledRecipes[data] then
state.disabledRecipes[data] = nil
else
state.disabledRecipes[data] = true
end
local short = data:gsub("^minecraft:", ""):gsub("_", " ")
log.debug("UI", "Recipe %s: %s", short, state.disabledRecipes[data] and "OFF" or "ON")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
elseif action == "enable_all" then
state.disabledRecipes = {}
log.debug("UI", "All recipes enabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
elseif action == "disable_all" then
for inputName in pairs(cfg.SMELTABLE) do
state.disabledRecipes[inputName] = true
end
log.debug("UI", "All recipes disabled")
ops.saveDisabledRecipes()
state.smelterNeedsRedraw = true
elseif action == "page_prev" then
if smelterPage > 1 then
smelterPage = smelterPage - 1
end
state.smelterNeedsRedraw = true
elseif action == "page_next" then
if smelterPage < smelterTotalPages then
smelterPage = smelterPage + 1
end
state.smelterNeedsRedraw = true
elseif action == "craft" then
local recipeIdx = data
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
log.info("CRAFT", "Craft request: %s (#%d)", short, recipeIdx)
local ok, err = ops.craftItem(recipeIdx)
if ok then
state.statusMessage = "Crafted " .. short .. " x" .. recipe.count
state.statusColor = colors.lime
else
state.statusMessage = "Craft failed: " .. (err or "unknown")
state.statusColor = colors.red
end
state.statusTimer = 5
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
end
end
return D
end

1160
manager/operations.lua Normal file

File diff suppressed because it is too large Load Diff

137
manager/state.lua Normal file
View File

@@ -0,0 +1,137 @@
-- manager/state.lua — Shared mutable state, cache, and coordination flags
-- Usage: local state = dofile("manager/state.lua")()
return function()
local S = {}
-------------------------------------------------
-- Cached data (updated by background scanner)
-------------------------------------------------
S.cache = {
catalogue = {},
itemList = {},
itemListDirty = false,
grandTotal = 0,
chestCount = 0,
totalSlots = 0,
usedSlots = 0,
freeSlots = 0,
usedRatio = 0,
dropperOk = false,
barrelOk = false,
furnaceCount = 0,
furnaceStatus = {},
droppers = {},
}
-- Client-registered droppers, keyed by clientId
S.clientDroppers = {}
-------------------------------------------------
-- Activity flags (shown on monitor)
-------------------------------------------------
S.activity = {
sorting = false,
dispensing = false,
scanning = false,
smelting = false,
defragging = false,
composting = false,
crafting = false,
}
-------------------------------------------------
-- State version tracking (for delta broadcasting)
-------------------------------------------------
S.stateVersion = 0
S.lastBroadcastVersion = -1
S.configDirty = true
function S.bumpStateVersion()
S.stateVersion = S.stateVersion + 1
end
-------------------------------------------------
-- UI coordination flags
-------------------------------------------------
S.needsRedraw = true
S.smelterNeedsRedraw = true
S.statusMessage = ""
S.statusColor = colors.white
S.statusTimer = 0
-------------------------------------------------
-- Alerts and smelting control
-------------------------------------------------
S.activeAlerts = {}
S.smeltingPaused = false
S.disabledRecipes = {}
-------------------------------------------------
-- Instant cache adjustment (no scan needed)
-------------------------------------------------
function S.adjustCache(itemName, chestName, delta)
if delta == 0 then return end
local cat = S.cache.catalogue
if delta > 0 then
if not cat[itemName] then cat[itemName] = {} end
local found = false
for _, entry in ipairs(cat[itemName]) do
if entry.chest == chestName then
entry.total = entry.total + delta
found = true
break
end
end
if not found then
table.insert(cat[itemName], { chest = chestName, total = delta })
end
else
if cat[itemName] then
for idx, entry in ipairs(cat[itemName]) do
if entry.chest == chestName then
entry.total = entry.total + delta
if entry.total <= 0 then
table.remove(cat[itemName], idx)
end
break
end
end
if #cat[itemName] == 0 then
cat[itemName] = nil
end
end
end
S.cache.itemListDirty = true
S.cache.grandTotal = S.cache.grandTotal + delta
S.bumpStateVersion()
end
function S.ensureItemList()
if not S.cache.itemListDirty then return end
local itemList = {}
local grandTotal = 0
for name, sources in pairs(S.cache.catalogue) do
local total = 0
for _, s in ipairs(sources) do total = total + s.total end
grandTotal = grandTotal + total
table.insert(itemList, { name = name, total = total })
end
table.sort(itemList, function(a, b) return a.total > b.total end)
S.cache.itemList = itemList
S.cache.grandTotal = grandTotal
S.cache.itemListDirty = false
end
return S
end

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

82
startup/bridge.lua Normal file
View File

@@ -0,0 +1,82 @@
-- startup.lua for Web Bridge computer
-- Auto-updates from git then launches inventoryWebBridge.lua
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
local FILES = {
["inventoryWebBridge.lua"] = "inventoryWebBridge.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(" Web Bridge - 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 inventoryWebBridge...")
sleep(1)
-- Reboot listener: reboots this computer on remote command
local SYSTEM_CHANNEL = 4205
local ROLE = "bridge"
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("inventoryWebBridge.lua") end,
rebootListener
)

203
startup/client.lua Normal file
View File

@@ -0,0 +1,203 @@
-- startup.lua for Inventory Client computer
-- Auto-updates from git then launches inventoryClient + dropperController in parallel
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",
["dropperController.lua"] = "dropperController.lua",
["lib/log.lua"] = "lib/log.lua",
["lib/ui.lua"] = "lib/ui.lua",
}
-------------------------------------------------
local function ensureDir(filePath)
local dir = filePath:match("^(.+)/")
if dir and not fs.isDir(dir) then
fs.makeDir(dir)
end
end
local function download(remotePath, localPath)
local url = REPO_RAW .. "/" .. remotePath
local response = http.get(url)
if response then
ensureDir(localPath)
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(" Inventory Client - Startup")
print(" Computer ID: " .. os.getComputerID())
print("==================================")
print("")
-- Update files
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))
print("Continuing with existing files...")
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("")
if dropperEnabled then
print("Starting inventoryClient + dropperController...")
else
print("Starting inventoryClient (no dropper)...")
end
sleep(1)
-- Reboot listener: reboots this computer on remote command
local SYSTEM_CHANNEL = 4205
local ROLE = "client"
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
local tasks = {
function() shell.run("inventoryClient.lua") end,
rebootListener,
}
if dropperEnabled then
table.insert(tasks, function() shell.run("dropperController.lua") end)
end
parallel.waitForAny(table.unpack(tasks))

105
startup/manager.lua Normal file
View File

@@ -0,0 +1,105 @@
-- startup.lua for Inventory Manager computer
-- Auto-updates from git then launches inventoryManager.lua
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
-- Files to download (destination -> repo path)
local FILES = {
["inventoryManager.lua"] = "inventoryManager.lua",
["manager/config.lua"] = "manager/config.lua",
["manager/state.lua"] = "manager/state.lua",
["manager/operations.lua"] = "manager/operations.lua",
["manager/display.lua"] = "manager/display.lua",
["lib/log.lua"] = "lib/log.lua",
["lib/ui.lua"] = "lib/ui.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",
}
-------------------------------------------------
local function ensureDir(filePath)
local dir = filePath:match("^(.+)/")
if dir and not fs.isDir(dir) then
fs.makeDir(dir)
end
end
local function download(remotePath, localPath)
local url = REPO_RAW .. "/" .. remotePath
local response = http.get(url)
if response then
ensureDir(localPath)
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(" Inventory Manager - Startup")
print(" Computer ID: " .. os.getComputerID())
print("==================================")
print("")
-- Update files
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))
print("Continuing with existing files...")
else
print(string.format("All %d files up to date.", updated))
end
print("")
print("Starting inventoryManager...")
sleep(1)
-- Reboot listener: reboots this computer when the manager sends
-- a reboot command to itself (target = "all" or "manager")
local SYSTEM_CHANNEL = 4205
local ROLE = "manager"
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("inventoryManager.lua") end,
rebootListener
)

58
startup/miner.lua Normal file
View File

@@ -0,0 +1,58 @@
-- 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)
shell.run("miningTurtle.lua")

82
startup/turtle.lua Normal file
View File

@@ -0,0 +1,82 @@
-- startup.lua for Crafting Turtle
-- Auto-updates from git then launches craftingTurtle.lua
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
local FILES = {
["craftingTurtle.lua"] = "craftingTurtle.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(" Crafting 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 craftingTurtle...")
sleep(1)
-- Reboot listener: reboots this turtle on remote command
local SYSTEM_CHANNEL = 4205
local ROLE = "turtle"
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("craftingTurtle.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
@@ -9,6 +9,10 @@ RUN npm install
COPY . .
# Accept API key at build time so Vite can inline it
ARG VITE_API_KEY=""
ENV VITE_API_KEY=${VITE_API_KEY}
RUN npm run build
# Stage 2: Serve with nginx

View File

@@ -1,3 +1,7 @@
# Texture proxy cache zone (100MB, inactive entries purged after 7 days)
proxy_cache_path /tmp/nginx_texture_cache levels=1:2 keys_zone=textures:10m
max_size=100m inactive=7d use_temp_path=off;
server {
listen 80;
server_name _;
@@ -5,6 +9,15 @@ server {
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Serve static files, fallback to index.html for SPA routing
location / {
try_files $uri $uri/ /index.html;
@@ -27,6 +40,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Let the server set Cache-Control, but also cache at nginx level
proxy_cache textures;
proxy_cache_valid 200 7d;
proxy_cache_valid 404 1d;
expires 7d;

View File

@@ -4,6 +4,31 @@
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
/* === Cross-link Button === */
.cross-link-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border: 2px solid var(--mc-dark);
background: #2e4a8b;
color: var(--mc-text-aqua);
font-size: 0.7rem;
font-weight: 700;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 var(--mc-dark);
text-decoration: none;
cursor: pointer;
transition: all 0.1s;
box-shadow: inset 0 1px 0 #4466bb, inset 0 -1px 0 #1a2a66;
white-space: nowrap;
}
.cross-link-btn:hover {
background: #3e5a9b;
color: white;
}
:root {
--mc-dark: #1a1a1a;
--mc-darker: #0e0e0e;

View File

@@ -22,6 +22,8 @@ function App() {
const [showSettings, setShowSettings] = useState(false);
const [, forceRender] = useState(0);
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
useEffect(() => {
connect();
}, [connect]);
@@ -34,10 +36,6 @@ function App() {
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
useEffect(() => {
connect();
}, [connect]);
const renderPanelContent = () => {
switch (panelTab) {
case 'inventory':
@@ -60,6 +58,16 @@ function App() {
<div className="app-header">
<h1> Inventory Manager</h1>
<div className="header-right">
{/* Cross-link to Turtle Dashboard */}
<a
href={turtleDashboardUrl}
className="cross-link-btn"
title="Open Turtle Control Dashboard"
target="_blank"
rel="noopener noreferrer"
>
🐢 Turtles
</a>
{/* Settings gear button */}
<button
className="settings-gear"

View File

@@ -200,6 +200,81 @@
line-height: 1;
}
/* === Remote Reboot Section === */
.reboot-status {
font-size: 0.65rem;
padding: 0.4rem 0.6rem;
margin-bottom: 0.6rem;
background: #1a1f2e;
border: 2px solid #3a4a6a;
color: var(--mc-text-aqua);
text-align: center;
}
.reboot-grid {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.reboot-target {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: #222;
border: 2px solid #3a3a3a;
transition: border-color 0.15s;
}
.reboot-target:hover {
border-color: #555;
}
.reboot-target-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.reboot-target-label {
font-size: 0.7rem;
color: var(--mc-text-white);
}
.reboot-target-desc {
font-size: 0.55rem;
color: var(--mc-text-gray);
}
.reboot-btn {
flex-shrink: 0;
}
.reboot-confirm-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.mc-btn.red {
border-color: #aa3333;
color: var(--mc-text-red);
background: linear-gradient(180deg, #5a2020, #3a1010);
box-shadow:
inset 1px 1px 0 #7a3030,
inset -1px -1px 0 #2a0808;
}
.mc-btn.red:hover {
background: linear-gradient(180deg, #7a3030, #4a1818);
color: #ff6666;
}
/* === Settings gear button (in header) === */
.settings-gear {

View File

@@ -2,14 +2,25 @@ import React, { useState, useCallback } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import './SettingsPanel.css';
const REBOOT_TARGETS = [
{ value: 'all', label: 'All Devices', icon: '🔄', desc: 'Reboot everything (manager + clients + turtles + bridge)' },
{ value: 'client', label: 'Clients', icon: '💻', desc: 'Reboot all inventory client displays' },
{ value: 'turtle', label: 'Crafting Turtles', icon: '🐢', desc: 'Reboot all crafting turtles' },
{ value: 'bridge', label: 'Web Bridge', icon: '🌉', desc: 'Reboot the HTTP/WS bridge computer' },
{ value: 'manager', label: 'Manager', icon: '📦', desc: 'Reboot the inventory manager (will disconnect briefly)' },
];
function SettingsPanel({ isOpen, onClose }) {
const droppers = useInventoryStore((state) => state.inventory.droppers) || [];
const dropperNicknames = useInventoryStore((state) => state.dropperNicknames) || {};
const setDropperNickname = useInventoryStore((state) => state.setDropperNickname);
const rebootComputers = useInventoryStore((state) => state.rebootComputers);
const [editingDropper, setEditingDropper] = useState(null);
const [editValue, setEditValue] = useState('');
const [saving, setSaving] = useState(false);
const [rebootConfirm, setRebootConfirm] = useState(null);
const [rebootStatus, setRebootStatus] = useState(null);
const startEditing = useCallback((dropperName) => {
setEditingDropper(dropperName);
@@ -39,6 +50,18 @@ function SettingsPanel({ isOpen, onClose }) {
}
}, [saveNickname]);
const handleReboot = useCallback(async (target) => {
setRebootConfirm(null);
setRebootStatus(`Sending reboot to ${target}...`);
const result = await rebootComputers(target);
if (result?.success) {
setRebootStatus(`✅ Reboot sent to: ${target}`);
} else {
setRebootStatus(`❌ Failed: ${result?.error || 'Unknown error'}`);
}
setTimeout(() => setRebootStatus(null), 4000);
}, [rebootComputers]);
if (!isOpen) return null;
return (
@@ -136,6 +159,51 @@ function SettingsPanel({ isOpen, onClose }) {
</div>
)}
</div>
<div className="settings-section">
<h3>🔄 Remote Reboot</h3>
<p className="settings-hint">
Reboot CC:Tweaked computers remotely. They will auto-update from git and restart.
</p>
{rebootStatus && (
<div className="reboot-status">{rebootStatus}</div>
)}
<div className="reboot-grid">
{REBOOT_TARGETS.map((t) => (
<div key={t.value} className="reboot-target">
<div className="reboot-target-info">
<span className="reboot-target-label">{t.icon} {t.label}</span>
<span className="reboot-target-desc">{t.desc}</span>
</div>
{rebootConfirm === t.value ? (
<div className="reboot-confirm-actions">
<button
className="mc-btn red"
onClick={() => handleReboot(t.value)}
>
Confirm
</button>
<button
className="mc-btn"
onClick={() => setRebootConfirm(null)}
>
Cancel
</button>
</div>
) : (
<button
className="mc-btn reboot-btn"
onClick={() => setRebootConfirm(t.value)}
>
Reboot
</button>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,12 +1,24 @@
import { create } from 'zustand';
const WS_URL = import.meta.env.VITE_WS_URL ||
const _baseWsUrl = import.meta.env.VITE_WS_URL ||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const API_URL = import.meta.env.VITE_API_URL ||
`${window.location.protocol}//${window.location.host}/api`;
const API_KEY = import.meta.env.VITE_API_KEY || '';
console.log('🔌 WebSocket URL:', WS_URL);
// Append API key to WebSocket URL as query param
const WS_URL = API_KEY ? `${_baseWsUrl}?key=${encodeURIComponent(API_KEY)}` : _baseWsUrl;
// Build common headers for authenticated requests
function authHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra };
if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;
return headers;
}
console.log('🔌 WebSocket URL:', _baseWsUrl);
console.log('📡 API URL:', API_URL);
if (API_KEY) console.log('🔒 API key configured');
// Generate a unique command ID for idempotent requests
function newCommandId() {
@@ -17,6 +29,7 @@ function newCommandId() {
let _httpPollTimer = null;
let _wsHealthTimer = null;
let _lastWsMessage = 0;
let _reconnectDelay = 1000;
const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback
const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect
@@ -33,7 +46,7 @@ function _applyStateData(data, current) {
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
lastUpdate: data.lastUpdate || Date.now(),
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
};
}
@@ -128,6 +141,7 @@ export const useInventoryStore = create((set, get) => ({
ws.onopen = () => {
console.log('✅ Connected to server');
_lastWsMessage = Date.now();
_reconnectDelay = 1000;
set({ connected: true, ws });
};
@@ -158,9 +172,11 @@ export const useInventoryStore = create((set, get) => ({
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
setTimeout(() => {
get().connect();
}, 3000);
}, delay);
};
ws.onerror = (error) => {
@@ -187,7 +203,7 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }),
});
return await response.json();
@@ -201,7 +217,7 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
@@ -215,7 +231,7 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/smelting/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
@@ -229,7 +245,7 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/recipes/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ recipe, commandId: newCommandId() }),
});
return await response.json();
@@ -241,25 +257,29 @@ export const useInventoryStore = create((set, get) => ({
enableAllRecipes: async () => {
try {
await fetch(`${API_URL}/recipes/enable-all`, {
const response = await fetch(`${API_URL}/recipes/enable-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error enabling all recipes:', error);
return { success: false, error: error.message };
}
},
disableAllRecipes: async () => {
try {
await fetch(`${API_URL}/recipes/disable-all`, {
const response = await fetch(`${API_URL}/recipes/disable-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error disabling all recipes:', error);
return { success: false, error: error.message };
}
},
@@ -267,7 +287,7 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/craft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ recipeIdx, commandId: newCommandId() }),
});
return await response.json();
@@ -277,11 +297,25 @@ export const useInventoryStore = create((set, get) => ({
}
},
rebootComputers: async (target = 'all') => {
try {
const response = await fetch(`${API_URL}/reboot`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ target, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error sending reboot:', error);
return { success: false, error: error.message };
}
},
sortBarrel: async (barrelName) => {
try {
const response = await fetch(`${API_URL}/sort-barrel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
body: JSON.stringify({ barrelName, commandId: newCommandId() }),
});
return await response.json();
@@ -296,8 +330,8 @@ export const useInventoryStore = create((set, get) => ({
try {
const response = await fetch(`${API_URL}/dropper-nicknames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dropperName, nickname }),
headers: authHeaders(),
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
});
const result = await response.json();
if (result.nicknames) {

View File

@@ -5,23 +5,23 @@ services:
- inventory-network
volumes:
- server-data:/data
environment:
- 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: ./client
build:
context: ./client
args:
VITE_API_KEY: ${API_KEY:-}
ports:
- "80:80"
networks:
- inventory-network
depends_on:
server:
condition: service_started
condition: service_healthy
restart: unless-stopped
networks:

View File

@@ -1,8 +1,10 @@
# Node.js backend
FROM node:18-alpine
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
@@ -11,6 +13,7 @@ COPY package*.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 . .
@@ -19,9 +22,14 @@ COPY . .
RUN mkdir -p /data
VOLUME /data
# 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

@@ -0,0 +1,207 @@
/**
* Tests for the Inventory Manager database layer (db.js).
*
* Sets DB_PATH to a temp file BEFORE importing the module so all
* operations hit a throwaway SQLite database. The underlying
* better-sqlite3 `Database` instance is also imported so we can
* DELETE rows between tests without reopening the file.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
// DB_PATH is set via vitest.config.js env before this module loads.
const TMP_DB = process.env.DB_PATH;
import db from '../db.js';
import {
saveItems, loadItems,
saveState, loadState,
saveFurnaces, loadFurnaces,
saveAlerts, loadAlerts,
recordItemHistory, getHistory, getHistorySummary,
saveFullState, flushPendingSave, loadFullState,
closeDb,
} from '../db.js';
// ── helpers ────────────────────────────────────────────────────────────
/** Wipe all rows from every table so each test starts fresh */
function clearAllTables() {
db.exec(`
DELETE FROM items;
DELETE FROM furnaces;
DELETE FROM alerts;
DELETE FROM state;
DELETE FROM item_history;
`);
}
// ── lifecycle ──────────────────────────────────────────────────────────
beforeEach(() => {
clearAllTables();
});
afterAll(() => {
try { closeDb(); } catch { /* already closed */ }
try { fs.unlinkSync(TMP_DB); } catch { /* ignore */ }
try { fs.unlinkSync(TMP_DB + '-wal'); } catch { /* ignore */ }
try { fs.unlinkSync(TMP_DB + '-shm'); } catch { /* ignore */ }
});
// ── tests ──────────────────────────────────────────────────────────────
describe('db item persistence', () => {
it('saveItems + loadItems round-trips item data', () => {
const items = [
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
{ name: 'minecraft:iron_ingot', count: 128, displayName: 'Iron Ingot' },
];
saveItems(items);
const loaded = loadItems();
expect(loaded).toHaveLength(2);
// loadItems returns ORDER BY count DESC
expect(loaded[0].name).toBe('minecraft:iron_ingot');
expect(loaded[0].count).toBe(128);
expect(loaded[1].name).toBe('minecraft:diamond');
expect(loaded[1].count).toBe(64);
});
it('saveItems upserts — updates existing rows instead of duplicating', () => {
saveItems([{ name: 'minecraft:diamond', count: 10, displayName: 'Diamond' }]);
saveItems([{ name: 'minecraft:diamond', count: 99, displayName: 'Diamond' }]);
const loaded = loadItems();
expect(loaded).toHaveLength(1);
expect(loaded[0].count).toBe(99);
});
});
describe('db state key-value store', () => {
it('saveState + loadState round-trips JSON', () => {
saveState('smeltingPaused', true);
expect(loadState('smeltingPaused')).toBe(true);
saveState('disabledRecipes', { 'minecraft:iron_ingot': true });
expect(loadState('disabledRecipes')).toEqual({ 'minecraft:iron_ingot': true });
});
it('loadState returns defaultValue when key missing', () => {
expect(loadState('nonexistent', 42)).toBe(42);
expect(loadState('nonexistent')).toBeNull();
});
});
describe('db furnace persistence', () => {
it('saveFurnaces + loadFurnaces round-trips furnace status', () => {
const furnaces = {
'minecraft:furnace_0': {
active: true,
type: 'minecraft:furnace',
input: { name: 'minecraft:raw_iron', count: 8 },
fuel: { name: 'minecraft:coal', count: 3 },
output: null,
},
};
saveFurnaces(furnaces);
const loaded = loadFurnaces();
expect(loaded['minecraft:furnace_0']).toBeDefined();
expect(loaded['minecraft:furnace_0'].active).toBe(true);
expect(loaded['minecraft:furnace_0'].input.name).toBe('minecraft:raw_iron');
});
});
describe('db alert persistence', () => {
it('saveAlerts + loadAlerts round-trips triggered alerts', () => {
saveAlerts([
{ item: 'minecraft:diamond', triggered: true, current: 5, threshold: 10 },
{ item: 'minecraft:coal', triggered: false, current: 200, threshold: 50 },
]);
const loaded = loadAlerts();
// loadAlerts only returns triggered=1
expect(loaded).toHaveLength(1);
expect(loaded[0].item).toBe('minecraft:diamond');
expect(loaded[0].triggered).toBe(true);
expect(loaded[0].current).toBe(5);
expect(loaded[0].threshold).toBe(10);
});
});
describe('db item history', () => {
it('getHistory returns recorded snapshots ordered newest-first', () => {
// recordItemHistory is throttled (5-min internal cooldown).
// First call after module load will go through.
recordItemHistory([
{ name: 'minecraft:diamond', count: 64 },
{ name: 'minecraft:iron_ingot', count: 128 },
]);
const history = getHistory('minecraft:diamond', 10);
expect(history.length).toBeGreaterThanOrEqual(1);
expect(history[0].count).toBe(64);
});
it('getHistorySummary groups by timestamp', () => {
// May be throttled if previous test already recorded in this module load.
// Insert directly via the underlying db for determinism.
const now = Date.now();
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('a', 10, now);
db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('b', 20, now);
const summary = getHistorySummary();
expect(summary.length).toBeGreaterThanOrEqual(1);
expect(summary[0].total).toBe(30); // 10 + 20
});
});
describe('db full state round-trip', () => {
it('saveFullState + flushPendingSave + loadFullState restores everything', () => {
const state = {
inventoryState: {
itemList: [
{ name: 'minecraft:diamond', count: 64, displayName: 'Diamond' },
],
grandTotal: 64,
chestCount: 2,
totalSlots: 54,
usedSlots: 1,
freeSlots: 53,
usedRatio: 0.02,
dropperOk: true,
barrelOk: false,
furnaceCount: 1,
furnaceStatus: {
'furnace_0': { active: false, type: 'minecraft:furnace', input: null, fuel: null, output: null },
},
droppers: ['dropper_0'],
},
activityState: { lastScan: 12345 },
alertsState: [{ item: 'minecraft:coal', triggered: true, current: 3, threshold: 10 }],
smeltingPaused: false,
disabledRecipes: { 'minecraft:charcoal': true },
smeltableRecipes: { 'minecraft:raw_iron': 'minecraft:iron_ingot' },
craftableRecipes: [{ output: 'minecraft:chest', count: 1, slots: { 1: 'minecraft:planks' } }],
craftTurtleOk: true,
};
saveFullState(state);
flushPendingSave(); // force the debounced write
const restored = loadFullState();
expect(restored.inventoryState.itemList).toHaveLength(1);
expect(restored.inventoryState.itemList[0].name).toBe('minecraft:diamond');
expect(restored.inventoryState.grandTotal).toBe(64);
expect(restored.smeltingPaused).toBe(false);
expect(restored.disabledRecipes).toEqual({ 'minecraft:charcoal': true });
expect(restored.craftTurtleOk).toBe(true);
expect(restored.alertsState).toHaveLength(1);
expect(restored.alertsState[0].item).toBe('minecraft:coal');
});
});

View File

@@ -0,0 +1,308 @@
/**
* Tests for Inventory Manager server logic.
*
* server.js is a monolith that starts listening on import, so we can't
* import it directly in a test. Instead we extract and test the pure
* helper logic that lives inside it:
*
* • Rate limiter algorithm
* • Input validation rules (order, craft, reboot targets)
* • Lua -> Frontend state normalization (itemList, furnaces, alerts, recipes)
* • Idempotent command tracking
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Extracted: createRateLimiter ───────────────────────────────────────
function createRateLimiter(windowMs, max) {
const hits = new Map();
// Note: in tests we skip the setInterval cleanup
return (req, res, next) => {
const key = req.ip || '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();
};
}
// ── Extracted: normalization helpers ───────────────────────────────────
function normalizeItemList(rawItems) {
return Array.isArray(rawItems)
? rawItems.map(item => ({
name: item.name || '',
count: item.total !== undefined ? item.total : (item.count || 0),
displayName: item.displayName || item.name || '',
}))
: [];
}
function normalizeFurnaces(rawFurnaces) {
let normalizedFurnaces = {};
if (Array.isArray(rawFurnaces)) {
rawFurnaces.forEach(f => {
if (f && f.name) {
normalizedFurnaces[f.name] = {
active: f.active || false,
type: f.type || 'minecraft:furnace',
input: f.input || null,
fuel: f.fuel || null,
output: f.output || null,
};
}
});
} else if (typeof rawFurnaces === 'object') {
normalizedFurnaces = rawFurnaces;
}
return normalizedFurnaces;
}
function normalizeAlerts(rawAlerts) {
return Array.isArray(rawAlerts)
? rawAlerts.map(a => ({
item: a.name || a.item || a.label || '',
triggered: true,
current: a.current || 0,
threshold: a.min || a.threshold || 0,
}))
: [];
}
function normalizeCraftable(rawCraftable) {
return Array.isArray(rawCraftable)
? rawCraftable.map(recipe => {
const slots = {};
if (recipe.grid && Array.isArray(recipe.grid)) {
recipe.grid.forEach((item, idx) => {
if (item) slots[idx + 1] = item;
});
} else if (recipe.slots) {
Object.assign(slots, recipe.slots);
}
return { output: recipe.output || '', count: recipe.count || 1, slots };
})
: [];
}
// ── Extracted: idempotent command tracking ─────────────────────────────
function createCommandTracker(ttl = 5 * 60 * 1000) {
const processedCommands = new Map();
return {
check(commandId) {
if (!commandId) return null;
return processedCommands.get(commandId)?.result || null;
},
record(commandId, result) {
if (!commandId) return;
processedCommands.set(commandId, { result, timestamp: Date.now() });
},
size() { return processedCommands.size; },
};
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('createRateLimiter', () => {
it('allows requests under the limit', () => {
const limiter = createRateLimiter(60_000, 3);
const req = { ip: '127.0.0.1' };
const next = vi.fn();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
limiter(req, res, next);
limiter(req, res, next);
limiter(req, res, next);
expect(next).toHaveBeenCalledTimes(3);
expect(res.status).not.toHaveBeenCalled();
});
it('blocks requests over the limit with 429', () => {
const limiter = createRateLimiter(60_000, 2);
const req = { ip: '10.0.0.1' };
const next = vi.fn();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
limiter(req, res, next); // 1 — allowed
limiter(req, res, next); // 2 — allowed
limiter(req, res, next); // 3 — blocked
expect(next).toHaveBeenCalledTimes(2);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(String));
});
it('tracks different IPs independently', () => {
const limiter = createRateLimiter(60_000, 1);
const next = vi.fn();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() };
limiter({ ip: 'a' }, res, next); // a:1 — allowed
limiter({ ip: 'b' }, res, next); // b:1 — allowed
limiter({ ip: 'a' }, res, next); // a:2 — blocked
expect(next).toHaveBeenCalledTimes(2);
});
});
describe('normalizeItemList', () => {
it('converts Lua-style { name, total } to { name, count }', () => {
const raw = [
{ name: 'minecraft:diamond', total: 64 },
{ name: 'minecraft:coal', total: 200 },
];
const result = normalizeItemList(raw);
expect(result[0]).toEqual({ name: 'minecraft:diamond', count: 64, displayName: 'minecraft:diamond' });
expect(result[1].count).toBe(200);
});
it('falls back to count if total is absent', () => {
const raw = [{ name: 'minecraft:iron_ingot', count: 32, displayName: 'Iron Ingot' }];
const result = normalizeItemList(raw);
expect(result[0].count).toBe(32);
expect(result[0].displayName).toBe('Iron Ingot');
});
it('returns empty array for non-array input', () => {
expect(normalizeItemList(null)).toEqual([]);
expect(normalizeItemList('bad')).toEqual([]);
});
});
describe('normalizeFurnaces', () => {
it('converts Lua array-of-objects to keyed map', () => {
const raw = [
{ name: 'furnace_0', active: true, type: 'minecraft:blast_furnace', input: { name: 'iron' }, fuel: null, output: null },
];
const result = normalizeFurnaces(raw);
expect(result['furnace_0'].active).toBe(true);
expect(result['furnace_0'].type).toBe('minecraft:blast_furnace');
});
it('passes through pre-keyed objects as-is', () => {
const raw = { furnace_0: { active: false } };
const result = normalizeFurnaces(raw);
expect(result).toBe(raw); // same reference
});
it('skips entries without a name', () => {
const raw = [{ active: true }, { name: 'f1', active: false }];
const result = normalizeFurnaces(raw);
expect(Object.keys(result)).toEqual(['f1']);
});
});
describe('normalizeAlerts', () => {
it('normalizes Lua-style {label, current, min} fields', () => {
const raw = [{ label: 'Diamond', current: 2, min: 10 }];
const result = normalizeAlerts(raw);
expect(result[0]).toEqual({ item: 'Diamond', triggered: true, current: 2, threshold: 10 });
});
it('accepts name or item field', () => {
expect(normalizeAlerts([{ name: 'A' }])[0].item).toBe('A');
expect(normalizeAlerts([{ item: 'B' }])[0].item).toBe('B');
});
});
describe('normalizeCraftable', () => {
it('converts Lua grid (flat array of 9) to slots object', () => {
const raw = [{
output: 'minecraft:chest',
count: 1,
grid: [null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null],
}];
const result = normalizeCraftable(raw);
expect(result[0].output).toBe('minecraft:chest');
// grid indices are 0-based, slots keys are 1-based
expect(result[0].slots).toEqual({ 2: 'minecraft:planks', 4: 'minecraft:planks', 6: 'minecraft:planks', 8: 'minecraft:planks' });
});
it('passes through pre-slotted recipes', () => {
const raw = [{ output: 'x', count: 2, slots: { 1: 'a', 5: 'b' } }];
const result = normalizeCraftable(raw);
expect(result[0].slots).toEqual({ 1: 'a', 5: 'b' });
});
});
describe('idempotent command tracking', () => {
let tracker;
beforeEach(() => {
tracker = createCommandTracker();
});
it('returns null for unseen commandId', () => {
expect(tracker.check('cmd-1')).toBeNull();
});
it('returns cached result for repeated commandId', () => {
const result = { success: true, message: 'Order sent' };
tracker.record('cmd-1', result);
expect(tracker.check('cmd-1')).toEqual(result);
});
it('ignores null/undefined commandId', () => {
tracker.record(null, { x: 1 });
tracker.record(undefined, { x: 2 });
expect(tracker.size()).toBe(0);
});
it('does not cross-contaminate different command IDs', () => {
tracker.record('a', { val: 1 });
tracker.record('b', { val: 2 });
expect(tracker.check('a')).toEqual({ val: 1 });
expect(tracker.check('b')).toEqual({ val: 2 });
});
});
describe('input validation rules', () => {
const VALID_REBOOT_TARGETS = ['all', 'manager', 'client', 'turtle', 'bridge'];
it('accepts valid reboot targets', () => {
for (const t of VALID_REBOOT_TARGETS) {
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(true);
}
});
it('accepts numeric computer IDs', () => {
expect(/^\d+$/.test('42')).toBe(true);
expect(/^\d+$/.test('0')).toBe(true);
});
it('rejects invalid reboot targets', () => {
for (const t of ['admin', 'drop ; rm -rf /', '', 'ALL']) {
expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(false);
}
});
it('validates order amount range (1100000)', () => {
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 100000;
expect(valid(1)).toBe(true);
expect(valid(100000)).toBe(true);
expect(valid(0)).toBe(false);
expect(valid(-1)).toBe(false);
expect(valid(100001)).toBe(false);
expect(valid(NaN)).toBe(false);
expect(valid(Infinity)).toBe(false);
});
it('validates craft recipeIdx range (11000)', () => {
const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 1000;
expect(valid(1)).toBe(true);
expect(valid(500)).toBe(true);
expect(valid(0)).toBe(false);
expect(valid(1001)).toBe(false);
});
});

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(`
@@ -475,7 +493,9 @@ export function flushPendingSave() {
*/
export function closeDb() {
flushPendingSave();
db.close();
if (db.open) {
db.close();
}
}
export default db;

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

@@ -0,0 +1,13 @@
#!/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"
# Drop privileges and exec the CMD
echo "[entrypoint] Dropping to user 'node', running: $*"
exec su-exec node "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
@@ -15,6 +17,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
"nodemon": "^3.0.2",
"vitest": "^3.2.1"
}
}

View File

@@ -1,6 +1,5 @@
import express from 'express';
import { WebSocketServer } from 'ws';
import cors from 'cors';
import { createServer } from 'http';
import fs from 'fs';
import path from 'path';
@@ -18,7 +17,9 @@ const app = express();
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
app.use(cors());
// 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 ==========
@@ -48,6 +49,40 @@ app.use((req, res, next) => {
return requireAuth(req, res, next);
});
// ========== Rate Limiting (in-memory, no external dependencies) ==========
function createRateLimiter(windowMs, max) {
const hits = new Map();
setInterval(() => {
const cutoff = Date.now() - windowMs;
for (const [key, entry] of hits) {
if (entry.start < cutoff) hits.delete(key);
}
}, windowMs);
return (req, res, next) => {
const key = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const entry = hits.get(key);
if (!entry || now - entry.start > windowMs) {
hits.set(key, { start: now, count: 1 });
return next();
}
entry.count++;
if (entry.count > max) {
res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000)));
return res.status(429).json({ error: 'Too many requests — try again later' });
}
return next();
};
}
// 30 mutating requests per minute per IP (excludes bridge state updates)
const commandLimiter = createRateLimiter(60_000, 30);
app.use((req, res, next) => {
if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next();
return commandLimiter(req, res, next);
});
// ========== State ==========
const webClients = new Set();
const bridgeClients = new Set();
@@ -107,7 +142,11 @@ function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
try {
client.send(message);
} catch (err) {
console.error('❌ WS send error (client):', err.message);
}
}
});
}
@@ -116,8 +155,12 @@ function pushCommandToBridge(command) {
let sent = false;
for (const bridge of bridgeClients) {
if (bridge.readyState === 1) {
bridge.send(JSON.stringify(command));
sent = true;
try {
bridge.send(JSON.stringify(command));
sent = true;
} catch (err) {
console.error('❌ WS send error (bridge):', err.message);
}
}
}
if (!sent) {
@@ -322,13 +365,20 @@ app.post('/api/order', (req, res) => {
if (!itemName || !amount) {
return res.status(400).json({ error: 'Missing itemName or amount' });
}
if (typeof itemName !== 'string' || itemName.length > 200) {
return res.status(400).json({ error: 'Invalid itemName' });
}
const parsedAmount = parseInt(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount < 1 || parsedAmount > 100000) {
return res.status(400).json({ error: 'Invalid amount (1100000)' });
}
const command = {
type: 'command',
action: 'order',
commandId,
itemName,
amount: parseInt(amount),
amount: parsedAmount,
dropperName: dropperName || null,
};
@@ -352,8 +402,17 @@ app.get('/api/dropper-nicknames', (req, res) => {
// Set a single dropper nickname
app.post('/api/dropper-nicknames', (req, res) => {
try {
const { dropperName, nickname } = req.body;
if (!dropperName) return res.status(400).json({ error: 'Missing dropperName' });
const { dropperName, nickname, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!dropperName || typeof dropperName !== 'string' || dropperName.length > 200) {
return res.status(400).json({ error: 'Missing or invalid dropperName' });
}
if (nickname && String(nickname).length > 50) {
return res.status(400).json({ error: 'Nickname too long (max 50)' });
}
if (nickname && nickname.trim()) {
dropperNicknames[dropperName] = nickname.trim();
@@ -363,7 +422,9 @@ app.post('/api/dropper-nicknames', (req, res) => {
saveState('dropperNicknames', dropperNicknames);
console.log(`🏷️ Dropper nickname: ${dropperName}${nickname || '(removed)'}`);
res.json({ success: true, nicknames: dropperNicknames });
const result = { success: true, nicknames: dropperNicknames };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -413,7 +474,9 @@ app.post('/api/recipes/toggle', (req, res) => {
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!recipe) return res.status(400).json({ error: 'Missing recipe name' });
if (!recipe || typeof recipe !== 'string' || recipe.length > 200) {
return res.status(400).json({ error: 'Missing or invalid recipe name' });
}
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe });
const result = { success: true, commandId };
@@ -427,25 +490,58 @@ app.post('/api/recipes/toggle', (req, res) => {
// Enable/disable all recipes
app.post('/api/recipes/enable-all', (req, res) => {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
try {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/recipes/disable-all', (req, res) => {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
try {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Remote reboot CC:Tweaked computers
app.post('/api/reboot', (req, res) => {
try {
const { target, commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
const validTargets = ['all', 'manager', 'client', 'turtle', 'bridge'];
const t = target || 'all';
// Allow valid role names or numeric computer IDs
if (!validTargets.includes(t) && !/^\d+$/.test(t)) {
return res.status(400).json({ error: 'Invalid target. Use: all, manager, client, turtle, bridge, or a computer ID' });
}
pushCommandToBridge({ type: 'command', action: 'reboot', commandId, target: t });
const result = { success: true, commandId, message: `Reboot sent to: ${t}` };
recordCommand(commandId, result);
console.log(`🔄 Reboot requested: target=${t}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Sort barrel
@@ -477,8 +573,12 @@ app.post('/api/craft', (req, res) => {
if (cached) return res.json(cached);
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
const parsedIdx = parseInt(recipeIdx);
if (!Number.isFinite(parsedIdx) || parsedIdx < 1 || parsedIdx > 1000) {
return res.status(400).json({ error: 'Invalid recipeIdx' });
}
pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parseInt(recipeIdx) });
pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parsedIdx });
const result = { success: true, commandId };
recordCommand(commandId, result);
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
@@ -502,8 +602,8 @@ app.post('/api/bridge/state', (req, res) => {
}
});
// Bridge polls for pending commands
app.get('/api/bridge/commands', (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)
@@ -662,6 +762,7 @@ function updateStateFromBridge(data) {
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
dropperNicknames,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
});
@@ -686,7 +787,7 @@ function updateStateFromBridge(data) {
// ========== WebSocket Server ==========
const wss = new WebSocketServer({ noServer: true });
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ });
console.log(`🚀 Inventory Manager Web Server starting...`);
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
@@ -721,6 +822,7 @@ wss.on('connection', (ws, req) => {
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({
@@ -766,6 +868,8 @@ wss.on('connection', (ws, req) => {
bridgeConnected: bridgeClients.size > 0,
});
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('error', (error) => {
console.error('❌ Bridge WS error:', error);
@@ -836,7 +940,7 @@ wss.on('connection', (ws, req) => {
});
// ========== WebSocket Keep-Alive ==========
// Ping all web clients every 25s to keep connections alive through reverse proxies
// Ping all web clients and bridge connections every 25s to keep connections alive
const WS_PING_INTERVAL = setInterval(() => {
webClients.forEach((ws) => {
if (!ws.isAlive) {
@@ -846,12 +950,94 @@ const WS_PING_INTERVAL = setInterval(() => {
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
const TURTLE_SERVER_URL = process.env.TURTLE_SERVER_URL || ''; // e.g. http://turtle-server:3001
// Find where a specific item is stored (for turtles to pick up items)
app.get('/api/integration/locate-item', (req, res) => {
const { name, minCount } = req.query;
if (!name) return res.status(400).json({ error: 'Item name required (?name=minecraft:diamond)' });
const items = inventoryState.itemList || [];
const match = items.find(i => i.name === name);
if (!match || match.count < (parseInt(minCount) || 1)) {
return res.json({ found: false, available: match ? match.count : 0 });
}
res.json({ found: true, name: match.name, count: match.count, displayName: match.displayName });
});
// Search items by partial name (for turtle autocomplete/fuzzy matching)
app.get('/api/integration/search-items', (req, res) => {
const { q, limit } = req.query;
if (!q) return res.status(400).json({ error: 'Search query required (?q=diamond)' });
const items = inventoryState.itemList || [];
const query = q.toLowerCase();
const results = items
.filter(i => i.name.toLowerCase().includes(query) || (i.displayName || '').toLowerCase().includes(query))
.sort((a, b) => b.count - a.count)
.slice(0, parseInt(limit) || 20);
res.json({ results });
});
// Get storage summary (available space, total items) for turtle decision-making
app.get('/api/integration/storage-status', (req, res) => {
res.json({
grandTotal: inventoryState.grandTotal || 0,
chestCount: inventoryState.chestCount || 0,
totalSlots: inventoryState.totalSlots || 0,
usedSlots: inventoryState.usedSlots || 0,
freeSlots: (inventoryState.totalSlots || 0) - (inventoryState.usedSlots || 0),
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
});
});
// Get full item list (for turtle dump target selection)
app.get('/api/integration/items', (req, res) => {
const items = inventoryState.itemList || [];
res.json({ items: items.map(i => ({ name: i.name, count: i.count })) });
});
// Get alerts (so turtles know what items are running low)
app.get('/api/integration/low-stock', (req, res) => {
const triggered = (alertsState || []).filter(a => a.triggered !== false);
res.json({ alerts: triggered });
});
// Proxy to turtle server for combined dashboard info
app.get('/api/integration/turtle-status', async (req, res) => {
if (!TURTLE_SERVER_URL) {
return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' });
}
try {
const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`);
const data = await resp.json();
res.json({ configured: true, ...data });
} catch (err) {
res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` });
}
});
// ========== Start Server ==========
server.listen(PORT, HOST, () => {
@@ -859,19 +1045,33 @@ server.listen(PORT, HOST, () => {
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 closing database:', err.message);
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();
});

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import os from 'os';
import path from 'path';
export default defineConfig({
test: {
env: {
DB_PATH: path.join(os.tmpdir(), `inv-test-${process.pid}.db`),
},
},
});