Compare commits

...

268 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
MayaTheShy
465efbeb0e Implement API key authentication for secure access to mutating endpoints and WebSocket connections 2026-03-22 02:13:24 -04:00
MayaTheShy
24683f23a5 Implement idempotency for command handling to skip duplicates and enhance request processing 2026-03-22 02:12:50 -04:00
MayaTheShy
bbae2740a7 Implement idempotent command tracking with cleanup and caching for improved request handling 2026-03-22 02:12:18 -04:00
MayaTheShy
224738f2e7 Add commandId generation for idempotent requests in API calls 2026-03-22 02:12:13 -04:00
MayaTheShy
e9c0cc03f0 Implement idempotent command tracking with TTL for improved command handling 2026-03-22 02:12:08 -04:00
MayaTheShy
ebc3efba6a Add commandId to transmitted messages for improved command tracking 2026-03-22 02:12:04 -04:00
MayaTheShy
0f9b7fcf68 Refactor logging in inventoryClient to use structured logging for improved clarity and consistency 2026-03-22 02:06:02 -04:00
MayaTheShy
304e779fd0 Add log level configuration to enhance logging flexibility 2026-03-22 02:03:37 -04:00
MayaTheShy
50ec1ee6c2 Refactor main function to replace print statements with structured logging for improved debugging 2026-03-22 02:03:31 -04:00
MayaTheShy
33cad06155 Refactor main function to replace print statements with structured logging for improved clarity and debugging 2026-03-22 02:02:58 -04:00
MayaTheShy
e61a13961a Refactor touch handling to use structured logging for improved debugging 2026-03-22 02:02:19 -04:00
MayaTheShy
e017eb1009 Refactor logging to use structured logging for improved clarity and consistency 2026-03-22 02:02:15 -04:00
MayaTheShy
71db1d9973 Add structured logging functionality to enhance debugging capabilities 2026-03-22 01:59:15 -04:00
MayaTheShy
0e4d184059 Add structured logging functionality to inventory manager 2026-03-22 01:59:08 -04:00
MayaTheShy
11c38dbabb Refactor monitor setup and drawing functions to utilize shared UI module 2026-03-22 01:58:13 -04:00
MayaTheShy
39b95b3663 Refactor crafting helpers to delegate functionality to shared UI module 2026-03-22 01:56:40 -04:00
MayaTheShy
8cdb2d3b98 Refactor dashboard drawing functions to use setDrawTarget for improved clarity 2026-03-22 01:56:34 -04:00
MayaTheShy
338af14b6b Refactor drawing helpers to utilize shared UI module functions 2026-03-22 01:56:15 -04:00
MayaTheShy
50fa771ca4 Refactor touch zone functions to utilize shared UI helpers 2026-03-22 01:56:07 -04:00
MayaTheShy
4dc06184de Refactor monitor setup functions to use shared UI helpers 2026-03-22 01:55:47 -04:00
MayaTheShy
1dca61eece Add shared UI helpers for inventory management and client interaction 2026-03-22 01:55:41 -04:00
MayaTheShy
c78e25deb2 Add smeltable items configuration for auto-smelting in inventory management 2026-03-22 01:55:05 -04:00
MayaTheShy
688db466ac Add fuel items configuration with burn times for inventory management 2026-03-22 01:55:00 -04:00
MayaTheShy
293dc7925b Add crafting recipes for various items in the inventory manager 2026-03-22 01:54:55 -04:00
MayaTheShy
9da5db084e Add compostable items list and trash items for inventory management 2026-03-22 01:54:51 -04:00
MayaTheShy
4b38a793c6 Add low-stock alerts configuration for inventory monitoring 2026-03-22 01:54:47 -04:00
MayaTheShy
7404e73fd3 Refactor smelting and composting logic: load data tables from external files for better maintainability 2026-03-22 01:54:44 -04:00
MayaTheShy
d80b0b155e Enhance network state management: skip broadcasting if state has not changed and update state version on configuration changes 2026-03-22 01:41:44 -04:00
MayaTheShy
1198c2e73a Enhance cache management: integrate state version bumping in refreshCache function 2026-03-22 01:40:31 -04:00
MayaTheShy
7ad68885d3 Enhance inventory management: add scanInventory function and improve furnace status refresh 2026-03-22 01:40:13 -04:00
MayaTheShy
491abf891f Enhance inventory scanning: integrate state version bumping in scanInventory function 2026-03-22 01:40:09 -04:00
MayaTheShy
2bcb907914 Enhance state management: implement version tracking and conditional broadcasting for configuration updates 2026-03-22 01:40:04 -04:00
MayaTheShy
b2c635b7ef Enhance configuration loading: implement file existence check and JSON parsing for dynamic settings 2026-03-22 01:38:51 -04:00
MayaTheShy
caf0ec85a1 Enhance configuration loading: implement file existence check and JSON parsing for dynamic channel settings 2026-03-22 01:38:47 -04:00
MayaTheShy
06dbaf5756 Enhance configuration loading: implement file check and JSON parsing for dynamic channel settings 2026-03-22 01:38:43 -04:00
MayaTheShy
bd3159cd39 Enhance peripheral handling: implement detach handler to invalidate cached handles 2026-03-22 01:37:22 -04:00
MayaTheShy
4637275529 Enhance peripheral handling: implement caching for peripheral wraps to improve performance 2026-03-22 01:36:45 -04:00
MayaTheShy
a5e6be7f4b Enhance command handling: implement monotonic ID for deduplication and improve acknowledgment process 2026-03-22 01:29:16 -04:00
MayaTheShy
c47ec63e50 Enhance command polling: implement deduplication of processed commands and acknowledgment of command IDs 2026-03-22 01:29:05 -04:00
MayaTheShy
98f8157bea Enhance crafting functionality: implement modem communication for turtle crafting requests and responses 2026-03-22 01:28:50 -04:00
MayaTheShy
cc6b1e999a Add modem-based crafting functionality: implement command handling and inventory management 2026-03-22 01:28:46 -04:00
MayaTheShy
582c92f090 Update README.md: enhance documentation with architecture and component details 2026-03-22 00:53:21 -04:00
MayaTheShy
bee1019fd6 Add dropper nickname management: implement get and set endpoints 2026-03-21 20:55:01 -04:00
MayaTheShy
12c17d3bfd Add functionality to manage dropper nicknames: implement set and fetch methods 2026-03-21 20:54:57 -04:00
MayaTheShy
846f3e8b80 Add SettingsPanel component for managing dropper nicknames 2026-03-21 20:54:55 -04:00
MayaTheShy
6e2c17de29 Add settings panel CSS styles for overlay modal and dropper nickname list 2026-03-21 20:54:52 -04:00
MayaTheShy
580b53ed6b Update dropper selection display: include nicknames for better identification 2026-03-21 20:54:49 -04:00
MayaTheShy
862002c0cf Add settings panel functionality: include toggle button and overlay for settings 2026-03-21 20:54:45 -04:00
MayaTheShy
b454fd5516 Update dropper selection display: include client ID in dropdown options for better identification 2026-03-21 20:37:20 -04:00
MayaTheShy
a2f7df448c Add client dropper registration and merging functionality 2026-03-21 20:36:58 -04:00
MayaTheShy
4fcec03a59 Add client dropper discovery and announcement functionality 2026-03-21 20:36:49 -04:00
MayaTheShy
a3d0a46b44 Enhance dropper detection logic: implement name-based matching for improved compatibility with CC:Tweaked versions 2026-03-21 20:31:24 -04:00
MayaTheShy
98f415d7d4 Refactor InventoryGrid styles: update category tab and item grid appearance for improved layout and visual consistency 2026-03-21 20:17:13 -04:00
MayaTheShy
993b5ac55a Update dropper selection condition: allow selection when one or more droppers are available 2026-03-21 20:12:58 -04:00
MayaTheShy
5416f18f4b Add droppers to full state load: include droppers array from inventory metadata for comprehensive state management 2026-03-21 20:12:54 -04:00
MayaTheShy
c3ee7d0d42 Refactor selected item handling: update state management to derive selected item from name and ensure real-time inventory count 2026-03-21 20:10:57 -04:00
MayaTheShy
9f75866ce7 Notify web clients on bridge connection status: broadcast updates when the bridge connects or disconnects 2026-03-21 20:05:45 -04:00
MayaTheShy
79eca14f4c Add dropper location selector: discover available droppers on network, pass through server, and add dropdown in order panel for location-based dispensing 2026-03-21 19:57:13 -04:00
MayaTheShy
fcfef379be Add droppers array to state update: ensure droppers data is handled correctly in the state from bridge 2026-03-21 19:41:56 -04:00
MayaTheShy
75f785d8da Add support for multiple droppers: discover and cache available dropper peripherals for location-based dispensing 2026-03-21 19:41:52 -04:00
MayaTheShy
05291c9373 Implement multi-line scrollable chart with tooltip support: enhance data visualization and user interaction 2026-03-21 19:40:33 -04:00
MayaTheShy
73fbe98d86 Add multi-line scrollable chart and tooltip styles: enhance user interaction and visual feedback 2026-03-21 19:40:04 -04:00
MayaTheShy
898353e8c6 Refactor texture URL handling: replace CDN links with proxy-based URLs for mod and vanilla textures 2026-03-21 19:30:13 -04:00
MayaTheShy
ba8acc1c27 Enhance Analytics Panel styles: improve layout, spacing, and hover effects for better user experience 2026-03-21 19:30:10 -04:00
MayaTheShy
3884b850c8 Add caching for texture requests: implement proxy and cache settings for texture assets 2026-03-21 19:30:03 -04:00
MayaTheShy
05f5a3519e Add texture proxy and caching mechanism: implement upstream fetching and local caching for texture assets 2026-03-21 19:29:59 -04:00
MayaTheShy
b5ae28944d Enhance health check and inventory endpoints: include bridge connection status and web client count 2026-03-21 19:26:36 -04:00
MayaTheShy
d1c9256ed8 Implement WebSocket health checks and HTTP polling for improved connection reliability 2026-03-21 19:26:33 -04:00
MayaTheShy
854fcaf66f Enhance connection status display: add bridge connection status and last update time 2026-03-21 19:26:28 -04:00
MayaTheShy
62984b5d18 Add styling for last update text: adjust font size, color, and text shadow for better visibility 2026-03-21 19:26:26 -04:00
MayaTheShy
39a04cc5e9 Refactor category tabs and item grid styles: adjust padding, margins, and colors for improved UI consistency 2026-03-21 19:14:14 -04:00
MayaTheShy
29498a2f6a Major UI overhaul: bigger icons, creative inventory tabs, grouped smelting, alerts popup, analytics, mod icons
- Inventory: Bigger item icons (48px) filling their slots, MC-style hover tooltips
- Creative inventory category tabs (All/Blocks/Tools/Combat/Food/Redstone/Materials/Misc)
- Smelting recipes grouped by output item with collapsible sections
- Alerts moved from full tab to popup overlay triggered from header bell icon
- New Analytics tab with SVG charts (storage over time, top items, item trend lookup)
- Mod icon support for ComputerCraft (CC:Tweaked) via GitHub CDN fallback
- Server: new /api/history-summary endpoint for aggregate storage history
- Store: fetchHistorySummary and fetchItemHistory for analytics data
2026-03-21 19:07:17 -04:00
MayaTheShy
c25ef9f2cc Fix inventory disappearing: add WS keep-alive pings + HTTP API fallback
- Server pings web clients every 25s to keep connections alive through reverse proxies
- Client fetches /api/inventory on page load (doesn't depend solely on WebSocket)
- Prevent duplicate WebSocket connections on reconnect
- Deduplicate initial_state/state_update handlers
2026-03-21 18:35:17 -04:00
MayaTheShy
4af91235e1 Refactor furnace input, fuel, and output handling: stringify on save and parse on load 2026-03-21 18:21:23 -04:00
MayaTheShy
80338d1973 Fix live updates: debounce DB writes, broadcast before saving, include smeltable/craftable in updates 2026-03-21 18:15:50 -04:00
MayaTheShy
aed7d1f735 Enhance database performance: add cache size and temp store settings; refactor alert clearing statement 2026-03-21 18:14:16 -04:00
MayaTheShy
9f322003db Add SQLite persistence + official Minecraft item icons
Database (better-sqlite3):
- Persist items, furnaces, alerts, recipes, settings to SQLite
- Auto-restore last known state when server restarts or bridge disconnects
- Item count history tracking (5-min snapshots, 7-day retention)
- /api/history/:itemName endpoint for item count history
- Docker volume for database file persistence
- Graceful shutdown with DB connection cleanup

Icons:
- Replace mc-heads.net with official Minecraft game textures via CDN
- Cascading fallback: item texture -> block texture -> emoji
- In-memory URL cache to avoid redundant network requests
- Block texture suffix mapping (furnace_front, barrel_top, etc.)
- Crisp pixel-art rendering with image-rendering: pixelated
2026-03-21 18:10:44 -04:00
MayaTheShy
bbc44c3d97 Fix healthcheck: use node instead of wget, relax client dependency to service_started 2026-03-21 18:03:22 -04:00
MayaTheShy
d4d6e5e480 Fix healthcheck: use wget -qO instead of --spider (BusyBox compat) 2026-03-21 17:57:25 -04:00
MayaTheShy
fe6ac23329 Harden components: add ErrorBoundary, null-safe rendering 2026-03-21 17:51:07 -04:00
MayaTheShy
0ce63bacd7 Normalize inventory and alerts data structure in updateStateFromBridge function 2026-03-21 17:42:49 -04:00
MayaTheShy
a16039e920 Update HEALTHCHECK command in Dockerfile to use wget for improved reliability 2026-03-21 17:35:53 -04:00
MayaTheShy
db0151a616 Update healthcheck command in Docker Compose to use wget for improved reliability 2026-03-21 17:35:50 -04:00
MayaTheShy
bd5b1db5a0 Refactor server configuration to use environment variables for PORT and HOST; normalize itemList structure in inventory state update 2026-03-21 17:30:59 -04:00
MayaTheShy
23d237fa20 Add null check to formatCount function for default zero return 2026-03-21 17:30:55 -04:00
MayaTheShy
2853ca98a0 Update server configuration and Docker setup for improved deployment 2026-03-21 17:15:04 -04:00
MayaTheShy
cc64b568a0 Remove LICENSE file for @babel/code-frame package 2026-03-21 16:55:47 -04:00
MayaTheShy
ba72ce40cd Implement feature X to enhance user experience and optimize performance 2026-03-21 16:53:18 -04:00
MayaTheShy
7935bd6938 Add .dockerignore file to exclude node_modules, dist, and .env 2026-03-21 16:53:15 -04:00
MayaTheShy
c1399fb44e Add StorageOverview component for inventory management display 2026-03-21 16:53:11 -04:00
MayaTheShy
1f3b961fa0 Add .gitignore file to exclude node_modules, dist, bin, and .env 2026-03-21 16:53:08 -04:00
MayaTheShy
86904bc01f Add CSS file for inventory management styles 2026-03-21 16:51:19 -04:00
MayaTheShy
4a664914e9 Add .dockerignore file to exclude node_modules, dist, and .env 2026-03-21 16:51:13 -04:00
MayaTheShy
6021b9603c Add initial HTML template for Inventory Manager 2026-03-21 16:50:44 -04:00
MayaTheShy
956bc82f7f Add symlink for jsesc CLI to improve accessibility 2026-03-21 16:50:40 -04:00
MayaTheShy
c4949eceb5 Add symlink for esbuild CLI to improve accessibility 2026-03-21 16:50:32 -04:00
MayaTheShy
0d68a83520 Add symlink for browserslist CLI to improve accessibility 2026-03-21 16:50:25 -04:00
MayaTheShy
a671e935be Add symlink for baseline-browser-mapping CLI to improve accessibility 2026-03-21 16:50:20 -04:00
MayaTheShy
ac421fcc2e Implement feature X to enhance user experience and fix bug Y in module Z 2026-03-21 16:50:12 -04:00
MayaTheShy
985f7bcf44 Implement feature X to enhance user experience and improve performance 2026-03-21 16:50:09 -04:00
MayaTheShy
43d11503c9 Add LICENSE file for Babel code-frame component 2026-03-21 16:50:06 -04:00
MayaTheShy
2ea340d4e4 Add StorageOverview.css for styling the storage overview component 2026-03-21 16:50:02 -04:00
MayaTheShy
e6f2ff5587 Add SmeltingPanel component for managing smelting operations and recipes 2026-03-21 16:49:50 -04:00
MayaTheShy
05bf5ce122 Add SmeltingPanel.css for styling the smelting panel and its components 2026-03-21 16:49:47 -04:00
MayaTheShy
a4df95e049 Add ItemIcon component for rendering Minecraft item icons 2026-03-21 16:49:44 -04:00
MayaTheShy
7358371727 Add ItemIcon.css for item icon styling 2026-03-21 16:49:41 -04:00
MayaTheShy
3f5ba532c6 Add InventoryGrid component for displaying and managing inventory items 2026-03-21 16:49:38 -04:00
MayaTheShy
f67cc3b36b Add InventoryGrid.css for inventory grid layout and styling 2026-03-21 16:49:34 -04:00
MayaTheShy
7c5fe019f1 Add CraftingPanel component for crafting functionality 2026-03-21 16:49:29 -04:00
MayaTheShy
28677b9b49 Add CraftingPanel.css for crafting interface styling 2026-03-21 16:49:25 -04:00
MayaTheShy
0619b3e529 Add AlertsPanel component for low-stock alerts display 2026-03-21 16:49:22 -04:00
MayaTheShy
e26406c4fc Add AlertsPanel.css for alert management styling 2026-03-21 16:49:17 -04:00
MayaTheShy
44f9e9f7c3 Add App.css for Minecraft-themed inventory manager styling 2026-03-21 16:49:13 -04:00
MayaTheShy
0434c0b5df Add Inventory Manager Web Bridge for modem and HTTP communication 2026-03-21 16:49:08 -04:00
MayaTheShy
31ac11038e Add main application component with inventory management panels and connection status 2026-03-21 16:43:44 -04:00
MayaTheShy
cf83e9ef35 Add item utility functions for Minecraft icons and formatting 2026-03-21 16:43:37 -04:00
MayaTheShy
7e95cf64c6 Add inventory store with WebSocket and REST API integration 2026-03-21 16:43:31 -04:00
MayaTheShy
a06b5f5ce2 Add main entry point for React client application 2026-03-21 16:43:26 -04:00
MayaTheShy
9ade5b138f Add Vite configuration for React client application 2026-03-21 16:43:21 -04:00
MayaTheShy
cf7f443e42 Add package.json for client application with dependencies and scripts 2026-03-21 16:42:54 -04:00
MayaTheShy
725f60aba6 Add initial HTML structure for the client application 2026-03-21 16:42:51 -04:00
MayaTheShy
1f57333cb0 Implement initial server setup with Express, WebSocket support, and API endpoints for inventory management 2026-03-21 16:42:30 -04:00
MayaTheShy
12b1165938 Add package.json for server configuration and dependencies 2026-03-21 16:42:21 -04:00
MayaTheShy
953e8aba99 Add Dockerfile for Node.js backend setup 2026-03-21 16:42:14 -04:00
MayaTheShy
8f0072dedf Add Dockerfile for production build with HTTP server 2026-03-21 16:42:10 -04:00
MayaTheShy
e01a605bb0 Add initial docker-compose configuration for server and client services 2026-03-21 16:42:03 -04:00
MayaTheShy
5bad9a1e71 Increase smelt and compost reserves for improved resource management and add trash items for composting 2026-03-16 22:25:38 -04:00
MayaTheShy
1abd6b4165 Add error handling for network order and craft message transmissions 2026-03-16 22:09:04 -04:00
MayaTheShy
32eaa2db3d Enhance autoSmelt function for balanced distribution of smeltable items across empty furnaces 2026-03-16 20:51:13 -04:00
MayaTheShy
9e1852a219 Optimize smelting process by pre-building furnace compatibility sets and using sorted candidate lists for improved efficiency 2026-03-16 19:21:44 -04:00
MayaTheShy
594d8c954c Implement lazy rebuild for itemList and enhance peripheral caching for improved performance 2026-03-16 18:56:34 -04:00
MayaTheShy
10edbe8fff Enhance autoSmelt function by adding cache adjustments for item movements 2026-03-16 18:41:15 -04:00
MayaTheShy
4faf8469e3 Update defrag interval to 10 minutes for improved performance 2026-03-16 18:34:31 -04:00
MayaTheShy
e8b2732692 Update client barrel auto-sort to trigger only when items are present 2026-03-16 18:33:16 -04:00
MayaTheShy
e6debef4fd Update smeltable items to restrict certain ores to regular furnace only 2026-03-16 18:31:48 -04:00
MayaTheShy
7699276c22 Remove bamboo from compostable items list 2026-03-16 00:46:54 -04:00
MayaTheShy
08f6f12764 Improve error handling in craftItem function with pcall for safer execution 2026-03-16 00:40:43 -04:00
MayaTheShy
59ce10b986 Enhance crafting error handling and logging in craftItem function 2026-03-16 00:40:39 -04:00
MayaTheShy
473570f398 Refactor crafting logic to enable auto-crafting by the turtle and improve item retrieval process 2026-03-16 00:37:32 -04:00
MayaTheShy
efa79e56cb Add barrel sorting functionality with optional barrel name override 2026-03-16 00:25:13 -04:00
MayaTheShy
d9bb529c21 Add client barrel auto-sort functionality to inventoryClient 2026-03-16 00:25:09 -04:00
MayaTheShy
fa6c682fd2 Add support for custom dropper/barrel names in inventoryClient 2026-03-16 00:17:47 -04:00
MayaTheShy
11c9599863 Refactor crafting logic to improve turtle inventory management and enhance error handling 2026-03-16 00:17:41 -04:00
MayaTheShy
50d38b86a2 Add crafting functionality to inventoryClient for display of available recipes and crafting status 2026-03-16 00:12:39 -04:00
MayaTheShy
10ec47611b Add crafting turtle script to listen for craft commands via modem 2026-03-16 00:07:20 -04:00
MayaTheShy
718cce0c7b Add crafting functionality to handle crafting requests and integrate with networked turtle 2026-03-16 00:07:15 -04:00
MayaTheShy
ceb8ed8819 Enhance smelter dashboard with crafting and missing recipes views 2026-03-16 00:06:03 -04:00
72 changed files with 24394 additions and 3662 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
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
]],
}

131
README.md
View File

@@ -1,2 +1,133 @@
# Inventory-Manager-CC
A Minecraft inventory management system built on [CC:Tweaked](https://tweaked.cc/). Automates storage sorting, smelting, crafting, and item dispensing across networked computers, turtles, and peripherals — with an optional web dashboard for remote monitoring and control.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Minecraft World │
│ │
│ ┌──────────────────────┐ modem channels 4200-4204 │
│ │ inventoryManager.lua │◄──────────────────────┐ │
│ │ (master computer) │───────────────────────┤ │
│ └──────┬───────────────┘ │ │
│ │ peripherals │ │
│ ┌─────┴──────┬───────────┐ ┌──────────┴────────┐ │
│ │ chests / │ furnaces │ │ inventoryClient │ │
│ │ barrels │ smokers │ │ (display client) │ │
│ │ │ blasters │ └───────────────────┘ │
│ └────────────┴───────────┘ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ craftingTurtle.lua│ │dropperController │ │
│ │ (auto-craft) │ │ (redstone pulses) │ │
│ └──────────────────┘ └────────────────────┘ │
│ ┌──────────────────────┐ HTTP │
│ │ inventoryWebBridge.lua│─────────────────┐ │
│ └──────────────────────┘ │ │
└────────────────────────────────────────────┼─────────────────┘
┌──────────────────┐
│ Web Server :3001 │ ◄── SQLite
└────────┬─────────┘
│ WebSocket + REST
┌────────▼─────────┐
│ React Dashboard │ (port 80)
└──────────────────┘
```
## Components
### In-Game (Lua)
| File | Role | Description |
|------|------|-------------|
| `inventoryManager.lua` | **Master Controller** | The brain — scans all networked chests, auto-sorts/defrags items, auto-smelts ores and food, dispatches crafting jobs, manages composting, fires alerts on low stock, and broadcasts state on modem channel 4200. Requires an attached monitor and wired modem. |
| `inventoryClient.lua` | **Display Client** | A read-only dashboard that mirrors the master's state on a separate monitor. Supports search, pagination, and sending item orders back to the master. |
| `craftingTurtle.lua` | **Crafting Worker** | Runs on a Crafting Turtle. When the master places ingredients in its grid, it auto-crafts and reports results. Must be connected to the master via wired network. |
| `dropperController.lua` | **Dropper Driver** | Pulses redstone to fire items out of a dropper block until empty. Runs on a computer adjacent to the dropper. |
| `inventoryWebBridge.lua` | **Web Bridge** | Bridges the in-game modem network to the external web server over HTTP/WebSocket. Forwards state from the master and relays commands from the web dashboard back into the game. |
| `listDevicesByType.lua` | **Diagnostic Utility** | Lists all peripherals on the wired network grouped by type. Useful for discovering connected chests, furnaces, etc. |
### Web Stack (Docker)
| Component | Description |
|-----------|-------------|
| **Server** (`web/server/`) | Express + WebSocket API backed by SQLite. Receives state from the Lua bridge, persists item history, and pushes real-time updates to browser clients. |
| **Client** (`web/client/`) | React SPA (Vite + Zustand) served via Nginx. Tabbed panels for inventory grid, smelting, crafting, analytics, alerts, and settings. |
| **Docker Compose** | Orchestrates server and client containers on an internal bridge network. Only port 80 is exposed; the Nginx client proxies API/WS requests to the server. |
## Quick Start
### Prerequisites
- Minecraft with [CC:Tweaked](https://tweaked.cc/) installed
- A CC:Tweaked computer with a wired modem and monitor attached
- HTTP access enabled in the CC:Tweaked config (for the web bridge)
### 1. Install the Master Controller
On your main CC:Tweaked computer (with the monitor and wired modem), open the terminal and run:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryManager.lua startup.lua
```
This downloads `inventoryManager.lua` and saves it as `startup.lua` so it runs automatically on boot. Reboot the computer or run `startup.lua` to start.
### 2. Install a Display Client (Optional)
On a separate CC:Tweaked computer with a monitor:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryClient.lua startup.lua
```
### 3. Install the Crafting Turtle (Optional)
On a **Crafting Turtle** connected to the wired network:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/craftingTurtle.lua startup.lua
```
### 4. Install the Dropper Controller (Optional)
On a computer adjacent to a dropper block:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/dropperController.lua startup.lua
```
### 5. Install the Web Bridge (Optional)
On any CC:Tweaked computer with a wired modem and HTTP access:
```
wget https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main/inventoryWebBridge.lua startup.lua
```
Configure the web server URL by editing `.webbridge_config` on the computer, or it will default to `http://localhost`.
### 6. Start the Web Dashboard (Optional)
On the host machine, navigate to the `web/` directory and run:
```bash
docker compose up -d --build
```
The dashboard will be available at `http://localhost` on port 80.
## Modem Channels
| Channel | Purpose |
|---------|---------|
| 4200 | Master → Clients/Bridge (state broadcast) |
| 4201 | Clients/Bridge → Master (orders & commands) |
| 4203 | Master → Crafting Turtle (craft requests) |
| 4204 | Crafting Turtle → Master (craft results) |
## License
See [LICENSE](LICENSE) for details.

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

307
craftingTurtle.lua Normal file
View File

@@ -0,0 +1,307 @@
-- Crafting Turtle Script
-- Run this on the crafting turtle (turtle with crafting table).
-- Listens for craft commands from the master via wired modem.
-- 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)
-------------------------------------------------
-- Modem channels (must match inventoryManager.lua)
local CRAFT_CHANNEL = 4203 -- master -> turtle (craft requests)
local CRAFT_REPLY_CHANNEL = 4204 -- turtle -> master (craft results)
-- Crafting grid slots in a turtle's 4x4 inventory
local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
-------------------------------------------------
-- 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 TURTLE_CONFIG_FILE = _configPath(".turtle_config")
local function loadConfig()
if not fs.exists(TURTLE_CONFIG_FILE) then return end
local f = fs.open(TURTLE_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 " .. TURTLE_CONFIG_FILE)
return
end
if cfg.craftChannel then CRAFT_CHANNEL = cfg.craftChannel end
if cfg.craftReplyChannel then CRAFT_REPLY_CHANNEL = cfg.craftReplyChannel end
print("[CONFIG] Loaded from " .. TURTLE_CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- Setup
-------------------------------------------------
print("=================================")
print(" Crafting Turtle (Modem-Based)")
print("=================================")
print("")
-- Verify this is a crafting turtle
if not turtle or not turtle.craft then
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting 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)
-- Open channel for receiving craft commands
modem.open(CRAFT_CHANNEL)
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
print("")
print("Waiting for craft commands from master...")
print("")
-------------------------------------------------
-- Helpers
-------------------------------------------------
local function clearInventory(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 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
end
return cleared
end
local function describeGrid()
local parts = {}
for _, slot in ipairs(CRAFT_SLOTS) do
local detail = turtle.getItemDetail(slot)
if detail then
table.insert(parts, string.format(" slot %d: %s x%d", slot, detail.name, detail.count))
end
end
return table.concat(parts, "\n")
end
-------------------------------------------------
-- Craft command handler
-------------------------------------------------
local function handleCraftCommand(message)
-- message format from master:
-- {
-- type = "craft_request",
-- recipeIdx = <number>,
-- output = <string>, -- expected output item name
-- slots = { [turtleSlot] = { chestName=..., chestSlot=..., itemName=..., count=... }, ... },
-- returnChests = { "chest_0", "chest_1", ... },
-- }
local slots = message.slots
local returnChests = message.returnChests
local output = message.output or "unknown"
if not slots or not returnChests then
print("[CRAFT] Invalid command: missing slots or returnChests")
return { type = "craft_result", success = false, error = "Invalid command" }
end
print(string.format("[CRAFT] Received craft request: %s", output))
-- 1. Clear turtle inventory (safety — should be empty)
clearInventory(returnChests)
-- 2. Pull ingredients from chests into crafting grid slots
local placedItems = {} -- turtleSlot -> itemName
local allPlaced = true
for turtleSlotStr, info in pairs(slots) do
local turtleSlot = tonumber(turtleSlotStr)
local chestName = info.chestName
local chestSlot = info.chestSlot
local itemName = info.itemName
local count = info.count or 1
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
itemName, chestName, chestSlot, 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))
else
if not ok then
print(string.format("[CRAFT] pullItems error: %s", tostring(n)))
else
print(string.format("[CRAFT] pullItems returned %s (expected %d)", tostring(n), count))
end
allPlaced = false
break
end
end
if not allPlaced then
-- Abort: push everything back
print("[CRAFT] Failed to place all ingredients, aborting")
local returnedItems = {}
for slot, itemName in pairs(placedItems) do
returnedItems[tostring(slot)] = itemName
end
clearInventory(returnChests)
return {
type = "craft_result",
success = false,
error = "Failed to pull ingredients",
returnedItems = returnedItems,
}
end
-- 3. Craft
print("[CRAFT] Grid contents:")
print(describeGrid())
print("[CRAFT] Attempting craft...")
local craftOk, craftErr = turtle.craft()
if not craftOk then
print("[CRAFT] Failed: " .. tostring(craftErr))
-- Collect what's still in the grid for the master to track
local returnedItems = {}
for slot, itemName in pairs(placedItems) do
returnedItems[tostring(slot)] = itemName
end
-- Push ingredients back
clearInventory(returnChests)
return {
type = "craft_result",
success = false,
error = "Craft failed: " .. tostring(craftErr),
returnedItems = returnedItems,
}
end
-- 4. Collect results info
local results = {}
local totalOutput = 0
for slot = 1, 16 do
local detail = turtle.getItemDetail(slot)
if detail then
table.insert(results, { name = detail.name, count = detail.count, slot = slot })
totalOutput = totalOutput + detail.count
end
end
print("[CRAFT] Success! Output:")
for _, r in ipairs(results) do
print(string.format(" %s x%d", r.name, r.count))
end
-- 5. Push results back to chests
local pushed = clearInventory(returnChests)
print(string.format("[CRAFT] Pushed %d items back to storage", pushed))
return {
type = "craft_result",
success = true,
output = output,
totalOutput = totalOutput,
results = results,
}
end
-------------------------------------------------
-- Main loop: listen for modem commands
-------------------------------------------------
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,
})
end
end
end
end)
if not ok then
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
end

20
data/alerts.lua Normal file
View File

@@ -0,0 +1,20 @@
-- Low-stock alerts.
-- When a tracked item drops below 'min', an alert
-- is shown on the inventory monitor.
-- Each entry: { name = "mod:item", min = N, label = "Display Name" }
return {
{ name = "minecraft:coal", min = 64, label = "Coal" },
{ name = "minecraft:charcoal", min = 64, label = "Charcoal" },
{ name = "minecraft:torch", min = 64, label = "Torches" },
{ name = "minecraft:arrow", min = 64, label = "Arrows" },
{ name = "minecraft:cooked_beef", min = 32, label = "Steak" },
{ name = "minecraft:cooked_porkchop",min = 32, label = "Porkchops" },
{ name = "minecraft:bread", min = 32, label = "Bread" },
{ name = "minecraft:iron_ingot", min = 64, label = "Iron" },
{ name = "minecraft:gold_ingot", min = 32, label = "Gold" },
{ name = "minecraft:diamond", min = 16, label = "Diamond" },
{ name = "minecraft:bone_meal", min = 32, label = "Bone Meal" },
{ name = "minecraft:oak_planks", min = 64, label = "Planks" },
{ name = "minecraft:cobblestone", min = 128, label = "Cobblestone" },
}

121
data/compostable.lua Normal file
View File

@@ -0,0 +1,121 @@
-- Compostable items (pushed into dropper, which distributes to composters via hoppers).
-- Bone meal returns through hopper.
-- Each entry is a Minecraft item ID string.
local items = {
-- Seeds & crops
"minecraft:wheat_seeds",
"minecraft:beetroot_seeds",
"minecraft:pumpkin_seeds",
"minecraft:melon_seeds",
"minecraft:torchflower_seeds",
"minecraft:pitcher_pod",
"minecraft:wheat",
"minecraft:beetroot",
"minecraft:carrot",
"minecraft:melon_slice",
"minecraft:pumpkin",
"minecraft:carved_pumpkin",
"minecraft:sweet_berries",
"minecraft:glow_berries",
-- Plant blocks
"minecraft:tall_grass",
"minecraft:short_grass",
"minecraft:fern",
"minecraft:large_fern",
"minecraft:dead_bush",
"minecraft:vine",
"minecraft:hanging_roots",
"minecraft:small_dripleaf",
"minecraft:big_dripleaf",
"minecraft:moss_block",
"minecraft:moss_carpet",
"minecraft:azalea",
"minecraft:flowering_azalea",
"minecraft:spore_blossom",
"minecraft:seagrass",
"minecraft:sea_pickle",
"minecraft:lily_pad",
"minecraft:sugar_cane",
"minecraft:kelp",
"minecraft:dried_kelp",
"minecraft:cactus",
"minecraft:nether_wart",
"minecraft:crimson_fungus",
"minecraft:warped_fungus",
"minecraft:crimson_roots",
"minecraft:warped_roots",
"minecraft:shroomlight",
"minecraft:weeping_vines",
"minecraft:twisting_vines",
-- Leaves
"minecraft:oak_leaves",
"minecraft:spruce_leaves",
"minecraft:birch_leaves",
"minecraft:jungle_leaves",
"minecraft:acacia_leaves",
"minecraft:dark_oak_leaves",
"minecraft:mangrove_leaves",
"minecraft:cherry_leaves",
"minecraft:azalea_leaves",
"minecraft:flowering_azalea_leaves",
-- Flowers
"minecraft:dandelion",
"minecraft:poppy",
"minecraft:blue_orchid",
"minecraft:allium",
"minecraft:azure_bluet",
"minecraft:red_tulip",
"minecraft:orange_tulip",
"minecraft:white_tulip",
"minecraft:pink_tulip",
"minecraft:oxeye_daisy",
"minecraft:cornflower",
"minecraft:lily_of_the_valley",
"minecraft:sunflower",
"minecraft:lilac",
"minecraft:rose_bush",
"minecraft:peony",
"minecraft:wither_rose",
"minecraft:torchflower",
"minecraft:pitcher_plant",
-- Saplings
"minecraft:oak_sapling",
"minecraft:spruce_sapling",
"minecraft:birch_sapling",
"minecraft:jungle_sapling",
"minecraft:acacia_sapling",
"minecraft:dark_oak_sapling",
"minecraft:mangrove_propagule",
"minecraft:cherry_sapling",
-- Food waste
"minecraft:rotten_flesh",
"minecraft:spider_eye",
"minecraft:poisonous_potato",
"minecraft:fermented_spider_eye",
"minecraft:apple",
"minecraft:bread",
"minecraft:cookie",
"minecraft:cake",
"minecraft:pumpkin_pie",
-- Farmer's Delight compostables
"farmersdelight:tree_bark",
"farmersdelight:straw",
"farmersdelight:canvas",
"farmersdelight:rice",
"farmersdelight:rice_panicle",
"farmersdelight:onion",
"farmersdelight:tomato",
"farmersdelight:cabbage",
"farmersdelight:cabbage_leaf",
}
-- Trash items: compostables with zero reserve (always fully composted)
local trash = {
["minecraft:rotten_flesh"] = true,
["minecraft:spider_eye"] = true,
["minecraft:poisonous_potato"] = true,
["minecraft:fermented_spider_eye"] = true,
}
return { items = items, trash = trash }

219
data/craftable.lua Normal file
View File

@@ -0,0 +1,219 @@
-- Crafting recipes (for networked crafting turtle).
-- grid: 9 entries mapping to turtle slots 1-3, 5-7, 9-11
-- Each recipe: { output = "mod:item", count = N, grid = { ... } }
return {
-- Basic materials
{
output = "minecraft:oak_planks",
count = 4,
grid = {
"minecraft:oak_log", nil, nil,
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:spruce_planks",
count = 4,
grid = {
"minecraft:spruce_log", nil, nil,
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:birch_planks",
count = 4,
grid = {
"minecraft:birch_log", nil, nil,
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:stick",
count = 4,
grid = {
"minecraft:oak_planks", nil, nil,
"minecraft:oak_planks", nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:oak_slab",
count = 6,
grid = {
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:torch",
count = 4,
grid = {
"minecraft:coal", nil, nil,
"minecraft:stick", nil, nil,
nil, nil, nil,
},
},
-- Crafting & storage
{
output = "minecraft:crafting_table",
count = 1,
grid = {
"minecraft:oak_planks", "minecraft:oak_planks", nil,
"minecraft:oak_planks", "minecraft:oak_planks", nil,
nil, nil, nil,
},
},
{
output = "minecraft:chest",
count = 1,
grid = {
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
"minecraft:oak_planks", nil, "minecraft:oak_planks",
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
},
},
{
output = "minecraft:barrel",
count = 1,
grid = {
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
"minecraft:oak_planks", nil, "minecraft:oak_planks",
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
},
},
{
output = "minecraft:hopper",
count = 1,
grid = {
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
"minecraft:iron_ingot", "minecraft:chest", "minecraft:iron_ingot",
nil, "minecraft:iron_ingot", nil,
},
},
-- Building
{
output = "minecraft:furnace",
count = 1,
grid = {
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
"minecraft:cobblestone", nil, "minecraft:cobblestone",
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
},
},
{
output = "minecraft:ladder",
count = 3,
grid = {
"minecraft:stick", nil, "minecraft:stick",
"minecraft:stick", "minecraft:stick", "minecraft:stick",
"minecraft:stick", nil, "minecraft:stick",
},
},
{
output = "minecraft:glass_pane",
count = 16,
grid = {
"minecraft:glass", "minecraft:glass", "minecraft:glass",
"minecraft:glass", "minecraft:glass", "minecraft:glass",
nil, nil, nil,
},
},
{
output = "minecraft:iron_bars",
count = 16,
grid = {
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
nil, nil, nil,
},
},
-- Tools & combat
{
output = "minecraft:bucket",
count = 1,
grid = {
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
nil, "minecraft:iron_ingot", nil,
nil, nil, nil,
},
},
{
output = "minecraft:arrow",
count = 4,
grid = {
"minecraft:flint", nil, nil,
"minecraft:stick", nil, nil,
"minecraft:feather", nil, nil,
},
},
-- Redstone
{
output = "minecraft:piston",
count = 1,
grid = {
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
"minecraft:cobblestone", "minecraft:iron_ingot", "minecraft:cobblestone",
"minecraft:cobblestone", "minecraft:redstone", "minecraft:cobblestone",
},
},
{
output = "minecraft:rail",
count = 16,
grid = {
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
"minecraft:iron_ingot", "minecraft:stick", "minecraft:iron_ingot",
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
},
},
{
output = "minecraft:powered_rail",
count = 6,
grid = {
"minecraft:gold_ingot", nil, "minecraft:gold_ingot",
"minecraft:gold_ingot", "minecraft:stick", "minecraft:gold_ingot",
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
},
},
-- Food & misc
{
output = "minecraft:bread",
count = 1,
grid = {
"minecraft:wheat", "minecraft:wheat", "minecraft:wheat",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:paper",
count = 3,
grid = {
"minecraft:sugar_cane", "minecraft:sugar_cane", "minecraft:sugar_cane",
nil, nil, nil,
nil, nil, nil,
},
},
{
output = "minecraft:compass",
count = 1,
grid = {
nil, "minecraft:iron_ingot", nil,
"minecraft:iron_ingot", "minecraft:redstone", "minecraft:iron_ingot",
nil, "minecraft:iron_ingot", nil,
},
},
{
output = "minecraft:clock",
count = 1,
grid = {
nil, "minecraft:gold_ingot", nil,
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
nil, "minecraft:gold_ingot", nil,
},
},
}

21
data/fuel.lua Normal file
View File

@@ -0,0 +1,21 @@
-- Fuel items, ordered by preference (best first).
-- burn_time = how many items one fuel smelts.
-- Add/remove entries to change which fuels are accepted.
return {
{ name = "minecraft:coal", burn_time = 8 },
{ name = "minecraft:charcoal", burn_time = 8 },
{ name = "minecraft:coal_block", burn_time = 80 },
{ name = "minecraft:blaze_rod", burn_time = 12 },
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
{ name = "minecraft:lava_bucket", burn_time = 100 },
{ name = "minecraft:oak_planks", burn_time = 1.5 },
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
{ name = "minecraft:birch_planks", burn_time = 1.5 },
{ name = "minecraft:jungle_planks",burn_time = 1.5 },
{ name = "minecraft:acacia_planks",burn_time = 1.5 },
{ name = "minecraft:dark_oak_planks",burn_time = 1.5 },
{ name = "minecraft:mangrove_planks",burn_time = 1.5 },
{ name = "minecraft:cherry_planks",burn_time = 1.5 },
{ name = "minecraft:stick", burn_time = 0.5 },
}

156
data/smeltable.lua Normal file
View File

@@ -0,0 +1,156 @@
-- Smeltable items: input -> output
-- Items in chests matching a key here get auto-smelted.
-- Add/remove entries to control what gets cooked.
--
-- Each entry: ["mod:item"] = { result = "mod:output", furnaces = { ... } }
-- Valid furnace types: "minecraft:furnace", "minecraft:smoker", "minecraft:blast_furnace"
return {
-- Ores (furnace + blast furnace only)
["minecraft:raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:deepslate_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:deepslate_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:deepslate_copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["minecraft:ancient_debris"] = { result = "minecraft:netherite_scrap",furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-- Sand / stone / clay (regular furnace only — blast furnace rejects these)
["minecraft:sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace"} },
["minecraft:red_sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace"} },
["minecraft:cobblestone"] = { result = "minecraft:stone", furnaces = {"minecraft:furnace"} },
["minecraft:stone"] = { result = "minecraft:smooth_stone", furnaces = {"minecraft:furnace"} },
["minecraft:clay_ball"] = { result = "minecraft:brick", furnaces = {"minecraft:furnace"} },
["minecraft:netherrack"] = { result = "minecraft:nether_brick", furnaces = {"minecraft:furnace"} },
["minecraft:sandstone"] = { result = "minecraft:smooth_sandstone", furnaces = {"minecraft:furnace"} },
-- Food (furnace + smoker only)
["minecraft:beef"] = { result = "minecraft:cooked_beef", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:porkchop"] = { result = "minecraft:cooked_porkchop", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:chicken"] = { result = "minecraft:cooked_chicken", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:mutton"] = { result = "minecraft:cooked_mutton", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:rabbit"] = { result = "minecraft:cooked_rabbit", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:cod"] = { result = "minecraft:cooked_cod", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:salmon"] = { result = "minecraft:cooked_salmon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:potato"] = { result = "minecraft:baked_potato", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["minecraft:kelp"] = { result = "minecraft:dried_kelp", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
-- Misc (furnace only)
["minecraft:wet_sponge"] = { result = "minecraft:sponge", furnaces = {"minecraft:furnace"} },
["minecraft:cactus"] = { result = "minecraft:green_dye", furnaces = {"minecraft:furnace"} },
["minecraft:sea_pickle"] = { result = "minecraft:lime_dye", furnaces = {"minecraft:furnace"} },
["minecraft:log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:spruce_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:birch_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:jungle_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:acacia_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:dark_oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:mangrove_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
["minecraft:cherry_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
-------------------------------------------------
-- Mythic Metals — raw ores (furnace + blast furnace)
-------------------------------------------------
["mythicmetals:raw_adamantite"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_aquarium"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_banglum"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_carmot"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_kyber"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_manganese"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_midas_gold"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_mythril"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_orichalcum"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_palladium"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_prometheum"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_quadrillum"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_runite"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_star_platinum"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_stormyx"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_tin"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:raw_silver"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-------------------------------------------------
-- Mythic Metals — ore blocks (furnace + blast furnace)
-------------------------------------------------
["mythicmetals:adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:aquarium_ore"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:nether_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:midas_gold_ore"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:end_stone_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:palladium_ore"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:end_stone_star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:end_stone_stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["mythicmetals:deepslate_silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-------------------------------------------------
-- Ad Astra — raw ores + planetary ores (furnace + blast furnace)
-------------------------------------------------
["ad_astra:raw_desh"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:raw_ostrum"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:raw_calorite"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:moon_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:deepslate_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:mars_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:deepslate_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:venus_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:deepslate_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:moon_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:mars_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:venus_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:venus_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:mars_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["ad_astra:moon_ice_shard_ore"] = { result = "ad_astra:ice_shard", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-------------------------------------------------
-- Create — zinc + crushed ores (furnace + blast furnace)
-------------------------------------------------
["create:raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:deepslate_zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:crushed_raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:crushed_raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:crushed_raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["create:crushed_raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-------------------------------------------------
-- BetterEnd — thallasium (furnace + blast furnace)
-------------------------------------------------
["betterend:thallasium_raw"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
["betterend:thallasium_ore"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
-------------------------------------------------
-- Farmer's Delight — food (furnace + smoker)
-------------------------------------------------
["farmersdelight:chicken_cuts"] = { result = "farmersdelight:cooked_chicken_cuts", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:bacon"] = { result = "farmersdelight:cooked_bacon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:cod_slice"] = { result = "farmersdelight:cooked_cod_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:salmon_slice"] = { result = "farmersdelight:cooked_salmon_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:mutton_chops"] = { result = "farmersdelight:cooked_mutton_chops", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:ham"] = { result = "farmersdelight:smoked_ham", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
["farmersdelight:minced_beef"] = { result = "farmersdelight:beef_patty", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
}

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

319
inventoryWebBridge.lua Normal file
View File

@@ -0,0 +1,319 @@
-- Inventory Manager Web Bridge
-- Runs on a CC:Tweaked computer with a modem and HTTP access.
-- Listens to the inventory master's broadcasts via modem and
-- forwards state to the web server via HTTP.
-- Also polls the web server for commands and sends them to the master.
-------------------------------------------------
-- Configuration
-------------------------------------------------
-- Web server URL (change to your Docker host IP/hostname)
local SERVER_URL = "http://localhost"
local POLL_INTERVAL = 0.5 -- seconds between command polls
local STATE_INTERVAL = 1 -- seconds between state forwards
-- Modem channels (must match inventoryManager.lua)
local BROADCAST_CHANNEL = 4200
local ORDER_CHANNEL = 4201
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local 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
local f = fs.open(CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if ok and cfg then
if cfg.serverUrl then SERVER_URL = cfg.serverUrl end
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
if cfg.stateInterval then STATE_INTERVAL = cfg.stateInterval end
if cfg.apiKey then API_KEY = cfg.apiKey end
print("[CONFIG] Loaded from " .. CONFIG_FILE)
end
end
end
-------------------------------------------------
-- State
-------------------------------------------------
local latestState = nil -- last broadcast from master
local modem = nil
local modemName = nil
local running = true
-------------------------------------------------
-- Find modem
-------------------------------------------------
local function findModem()
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
modem = peripheral.wrap(name)
modemName = name
modem.open(BROADCAST_CHANNEL)
return true
end
end
return false
end
-------------------------------------------------
-- HTTP helpers
-------------------------------------------------
local function httpPost(path, body)
local url = SERVER_URL .. path
local data = textutils.serialiseJSON(body)
local headers = { ["Content-Type"] = "application/json" }
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
local ok, result = pcall(function()
local response = http.post(url, data, headers)
if response then
local responseData = response.readAll()
response.close()
return responseData
end
end)
if ok then
return result
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, headers)
if response then
local data = response.readAll()
response.close()
return textutils.unserialiseJSON(data)
end
end)
if ok then
return result
end
return nil
end
-------------------------------------------------
-- Forward state to web server
-------------------------------------------------
local function forwardState()
if not latestState then return end
httpPost("/api/bridge/state", latestState)
end
-------------------------------------------------
-- Process commands from web server
-------------------------------------------------
local function processCommand(cmd)
if not modem then return end
local action = cmd.action
if not action then return end
print(string.format("[CMD] %s", action))
if action == "order" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "order",
commandId = cmd.commandId,
itemName = cmd.itemName,
amount = cmd.amount,
dropperName = cmd.dropperName,
})
elseif action == "scan" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "scan",
commandId = cmd.commandId,
})
elseif action == "toggle_pause" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "toggle_pause",
commandId = cmd.commandId,
})
elseif action == "toggle_recipe" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "toggle_recipe",
commandId = cmd.commandId,
recipe = cmd.recipe,
})
elseif action == "enable_all" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "enable_all",
commandId = cmd.commandId,
})
elseif action == "disable_all" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "disable_all",
commandId = cmd.commandId,
})
elseif action == "sort_barrel" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "sort_barrel",
commandId = cmd.commandId,
barrelName = cmd.barrelName,
})
elseif action == "craft" then
modem.transmit(ORDER_CHANNEL, BROADCAST_CHANNEL, {
type = "craft",
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
end
-------------------------------------------------
-- Tasks
-------------------------------------------------
-- Task 1: Listen for modem broadcasts from master
local function modemListener()
while running do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
if channel == BROADCAST_CHANNEL and type(message) == "table" then
if message.type == "state" then
latestState = message
end
end
end
end
-- Task 2: Forward state to web server periodically
local function stateForwarder()
while running do
local ok, err = pcall(forwardState)
if not ok then
-- Connection error, will retry
end
sleep(STATE_INTERVAL)
end
end
-- Task 3: Poll web server for commands
local lastProcessedId = 0 -- track highest processed command ID for dedup
local function commandPoller()
while running do
local ok, err = pcall(function()
local result = httpGet("/api/bridge/commands")
if result and result.commands and #result.commands > 0 then
local maxId = lastProcessedId
-- Process each command, skipping already-processed ones
for _, cmd in ipairs(result.commands) do
local cmdId = cmd.id or 0
if cmdId > lastProcessedId then
pcall(processCommand, cmd)
if cmdId > maxId then maxId = cmdId end
end
end
-- Acknowledge up to the highest processed ID
if maxId > lastProcessedId then
lastProcessedId = maxId
httpPost("/api/bridge/commands/ack", { lastProcessedId = lastProcessedId })
end
end
end)
sleep(POLL_INTERVAL)
end
end
-- Task 4: Heartbeat / connection status display
local function heartbeat()
local connected = false
while running do
local ok, result = pcall(function()
return httpGet("/api/health")
end)
local nowConnected = ok and result ~= nil
if nowConnected ~= connected then
connected = nowConnected
if connected then
print("[WEB] Connected to web server")
else
print("[WEB] Disconnected from web server")
end
end
sleep(5)
end
end
-------------------------------------------------
-- Main
-------------------------------------------------
local function main()
print("===================================")
print(" Inventory Manager - Web Bridge")
print("===================================")
print("")
loadConfig()
if findModem() then
print("[OK] Modem: " .. modemName)
else
print("[WARN] No modem found! Bridge needs a modem.")
print(" Attach a modem and restart.")
return
end
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)
print("")
parallel.waitForAny(
modemListener,
stateForwarder,
commandPoller,
heartbeat
)
end
main()

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

45
lib/log.lua Normal file
View File

@@ -0,0 +1,45 @@
-- lib/log.lua — Structured logging for CC:Tweaked programs
-- Usage:
-- local log = dofile("lib/log.lua")
-- log.info("SMELT", "Loaded %d recipes", count)
-- log.warn("NET", "No modem found")
-- log.setLevel("DEBUG") -- show everything
--
-- Levels: DEBUG < INFO < WARN < ERROR
-- Default level: INFO (suppresses DEBUG)
local LEVELS = { DEBUG = 1, INFO = 2, WARN = 3, ERROR = 4 }
local minLevel = LEVELS.INFO
local log = {}
--- Set minimum log level (string: "DEBUG", "INFO", "WARN", "ERROR")
function log.setLevel(level)
minLevel = LEVELS[level] or LEVELS.INFO
end
--- Get the current minimum level name
function log.getLevel()
for name, val in pairs(LEVELS) do
if val == minLevel then return name end
end
return "INFO"
end
local function emit(levelName, tag, msg, ...)
if LEVELS[levelName] < minLevel then return end
local text
if select("#", ...) > 0 then
text = string.format(msg, ...)
else
text = msg
end
print(string.format("[%s][%s] %s", levelName, tag, text))
end
function log.debug(tag, msg, ...) emit("DEBUG", tag, msg, ...) end
function log.info(tag, msg, ...) emit("INFO", tag, msg, ...) end
function log.warn(tag, msg, ...) emit("WARN", tag, msg, ...) end
function log.error(tag, msg, ...) emit("ERROR", tag, msg, ...) end
return log

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

216
lib/ui.lua Normal file
View File

@@ -0,0 +1,216 @@
-- lib/ui.lua — Shared UI helpers for manager & client
-- Usage: local ui = dofile("lib/ui.lua")
--
-- Set ui.draw to the current render target (window/monitor)
-- before calling any drawing functions.
local ui = {}
-------------------------------------------------
-- Drawing target — set this before drawing
-------------------------------------------------
ui.draw = nil
-------------------------------------------------
-- Drawing primitives
-------------------------------------------------
function ui.monWrite(x, y, text, fg, bg)
ui.draw.setCursorPos(x, y)
if fg then ui.draw.setTextColor(fg) end
if bg then ui.draw.setBackgroundColor(bg) end
ui.draw.write(text)
end
function ui.monFill(y, color)
local w, _ = ui.draw.getSize()
ui.draw.setCursorPos(1, y)
ui.draw.setBackgroundColor(color)
ui.draw.write(string.rep(" ", w))
end
function ui.monCenter(y, text, fg, bg)
local w, _ = ui.draw.getSize()
local x = math.floor((w - #text) / 2) + 1
ui.monWrite(x, y, text, fg, bg)
end
function ui.monBar(x, y, width, ratio, barColor, bgColor)
local filled = math.floor(ratio * width)
ui.draw.setCursorPos(x, y)
ui.draw.setBackgroundColor(barColor)
ui.draw.write(string.rep(" ", filled))
ui.draw.setBackgroundColor(bgColor)
ui.draw.write(string.rep(" ", width - filled))
end
function ui.drawButton(x, y, text, fg, bg, padLeft, padRight)
padLeft = padLeft or 1
padRight = padRight or 1
local full = string.rep(" ", padLeft) .. text .. string.rep(" ", padRight)
ui.monWrite(x, y, full, fg, bg)
return x, y, x + #full - 1, y
end
-------------------------------------------------
-- Touch zone management
-------------------------------------------------
--- Append a clickable zone to a pending-zone list.
-- @param zones table — the pending zone array to append to
function ui.addZone(zones, x1, y1, x2, y2, action, data)
table.insert(zones, {
x1 = x1, y1 = y1, x2 = x2, y2 = y2,
action = action, data = data,
})
end
--- Find which zone was hit.
-- @param zones table — the active zone array to search
function ui.hitTest(zones, x, y)
for _, zone in ipairs(zones) do
if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then
return zone.action, zone.data
end
end
return nil, nil
end
-------------------------------------------------
-- Monitor setup
-------------------------------------------------
--- Wrap and configure the main inventory monitor.
-- @param side string — preferred peripheral side/name
-- @param excludeSide string — side to skip (e.g. smelter monitor)
-- @return mon, monName or nil, nil
function ui.setupMonitor(side, excludeSide)
local mon = peripheral.wrap(side)
local monName
if mon and mon.setTextScale then
monName = side
else
mon = nil
end
if not mon then
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and name ~= excludeSide then
mon = peripheral.wrap(name)
monName = name
break
end
end
end
if not mon then return nil, nil end
mon.setTextScale(0.5)
mon.clear()
return mon, monName
end
--- Wrap and configure the smelter monitor.
-- @param side string — preferred peripheral side/name
-- @param excludeName string — main monitor name to skip
-- @return smelterMon, smelterMonName or nil, nil
function ui.setupSmelterMonitor(side, excludeName)
local smelterMon = peripheral.wrap(side)
local smelterMonName
if smelterMon and smelterMon.setTextScale then
smelterMonName = side
else
smelterMon = nil
end
if not smelterMon then
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" and name ~= excludeName then
smelterMon = peripheral.wrap(name)
smelterMonName = name
break
end
end
end
if not smelterMon then return nil, nil end
smelterMon.setTextScale(0.5)
smelterMon.clear()
return smelterMon, smelterMonName
end
-------------------------------------------------
-- Item filtering
-------------------------------------------------
--- Filter an item list by search query.
-- @param itemList table — array of { name, total }
-- @param searchQuery string — filter text (case-insensitive, plain match)
-- @return filtered table — matching subset
function ui.getFilteredItems(itemList, searchQuery)
local filtered = {}
for _, item in ipairs(itemList) do
if searchQuery == "" then
table.insert(filtered, item)
else
local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ")
if lower:find(searchQuery:lower(), 1, true) then
table.insert(filtered, item)
end
end
end
return filtered
end
-------------------------------------------------
-- Crafting recipe helpers
-------------------------------------------------
--- Count ingredients needed for one craft.
function ui.getRecipeIngredients(recipe)
local ingredients = {}
for _, item in ipairs(recipe.grid) do
if item then
ingredients[item] = (ingredients[item] or 0) + 1
end
end
return ingredients
end
--- Check if a recipe can be crafted with current stock.
-- @param recipe table — recipe with .grid
-- @param getTotal function(itemName) -> number — returns stock count
function ui.canCraftRecipe(recipe, getTotal)
local ingredients = ui.getRecipeIngredients(recipe)
for itemName, needed in pairs(ingredients) do
if (getTotal(itemName) or 0) < needed then return false end
end
return true
end
--- How many batches can be crafted.
-- @param recipe table — recipe with .grid
-- @param getTotal function(itemName) -> number
function ui.maxCraftBatches(recipe, getTotal)
local ingredients = ui.getRecipeIngredients(recipe)
local minBatches = math.huge
for itemName, needed in pairs(ingredients) do
local batches = math.floor((getTotal(itemName) or 0) / needed)
if batches < minBatches then minBatches = batches end
end
if minBatches == math.huge then return 0 end
return minBatches
end
--- Get list of ingredients where stock < needed.
-- @param recipe table — recipe with .grid
-- @param getTotal function(itemName) -> number
function ui.getMissingIngredients(recipe, getTotal)
local ingredients = ui.getRecipeIngredients(recipe)
local missing = {}
for itemName, needed in pairs(ingredients) do
local have = getTotal(itemName) or 0
if have < needed then
table.insert(missing, { name = itemName, have = have, need = needed })
end
end
return missing
end
return ui

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
)

3
web/client/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

26
web/client/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Stage 1: Build the React app
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
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
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
web/client/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inventory Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

75
web/client/nginx.conf Normal file
View File

@@ -0,0 +1,75 @@
# 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 _;
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;
}
# Proxy API requests to backend server
location /api/ {
proxy_pass http://server:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy and cache texture requests
location /api/texture/ {
proxy_pass http://server:3001/api/texture/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
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;
add_header X-Nginx-Cache $upstream_cache_status;
}
# Proxy WebSocket for browser clients
location /ws {
proxy_pass http://server:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Proxy WebSocket for bridge clients
location /ws/bridge {
proxy_pass http://server:3001/ws/bridge;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}

1791
web/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/client/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "inventory-manager-client",
"version": "1.0.0",
"description": "CC:Tweaked Inventory Manager - Web Dashboard",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

452
web/client/src/App.css Normal file
View File

@@ -0,0 +1,452 @@
/* ============================================
Minecraft-Themed Inventory Manager Dashboard
============================================ */
@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;
--mc-stone: #7b7b7b;
--mc-stone-light: #9d9d9d;
--mc-stone-dark: #4b4b4b;
--mc-dirt: #6b5030;
--mc-dirt-dark: #4a3520;
--mc-oak: #8b6d3c;
--mc-oak-dark: #6b4e28;
--mc-oak-light: #b89b60;
--mc-grass: #5b8731;
--mc-grass-light: #80b94e;
--mc-grass-dark: #3d6b1a;
--mc-inv-bg: #c6c6c6;
--mc-inv-slot: #8b8b8b;
--mc-inv-slot-border: #373737;
--mc-inv-slot-light: #ffffff;
--mc-text-white: #e0e0e0;
--mc-text-gray: #a0a0a0;
--mc-text-dark: #404040;
--mc-text-yellow: #ffff55;
--mc-text-green: #55ff55;
--mc-text-red: #ff5555;
--mc-text-aqua: #55ffff;
--mc-text-gold: #ffaa00;
--mc-text-light-purple: #ff55ff;
--mc-text-blue: #5555ff;
--mc-diamond: #4aedd9;
--mc-emerald: #17dd62;
--mc-redstone: #ff0000;
--mc-lapis: #345ec3;
--mc-border-highlight: #ffffff55;
--mc-border-shadow: #00000088;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Silkscreen', 'Courier New', monospace;
background: #2c2c2c;
color: var(--mc-text-white);
image-rendering: pixelated;
overflow: hidden;
}
.app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* === Header === */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: var(--mc-oak-dark);
border-bottom: 3px solid var(--mc-dark);
box-shadow:
inset 0 2px 0 var(--mc-oak-light),
inset 0 -2px 0 var(--mc-dirt-dark);
flex-shrink: 0;
}
.app-header h1 {
font-size: 1.1rem;
font-weight: 700;
color: var(--mc-text-yellow);
text-shadow: 2px 2px 0 var(--mc-dark);
letter-spacing: 0.05em;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* === Alerts Bell === */
.alerts-bell {
position: relative;
background: none;
border: 2px solid transparent;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.375rem;
border-radius: 0;
transition: all 0.1s;
line-height: 1;
}
.alerts-bell:hover {
background: rgba(255, 255, 255, 0.1);
border-color: #555;
}
.alerts-bell.has-alerts {
animation: bell-shake 2s infinite ease-in-out;
}
.alerts-bell-badge {
position: absolute;
top: -4px;
right: -4px;
background: var(--mc-text-red);
color: white;
font-size: 0.5rem;
font-weight: 700;
font-family: 'Silkscreen', 'Courier New', monospace;
padding: 1px 3px;
min-width: 14px;
text-align: center;
border: 1px solid var(--mc-dark);
text-shadow: 1px 1px 0 #000;
}
@keyframes bell-shake {
0%, 80%, 100% { transform: rotate(0deg); }
85% { transform: rotate(8deg); }
90% { transform: rotate(-8deg); }
95% { transform: rotate(4deg); }
}
/* === Connection Status === */
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 2px solid var(--mc-dark);
font-weight: 700;
font-size: 0.7rem;
text-shadow: 1px 1px 0 var(--mc-dark);
}
.connection-status.connected {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
box-shadow: inset 0 1px 0 var(--mc-grass-light), inset 0 -1px 0 #2a5010;
}
.connection-status.disconnected {
background: #6b1a1a;
color: var(--mc-text-red);
box-shadow: inset 0 1px 0 #a03030, inset 0 -1px 0 #400e0e;
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 0;
animation: mc-blink 1.5s steps(2) infinite;
}
.connected .status-dot {
background: var(--mc-text-green);
box-shadow: 0 0 4px var(--mc-text-green);
}
.disconnected .status-dot {
background: var(--mc-text-red);
box-shadow: 0 0 4px var(--mc-text-red);
}
@keyframes mc-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.last-update-text {
font-size: 0.65rem;
color: var(--mc-text-gray, #aaa);
text-shadow: 1px 1px 0 var(--mc-dark);
white-space: nowrap;
}
/* === Command Toast === */
.command-toast {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem 1.5rem;
border: 2px solid var(--mc-dark);
font-size: 0.75rem;
font-weight: 700;
z-index: 1000;
text-shadow: 1px 1px 0 var(--mc-dark);
animation: toast-in 0.2s ease;
}
.command-toast.success {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
}
.command-toast.error {
background: #6b1a1a;
color: var(--mc-text-red);
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* === Main Content === */
.app-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* === Sidebar === */
.sidebar {
width: 280px;
background: #333;
border-right: 3px solid var(--mc-dark);
overflow-y: auto;
flex-shrink: 0;
}
/* === Main Panel === */
.main-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* === Panel Tabs === */
.panel-tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
background: #3b3b3b;
border-bottom: 3px solid var(--mc-dark);
overflow-x: auto;
flex-shrink: 0;
}
.panel-tabs button {
padding: 0.5rem 1rem;
border: 2px solid var(--mc-dark);
background: #5a5a5a;
color: var(--mc-text-gray);
border-radius: 0;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: all 0.1s;
white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 var(--mc-dark);
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
}
.panel-tabs button:hover {
background: #6b6b6b;
color: var(--mc-text-white);
}
.panel-tabs button.active {
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.panel-content-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* === Scrollbar === */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #2c2c2c;
}
::-webkit-scrollbar-thumb {
background: #5a5a5a;
border: 1px solid var(--mc-dark);
}
::-webkit-scrollbar-thumb:hover {
background: #6b6b6b;
}
/* === Section styling === */
.detail-section {
margin-bottom: 1rem;
padding: 0.875rem;
background: #3b3b3b;
border: 3px solid var(--mc-dark);
border-radius: 0;
box-shadow:
inset 0 2px 0 #555,
inset 0 -2px 0 #2a2a2a;
}
.detail-section h3 {
font-size: 0.8rem;
margin-bottom: 0.75rem;
color: var(--mc-text-gold);
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
text-shadow: 1px 1px 0 var(--mc-dark);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* === MC Button === */
.mc-btn {
padding: 0.5rem 0.75rem;
border: 2px solid var(--mc-dark);
border-radius: 0;
font-size: 0.7rem;
font-weight: 700;
cursor: pointer;
transition: all 0.05s;
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 var(--mc-dark);
background: #6b6b6b;
box-shadow: inset 0 2px 0 #999, inset 0 -2px 0 #444;
}
.mc-btn:hover {
background: #7b7b7b;
box-shadow: inset 0 2px 0 #aaa, inset 0 -2px 0 #555;
}
.mc-btn:active {
background: #555;
box-shadow: inset 0 2px 0 #333, inset 0 -2px 0 #777;
}
.mc-btn.green {
background: var(--mc-grass-dark);
box-shadow: inset 0 2px 0 var(--mc-grass-light), inset 0 -2px 0 #2a5010;
}
.mc-btn.green:hover { background: var(--mc-grass); }
.mc-btn.red {
background: #8b2e2e;
box-shadow: inset 0 2px 0 #bb4444, inset 0 -2px 0 #661a1a;
}
.mc-btn.red:hover { background: #a03e3e; }
.mc-btn.blue {
background: #2e4a8b;
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
}
.mc-btn.blue:hover { background: #3e5a9b; }
.mc-btn.gold {
background: #8b6b2e;
box-shadow: inset 0 2px 0 #bb9944, inset 0 -2px 0 #664a1a;
}
.mc-btn.gold:hover { background: #9b7b3e; }
/* === Responsive === */
@media (max-width: 1024px) {
.sidebar {
width: 220px;
}
}
@media (max-width: 768px) {
.app-content {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 3px solid var(--mc-dark);
}
}
@media (max-width: 480px) {
.app-header h1 {
font-size: 0.85rem;
}
.panel-tabs button {
font-size: 0.7rem;
padding: 0.4rem 0.6rem;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

164
web/client/src/App.jsx Normal file
View File

@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import InventoryGrid from './components/InventoryGrid';
import StorageOverview from './components/StorageOverview';
import SmeltingPanel from './components/SmeltingPanel';
import CraftingPanel from './components/CraftingPanel';
import AlertsPanel from './components/AlertsPanel';
import AnalyticsPanel from './components/AnalyticsPanel';
import SettingsPanel from './components/SettingsPanel';
import ErrorBoundary from './components/ErrorBoundary';
import { useInventoryStore } from './store/inventoryStore';
import './App.css';
function App() {
const connect = useInventoryStore((state) => state.connect);
const connected = useInventoryStore((state) => state.connected);
const bridgeConnected = useInventoryStore((state) => state.bridgeConnected);
const lastUpdate = useInventoryStore((state) => state.lastUpdate);
const commandResult = useInventoryStore((state) => state.commandResult);
const alerts = useInventoryStore((state) => state.alerts) || [];
const [panelTab, setPanelTab] = useState('inventory');
const [showAlerts, setShowAlerts] = useState(false);
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]);
// Re-render every 5s so the "last updated" text stays fresh
useEffect(() => {
const id = setInterval(() => forceRender((n) => n + 1), 5000);
return () => clearInterval(id);
}, []);
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
const renderPanelContent = () => {
switch (panelTab) {
case 'inventory':
return <InventoryGrid />;
case 'smelting':
return <SmeltingPanel />;
case 'crafting':
return <CraftingPanel />;
case 'analytics':
return <AnalyticsPanel />;
default:
return <InventoryGrid />;
}
};
const triggeredAlerts = alerts.filter((a) => a.triggered !== false);
return (
<div className="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"
onClick={() => setShowSettings(!showSettings)}
title="Settings"
>
</button>
{/* Alerts bell button */}
<button
className={`alerts-bell ${triggeredAlerts.length > 0 ? 'has-alerts' : ''}`}
onClick={() => setShowAlerts(!showAlerts)}
title="Low-stock alerts"
>
🔔
{triggeredAlerts.length > 0 && (
<span className="alerts-bell-badge">{triggeredAlerts.length}</span>
)}
</button>
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
<span className="status-dot"></span>
{connected ? 'Connected' : 'Disconnected'}
</div>
{connected && (
<div className={`connection-status ${bridgeConnected ? 'connected' : 'disconnected'}`} title="Minecraft CC:Tweaked bridge">
<span className="status-dot"></span>
{bridgeConnected ? 'Bridge OK' : 'Bridge Off'}
</div>
)}
{staleSecs !== null && staleSecs > 0 && (
<span className="last-update-text" title="Time since last data update">
{staleSecs < 60 ? `${staleSecs}s ago` : `${Math.floor(staleSecs / 60)}m ago`}
</span>
)}
</div>
</div>
{commandResult && (
<div className={`command-toast ${commandResult.success ? 'success' : 'error'}`}>
{commandResult.message || commandResult.error || (commandResult.success ? 'OK' : 'Failed')}
</div>
)}
{/* Alerts popup overlay */}
<AlertsPanel isOpen={showAlerts} onClose={() => setShowAlerts(false)} />
{/* Settings overlay */}
<SettingsPanel isOpen={showSettings} onClose={() => setShowSettings(false)} />
<div className="app-content">
<div className="sidebar">
<ErrorBoundary>
<StorageOverview />
</ErrorBoundary>
</div>
<div className="main-panel">
<div className="panel-tabs">
<button
className={panelTab === 'inventory' ? 'active' : ''}
onClick={() => setPanelTab('inventory')}
>
📦 Inventory
</button>
<button
className={panelTab === 'smelting' ? 'active' : ''}
onClick={() => setPanelTab('smelting')}
>
🔥 Smelting
</button>
<button
className={panelTab === 'crafting' ? 'active' : ''}
onClick={() => setPanelTab('crafting')}
>
🔨 Crafting
</button>
<button
className={panelTab === 'analytics' ? 'active' : ''}
onClick={() => setPanelTab('analytics')}
>
📊 Analytics
</button>
</div>
<div className="panel-content-wrapper">
<ErrorBoundary>
{renderPanelContent()}
</ErrorBoundary>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,167 @@
/* ===== Alerts Popup Overlay ===== */
.alerts-overlay {
position: fixed;
top: 50px;
right: 16px;
z-index: 1000;
animation: alerts-slide-in 0.15s ease;
}
@keyframes alerts-slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.alerts-popup {
width: 340px;
max-height: 400px;
background: #333;
border: 3px solid var(--mc-dark);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.6),
inset 0 2px 0 #555,
inset 0 -2px 0 #2a2a2a;
display: flex;
flex-direction: column;
}
.alerts-popup-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #3b3b3b;
border-bottom: 2px solid var(--mc-dark);
flex-shrink: 0;
}
.alerts-popup-header h3 {
font-size: 0.75rem;
color: var(--mc-text-yellow);
text-shadow: 1px 1px 0 var(--mc-dark);
flex: 1;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.alerts-close {
background: none;
border: 2px solid #555;
color: var(--mc-text-gray);
cursor: pointer;
font-size: 0.7rem;
width: 1.3rem;
height: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.alerts-close:hover {
color: var(--mc-text-red);
border-color: var(--mc-text-red);
}
.alerts-popup-body {
overflow-y: auto;
max-height: 330px;
padding: 0.5rem;
}
.alert-count {
font-size: 0.7rem;
color: var(--mc-text-gray);
padding: 0.25rem 0.5rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem;
background: #3b3b3b;
border: 3px solid var(--mc-dark);
box-shadow: inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a;
}
.alert-card.triggered {
border-color: #661a1a;
background: #3b2a2a;
}
.alert-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.alert-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.alert-item-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.alert-item-name {
font-size: 0.75rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 var(--mc-dark);
}
.alert-details {
display: flex;
gap: 0.75rem;
font-size: 0.6rem;
color: var(--mc-text-gray);
}
.alert-details strong {
color: var(--mc-text-white);
}
.alert-badge {
padding: 0.25rem 0.5rem;
font-size: 0.6rem;
font-weight: 700;
border: 2px solid var(--mc-dark);
text-shadow: 1px 1px 0 #000;
flex-shrink: 0;
}
.alert-badge.low {
background: #6b1a1a;
color: var(--mc-text-red);
}
.alert-badge.ok {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
}
.no-alerts {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
color: var(--mc-text-gray);
font-size: 0.75rem;
}
.no-alerts-icon {
font-size: 2rem;
}

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useRef } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './AlertsPanel.css';
function AlertsPanel({ isOpen, onClose }) {
const alerts = useInventoryStore((state) => state.alerts) || [];
const panelRef = useRef(null);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target)) {
// Check if the click was on the bell button (has alerts-bell class)
if (e.target.closest('.alerts-bell')) return;
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="alerts-overlay" ref={panelRef}>
<div className="alerts-popup">
<div className="alerts-popup-header">
<h3>🔔 Low-Stock Alerts</h3>
<span className="alert-count">
{alerts.length} alert{alerts.length !== 1 ? 's' : ''}
</span>
<button className="alerts-close" onClick={onClose}></button>
</div>
<div className="alerts-popup-body">
{alerts.length > 0 ? (
<div className="alert-list">
{alerts.map((alert, idx) => {
const itemName = alert.item || alert.name || alert.label || '';
const isTriggered = alert.triggered !== undefined ? alert.triggered : true;
const current = alert.current ?? '?';
const threshold = alert.threshold || alert.min || '?';
return (
<div key={idx} className={`alert-card ${isTriggered ? 'triggered' : 'ok'}`}>
<div className="alert-icon">
{isTriggered ? '🔴' : '🟢'}
</div>
<div className="alert-info">
<div className="alert-item-row">
<ItemIcon itemName={itemName} size={20} />
<span className="alert-item-name">{formatItemName(itemName)}</span>
</div>
<div className="alert-details">
<span className="alert-stock">
Stock: <strong>{current}</strong>
</span>
<span className="alert-threshold">
Min: <strong>{threshold}</strong>
</span>
</div>
</div>
<div className={`alert-badge ${isTriggered ? 'low' : 'ok'}`}>
{isTriggered ? 'LOW' : 'OK'}
</div>
</div>
);
})}
</div>
) : (
<div className="no-alerts">
<span className="no-alerts-icon"></span>
<span>All stock levels are OK</span>
</div>
)}
</div>
</div>
</div>
);
}
export default AlertsPanel;

View File

@@ -0,0 +1,718 @@
/* ============================================
Analytics Panel — Minecraft Dashboard
============================================ */
.analytics-panel {
height: 100%;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ===== Hero Stats Row ===== */
.analytics-hero {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hero-stat {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 2px solid #333;
border-radius: 2px;
flex: 1;
min-width: 120px;
position: relative;
overflow: hidden;
transition: border-color 0.2s, transform 0.15s;
}
.hero-stat::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
}
.hero-stat--items::before { background: var(--mc-text-green); }
.hero-stat--types::before { background: var(--mc-text-aqua); }
.hero-stat--chests::before { background: var(--mc-text-gold); }
.hero-stat--furnaces::before { background: var(--mc-text-red); }
.hero-stat--recipes::before { background: var(--mc-text-light-purple); }
.hero-stat:hover {
border-color: #555;
transform: translateY(-1px);
}
.hero-stat-icon {
font-size: 1.25rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.hero-stat-content {
display: flex;
flex-direction: column;
gap: 0;
}
.hero-stat-value {
font-size: 1rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 #000;
line-height: 1.2;
}
.hero-stat-label {
font-size: 0.45rem;
color: var(--mc-text-gray);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
}
.hero-stat-delta {
font-size: 0.45rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.hero-stat-delta.positive {
color: var(--mc-text-green);
}
.hero-stat-delta.negative {
color: var(--mc-text-red);
}
.hero-active {
color: var(--mc-text-green);
}
/* ===== Analytics Grid ===== */
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
}
/* ===== Cards ===== */
.analytics-card {
padding: 0.875rem;
background: linear-gradient(180deg, #3b3b3b 0%, #333 100%);
border: 2px solid #444;
border-radius: 2px;
box-shadow:
inset 0 1px 0 #555,
inset 0 -1px 0 #2a2a2a,
0 4px 12px rgba(0, 0, 0, 0.4);
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
}
.analytics-card:hover {
border-color: #555;
box-shadow:
inset 0 1px 0 #555,
inset 0 -1px 0 #2a2a2a,
0 6px 20px rgba(0, 0, 0, 0.5);
}
.analytics-card-wide {
grid-column: 1 / -1;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.625rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #444;
}
.card-header h3 {
font-size: 0.7rem;
color: var(--mc-text-gold);
font-weight: 700;
text-shadow: 1px 1px 0 var(--mc-dark);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.card-badge {
font-size: 0.45rem;
color: var(--mc-text-gray);
background: #2a2a2a;
padding: 0.15rem 0.4rem;
border: 1px solid #444;
border-radius: 1px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ===== Chart Area ===== */
.chart-wrapper {
background: #2a2a2a;
border: 1px solid #333;
border-radius: 1px;
padding: 0.5rem 0.25rem 0.25rem;
}
/* ===== Multi-line Scrollable Chart ===== */
.multiline-chart-container {
position: relative;
width: 100%;
-webkit-user-select: none;
user-select: none;
}
.multiline-chart-container.dragging {
cursor: grabbing;
}
.multiline-chart-container:not(.dragging) {
cursor: crosshair;
}
/* Tooltip */
.chart-tooltip {
position: absolute;
background: #1a1a1acc;
border: 1px solid #555;
border-radius: 2px;
padding: 0.375rem 0.5rem;
pointer-events: none;
z-index: 30;
min-width: 100px;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
}
.chart-tooltip-time {
font-size: 0.5rem;
color: var(--mc-text-gray);
font-weight: 700;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #333;
padding-bottom: 0.2rem;
}
.chart-tooltip-row {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.5rem;
padding: 0.1rem 0;
}
.chart-tooltip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.chart-tooltip-name {
color: var(--mc-text-gray);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80px;
}
.chart-tooltip-val {
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
/* Scrollbar */
.chart-scrollbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0 0.125rem;
}
.chart-scrollbar-track {
flex: 1;
height: 6px;
background: #222;
border: 1px solid #333;
border-radius: 3px;
position: relative;
overflow: hidden;
}
.chart-scrollbar-thumb {
position: absolute;
top: 0;
height: 100%;
background: linear-gradient(90deg, #555, #666);
border-radius: 3px;
transition: background 0.15s;
min-width: 12px;
}
.chart-scrollbar-thumb:hover,
.multiline-chart-container.dragging .chart-scrollbar-thumb {
background: linear-gradient(90deg, #777, #888);
}
.chart-scrollbar-hint {
font-size: 0.4rem;
color: #444;
text-transform: uppercase;
white-space: nowrap;
letter-spacing: 0.03em;
}
/* ===== Tracked Items Chips Bar ===== */
.tracked-items-bar {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.tracked-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.4rem;
background: #2a2a2a;
border: 1px solid #555;
border-radius: 2px;
font-size: 0.5rem;
color: var(--mc-text-white);
transition: border-color 0.15s, background 0.15s;
}
.tracked-chip:hover {
background: #333;
}
.tracked-chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tracked-chip-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tracked-chip-remove {
background: none;
border: none;
color: #666;
font-size: 0.55rem;
cursor: pointer;
padding: 0 0.15rem;
font-family: 'Silkscreen', monospace;
line-height: 1;
transition: color 0.15s;
}
.tracked-chip-remove:hover {
color: var(--mc-text-red);
}
.svg-chart {
display: block;
width: 100%;
height: auto;
}
.chart-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--mc-text-gray);
font-size: 0.65rem;
gap: 0.375rem;
background: #222;
border: 1px dashed #444;
border-radius: 2px;
padding: 1.5rem;
min-height: 120px;
}
.chart-empty--compact {
min-height: 140px;
}
.chart-empty-icon {
font-size: 1.5rem;
opacity: 0.5;
margin-bottom: 0.25rem;
}
.chart-empty-sub {
font-size: 0.5rem;
color: #555;
}
/* ===== Storage Health Card ===== */
.analytics-card--overview {
display: flex;
flex-direction: column;
}
.overview-split {
display: flex;
align-items: center;
gap: 1rem;
}
.overview-stats-col {
display: flex;
flex-direction: column;
gap: 0.375rem;
flex: 1;
}
.mini-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
background: #2a2a2a;
border: 1px solid #333;
border-radius: 1px;
}
.mini-stat-label {
font-size: 0.5rem;
color: var(--mc-text-gray);
text-transform: uppercase;
font-weight: 700;
}
.mini-stat-value {
font-size: 0.7rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
/* ===== Capacity Gauge ===== */
.capacity-gauge {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.capacity-gauge svg {
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
}
.capacity-gauge-detail {
font-size: 0.45rem;
color: var(--mc-text-gray);
text-transform: uppercase;
text-align: center;
}
/* ===== Category Donut ===== */
.analytics-card--categories {
display: flex;
flex-direction: column;
}
.donut-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.donut-chart {
flex-shrink: 0;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.4));
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.donut-legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.5rem;
padding: 0.2rem 0;
}
.donut-dot {
width: 8px;
height: 8px;
border-radius: 1px;
flex-shrink: 0;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
}
.donut-legend-label {
color: var(--mc-text-gray);
flex: 1;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.03em;
}
.donut-legend-value {
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
/* ===== Bar Chart ===== */
.bar-chart {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100% !important;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.375rem;
animation: bar-slide-in 0.4s ease both;
}
@keyframes bar-slide-in {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.bar-rank {
min-width: 24px;
text-align: center;
font-size: 0.75rem;
line-height: 1;
}
.bar-rank-num {
font-size: 0.5rem;
color: #555;
font-weight: 700;
}
.bar-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.5rem;
color: var(--mc-text-gray);
min-width: 90px;
max-width: 100px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.bar-label span {
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
flex: 1;
height: 18px;
background: #222;
border: 1px solid #333;
border-radius: 1px;
overflow: hidden;
}
.bar-fill {
height: 100%;
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
border-radius: 0 1px 1px 0;
box-shadow: 0 0 8px rgba(255, 170, 0, 0.15);
}
.bar-value {
font-size: 0.55rem;
color: var(--mc-text-white);
min-width: 32px;
text-align: right;
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
/* ===== Item History Search ===== */
.item-history-search {
position: relative;
margin-bottom: 0.625rem;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 0.5rem;
font-size: 0.65rem;
opacity: 0.5;
pointer-events: none;
}
.item-history-search .search-input {
width: 100%;
background: #222;
border: 2px solid #444;
color: var(--mc-text-white);
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.6rem;
padding: 0.5rem 0.5rem 0.5rem 1.75rem;
outline: none;
border-radius: 1px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.item-history-search .search-input:focus {
border-color: var(--mc-text-aqua);
box-shadow: 0 0 8px rgba(85, 255, 255, 0.15);
}
.item-history-search .search-input::placeholder {
color: #555;
}
.item-history-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #222;
border: 2px solid #555;
border-top: none;
z-index: 50;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
}
.item-history-option {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.5rem;
cursor: pointer;
font-size: 0.55rem;
color: var(--mc-text-white);
transition: background 0.1s;
border-bottom: 1px solid #2a2a2a;
}
.item-history-option:last-child {
border-bottom: none;
}
.item-history-option:hover {
background: #3a3a3a;
}
.option-count {
margin-left: auto;
color: var(--mc-text-gray);
font-size: 0.45rem;
font-weight: 700;
}
.item-history-chart {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-history-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 var(--mc-dark);
padding: 0.375rem 0.5rem;
background: #2a2a2a;
border: 1px solid #333;
border-radius: 1px;
}
/* ===== Responsive ===== */
@media (max-width: 900px) {
.analytics-grid {
grid-template-columns: 1fr;
}
.analytics-hero {
flex-wrap: wrap;
}
.hero-stat {
min-width: 100px;
}
.overview-split {
flex-direction: column;
}
.donut-container {
flex-direction: column;
}
}
@media (max-width: 600px) {
.analytics-panel {
padding: 0.5rem;
}
.hero-stat {
min-width: 80px;
padding: 0.5rem;
}
.hero-stat-value {
font-size: 0.85rem;
}
.bar-label {
min-width: 70px;
max-width: 70px;
}
}

View File

@@ -0,0 +1,845 @@
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './AnalyticsPanel.css';
// ===== Palette for multi-line series =====
const SERIES_COLORS = [
'#55ff55', '#55ffff', '#ffaa00', '#ff55ff', '#ff5555',
'#5555ff', '#ffff55', '#ff8844', '#44ddaa', '#dd88ff',
'#88ccff', '#ffcc88',
];
// ===== Smooth curve through points (cardinal spline) =====
function smoothPath(pts) {
if (!pts || pts.length === 0) return '';
if (pts.length < 3) {
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
}
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return d;
}
// ===== Multi-line scrollable SVG chart =====
function MultiLineChart({ series, height = 220, id = 'ml-chart' }) {
// series = [{ name, color, data: [{ time, value }] }, ...]
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(700);
const [viewStart, setViewStart] = useState(0); // 0..1 fractional scroll position
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartView, setDragStartView] = useState(0);
const [tooltip, setTooltip] = useState(null);
// Use a visible window size — show ~30 data points at a time
const VISIBLE_WINDOW = 30;
// Measure container
useEffect(() => {
if (!containerRef.current) return;
const obs = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
obs.observe(containerRef.current);
return () => obs.disconnect();
}, []);
// Collect all unique timestamps across all series
const allTimestamps = useMemo(() => {
const tsSet = new Set();
(series || []).forEach((s) => (s.data || []).forEach((d) => tsSet.add(d.time)));
return [...tsSet].sort((a, b) => new Date(a) - new Date(b));
}, [series]);
const totalPoints = allTimestamps.length;
const canScroll = totalPoints > VISIBLE_WINDOW;
// Compute the visible slice of timestamps
const maxOffset = Math.max(0, totalPoints - VISIBLE_WINDOW);
const scrollOffset = Math.round(viewStart * maxOffset);
const visibleTimestamps = canScroll
? allTimestamps.slice(scrollOffset, scrollOffset + VISIBLE_WINDOW)
: allTimestamps;
// Reset scroll when series change significantly
useEffect(() => {
setViewStart(1); // default: show latest
}, [series.length]);
// Build data per series for visible window
const pad = { top: 24, right: 14, bottom: 28, left: 52 };
const w = containerWidth - pad.left - pad.right;
const h = height - pad.top - pad.bottom;
const computedSeries = useMemo(() => {
if (visibleTimestamps.length < 1) return [];
// Build a timestamp → index map for x positioning
const tsIdx = {};
visibleTimestamps.forEach((ts, i) => { tsIdx[ts] = i; });
const count = visibleTimestamps.length;
// Global min/max over ALL visible series in this window
let globalMin = Infinity;
let globalMax = -Infinity;
const seriesLookups = (series || []).map((s) => {
const lookup = {};
(s.data || []).forEach((d) => { lookup[d.time] = d.value; });
return lookup;
});
seriesLookups.forEach((lookup) => {
visibleTimestamps.forEach((ts) => {
const v = lookup[ts];
if (v != null) {
if (v < globalMin) globalMin = v;
if (v > globalMax) globalMax = v;
}
});
});
if (!isFinite(globalMin)) globalMin = 0;
if (!isFinite(globalMax)) globalMax = 1;
const range = globalMax - globalMin || 1;
return (series || []).map((s, si) => {
const lookup = seriesLookups[si];
const points = [];
visibleTimestamps.forEach((ts, i) => {
const val = lookup[ts];
if (val != null) {
points.push({
x: pad.left + (i / Math.max(count - 1, 1)) * w,
y: pad.top + h - ((val - globalMin) / range) * h,
value: val,
time: ts,
});
}
});
const path = smoothPath(points);
const lastPt = points.length > 0 ? points[points.length - 1] : null;
return { ...s, points, path, lastPt, globalMin, globalMax, range };
});
}, [series, visibleTimestamps, w, h, pad.left, pad.top]);
// Y axis labels
const yLabels = useMemo(() => {
if (computedSeries.length === 0) return [];
const { globalMin, range } = computedSeries[0];
return [0, 0.25, 0.5, 0.75, 1].map((frac) => ({
y: pad.top + h * (1 - frac),
text: formatCount(Math.round(globalMin + range * frac)),
}));
}, [computedSeries, h, pad.top]);
// X axis labels
const xLabels = useMemo(() => {
const count = Math.min(6, visibleTimestamps.length);
if (count < 2) return [];
const indices = Array.from({ length: count }, (_, i) => Math.round(i * (visibleTimestamps.length - 1) / (count - 1)));
return indices.filter((idx) => visibleTimestamps[idx]).map((idx) => ({
x: pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w,
text: formatTime(visibleTimestamps[idx]),
}));
}, [visibleTimestamps, w, pad.left]);
// Scroll handler (wheel)
const handleWheel = useCallback((e) => {
if (!canScroll) return;
e.preventDefault();
setViewStart((prev) => {
const delta = e.deltaY > 0 ? 0.05 : -0.05;
return Math.max(0, Math.min(1, prev + delta));
});
}, [canScroll]);
// Drag-to-scroll handlers
const handleMouseDown = useCallback((e) => {
if (!canScroll) return;
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartView(viewStart);
}, [canScroll, viewStart]);
const handleMouseMove = useCallback((e) => {
if (!isDragging || !canScroll) return;
const dx = e.clientX - dragStartX;
const pctDelta = -dx / containerWidth;
setViewStart(Math.max(0, Math.min(1, dragStartView + pctDelta)));
}, [isDragging, canScroll, dragStartX, dragStartView, containerWidth]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
// Tooltip handler
const handleChartMouseMove = useCallback((e) => {
if (isDragging || !containerRef.current || computedSeries.length === 0) {
setTooltip(null);
return;
}
const rect = containerRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Find closest timestamp index
const relX = mx - pad.left;
if (relX < 0 || relX > w) { setTooltip(null); return; }
const frac = relX / w;
const idx = Math.round(frac * (visibleTimestamps.length - 1));
if (idx < 0 || idx >= visibleTimestamps.length) { setTooltip(null); return; }
const ts = visibleTimestamps[idx];
const items = computedSeries.map((s) => {
const pt = s.points.find((p) => p.time === ts);
return pt ? { name: s.name, color: s.color, value: pt.value } : null;
}).filter(Boolean);
if (items.length === 0) { setTooltip(null); return; }
const snapX = pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w;
setTooltip({ x: snapX, y: my, time: ts, items });
}, [isDragging, computedSeries, w, pad.left, visibleTimestamps]);
const handleChartMouseLeave = useCallback(() => setTooltip(null), []);
const hasData = series && series.length > 0 && series.some((s) => s.data && s.data.length >= 2);
if (!hasData) {
return (
<div className="chart-empty" style={{ height }}>
<span className="chart-empty-icon">📉</span>
<span>Not enough data yet</span>
<span className="chart-empty-sub">History is recorded every 5 minutes</span>
</div>
);
}
// Scrollbar thumb position
const thumbWidth = canScroll ? Math.max(15, (VISIBLE_WINDOW / totalPoints) * 100) : 100;
const thumbLeft = canScroll ? viewStart * (100 - thumbWidth) : 0;
return (
<div
className={`multiline-chart-container ${isDragging ? 'dragging' : ''}`}
ref={containerRef}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleChartMouseMove}
onMouseLeave={handleChartMouseLeave}
>
<svg width={containerWidth} height={height} className="svg-chart" viewBox={`0 0 ${containerWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
<defs>
{computedSeries.map((s, i) => (
<React.Fragment key={i}>
<linearGradient id={`${id}-grad-${i}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity="0.20" />
<stop offset="100%" stopColor={s.color} stopOpacity="0.01" />
</linearGradient>
<filter id={`${id}-glow-${i}`}>
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</React.Fragment>
))}
</defs>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
<line key={frac} x1={pad.left} y1={pad.top + h * (1 - frac)} x2={pad.left + w} y2={pad.top + h * (1 - frac)}
stroke="#2a2a2a" strokeWidth="1" strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'} />
))}
{/* Y axis labels */}
{yLabels.map((l, i) => (
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">{l.text}</text>
))}
{/* X axis labels */}
{xLabels.map((l, i) => (
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">{l.text}</text>
))}
{/* Series — area fills, lines, dots */}
{computedSeries.map((s, si) => {
if (s.points.length < 2) return null;
const lineD = s.path;
const areaD = lineD
+ ` L ${s.points[s.points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)}`
+ ` L ${s.points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
return (
<g key={si}>
{/* Area fill (only for first series to keep it clean) */}
{si === 0 && <path d={areaD} fill={`url(#${id}-grad-${si})`} />}
{/* Line */}
<path d={lineD} fill="none" stroke={s.color} strokeWidth="2" filter={`url(#${id}-glow-${si})`} strokeLinecap="round" opacity="0.9" />
<path d={lineD} fill="none" stroke={s.color} strokeWidth="1.5" strokeLinecap="round" />
{/* Dots — fewer when dense */}
{s.points.filter((_, i) => s.points.length <= 25 || i % Math.ceil(s.points.length / 18) === 0 || i === s.points.length - 1).map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="2" fill={s.color} stroke="#1a1a1a" strokeWidth="0.8" opacity="0.7" />
))}
{/* Endpoint */}
{s.lastPt && (
<circle cx={s.lastPt.x} cy={s.lastPt.y} r="3.5" fill={s.color} stroke="#fff" strokeWidth="1" />
)}
</g>
);
})}
{/* Tooltip crosshair */}
{tooltip && (
<line x1={tooltip.x} y1={pad.top} x2={tooltip.x} y2={pad.top + h} stroke="#666" strokeWidth="1" strokeDasharray="3,2" />
)}
</svg>
{/* HTML tooltip overlay */}
{tooltip && (
<div
className="chart-tooltip"
style={{ left: Math.min(tooltip.x, containerWidth - 120), top: 8 }}
>
<div className="chart-tooltip-time">{formatTime(tooltip.time)}</div>
{tooltip.items.map((item, i) => (
<div key={i} className="chart-tooltip-row">
<span className="chart-tooltip-dot" style={{ background: item.color }} />
<span className="chart-tooltip-name">{formatItemName(item.name)}</span>
<span className="chart-tooltip-val">{formatCount(item.value)}</span>
</div>
))}
</div>
)}
{/* Scrollbar */}
{canScroll && (
<div className="chart-scrollbar">
<div className="chart-scrollbar-track">
<div
className="chart-scrollbar-thumb"
style={{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }}
/>
</div>
<span className="chart-scrollbar-hint">Scroll or drag to navigate</span>
</div>
)}
</div>
);
}
// ===== Horizontal Bar Chart with rank medals =====
function BarChart({ data, width = 400, height = 200, color = '#ffaa00' }) {
if (!data || data.length === 0) {
return (
<div className="chart-empty" style={{ height }}>
<span>No data</span>
</div>
);
}
const maxVal = Math.max(...data.map((d) => d.value)) || 1;
const medals = ['🥇', '🥈', '🥉'];
const barColors = [
'#ffcc00', '#e8b800', '#d4a800', '#c09800', '#aa8800',
'#997a00', '#886c00', '#775e00', '#665100', '#554400',
];
return (
<div className="bar-chart" style={{ width }}>
{data.map((d, i) => (
<div key={d.name || i} className="bar-row" style={{ animationDelay: `${i * 50}ms` }}>
<div className="bar-rank">{i < 3 ? medals[i] : <span className="bar-rank-num">#{i + 1}</span>}</div>
<div className="bar-label">
<ItemIcon itemName={d.name} size={16} />
<span>{formatItemName(d.name)}</span>
</div>
<div className="bar-track">
<div
className="bar-fill"
style={{
width: `${(d.value / maxVal) * 100}%`,
background: `linear-gradient(90deg, ${barColors[i] || color}, ${barColors[i] || color}88)`,
}}
/>
</div>
<span className="bar-value">{formatCount(d.value)}</span>
</div>
))}
</div>
);
}
// ===== Category Donut Chart =====
function CategoryDonut({ categories, size = 140 }) {
const total = categories.reduce((s, c) => s + c.count, 0) || 1;
const cx = size / 2;
const cy = size / 2;
const r = size * 0.36;
const strokeWidth = size * 0.15;
const categoryColors = {
blocks: '#8b6d3c',
tools: '#9d9d9d',
combat: '#ff5555',
food: '#80b94e',
redstone: '#ff0000',
materials: '#4aedd9',
misc: '#a0a0a0',
};
let cumAngle = -90; // start from top
const segments = categories.map((c) => {
const angle = (c.count / total) * 360;
const seg = { ...c, startAngle: cumAngle, angle, color: categoryColors[c.id] || '#666' };
cumAngle += angle;
return seg;
});
// Convert angle to SVG arc
const describeArc = (startAngle, endAngle) => {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
};
const polarToCartesian = (cx, cy, r, angleDeg) => {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
};
return (
<div className="donut-container">
<svg width={size} height={size} className="donut-chart">
{segments.map((seg, i) => (
seg.angle > 0.5 && (
<path
key={i}
d={describeArc(seg.startAngle, seg.startAngle + Math.max(seg.angle - 1, 0.5))}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeLinecap="butt"
opacity="0.85"
/>
)
))}
<text x={cx} y={cy - 4} textAnchor="middle" fill="#fff" fontSize="12" fontWeight="700" fontFamily="'Silkscreen', monospace">
{categories.length}
</text>
<text x={cx} y={cy + 10} textAnchor="middle" fill="#888" fontSize="7" fontFamily="'Silkscreen', monospace">
TYPES
</text>
</svg>
<div className="donut-legend">
{segments.filter(s => s.count > 0).map((seg) => (
<div key={seg.id} className="donut-legend-item">
<span className="donut-dot" style={{ background: seg.color }} />
<span className="donut-legend-label">{seg.label}</span>
<span className="donut-legend-value">{formatCount(seg.count)}</span>
</div>
))}
</div>
</div>
);
}
// ===== Capacity Ring Gauge =====
function CapacityGauge({ used, total, size = 100 }) {
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
const cx = size / 2;
const cy = size / 2;
const r = size * 0.38;
const circ = 2 * Math.PI * r;
const offset = circ - (pct / 100) * circ;
const getColor = (p) => {
if (p >= 90) return '#ff5555';
if (p >= 70) return '#ffaa00';
return '#55ff55';
};
const color = getColor(pct);
return (
<div className="capacity-gauge">
<svg width={size} height={size}>
<defs>
<filter id="gauge-glow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Track */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#2a2a2a" strokeWidth="8" />
{/* Fill */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={color}
strokeWidth="8"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${cx} ${cy})`}
filter="url(#gauge-glow)"
style={{ transition: 'stroke-dashoffset 1s ease' }}
/>
<text x={cx} y={cy - 2} textAnchor="middle" fill={color} fontSize="16" fontWeight="700" fontFamily="'Silkscreen', monospace">
{pct}%
</text>
<text x={cx} y={cy + 12} textAnchor="middle" fill="#666" fontSize="6" fontFamily="'Silkscreen', monospace">
CAPACITY
</text>
</svg>
<div className="capacity-gauge-detail">
{formatCount(used)} / {formatCount(total)} slots
</div>
</div>
);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
function formatDate(ts) {
if (!ts) return '';
const d = new Date(ts);
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${h}:${m}`;
}
function AnalyticsPanel() {
const inventory = useInventoryStore((state) => state.inventory);
const historySummary = useInventoryStore((state) => state.historySummary);
const itemHistory = useInventoryStore((state) => state.itemHistory);
const fetchHistorySummary = useInventoryStore((state) => state.fetchHistorySummary);
const fetchItemHistory = useInventoryStore((state) => state.fetchItemHistory);
const smeltable = useInventoryStore((state) => state.smeltable);
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
// Multi-item tracking
const [trackedItems, setTrackedItems] = useState([]); // [{ name, color }]
const [historySearch, setHistorySearch] = useState('');
// Fetch summary on mount
useEffect(() => {
fetchHistorySummary();
const interval = setInterval(fetchHistorySummary, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [fetchHistorySummary]);
// Fetch history for each tracked item
useEffect(() => {
trackedItems.forEach((t) => {
if (!itemHistory[t.name]) {
fetchItemHistory(t.name);
}
});
}, [trackedItems, fetchItemHistory, itemHistory]);
// Top 10 items by count
const topItems = useMemo(() => {
const items = inventory.itemList || [];
return [...items]
.sort((a, b) => (b.count || 0) - (a.count || 0))
.slice(0, 10)
.map((item) => ({ name: item.name, value: item.count || 0 }));
}, [inventory.itemList]);
// Category breakdown
const categoryData = useMemo(() => {
const items = inventory.itemList || [];
const catCounts = {};
items.forEach((item) => {
const cat = getItemCategory(item.name);
catCounts[cat] = (catCounts[cat] || 0) + (item.count || 0);
});
return ITEM_CATEGORIES
.filter(c => c.id !== 'all')
.map(c => ({ ...c, count: catCounts[c.id] || 0 }))
.sort((a, b) => b.count - a.count);
}, [inventory.itemList]);
// Storage history — as a single series (total)
const storageSeries = useMemo(() => {
if (!historySummary || historySummary.length === 0) return [];
return [{
name: 'Total',
color: '#55ff55',
data: historySummary.map((h) => ({ time: h.recordedAt, value: h.total })),
}];
}, [historySummary]);
// Multi-item trend series
const itemTrendSeries = useMemo(() => {
return trackedItems.map((t) => {
const history = itemHistory[t.name];
if (!history || history.length === 0) return { name: t.name, color: t.color, data: [] };
return {
name: t.name,
color: t.color,
data: history.slice().reverse().map((h) => ({ time: h.recordedAt, value: h.count })),
};
});
}, [trackedItems, itemHistory]);
// Filtered items for search dropdown
const filteredItems = useMemo(() => {
if (!historySearch) return [];
const q = historySearch.toLowerCase();
const trackedNames = new Set(trackedItems.map((t) => t.name));
return (inventory.itemList || [])
.filter((item) => {
const name = (item.displayName || item.name || '').toLowerCase();
return name.includes(q) && !trackedNames.has(item.name);
})
.slice(0, 8);
}, [historySearch, inventory.itemList, trackedItems]);
// Smelting stats
const smeltingStats = useMemo(() => {
const entries = Object.entries(smeltable || {});
const enabled = entries.filter(([k]) => !disabledRecipes[k]).length;
const furnaces = Object.values(inventory.furnaceStatus || {});
const activeFurnaces = furnaces.filter((f) => f && f.active).length;
return {
totalRecipes: entries.length,
enabledRecipes: enabled,
totalFurnaces: furnaces.length,
activeFurnaces,
};
}, [smeltable, disabledRecipes, inventory.furnaceStatus]);
// Storage delta (from history)
const storageDelta = useMemo(() => {
if (!historySummary || historySummary.length < 2) return null;
const latest = historySummary[historySummary.length - 1];
const prev = historySummary[historySummary.length - 2];
return latest.total - prev.total;
}, [historySummary]);
// Add / remove tracked items
const addTrackedItem = (itemName) => {
if (trackedItems.length >= 12) return;
if (trackedItems.some((t) => t.name === itemName)) return;
const colorIdx = trackedItems.length % SERIES_COLORS.length;
setTrackedItems((prev) => [...prev, { name: itemName, color: SERIES_COLORS[colorIdx] }]);
setHistorySearch('');
fetchItemHistory(itemName);
};
const removeTrackedItem = (itemName) => {
setTrackedItems((prev) => prev.filter((t) => t.name !== itemName));
};
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
return (
<div className="analytics-panel">
{/* Hero Stats Row */}
<div className="analytics-hero">
<div className="hero-stat hero-stat--items">
<div className="hero-stat-icon">📦</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
<span className="hero-stat-label">Total Items</span>
{storageDelta !== null && (
<span className={`hero-stat-delta ${storageDelta >= 0 ? 'positive' : 'negative'}`}>
{storageDelta >= 0 ? '▲' : '▼'} {Math.abs(storageDelta).toLocaleString()}
</span>
)}
</div>
</div>
<div className="hero-stat hero-stat--types">
<div className="hero-stat-icon">🧊</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{(inventory.itemList || []).length}</span>
<span className="hero-stat-label">Unique Types</span>
</div>
</div>
<div className="hero-stat hero-stat--chests">
<div className="hero-stat-icon">🗄</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{inventory.chestCount || 0}</span>
<span className="hero-stat-label">Chests</span>
</div>
</div>
<div className="hero-stat hero-stat--furnaces">
<div className="hero-stat-icon">🔥</div>
<div className="hero-stat-content">
<span className="hero-stat-value">
<span className="hero-active">{smeltingStats.activeFurnaces}</span>/{smeltingStats.totalFurnaces}
</span>
<span className="hero-stat-label">Furnaces</span>
</div>
</div>
<div className="hero-stat hero-stat--recipes">
<div className="hero-stat-icon">📖</div>
<div className="hero-stat-content">
<span className="hero-stat-value">
<span className="hero-active">{smeltingStats.enabledRecipes}</span>/{smeltingStats.totalRecipes}
</span>
<span className="hero-stat-label">Recipes</span>
</div>
</div>
</div>
{/* Main Grid */}
<div className="analytics-grid">
{/* Storage Over Time — full width */}
<div className="analytics-card analytics-card-wide analytics-card--chart">
<div className="card-header">
<h3>📈 Storage Over Time</h3>
{storageSeries.length > 0 && storageSeries[0].data.length >= 2 && (
<span className="card-badge">{storageSeries[0].data.length} snapshots</span>
)}
</div>
<div className="chart-wrapper">
<MultiLineChart series={storageSeries} height={200} id="storage" />
</div>
</div>
{/* Capacity + Category Breakdown */}
<div className="analytics-card analytics-card--overview">
<div className="card-header">
<h3> Storage Health</h3>
</div>
<div className="overview-split">
<CapacityGauge used={inventory.usedSlots || 0} total={inventory.totalSlots || 0} size={120} />
<div className="overview-stats-col">
<div className="mini-stat">
<span className="mini-stat-label">Used Slots</span>
<span className="mini-stat-value">{(inventory.usedSlots || 0).toLocaleString()}</span>
</div>
<div className="mini-stat">
<span className="mini-stat-label">Free Slots</span>
<span className="mini-stat-value" style={{color: 'var(--mc-text-green)'}}>{(inventory.freeSlots || 0).toLocaleString()}</span>
</div>
<div className="mini-stat">
<span className="mini-stat-label">Total Slots</span>
<span className="mini-stat-value">{(inventory.totalSlots || 0).toLocaleString()}</span>
</div>
</div>
</div>
</div>
{/* Category Breakdown */}
<div className="analytics-card analytics-card--categories">
<div className="card-header">
<h3>📊 Category Breakdown</h3>
</div>
<CategoryDonut categories={categoryData} size={130} />
</div>
{/* Top Items */}
<div className="analytics-card analytics-card--ranking">
<div className="card-header">
<h3>🏆 Top Items</h3>
<span className="card-badge">{(inventory.itemList || []).length} total</span>
</div>
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
</div>
{/* Item Trend — multi-line, full width */}
<div className="analytics-card analytics-card-wide analytics-card--trend">
<div className="card-header">
<h3>🔍 Item Trends</h3>
<span className="card-badge">{trackedItems.length} / 12 tracked</span>
</div>
{/* Search bar to add items */}
<div className="item-history-search">
<div className="search-input-wrapper">
<span className="search-icon">🔎</span>
<input
type="text"
placeholder="Search and add items to track..."
value={historySearch}
onChange={(e) => setHistorySearch(e.target.value)}
className="search-input"
/>
</div>
{filteredItems.length > 0 && historySearch && (
<div className="item-history-dropdown">
{filteredItems.map((item) => (
<div
key={item.name}
className="item-history-option"
onClick={() => addTrackedItem(item.name)}
>
<ItemIcon itemName={item.name} size={16} />
<span>{formatItemName(item.name)}</span>
<span className="option-count">{formatCount(item.count)}</span>
</div>
))}
</div>
)}
</div>
{/* Tracked item chips */}
{trackedItems.length > 0 && (
<div className="tracked-items-bar">
{trackedItems.map((t) => (
<div key={t.name} className="tracked-chip" style={{ borderColor: t.color }}>
<span className="tracked-chip-dot" style={{ background: t.color }} />
<ItemIcon itemName={t.name} size={14} />
<span className="tracked-chip-name">{formatItemName(t.name)}</span>
<button className="tracked-chip-remove" onClick={() => removeTrackedItem(t.name)} title="Remove"></button>
</div>
))}
</div>
)}
{/* Chart */}
{itemTrendSeries.length > 0 && itemTrendSeries.some((s) => s.data.length >= 2) ? (
<div className="chart-wrapper">
<MultiLineChart series={itemTrendSeries} height={220} id="item-trends" />
</div>
) : (
<div className="chart-empty chart-empty--compact">
<span className="chart-empty-icon">📋</span>
<span>{trackedItems.length > 0 ? 'Loading history data...' : 'Search and add items above to track their trends'}</span>
<span className="chart-empty-sub">Each item gets its own colored line</span>
</div>
)}
</div>
</div>
</div>
);
}
export default AnalyticsPanel;

View File

@@ -0,0 +1,130 @@
.crafting-panel {
height: 100%;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crafting-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--mc-dark);
}
.crafting-header h2 {
font-size: 1rem;
color: var(--mc-text-yellow);
text-shadow: 2px 2px 0 var(--mc-dark);
}
.turtle-status {
font-size: 0.7rem;
font-weight: 700;
padding: 0.25rem 0.5rem;
border: 2px solid var(--mc-dark);
text-shadow: 1px 1px 0 var(--mc-dark);
}
.turtle-status.online {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
}
.turtle-status.offline {
background: #6b1a1a;
color: var(--mc-text-red);
}
.turtle-warning {
padding: 0.5rem;
background: #6b4a1a;
border: 2px solid var(--mc-dark);
color: var(--mc-text-gold);
font-size: 0.7rem;
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
.craft-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.craft-card {
padding: 0.75rem;
background: #3b3b3b;
border: 3px solid var(--mc-dark);
box-shadow: inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.craft-output {
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 0.375rem;
border-bottom: 2px solid var(--mc-dark);
}
.craft-info {
display: flex;
flex-direction: column;
}
.craft-name {
font-size: 0.8rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 var(--mc-dark);
}
.craft-count {
font-size: 0.55rem;
color: var(--mc-text-gray);
}
.craft-ingredients {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.craft-ingredient {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.55rem;
color: var(--mc-text-gray);
padding: 0.125rem 0.375rem;
background: var(--mc-dark);
border: 1px solid #333;
}
.craft-btn {
width: 100%;
}
.craft-btn.crafting {
opacity: 0.7;
cursor: not-allowed;
}
.craft-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.no-data {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--mc-text-gray);
font-size: 0.75rem;
}

View File

@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './CraftingPanel.css';
function CraftingPanel() {
const craftable = useInventoryStore((state) => state.craftable);
const craftTurtleOk = useInventoryStore((state) => state.craftTurtleOk);
const craftItem = useInventoryStore((state) => state.craftItem);
const [craftingIdx, setCraftingIdx] = useState(null);
const handleCraft = async (idx) => {
setCraftingIdx(idx);
await craftItem(idx);
setCraftingIdx(null);
};
return (
<div className="crafting-panel">
<div className="crafting-header">
<h2>🔨 Crafting</h2>
<div className={`turtle-status ${craftTurtleOk ? 'online' : 'offline'}`}>
🐢 {craftTurtleOk ? 'Turtle Online' : 'Turtle Offline'}
</div>
</div>
{!craftTurtleOk && (
<div className="turtle-warning">
Crafting turtle is not connected. Crafting commands will fail.
</div>
)}
<div className="craft-list">
{(craftable || []).map((recipe, idx) => (
<div key={idx} className="craft-card">
<div className="craft-output">
<ItemIcon itemName={recipe.output || ''} size={32} />
<div className="craft-info">
<span className="craft-name">{formatItemName(recipe.output || '')}</span>
<span className="craft-count">Produces {recipe.count || 1}</span>
</div>
</div>
<div className="craft-ingredients">
{recipe.slots && Object.entries(recipe.slots).map(([slot, item]) => (
<div key={slot} className="craft-ingredient">
<ItemIcon itemName={item || ''} size={16} />
<span>{formatItemName(item || '')}</span>
</div>
))}
</div>
<button
className={`mc-btn green craft-btn ${craftingIdx === idx ? 'crafting' : ''}`}
onClick={() => handleCraft(idx + 1)}
disabled={craftingIdx !== null || !craftTurtleOk}
>
{craftingIdx === idx + 1 ? '⏳ Crafting...' : '🔨 Craft'}
</button>
</div>
))}
{(!craftable || craftable.length === 0) && (
<div className="no-data">No crafting recipes configured</div>
)}
</div>
</div>
);
}
export default CraftingPanel;

View File

@@ -0,0 +1,54 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '2rem',
background: '#1a1a2e',
color: '#ff6b6b',
borderRadius: '8px',
margin: '1rem',
fontFamily: 'monospace',
}}>
<h2> Something went wrong</h2>
<p style={{ color: '#ccc' }}>
{this.state.error?.message || 'Unknown error'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
padding: '0.5rem 1rem',
background: '#4a7c59',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
🔄 Try Again
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,495 @@
.inventory-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Toolbar */
.inventory-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: #3b3b3b;
border-bottom: 2px solid var(--mc-dark);
flex-shrink: 0;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
background: var(--mc-dark);
border: 2px solid #333;
padding: 0.25rem 0.5rem;
flex: 1;
min-width: 180px;
}
.search-icon {
font-size: 0.8rem;
margin-right: 0.375rem;
}
.search-input {
border: none;
background: transparent;
color: var(--mc-text-white);
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.7rem;
outline: none;
width: 100%;
}
.search-input::placeholder {
color: var(--mc-text-gray);
}
.search-clear {
background: none;
border: none;
color: var(--mc-text-gray);
cursor: pointer;
font-size: 0.7rem;
padding: 0 0.25rem;
}
.search-clear:hover {
color: var(--mc-text-red);
}
.sort-controls {
display: flex;
gap: 0.25rem;
}
.item-count-label {
font-size: 0.6rem;
color: var(--mc-text-gray);
white-space: nowrap;
}
/* ===== Creative Inventory Category Tabs ===== */
.category-tabs {
display: flex;
gap: 0;
padding: 0 6px;
padding-top: 4px;
background: transparent;
flex-shrink: 0;
overflow-x: auto;
margin: 0 0.5rem;
margin-bottom: -3px;
position: relative;
z-index: 2;
}
.category-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px 4px;
background: #8b8b8b;
border: 2px solid;
border-color: #555 #fefefe #c6c6c6 #555;
border-bottom: 2px solid #555;
border-radius: 0;
cursor: pointer;
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.6rem;
color: var(--mc-text-dark);
font-weight: 700;
text-shadow: none;
transition: background 0.05s;
white-space: nowrap;
position: relative;
margin-right: 1px;
}
.category-tab:hover {
background: #aaa;
color: var(--mc-text-dark);
}
.category-tab.active {
background: var(--mc-inv-bg);
color: var(--mc-text-dark);
border-color: #fefefe #555 transparent #fefefe;
text-shadow: none;
z-index: 1;
padding-bottom: 6px;
margin-bottom: -2px;
}
.category-label {
display: none;
}
.category-count {
font-size: 0.5rem;
color: #555;
opacity: 0.7;
}
.category-tab.active .category-count {
color: var(--mc-text-dark);
opacity: 0.6;
}
@media (min-width: 900px) {
.category-label {
display: inline;
}
}
/* Body */
.inventory-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* Item Grid */
.item-grid-wrapper {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.5rem;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 2px;
padding: 6px;
background: var(--mc-inv-bg);
border: 3px solid var(--mc-dark);
box-shadow:
inset 3px 0 0 #fefefe,
inset 0 3px 0 #fefefe,
inset -3px 0 0 #555,
inset 0 -3px 0 #555;
min-height: 200px;
}
.item-slot {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--mc-inv-slot);
border: 2px solid;
border-color: var(--mc-inv-slot-border) var(--mc-inv-slot-light) var(--mc-inv-slot-light) var(--mc-inv-slot-border);
cursor: pointer;
position: relative;
overflow: visible;
padding: 2px;
transition: background 0.05s;
}
.item-slot:hover {
background: #aaa;
z-index: 10;
}
.item-slot.selected {
background: #b8d4f0;
box-shadow: 0 0 0 2px var(--mc-text-aqua);
z-index: 10;
}
.item-slot .item-icon-img,
.item-slot .item-icon-emoji {
width: 100%;
height: 100%;
object-fit: contain;
}
.item-slot-count {
position: absolute;
bottom: 1px;
right: 2px;
color: white;
font-size: 0.55rem;
font-weight: 700;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow:
1px 0 0 var(--mc-dark),
-1px 0 0 var(--mc-dark),
0 1px 0 var(--mc-dark),
0 -1px 0 var(--mc-dark);
pointer-events: none;
z-index: 2;
}
/* ===== Minecraft-Style Tooltip ===== */
.mc-tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #100010f0;
padding: 5px 8px;
pointer-events: none;
z-index: 100;
white-space: nowrap;
border: 1px solid #100010;
box-shadow:
inset 0 1px 0 0 #5000ff50,
inset 1px 0 0 0 #5000ff50,
inset -1px 0 0 0 #28007f50,
inset 0 -1px 0 0 #28007f50,
0 0 0 1px #100010;
transition: opacity 0.1s;
}
.item-slot:hover .mc-tooltip {
visibility: visible;
opacity: 1;
}
.mc-tooltip-name {
color: #fff;
font-size: 0.65rem;
font-weight: 700;
text-shadow: 1px 1px 0 #3e3e3e;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.mc-tooltip-count {
color: #aaa;
font-size: 0.55rem;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.mc-tooltip-id {
color: #555;
font-size: 0.45rem;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.empty-grid {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--mc-text-gray);
font-size: 0.75rem;
}
/* Detail Panel */
.item-detail-panel {
width: 280px;
background: #333;
border-left: 3px solid var(--mc-dark);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
flex-shrink: 0;
}
.detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--mc-dark);
}
.detail-info {
flex: 1;
}
.detail-info h3 {
font-size: 0.8rem;
color: var(--mc-text-white);
text-shadow: 1px 1px 0 var(--mc-dark);
}
.detail-id {
font-size: 0.55rem;
color: var(--mc-text-gray);
}
.detail-close {
background: none;
border: 2px solid #555;
color: var(--mc-text-gray);
cursor: pointer;
font-size: 0.8rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.detail-close:hover {
color: var(--mc-text-red);
border-color: var(--mc-text-red);
}
/* Detail stats */
.detail-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.375rem;
}
.detail-stat {
display: flex;
flex-direction: column;
padding: 0.375rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.detail-stat .label {
font-size: 0.55rem;
color: var(--mc-text-gray);
font-weight: 700;
text-transform: uppercase;
}
.detail-stat .value {
font-size: 0.8rem;
color: var(--mc-text-white);
font-weight: 700;
}
/* Order section */
.order-section {
padding: 0.5rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.order-section h4 {
font-size: 0.7rem;
color: var(--mc-text-gold);
margin-bottom: 0.5rem;
text-shadow: 1px 1px 0 #000;
}
.order-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.order-amount-controls {
display: flex;
gap: 0.25rem;
align-items: center;
}
.order-input {
flex: 1;
background: #2a2a2a;
border: 2px solid #444;
color: var(--mc-text-white);
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.75rem;
text-align: center;
padding: 0.25rem;
outline: none;
border-radius: 0;
}
.order-input:focus {
border-color: var(--mc-text-green);
}
.order-presets {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
/* Dropper location selector */
.dropper-select-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dropper-label {
font-size: 0.65rem;
color: var(--mc-text-gold);
text-shadow: 1px 1px 0 #000;
}
.dropper-select {
background: #2a2a2a;
border: 2px solid #444;
color: var(--mc-text-white);
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.7rem;
padding: 0.375rem 0.5rem;
outline: none;
border-radius: 0;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23aaa'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
padding-right: 1.5rem;
}
.dropper-select:hover {
border-color: var(--mc-text-green);
}
.dropper-select:focus {
border-color: var(--mc-text-green);
box-shadow: inset 0 0 4px rgba(85, 255, 85, 0.15);
}
.dropper-select option {
background: #1a1a1a;
color: var(--mc-text-white);
}
.order-btn {
width: 100%;
padding: 0.625rem !important;
font-size: 0.8rem !important;
}
/* Responsive */
@media (max-width: 768px) {
.inventory-body {
flex-direction: column;
}
.item-detail-panel {
width: 100%;
border-left: none;
border-top: 3px solid var(--mc-dark);
max-height: 300px;
}
.item-grid {
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
}
}
@media (max-width: 480px) {
.item-grid {
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 2px;
padding: 4px;
}
.category-tabs {
padding: 0 0.25rem;
}
.category-tab {
padding: 4px 6px;
}
}

View File

@@ -0,0 +1,230 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName, getItemEmoji, formatCount, ITEM_CATEGORIES, getItemCategory } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './InventoryGrid.css';
function InventoryGrid() {
const inventory = useInventoryStore((state) => state.inventory);
const searchQuery = useInventoryStore((state) => state.searchQuery);
const setSearchQuery = useInventoryStore((state) => state.setSearchQuery);
const getFilteredItems = useInventoryStore((state) => state.getFilteredItems);
const orderItem = useInventoryStore((state) => state.orderItem);
const dropperNicknames = useInventoryStore((state) => state.dropperNicknames) || {};
const [selectedItemName, setSelectedItemName] = useState(null);
const [orderAmount, setOrderAmount] = useState(1);
const [sortBy, setSortBy] = useState('count');
const [activeCategory, setActiveCategory] = useState('all');
const [selectedDropper, setSelectedDropper] = useState('');
// Derive selectedItem from live inventory so count always reflects real-time data
const selectedItem = useMemo(() => {
if (!selectedItemName) return null;
const items = inventory.itemList || [];
return items.find((i) => i.name === selectedItemName) || null;
}, [selectedItemName, inventory.itemList]);
const droppers = inventory.droppers || [];
const items = getFilteredItems();
const filteredByCategory = useMemo(() => {
if (activeCategory === 'all') return items;
return items.filter((item) => getItemCategory(item.name) === activeCategory);
}, [items, activeCategory]);
const sortedItems = useMemo(() => {
return [...filteredByCategory].sort((a, b) => {
if (sortBy === 'count') return (b.count || 0) - (a.count || 0);
if (sortBy === 'name') return (a.displayName || a.name || '').localeCompare(b.displayName || b.name || '');
return 0;
});
}, [filteredByCategory, sortBy]);
const handleOrder = useCallback(async () => {
if (!selectedItem || orderAmount <= 0) return;
await orderItem(selectedItem.name, orderAmount, selectedDropper || undefined);
}, [selectedItem, orderAmount, orderItem, selectedDropper]);
const handleItemClick = (item) => {
setSelectedItemName(item.name);
setOrderAmount(1);
};
// Count items per category for badges
const categoryCounts = useMemo(() => {
const counts = { all: items.length };
for (const cat of ITEM_CATEGORIES) {
if (cat.id !== 'all') {
counts[cat.id] = items.filter((item) => getItemCategory(item.name) === cat.id).length;
}
}
return counts;
}, [items]);
return (
<div className="inventory-panel">
{/* Search & controls bar */}
<div className="inventory-toolbar">
<div className="search-box">
<span className="search-icon">🔍</span>
<input
type="text"
placeholder="Search items..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button className="search-clear" onClick={() => setSearchQuery('')}></button>
)}
</div>
<div className="sort-controls">
<button
className={`mc-btn ${sortBy === 'count' ? 'green' : ''}`}
onClick={() => setSortBy('count')}
>
# Count
</button>
<button
className={`mc-btn ${sortBy === 'name' ? 'green' : ''}`}
onClick={() => setSortBy('name')}
>
A Name
</button>
</div>
<div className="item-count-label">
{sortedItems.length} types · {(inventory.grandTotal || 0).toLocaleString()} items
</div>
</div>
{/* Creative inventory category tabs */}
<div className="category-tabs">
{ITEM_CATEGORIES.map((cat) => (
<button
key={cat.id}
className={`category-tab ${activeCategory === cat.id ? 'active' : ''}`}
onClick={() => setActiveCategory(cat.id)}
title={cat.label}
>
<ItemIcon itemName={`minecraft:${cat.icon}`} size={20} />
<span className="category-label">{cat.label}</span>
{categoryCounts[cat.id] > 0 && (
<span className="category-count">{categoryCounts[cat.id]}</span>
)}
</button>
))}
</div>
<div className="inventory-body">
{/* Item grid */}
<div className="item-grid-wrapper">
<div className="item-grid">
{sortedItems.map((item) => (
<div
key={item.name}
className={`item-slot ${selectedItem?.name === item.name ? 'selected' : ''}`}
onClick={() => handleItemClick(item)}
>
<ItemIcon itemName={item.name} size={48} />
<span className="item-slot-count">{formatCount(item.count)}</span>
{/* Minecraft-style tooltip */}
<div className="mc-tooltip">
<div className="mc-tooltip-name">{formatItemName(item.name)}</div>
<div className="mc-tooltip-count">{(item.count || 0).toLocaleString()} items</div>
<div className="mc-tooltip-id">{item.name}</div>
</div>
</div>
))}
{sortedItems.length === 0 && (
<div className="empty-grid">
{searchQuery ? `No items matching "${searchQuery}"` : activeCategory !== 'all' ? 'No items in this category' : 'No items in storage'}
</div>
)}
</div>
</div>
{/* Item detail / order panel */}
{selectedItem && (
<div className="item-detail-panel">
<div className="detail-header">
<ItemIcon itemName={selectedItem.name || ''} size={48} />
<div className="detail-info">
<h3>{formatItemName(selectedItem.name || '')}</h3>
<span className="detail-id">{selectedItem.name || ''}</span>
</div>
<button className="detail-close" onClick={() => setSelectedItemName(null)}></button>
</div>
<div className="detail-stats">
<div className="detail-stat">
<span className="label">Total</span>
<span className="value">{(selectedItem.count || 0).toLocaleString()}</span>
</div>
<div className="detail-stat">
<span className="label">Stacks</span>
<span className="value">
{Math.ceil((selectedItem.count || 0) / 64)}
</span>
</div>
</div>
<div className="order-section">
<h4>📤 Order Items</h4>
<div className="order-controls">
{droppers.length >= 1 && (
<div className="dropper-select-wrapper">
<label className="dropper-label">📍 Dispense to:</label>
<select
className="dropper-select"
value={selectedDropper}
onChange={(e) => setSelectedDropper(e.target.value)}
>
{droppers.map((d) => {
const nick = dropperNicknames[d.name];
const shortName = d.name.replace(/^minecraft:/, '');
const label = nick
? `${nick} (${shortName})`
: `${shortName}${d.isDefault ? ' (default)' : d.clientId ? ` (client ${d.clientId})` : ''}`;
return (
<option key={d.name} value={d.name}>
{label}
</option>
);
})}
</select>
</div>
)}
<div className="order-amount-controls">
<button className="mc-btn" onClick={() => setOrderAmount(Math.max(1, orderAmount - 1))}>-</button>
<input
type="number"
className="order-input"
value={orderAmount}
onChange={(e) => setOrderAmount(Math.max(1, parseInt(e.target.value) || 1))}
min="1"
max={selectedItem.count || 1}
/>
<button className="mc-btn" onClick={() => setOrderAmount(Math.min(selectedItem.count || 1, orderAmount + 1))}>+</button>
</div>
<div className="order-presets">
<button className="mc-btn" onClick={() => setOrderAmount(1)}>1</button>
<button className="mc-btn" onClick={() => setOrderAmount(16)}>16</button>
<button className="mc-btn" onClick={() => setOrderAmount(32)}>32</button>
<button className="mc-btn" onClick={() => setOrderAmount(64)}>64</button>
<button className="mc-btn" onClick={() => setOrderAmount(selectedItem.count || 1)}>All</button>
</div>
<button className="mc-btn green order-btn" onClick={handleOrder}>
📤 Dispense x{orderAmount}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default InventoryGrid;

View File

@@ -0,0 +1,18 @@
.item-icon-img {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
display: block;
-webkit-user-drag: none;
user-select: none;
/* Slight border to separate from dark backgrounds */
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
}
.item-icon-emoji {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
user-select: none;
}

View File

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

View File

@@ -0,0 +1,317 @@
/* ============================================
Settings Panel (overlay modal)
============================================ */
.settings-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
animation: settingsFadeIn 0.15s ease-out;
}
@keyframes settingsFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.settings-panel {
background: #2a2a2a;
border: 3px solid var(--mc-stone-dark);
box-shadow:
inset 1px 1px 0 var(--mc-border-highlight),
inset -1px -1px 0 var(--mc-border-shadow),
0 8px 32px rgba(0, 0, 0, 0.6);
width: 90%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: settingsSlideIn 0.15s ease-out;
}
@keyframes settingsSlideIn {
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--mc-oak-dark);
border-bottom: 2px solid var(--mc-dark);
box-shadow:
inset 0 2px 0 var(--mc-oak-light),
inset 0 -1px 0 var(--mc-dirt-dark);
}
.settings-header h2 {
font-size: 0.9rem;
color: var(--mc-text-yellow);
text-shadow: 2px 2px 0 var(--mc-dark);
}
.settings-close {
background: none;
border: 2px solid transparent;
color: var(--mc-text-gray);
font-family: 'Silkscreen', monospace;
font-size: 0.9rem;
cursor: pointer;
padding: 0.15rem 0.4rem;
transition: color 0.15s, border-color 0.15s;
}
.settings-close:hover {
color: var(--mc-text-red);
border-color: var(--mc-text-red);
}
.settings-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.settings-section h3 {
font-size: 0.8rem;
color: var(--mc-text-gold);
text-shadow: 1px 1px 0 #000;
margin-bottom: 0.4rem;
}
.settings-hint {
font-size: 0.6rem;
color: var(--mc-text-gray);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.settings-empty {
font-size: 0.65rem;
color: var(--mc-text-gray);
font-style: italic;
text-align: center;
padding: 1.5rem 0;
}
/* === Dropper nickname list === */
.dropper-nickname-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.dropper-nickname-row {
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;
}
.dropper-nickname-row:hover {
border-color: #555;
}
.dropper-nickname-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.dropper-raw-name {
font-size: 0.65rem;
color: var(--mc-text-white);
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.dropper-badge {
font-size: 0.5rem;
padding: 0.1rem 0.3rem;
border-radius: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dropper-badge.default {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
border: 1px solid var(--mc-grass);
}
.dropper-badge.client {
background: #2a2854;
color: var(--mc-text-aqua);
border: 1px solid #4a48a4;
}
.dropper-current-nick {
font-size: 0.6rem;
color: var(--mc-text-gold);
font-style: italic;
}
.dropper-nickname-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.nickname-input {
background: #1a1a1a;
border: 2px solid #444;
color: var(--mc-text-white);
font-family: 'Silkscreen', monospace;
font-size: 0.6rem;
padding: 0.3rem 0.4rem;
outline: none;
width: 120px;
transition: border-color 0.15s;
}
.nickname-input:focus {
border-color: var(--mc-text-green);
}
.nickname-input::placeholder {
color: #555;
}
.nick-btn {
padding: 0.25rem 0.4rem !important;
font-size: 0.65rem !important;
min-width: unset !important;
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 {
background: none;
border: 2px solid transparent;
cursor: pointer;
font-size: 1.15rem;
padding: 0.2rem 0.35rem;
transition: border-color 0.15s, transform 0.3s;
color: var(--mc-text-gray);
}
.settings-gear:hover {
border-color: var(--mc-text-gold);
transform: rotate(45deg);
color: var(--mc-text-gold);
}
/* === Responsive === */
@media (max-width: 480px) {
.settings-panel {
width: 96%;
max-height: 90vh;
}
.nickname-input {
width: 80px;
}
.dropper-nickname-row {
flex-direction: column;
align-items: flex-start;
}
.dropper-nickname-actions {
width: 100%;
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,213 @@
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);
setEditValue(dropperNicknames[dropperName] || '');
}, [dropperNicknames]);
const saveNickname = useCallback(async () => {
if (!editingDropper) return;
setSaving(true);
await setDropperNickname(editingDropper, editValue);
setSaving(false);
setEditingDropper(null);
setEditValue('');
}, [editingDropper, editValue, setDropperNickname]);
const removeNickname = useCallback(async (dropperName) => {
setSaving(true);
await setDropperNickname(dropperName, '');
setSaving(false);
}, [setDropperNickname]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter') saveNickname();
if (e.key === 'Escape') {
setEditingDropper(null);
setEditValue('');
}
}, [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 (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2> Settings</h2>
<button className="settings-close" onClick={onClose}></button>
</div>
<div className="settings-body">
<div className="settings-section">
<h3>🏷 Dropper Nicknames</h3>
<p className="settings-hint">
Give your dispensers friendly names so they're easier to identify.
</p>
{droppers.length === 0 ? (
<div className="settings-empty">
No droppers detected. Connect the bridge to discover droppers.
</div>
) : (
<div className="dropper-nickname-list">
{droppers.map((d) => {
const nickname = dropperNicknames[d.name];
const isEditing = editingDropper === d.name;
const shortName = d.name.replace(/^minecraft:/, '');
return (
<div key={d.name} className="dropper-nickname-row">
<div className="dropper-nickname-info">
<span className="dropper-raw-name">
{shortName}
{d.isDefault && <span className="dropper-badge default">default</span>}
{d.clientId && <span className="dropper-badge client">client {d.clientId}</span>}
</span>
{nickname && !isEditing && (
<span className="dropper-current-nick">"{nickname}"</span>
)}
</div>
<div className="dropper-nickname-actions">
{isEditing ? (
<>
<input
type="text"
className="nickname-input"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter nickname..."
maxLength={32}
autoFocus
disabled={saving}
/>
<button
className="mc-btn green nick-btn"
onClick={saveNickname}
disabled={saving}
>
</button>
<button
className="mc-btn nick-btn"
onClick={() => { setEditingDropper(null); setEditValue(''); }}
disabled={saving}
>
</button>
</>
) : (
<>
<button
className="mc-btn nick-btn"
onClick={() => startEditing(d.name)}
title="Edit nickname"
>
</button>
{nickname && (
<button
className="mc-btn red nick-btn"
onClick={() => removeNickname(d.name)}
title="Remove nickname"
disabled={saving}
>
🗑
</button>
)}
</>
)}
</div>
</div>
);
})}
</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>
);
}
export default SettingsPanel;

View File

@@ -0,0 +1,223 @@
.smelting-panel {
height: 100%;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.smelting-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--mc-dark);
}
.smelting-header h2 {
font-size: 1rem;
color: var(--mc-text-yellow);
text-shadow: 2px 2px 0 var(--mc-dark);
}
.smelting-paused-banner {
padding: 0.5rem;
background: #6b1a1a;
border: 2px solid var(--mc-dark);
color: var(--mc-text-red);
text-align: center;
font-size: 0.75rem;
font-weight: 700;
text-shadow: 1px 1px 0 #000;
animation: blink-warn 2s infinite;
}
@keyframes blink-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Furnace grid */
.furnace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.furnace-card {
padding: 0.5rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.furnace-card.active {
border-color: var(--mc-text-gold);
box-shadow: 0 0 4px rgba(255, 170, 0, 0.2);
}
.furnace-name {
font-size: 0.6rem;
color: var(--mc-text-gray);
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.375rem;
}
.furnace-slot {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6rem;
margin: 0.125rem 0;
}
.slot-label {
color: var(--mc-text-gray);
font-weight: 700;
min-width: 2rem;
}
.furnace-empty {
font-size: 0.6rem;
color: var(--mc-text-gray);
font-style: italic;
}
/* Recipe groups */
.recipe-bulk-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.recipe-groups {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 500px;
overflow-y: auto;
}
.recipe-group {
border: 2px solid #333;
background: #2e2e2e;
}
.recipe-group-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #3a3a3a;
cursor: pointer;
transition: background 0.05s;
border-bottom: 1px solid #333;
}
.recipe-group-header:hover {
background: #444;
}
.recipe-group-toggle {
font-size: 0.6rem;
color: var(--mc-text-gold);
flex-shrink: 0;
width: 0.8rem;
}
.recipe-group-name {
font-size: 0.7rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 var(--mc-dark);
flex: 1;
}
.recipe-group-count {
font-size: 0.55rem;
color: var(--mc-text-gray);
flex-shrink: 0;
}
.recipe-group-items {
display: flex;
flex-direction: column;
}
.recipe-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 400px;
overflow-y: auto;
}
.recipe-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--mc-dark);
border: 2px solid #333;
cursor: pointer;
transition: background 0.05s;
}
.recipe-item:hover {
background: #2a2a2a;
border-color: var(--mc-text-gold);
}
.recipe-item.disabled {
opacity: 0.5;
}
.recipe-toggle {
font-size: 0.8rem;
flex-shrink: 0;
}
.recipe-input,
.recipe-output {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6rem;
min-width: 0;
}
.recipe-input span,
.recipe-output span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-arrow {
color: var(--mc-text-gold);
font-weight: 700;
flex-shrink: 0;
}
.recipe-furnaces {
display: flex;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.furnace-tag {
font-size: 0.5rem;
padding: 0.125rem 0.25rem;
background: #444;
border: 1px solid #555;
color: var(--mc-text-gray);
}
.no-data {
padding: 1rem;
text-align: center;
color: var(--mc-text-gray);
font-size: 0.7rem;
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useMemo } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './SmeltingPanel.css';
function SmeltingPanel() {
const inventory = useInventoryStore((state) => state.inventory);
const smeltingPaused = useInventoryStore((state) => state.smeltingPaused);
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
const smeltable = useInventoryStore((state) => state.smeltable);
const toggleSmelting = useInventoryStore((state) => state.toggleSmelting);
const toggleRecipe = useInventoryStore((state) => state.toggleRecipe);
const enableAllRecipes = useInventoryStore((state) => state.enableAllRecipes);
const disableAllRecipes = useInventoryStore((state) => state.disableAllRecipes);
const [collapsedGroups, setCollapsedGroups] = useState({});
const furnaceStatus = inventory.furnaceStatus || {};
const furnaceEntries = Object.entries(furnaceStatus);
const smeltableEntries = Object.entries(smeltable || {});
// Group recipes by output item
const groupedRecipes = useMemo(() => {
const groups = {};
for (const [input, recipe] of smeltableEntries) {
const result = (recipe && recipe.result) || 'unknown';
if (!groups[result]) {
groups[result] = [];
}
groups[result].push({ input, recipe });
}
// Sort groups by output name
return Object.entries(groups).sort(([a], [b]) =>
formatItemName(a).localeCompare(formatItemName(b))
);
}, [smeltableEntries]);
const toggleGroup = (groupKey) => {
setCollapsedGroups((prev) => ({
...prev,
[groupKey]: !prev[groupKey],
}));
};
return (
<div className="smelting-panel">
{/* Header */}
<div className="smelting-header">
<h2>🔥 Smelting Dashboard</h2>
<div className="smelting-controls">
<button
className={`mc-btn ${smeltingPaused ? 'green' : 'red'}`}
onClick={toggleSmelting}
>
{smeltingPaused ? '▶ Resume' : '⏸ Pause'}
</button>
</div>
</div>
{smeltingPaused && (
<div className="smelting-paused-banner"> Smelting is PAUSED</div>
)}
{/* Furnace status */}
<div className="furnace-section detail-section">
<h3>🔥 Furnaces ({inventory.furnaceCount || 0})</h3>
{furnaceEntries.length > 0 ? (
<div className="furnace-grid">
{furnaceEntries.map(([name, status]) => (
<div key={name} className={`furnace-card ${(status && status.active) ? 'active' : 'idle'}`}>
<div className="furnace-name">{(name || '').replace(/^minecraft:/, '')}</div>
<div className="furnace-status">
{status && status.input && (
<div className="furnace-slot">
<span className="slot-label">In:</span>
<ItemIcon itemName={status.input.name} size={16} />
<span>{status.input.count || 0}</span>
</div>
)}
{status && status.fuel && (
<div className="furnace-slot">
<span className="slot-label">Fuel:</span>
<ItemIcon itemName={status.fuel.name} size={16} />
<span>{status.fuel.count || 0}</span>
</div>
)}
{status && status.output && (
<div className="furnace-slot">
<span className="slot-label">Out:</span>
<ItemIcon itemName={status.output.name} size={16} />
<span>{status.output.count || 0}</span>
</div>
)}
{(!status || (!status.input && !status.fuel && !status.output)) && (
<span className="furnace-empty">Empty</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="no-data">No furnace data available</div>
)}
</div>
{/* Recipe list - Grouped by output */}
<div className="recipe-section detail-section">
<h3>📋 Smelting Recipes ({smeltableEntries.length})</h3>
<div className="recipe-bulk-controls">
<button className="mc-btn green" onClick={enableAllRecipes}> Enable All</button>
<button className="mc-btn red" onClick={disableAllRecipes}> Disable All</button>
</div>
<div className="recipe-groups">
{groupedRecipes.map(([outputName, recipes]) => {
const isCollapsed = collapsedGroups[outputName];
const enabledCount = recipes.filter((r) => !disabledRecipes[r.input]).length;
return (
<div key={outputName} className="recipe-group">
<div
className="recipe-group-header"
onClick={() => toggleGroup(outputName)}
>
<span className="recipe-group-toggle">
{isCollapsed ? '▶' : '▼'}
</span>
<ItemIcon itemName={outputName} size={24} />
<span className="recipe-group-name">
{formatItemName(outputName)}
</span>
<span className="recipe-group-count">
{enabledCount}/{recipes.length} enabled
</span>
</div>
{!isCollapsed && (
<div className="recipe-group-items">
{recipes.map(({ input, recipe }) => {
const isDisabled = disabledRecipes[input];
const furnaces = (recipe && recipe.furnaces) || [];
return (
<div
key={input}
className={`recipe-item ${isDisabled ? 'disabled' : 'enabled'}`}
onClick={() => toggleRecipe(input)}
>
<div className="recipe-toggle">
{isDisabled ? '⬜' : '✅'}
</div>
<div className="recipe-input">
<ItemIcon itemName={input} size={20} />
<span>{formatItemName(input)}</span>
</div>
<span className="recipe-arrow"></span>
<div className="recipe-output">
<ItemIcon itemName={outputName} size={20} />
</div>
<div className="recipe-furnaces">
{furnaces.map((f) => (
<span key={f} className="furnace-tag">
{(f || '').replace(/^minecraft:/, '')}
</span>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
{groupedRecipes.length === 0 && (
<div className="no-data">No smelting recipes loaded</div>
)}
</div>
</div>
</div>
);
}
export default SmeltingPanel;

View File

@@ -0,0 +1,164 @@
.storage-overview {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--mc-dark);
}
.overview-header h2 {
font-size: 0.9rem;
color: var(--mc-text-yellow);
text-shadow: 1px 1px 0 var(--mc-dark);
}
/* Capacity */
.capacity-section {
padding: 0.5rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.capacity-label {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
font-weight: 700;
margin-bottom: 0.375rem;
color: var(--mc-text-gray);
text-transform: uppercase;
}
.capacity-percent.normal { color: var(--mc-text-green); }
.capacity-percent.warning { color: var(--mc-text-gold); }
.capacity-percent.critical { color: var(--mc-text-red); }
.capacity-bar {
width: 100%;
height: 12px;
background: #333;
border: 2px solid;
border-color: #222 #555 #555 #222;
overflow: hidden;
}
.capacity-fill {
height: 100%;
transition: width 0.5s;
}
.capacity-fill.normal { background: var(--mc-grass); }
.capacity-fill.warning { background: var(--mc-text-gold); }
.capacity-fill.critical { background: var(--mc-text-red); }
.capacity-detail {
font-size: 0.6rem;
color: var(--mc-text-gray);
text-align: center;
margin-top: 0.25rem;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.375rem;
}
.stat-item {
display: flex;
flex-direction: column;
padding: 0.375rem;
background: var(--mc-dark);
border: 2px solid #333;
}
.stat-label {
font-size: 0.55rem;
color: var(--mc-text-gray);
font-weight: 700;
text-transform: uppercase;
}
.stat-value {
font-size: 0.75rem;
color: var(--mc-text-white);
font-weight: 700;
text-shadow: 1px 1px 0 #000;
}
.stat-value.ok { color: var(--mc-text-green); }
.stat-value.offline { color: var(--mc-text-red); }
/* Peripherals */
.peripherals-section h3,
.activity-section h3 {
font-size: 0.65rem;
color: var(--mc-text-gold);
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.375rem;
text-shadow: 1px 1px 0 var(--mc-dark);
}
.peripheral-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.peripheral-item {
font-size: 0.65rem;
padding: 0.25rem 0.375rem;
background: var(--mc-dark);
border: 1px solid #333;
}
.peripheral-item.ok { color: var(--mc-text-green); }
.peripheral-item.error { color: var(--mc-text-red); }
/* Activity */
.activity-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.activity-item {
font-size: 0.6rem;
padding: 0.25rem 0.5rem;
border: 1px solid #333;
font-weight: 700;
}
.activity-item.active {
background: var(--mc-grass-dark);
color: var(--mc-text-green);
animation: activity-pulse 2s infinite;
}
.activity-item.idle {
background: var(--mc-dark);
color: var(--mc-text-gray);
}
@keyframes activity-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Last update */
.last-update {
font-size: 0.55rem;
color: var(--mc-text-gray);
text-align: center;
padding-top: 0.5rem;
border-top: 1px solid #444;
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import './StorageOverview.css';
function StorageOverview() {
const inventory = useInventoryStore((state) => state.inventory);
const activity = useInventoryStore((state) => state.activity);
const connected = useInventoryStore((state) => state.connected);
const lastUpdate = useInventoryStore((state) => state.lastUpdate);
const craftTurtleOk = useInventoryStore((state) => state.craftTurtleOk);
const requestScan = useInventoryStore((state) => state.requestScan);
const usedPercent = inventory.totalSlots > 0
? Math.round((inventory.usedSlots / inventory.totalSlots) * 100)
: 0;
const getUsageColor = () => {
if (usedPercent >= 90) return 'critical';
if (usedPercent >= 75) return 'warning';
return 'normal';
};
const timeSinceUpdate = lastUpdate
? Math.floor((Date.now() - lastUpdate) / 1000)
: null;
return (
<div className="storage-overview">
<div className="overview-header">
<h2>📊 Storage</h2>
<button className="mc-btn blue" onClick={requestScan} title="Refresh inventory scan">
🔍 Scan
</button>
</div>
{/* Storage capacity bar */}
<div className="capacity-section">
<div className="capacity-label">
<span>Capacity</span>
<span className={`capacity-percent ${getUsageColor()}`}>{usedPercent}%</span>
</div>
<div className="capacity-bar">
<div
className={`capacity-fill ${getUsageColor()}`}
style={{ width: `${usedPercent}%` }}
></div>
</div>
<div className="capacity-detail">
{inventory.usedSlots} / {inventory.totalSlots} slots
</div>
</div>
{/* Stats grid */}
<div className="stats-grid">
<div className="stat-item">
<span className="stat-label">Total Items</span>
<span className="stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
</div>
<div className="stat-item">
<span className="stat-label">Item Types</span>
<span className="stat-value">{(inventory.itemList || []).length}</span>
</div>
<div className="stat-item">
<span className="stat-label">Chests</span>
<span className="stat-value">{inventory.chestCount || 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Free Slots</span>
<span className="stat-value">{inventory.freeSlots || 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Furnaces</span>
<span className="stat-value">{inventory.furnaceCount || 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Craft Turtle</span>
<span className={`stat-value ${craftTurtleOk ? 'ok' : 'offline'}`}>
{craftTurtleOk ? '✅ Online' : '❌ Offline'}
</span>
</div>
</div>
{/* Peripherals */}
<div className="peripherals-section">
<h3>Peripherals</h3>
<div className="peripheral-list">
<div className={`peripheral-item ${inventory.dropperOk ? 'ok' : 'error'}`}>
<span>{inventory.dropperOk ? '✅' : '❌'} Dropper</span>
</div>
<div className={`peripheral-item ${inventory.barrelOk ? 'ok' : 'error'}`}>
<span>{inventory.barrelOk ? '✅' : '❌'} Barrel</span>
</div>
</div>
</div>
{/* Activity indicators */}
<div className="activity-section">
<h3>Activity</h3>
<div className="activity-list">
{activity.sorting && <div className="activity-item active">📦 Sorting</div>}
{activity.smelting && <div className="activity-item active">🔥 Smelting</div>}
{activity.dispensing && <div className="activity-item active">📤 Dispensing</div>}
{activity.composting && <div className="activity-item active">🌱 Composting</div>}
{activity.defragging && <div className="activity-item active">🔧 Defragging</div>}
{activity.crafting && <div className="activity-item active">🔨 Crafting</div>}
{!activity.sorting && !activity.smelting && !activity.dispensing &&
!activity.composting && !activity.defragging && !activity.crafting && (
<div className="activity-item idle">💤 Idle</div>
)}
</div>
</div>
{/* Last update */}
{lastUpdate > 0 && (
<div className="last-update">
Last update: {timeSinceUpdate !== null ? `${timeSinceUpdate}s ago` : 'Never'}
</div>
)}
</div>
);
}
export default StorageOverview;

9
web/client/src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,383 @@
import { create } from 'zustand';
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 || '';
// 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() {
return crypto.randomUUID();
}
// ========== Timers (module-level so they survive store re-creates) ==========
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
function _applyStateData(data, current) {
return {
inventory: data.inventory || current.inventory,
activity: data.activity || current.activity,
alerts: data.alerts || current.alerts,
smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused,
disabledRecipes: data.disabledRecipes || current.disabledRecipes,
smeltable: data.smeltable || current.smeltable,
craftable: data.craftable || current.craftable,
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
};
}
export const useInventoryStore = create((set, get) => ({
// State
inventory: {
itemList: [],
grandTotal: 0,
chestCount: 0,
totalSlots: 0,
usedSlots: 0,
freeSlots: 0,
usedRatio: 0,
dropperOk: false,
barrelOk: false,
furnaceCount: 0,
furnaceStatus: {},
droppers: [],
},
activity: {},
alerts: [],
smeltingPaused: false,
disabledRecipes: {},
smeltable: {},
craftable: [],
craftTurtleOk: false,
bridgeConnected: false,
dropperNicknames: {},
connected: false,
ws: null,
lastUpdate: 0,
searchQuery: '',
commandResult: null,
historySummary: [],
itemHistory: {},
// Fetch state via HTTP API (fallback / initial load)
fetchState: async () => {
try {
const response = await fetch(`${API_URL}/inventory`);
if (!response.ok) return;
const data = await response.json();
const current = get();
// Only apply if we have actual data with items
if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) {
set(_applyStateData(data, current));
}
} catch (error) {
// Silent fail — WebSocket will provide data
}
},
// Start periodic HTTP polling (runs alongside WS for redundancy)
_startHttpPoll: () => {
if (_httpPollTimer) return;
_httpPollTimer = setInterval(() => {
get().fetchState();
}, HTTP_POLL_INTERVAL);
},
// Client-side WS health check — reconnect if no message received recently
_startWsHealthCheck: () => {
if (_wsHealthTimer) return;
_wsHealthTimer = setInterval(() => {
const ws = get().ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (_lastWsMessage > 0 && Date.now() - _lastWsMessage > WS_STALE_TIMEOUT) {
console.warn('⚠️ WebSocket stale (no messages for', Math.round((Date.now() - _lastWsMessage) / 1000), 's) — reconnecting');
ws.close(); // triggers onclose → reconnect
_lastWsMessage = 0; // reset so we don't loop
}
}, 10_000);
},
// WebSocket connection
connect: () => {
// Prevent duplicate connections
const existing = get().ws;
if (existing && (existing.readyState === WebSocket.CONNECTING || existing.readyState === WebSocket.OPEN)) {
return;
}
// Fetch state via HTTP immediately (don't wait for WS)
get().fetchState();
// Ensure background timers are running
get()._startHttpPoll();
get()._startWsHealthCheck();
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('✅ Connected to server');
_lastWsMessage = Date.now();
_reconnectDelay = 1000;
set({ connected: true, ws });
};
ws.onmessage = (event) => {
_lastWsMessage = Date.now();
try {
const data = JSON.parse(event.data);
if (data.type === 'initial_state' || data.type === 'state_update') {
set(_applyStateData(data, get()));
} else if (data.type === 'command_result') {
set({ commandResult: data });
// Clear after 5s
setTimeout(() => {
set((state) => {
if (state.commandResult === data) {
return { commandResult: null };
}
return {};
});
}, 5000);
}
} catch (error) {
console.error('Error processing message:', error);
}
};
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
setTimeout(() => {
get().connect();
}, delay);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
},
// Search
setSearchQuery: (query) => set({ searchQuery: query }),
getFilteredItems: () => {
const { inventory, searchQuery } = get();
const items = inventory.itemList || [];
if (!searchQuery) return items;
const q = searchQuery.toLowerCase();
return items.filter((item) => {
const name = (item.displayName || item.name || '').toLowerCase();
return name.includes(q);
});
},
// Actions via REST API
orderItem: async (itemName, amount, dropperName) => {
try {
const response = await fetch(`${API_URL}/order`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error ordering item:', error);
return { success: false, error: error.message };
}
},
requestScan: async () => {
try {
const response = await fetch(`${API_URL}/scan`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error requesting scan:', error);
return { success: false, error: error.message };
}
},
toggleSmelting: async () => {
try {
const response = await fetch(`${API_URL}/smelting/toggle`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error toggling smelting:', error);
return { success: false, error: error.message };
}
},
toggleRecipe: async (recipe) => {
try {
const response = await fetch(`${API_URL}/recipes/toggle`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ recipe, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error toggling recipe:', error);
return { success: false, error: error.message };
}
},
enableAllRecipes: async () => {
try {
const response = await fetch(`${API_URL}/recipes/enable-all`, {
method: 'POST',
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 {
const response = await fetch(`${API_URL}/recipes/disable-all`, {
method: 'POST',
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 };
}
},
craftItem: async (recipeIdx) => {
try {
const response = await fetch(`${API_URL}/craft`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ recipeIdx, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error crafting item:', error);
return { success: false, error: error.message };
}
},
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: authHeaders(),
body: JSON.stringify({ barrelName, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error sorting barrel:', error);
return { success: false, error: error.message };
}
},
// Dropper nicknames
setDropperNickname: async (dropperName, nickname) => {
try {
const response = await fetch(`${API_URL}/dropper-nicknames`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
});
const result = await response.json();
if (result.nicknames) {
set({ dropperNicknames: result.nicknames });
}
return result;
} catch (error) {
console.error('❌ Error setting dropper nickname:', error);
return { success: false, error: error.message };
}
},
fetchDropperNicknames: async () => {
try {
const response = await fetch(`${API_URL}/dropper-nicknames`);
if (!response.ok) return;
const data = await response.json();
set({ dropperNicknames: data.nicknames || {} });
} catch (error) {
console.error('❌ Error fetching dropper nicknames:', error);
}
},
// Analytics - fetch aggregate storage history
fetchHistorySummary: async () => {
try {
const response = await fetch(`${API_URL}/history-summary`);
if (!response.ok) return;
const data = await response.json();
set({ historySummary: data.history || [] });
} catch (error) {
console.error('❌ Error fetching history summary:', error);
}
},
// Analytics - fetch single item history
fetchItemHistory: async (itemName) => {
try {
const response = await fetch(`${API_URL}/history/${encodeURIComponent(itemName)}?limit=200`);
if (!response.ok) return;
const data = await response.json();
set((state) => ({
itemHistory: { ...state.itemHistory, [itemName]: data.history || [] },
}));
} catch (error) {
console.error('❌ Error fetching item history:', error);
}
},
}));

View File

@@ -0,0 +1,163 @@
/**
* Minecraft item utility functions
* Icons use official game textures via ItemIcon component
*/
// Format an item name for display
export function formatItemName(name) {
if (!name) return '';
return name
.replace(/^[a-z0-9_.-]+:/, '') // Strip mod prefix
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
// ========== Creative Inventory Categories ==========
export const ITEM_CATEGORIES = [
{ id: 'all', label: 'All', icon: 'chest' },
{ id: 'blocks', label: 'Blocks', icon: 'bricks' },
{ id: 'tools', label: 'Tools', icon: 'iron_pickaxe' },
{ id: 'combat', label: 'Combat', icon: 'iron_sword' },
{ id: 'food', label: 'Food', icon: 'apple' },
{ id: 'redstone', label: 'Redstone', icon: 'redstone' },
{ id: 'materials', label: 'Materials', icon: 'iron_ingot' },
{ id: 'misc', label: 'Misc', icon: 'bone' },
];
export function getItemCategory(itemName) {
if (!itemName) return 'misc';
const n = itemName.replace(/^[a-z0-9_.-]+:/, '');
// Redstone (check first - redstone_block should be redstone, not blocks)
if (/redstone|repeater|comparator|piston|observer|dropper|dispenser|hopper|lever|tripwire|daylight|note_block|sculk|target/.test(n)) return 'redstone';
// Combat
if (/sword|crossbow|trident|shield|helmet|chestplate|leggings|boots|horse_armor|arrow/.test(n)) return 'combat';
if (/bow/.test(n) && !/bowl|elbow/.test(n)) return 'combat';
// Tools
if (/pickaxe|shovel|_hoe|shears|fishing_rod|flint_and_steel|compass|clock|spyglass|bucket|name_tag|brush/.test(n)) return 'tools';
if (/axe/.test(n) && !/pickaxe/.test(n)) return 'tools';
// Food
if (/apple|bread|beef|porkchop|mutton|rabbit|salmon|potato|carrot|melon_slice|berries|cookie|pie|stew|soup|honey_bottle|dried_kelp|chorus_fruit|rotten_flesh|cooked_|baked_|golden_carrot/.test(n)) return 'food';
if (/^cake$/.test(n)) return 'food';
// Materials
if (/ingot|nugget|raw_iron|raw_gold|raw_copper|_shard|_dust|_powder|_scrap|bone_meal|slime_ball|magma_cream|ender_pearl|blaze|nether_star|ghast_tear|gunpowder|phantom_membrane|experience_bottle|ink_sac|nether_wart|_dye$/.test(n)) return 'materials';
if (/^(diamond|emerald|coal|charcoal|quartz|bone|sugar|stick|flint|paper|wheat|egg|book|leather|string|feather)$/.test(n)) return 'materials';
if (/seeds$|enchanted_book/.test(n)) return 'materials';
// Blocks
if (/planks|_log|_wood|stone|brick|_slab|stairs|_wall|_fence|_door|trapdoor|wool|concrete|terracotta|glass|_pane|sandstone|deepslate|obsidian|ore|_block|leaves|torch|lantern|crafting_table|furnace|anvil|chest|barrel|sponge|carpet|banner/.test(n)) return 'blocks';
if (/^(dirt|mud|clay|sand|red_sand|gravel|netherrack|tnt|cactus|pumpkin|melon|bamboo|cobblestone|ice|packed_ice|blue_ice)$/.test(n)) return 'blocks';
return 'misc';
}
// Fallback emoji mapping for common item categories
const CATEGORY_EMOJI = {
ingot: '🪙',
ore: '⛏️',
raw: '🪨',
log: '🪵',
planks: '🪵',
wool: '🧶',
dye: '🎨',
seed: '🌱',
crop: '🌾',
wheat: '🌾',
food: '🍖',
cooked: '🍗',
beef: '🥩',
porkchop: '🥩',
chicken: '🍗',
mutton: '🥩',
cod: '🐟',
salmon: '🐟',
potato: '🥔',
carrot: '🥕',
apple: '🍎',
bread: '🍞',
sword: '⚔️',
pickaxe: '⛏️',
axe: '🪓',
shovel: '🪏',
hoe: '🌿',
helmet: '🪖',
chestplate: '👕',
leggings: '👖',
boots: '👢',
bow: '🏹',
arrow: '🏹',
shield: '🛡️',
diamond: '💎',
emerald: '💚',
redstone: '🔴',
lapis: '🔵',
coal: '⚫',
iron: '⬜',
gold: '🟡',
netherite: '⬛',
stone: '🪨',
cobblestone: '🪨',
dirt: '🟫',
sand: '🟨',
gravel: '⬜',
glass: '🔲',
brick: '🧱',
torch: '🔦',
bone: '🦴',
string: '🧵',
leather: '🟤',
feather: '🪶',
egg: '🥚',
book: '📕',
paper: '📄',
bucket: '🪣',
potion: '🧪',
pearl: '🟣',
blaze: '🔥',
gunpowder: '💣',
sugar: '🍬',
stick: '🪵',
chest: '📦',
furnace: '🔥',
sponge: '🧽',
kelp: '🌿',
cactus: '🌵',
};
export function getItemEmoji(itemName) {
if (!itemName) return '📦';
const name = itemName.replace(/^[a-z0-9_]+:/, '').toLowerCase();
// Check direct matches
for (const [key, emoji] of Object.entries(CATEGORY_EMOJI)) {
if (name.includes(key)) return emoji;
}
return '📦';
}
// Compact number formatting
export function formatCount(count) {
if (count == null) return '0';
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
}
// Get rarity color based on item name
export function getRarityColor(itemName) {
if (!itemName) return '#ffffff';
const name = itemName.toLowerCase();
if (name.includes('netherite')) return '#4a2a4a';
if (name.includes('diamond')) return '#4aedd9';
if (name.includes('emerald')) return '#17dd62';
if (name.includes('gold') || name.includes('golden')) return '#ffaa00';
if (name.includes('iron')) return '#d8d8d8';
if (name.includes('enchanted') || name.includes('nether_star')) return '#ff55ff';
return '#ffffff';
}

26
web/client/vite.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
cors: true,
proxy: {
'/api': {
target: 'http://server:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://server:3001',
ws: true,
},
},
},
preview: {
host: '0.0.0.0',
port: 3000,
cors: true,
},
});

32
web/docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
server:
build: ./server
networks:
- inventory-network
volumes:
- server-data:/data
environment:
- API_KEY=${API_KEY:-}
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
restart: unless-stopped
client:
build:
context: ./client
args:
VITE_API_KEY: ${API_KEY:-}
ports:
- "80:80"
networks:
- inventory-network
depends_on:
server:
condition: service_healthy
restart: unless-stopped
networks:
inventory-network:
driver: bridge
volumes:
server-data:

3
web/server/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

35
web/server/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# Node.js backend
FROM node:20-alpine
# Build tools needed for better-sqlite3 native compilation
# su-exec for dropping privileges in entrypoint
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
RUN apk add --no-cache python3 make g++ su-exec libstdc++
WORKDIR /app
COPY 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 . .
# Create data directory for SQLite
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=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);
});
});

501
web/server/db.js Normal file
View File

@@ -0,0 +1,501 @@
import Database from 'better-sqlite3';
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 });
}
// 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');
db.pragma('synchronous = NORMAL');
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(`
CREATE TABLE IF NOT EXISTS items (
name TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
display_name TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS furnaces (
name TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'minecraft:furnace',
active INTEGER NOT NULL DEFAULT 0,
input TEXT,
fuel TEXT,
output TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS alerts (
item TEXT PRIMARY KEY,
triggered INTEGER NOT NULL DEFAULT 0,
current INTEGER NOT NULL DEFAULT 0,
threshold INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS item_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
count INTEGER NOT NULL,
recorded_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_item_history_name ON item_history(name);
CREATE INDEX IF NOT EXISTS idx_item_history_time ON item_history(recorded_at);
`);
// ========== Prepared Statements ==========
const upsertItem = db.prepare(`
INSERT INTO items (name, count, display_name, updated_at)
VALUES (@name, @count, @displayName, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
count = @count,
display_name = @displayName,
updated_at = @updatedAt
`);
const upsertFurnace = db.prepare(`
INSERT INTO furnaces (name, type, active, input, fuel, output, updated_at)
VALUES (@name, @type, @active, @input, @fuel, @output, @updatedAt)
ON CONFLICT(name) DO UPDATE SET
type = @type,
active = @active,
input = @input,
fuel = @fuel,
output = @output,
updated_at = @updatedAt
`);
const upsertAlert = db.prepare(`
INSERT INTO alerts (item, triggered, current, threshold, updated_at)
VALUES (@item, @triggered, @current, @threshold, @updatedAt)
ON CONFLICT(item) DO UPDATE SET
triggered = @triggered,
current = @current,
threshold = @threshold,
updated_at = @updatedAt
`);
const upsertState = db.prepare(`
INSERT INTO state (key, value, updated_at)
VALUES (@key, @value, @updatedAt)
ON CONFLICT(key) DO UPDATE SET
value = @value,
updated_at = @updatedAt
`);
const insertHistory = db.prepare(`
INSERT INTO item_history (name, count, recorded_at)
VALUES (@name, @count, @recordedAt)
`);
const getState = db.prepare(`SELECT value FROM state WHERE key = ?`);
const getAllItems = db.prepare(`SELECT name, count, display_name as displayName FROM items ORDER BY count DESC`);
const getAllFurnaces = db.prepare(`SELECT * FROM furnaces`);
const getAllAlerts = db.prepare(`SELECT item, triggered, current, threshold FROM alerts WHERE triggered = 1`);
const clearAlertTriggered = db.prepare(`UPDATE alerts SET triggered = 0`);
const getItemHistory = db.prepare(`
SELECT count, recorded_at as recordedAt
FROM item_history
WHERE name = ?
ORDER BY recorded_at DESC
LIMIT ?
`);
const getHistorySummaryStmt = db.prepare(`
SELECT recorded_at as recordedAt, SUM(count) as total
FROM item_history
GROUP BY recorded_at
ORDER BY recorded_at ASC
`);
// Cleanup old history (keep last 7 days)
const cleanupHistory = db.prepare(`
DELETE FROM item_history WHERE recorded_at < ?
`);
// ========== Batch Operations ==========
const saveItemsBatch = db.transaction((items, now) => {
for (const item of items) {
upsertItem.run({
name: item.name || '',
count: item.count || 0,
displayName: item.displayName || item.name || '',
updatedAt: now,
});
}
});
const saveHistoryBatch = db.transaction((items, now) => {
for (const item of items) {
insertHistory.run({
name: item.name || '',
count: item.count || 0,
recordedAt: now,
});
}
});
const saveFurnacesBatch = db.transaction((furnaces, now) => {
for (const [name, f] of Object.entries(furnaces)) {
upsertFurnace.run({
name,
type: f.type || 'minecraft:furnace',
active: f.active ? 1 : 0,
input: f.input ? JSON.stringify(f.input) : null,
fuel: f.fuel ? JSON.stringify(f.fuel) : null,
output: f.output ? JSON.stringify(f.output) : null,
updatedAt: now,
});
}
});
const saveAlertsBatch = db.transaction((alerts, now) => {
// Clear old triggered status
clearAlertTriggered.run();
for (const alert of alerts) {
upsertAlert.run({
item: alert.item || '',
triggered: alert.triggered ? 1 : 0,
current: alert.current || 0,
threshold: alert.threshold || 0,
updatedAt: now,
});
}
});
// ========== Public API ==========
/**
* Save inventory items to the database
*/
export function saveItems(items) {
const now = Date.now();
saveItemsBatch(items, now);
}
/**
* Record a snapshot of item counts for history tracking
*/
let lastHistoryRecord = 0;
const HISTORY_INTERVAL = 5 * 60 * 1000; // Record history every 5 minutes
export function recordItemHistory(items) {
const now = Date.now();
if (now - lastHistoryRecord < HISTORY_INTERVAL) return;
lastHistoryRecord = now;
saveHistoryBatch(items, now);
// Cleanup entries older than 7 days
cleanupHistory.run(now - 7 * 24 * 60 * 60 * 1000);
}
/**
* Save furnace status to the database
*/
export function saveFurnaces(furnaceStatus) {
const now = Date.now();
saveFurnacesBatch(furnaceStatus, now);
}
/**
* Save alerts to the database
*/
export function saveAlerts(alerts) {
const now = Date.now();
saveAlertsBatch(alerts, now);
}
/**
* Save a key-value state entry (JSON serialized)
*/
export function saveState(key, value) {
upsertState.run({
key,
value: JSON.stringify(value),
updatedAt: Date.now(),
});
}
/**
* Load a key-value state entry
*/
export function loadState(key, defaultValue = null) {
const row = getState.get(key);
if (!row) return defaultValue;
try {
return JSON.parse(row.value);
} catch {
return defaultValue;
}
}
/**
* Load all persisted items
*/
export function loadItems() {
return getAllItems.all();
}
/**
* Load all persisted furnace statuses
*/
export function loadFurnaces() {
const rows = getAllFurnaces.all();
const result = {};
for (const row of rows) {
let input = null, fuel = null, output = null;
try { input = row.input ? JSON.parse(row.input) : null; } catch { input = row.input; }
try { fuel = row.fuel ? JSON.parse(row.fuel) : null; } catch { fuel = row.fuel; }
try { output = row.output ? JSON.parse(row.output) : null; } catch { output = row.output; }
result[row.name] = {
active: !!row.active,
type: row.type,
input,
fuel,
output,
};
}
return result;
}
/**
* Load all triggered alerts
*/
export function loadAlerts() {
return getAllAlerts.all().map(row => ({
...row,
triggered: !!row.triggered,
}));
}
/**
* Get item count history for a specific item
*/
export function getHistory(itemName, limit = 100) {
return getItemHistory.all(itemName, limit);
}
/**
* Get aggregate total item count over time
*/
export function getHistorySummary() {
return getHistorySummaryStmt.all();
}
/**
* Load the full last-known inventory state from DB
*/
export function loadFullState() {
const items = loadItems();
const furnaces = loadFurnaces();
const alerts = loadAlerts();
const smeltingPaused = loadState('smeltingPaused', false);
const disabledRecipes = loadState('disabledRecipes', {});
const smeltableRecipes = loadState('smeltableRecipes', {});
const craftableRecipes = loadState('craftableRecipes', []);
const craftTurtleOk = loadState('craftTurtleOk', false);
const activity = loadState('activity', {});
const lastUpdate = loadState('lastUpdate', 0);
// Reconstruct inventoryState
const inventoryMeta = loadState('inventoryMeta', {});
const inventoryState = {
itemList: items,
grandTotal: inventoryMeta.grandTotal || 0,
chestCount: inventoryMeta.chestCount || 0,
totalSlots: inventoryMeta.totalSlots || 0,
usedSlots: inventoryMeta.usedSlots || 0,
freeSlots: inventoryMeta.freeSlots || 0,
usedRatio: inventoryMeta.usedRatio || 0,
dropperOk: inventoryMeta.dropperOk || false,
barrelOk: inventoryMeta.barrelOk || false,
furnaceCount: inventoryMeta.furnaceCount || 0,
furnaceStatus: furnaces,
droppers: Array.isArray(inventoryMeta.droppers) ? inventoryMeta.droppers : [],
};
return {
inventoryState,
activityState: activity,
alertsState: alerts,
smeltingPaused,
disabledRecipes,
smeltableRecipes,
craftableRecipes,
craftTurtleOk,
lastUpdate,
};
}
/**
* Persist the full state to DB in one transaction.
* Uses a single flat transaction (no nested sub-transactions).
*/
const _saveFullStateTransaction = db.transaction((state, now) => {
// Items — inline loop instead of calling saveItemsBatch to avoid nested transaction
if (state.inventoryState?.itemList?.length) {
for (const item of state.inventoryState.itemList) {
upsertItem.run({
name: item.name || '',
count: item.count || 0,
displayName: item.displayName || item.name || '',
updatedAt: now,
});
}
}
// Inventory metadata (totals, slot info, etc.)
if (state.inventoryState) {
const { itemList, furnaceStatus, ...meta } = state.inventoryState;
upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now });
}
// Furnaces — inline loop
if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') {
for (const [name, f] of Object.entries(state.inventoryState.furnaceStatus)) {
upsertFurnace.run({
name,
type: f.type || 'minecraft:furnace',
active: f.active ? 1 : 0,
input: f.input ? JSON.stringify(f.input) : null,
fuel: f.fuel ? JSON.stringify(f.fuel) : null,
output: f.output ? JSON.stringify(f.output) : null,
updatedAt: now,
});
}
}
// Alerts — inline loop
if (Array.isArray(state.alertsState)) {
clearAlertTriggered.run();
for (const alert of state.alertsState) {
upsertAlert.run({
item: alert.item || '',
triggered: alert.triggered ? 1 : 0,
current: alert.current || 0,
threshold: alert.threshold || 0,
updatedAt: now,
});
}
}
// Key-value state
if (state.activityState !== undefined) {
upsertState.run({ key: 'activity', value: JSON.stringify(state.activityState), updatedAt: now });
}
if (state.smeltingPaused !== undefined) {
upsertState.run({ key: 'smeltingPaused', value: JSON.stringify(state.smeltingPaused), updatedAt: now });
}
if (state.disabledRecipes !== undefined) {
upsertState.run({ key: 'disabledRecipes', value: JSON.stringify(state.disabledRecipes), updatedAt: now });
}
if (state.smeltableRecipes !== undefined) {
upsertState.run({ key: 'smeltableRecipes', value: JSON.stringify(state.smeltableRecipes), updatedAt: now });
}
if (state.craftableRecipes !== undefined) {
upsertState.run({ key: 'craftableRecipes', value: JSON.stringify(state.craftableRecipes), updatedAt: now });
}
if (state.craftTurtleOk !== undefined) {
upsertState.run({ key: 'craftTurtleOk', value: JSON.stringify(state.craftTurtleOk), updatedAt: now });
}
upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now });
});
/**
* Debounced save — buffers rapid updates and writes to DB at most every SAVE_INTERVAL ms.
* This prevents DB writes from blocking WebSocket broadcasts on every bridge update.
*/
const SAVE_INTERVAL = 2000; // Write at most every 2 seconds
let pendingSaveState = null;
let saveTimer = null;
export function saveFullState(state) {
pendingSaveState = state;
if (!saveTimer) {
saveTimer = setTimeout(() => {
saveTimer = null;
if (pendingSaveState) {
const toSave = pendingSaveState;
pendingSaveState = null;
try {
_saveFullStateTransaction(toSave, Date.now());
} catch (err) {
console.error('❌ DB save error:', err.message);
}
}
}, SAVE_INTERVAL);
}
}
/**
* Flush any pending save immediately (used during shutdown)
*/
export function flushPendingSave() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (pendingSaveState) {
const toSave = pendingSaveState;
pendingSaveState = null;
try {
_saveFullStateTransaction(toSave, Date.now());
} catch (err) {
console.error('❌ DB flush error:', err.message);
}
}
}
/**
* Close the database connection gracefully
*/
export function closeDb() {
flushPendingSave();
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 "$@"

3328
web/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/server/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "inventory-manager-server",
"version": "1.0.0",
"description": "CC:Tweaked Inventory Manager - Web Server",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"ws": "^8.14.2"
},
"devDependencies": {
"nodemon": "^3.0.2",
"vitest": "^3.2.1"
}
}

1077
web/server/server.js Normal file

File diff suppressed because it is too large Load Diff

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`),
},
},
});