Compare commits

...

192 Commits

Author SHA1 Message Date
MayaTheShy
0a66cad13a fix(docker): clone platform from git instead of additional_contexts
The additional_contexts approach required cc-platform-core to exist on
the Docker host at a relative path. This fails on servers where the
repo layout differs. Instead, use a multi-stage build: stage 1 clones
cc-platform-core from Gitea (depth 1), stage 2 copies server/ into the
app and rewrites the file: path. Fully self-contained — no host deps.
Applied to both production and dev Dockerfiles.
2026-03-28 22:38:07 -04:00
MayaTheShy
f008a9e665 fix(docker): resolve @cc-platform/server file: dep in container build
Use additional_contexts to copy platform server package into the Docker
build context. Rewrites the file: dependency path and removes the
lockfile so npm install can resolve the local package correctly.
Applied to both production and dev Dockerfiles.
2026-03-28 22:35:53 -04:00
MayaTheShy
ed612f3e38 refactor: enhance message handling for dual-mode channel compatibility using Channels.match() 2026-03-26 16:19:42 -04:00
MayaTheShy
dcd9e22b6f refactor: enhance command processing for dual-mode channel compatibility 2026-03-26 16:19:38 -04:00
MayaTheShy
f1c8f08272 refactor: enhance status message handling for dual-mode channel compatibility 2026-03-26 16:19:33 -04:00
MayaTheShy
ffb6d679c0 refactor: enhance status message handling for dual-mode channel compatibility 2026-03-26 16:19:29 -04:00
MayaTheShy
ea90a860e9 refactor: enhance channel message handling with dual-mode support for legacy and target channels 2026-03-26 16:19:25 -04:00
MayaTheShy
bdf7a51675 refactor: replace hardcoded channel IDs with dynamic retrieval from platform.channels 2026-03-26 15:22:51 -04:00
MayaTheShy
c4b9509b5c refactor: replace hardcoded channel IDs with dynamic retrieval from platform.channels 2026-03-26 15:22:36 -04:00
MayaTheShy
92ea13a680 refactor: replace hardcoded status channel ID with dynamic retrieval from platform.channels 2026-03-26 15:22:31 -04:00
MayaTheShy
05cf7e98d9 refactor: replace hardcoded channel IDs with dynamic channel retrieval from platform.channels 2026-03-26 15:22:23 -04:00
MayaTheShy
9291b063d0 refactor: replace express server setup with platform server integration and streamline proxy endpoints 2026-03-26 15:19:53 -04:00
MayaTheShy
6f462c97e0 fix: add missing dependency for cc-platform server in package.json 2026-03-26 15:19:47 -04:00
MayaTheShy
9ff2ce7ff2 refactor: streamline configuration loading and channel setup in web bridge 2026-03-26 15:19:42 -04:00
MayaTheShy
fc1b23470e feat: add required platform specification in package configuration 2026-03-26 15:19:37 -04:00
MayaTheShy
cb44dd8d0f fix: correct requires format in apps.db and add missing entries
Opus Overview expects requires as a string (e.g. 'pocket'),
not a table. Fixed pocket apps and added turtle requirement
for turtle controller. Added pocketcontrol.lua entry.
2026-03-22 18:40:20 -04:00
MayaTheShy
1f41e1fa51 docs: add GPS alternative and pathfinding usage to quickstart guide 2026-03-22 18:24:27 -04:00
MayaTheShy
fa085339b8 feat: implement movement wrapping and pathfinding module for turtle 2026-03-22 18:16:06 -04:00
MayaTheShy
5ad01dfd1d feat: add web bridge configuration setup to installation process 2026-03-22 16:11:11 -04:00
MayaTheShy
b72826bc46 feat: implement dynamic configuration loading for server and WebSocket URLs 2026-03-22 16:07:55 -04:00
MayaTheShy
459664825c fix: update exclude patterns in package configuration for clarity 2026-03-22 16:07:52 -04:00
MayaTheShy
b13905dade feat: add initial apps configuration file for RemoteTurtle components 2026-03-22 15:55:49 -04:00
MayaTheShy
5a4af6c986 feat: add initial package configuration file 2026-03-22 15:55:25 -04:00
MayaTheShy
633d162d81 fix: reorder dependencies and update vitest version in package.json 2026-03-22 11:48:02 -04:00
MayaTheShy
aa3b166453 feat: add comprehensive tests for WorldBlockCache functionality 2026-03-22 11:47:57 -04:00
MayaTheShy
56fc79f5f2 feat: add TaskDispatcher tests for task assignment and management 2026-03-22 11:47:22 -04:00
MayaTheShy
b6ab6f94f6 feat: add testing scripts and vitest as a dev dependency 2026-03-22 11:46:51 -04:00
MayaTheShy
4d5d2162e6 feat: refactor world block retrieval to use getAllBlocksForAPI method 2026-03-22 11:45:24 -04:00
MayaTheShy
24570d0fc0 feat: integrate WorldBlockCache for efficient world block management and update logging 2026-03-22 11:45:10 -04:00
MayaTheShy
6312e45bf1 feat: add getWorldBlockCount function to retrieve the total number of world blocks 2026-03-22 11:45:04 -04:00
MayaTheShy
34725d7d71 feat: implement WorldBlockCache for efficient block management with LRU caching 2026-03-22 11:44:58 -04:00
MayaTheShy
811e2a6e18 feat: enhance task assignment logic to support un-assigning tasks 2026-03-22 11:44:14 -04:00
MayaTheShy
ad0754113d feat: add endpoints for task cancellation and dispatcher control 2026-03-22 11:43:09 -04:00
MayaTheShy
3e55d77592 feat: implement TaskDispatcher for automatic task management and dispatching 2026-03-22 11:43:02 -04:00
MayaTheShy
9984dc0760 refactor: start task dispatcher after server initialization 2026-03-22 11:42:52 -04:00
MayaTheShy
88163be0dd refactor: add TaskDispatcher for automatic task assignment to idle turtles 2026-03-22 11:42:46 -04:00
MayaTheShy
679a249f8b refactor: implement API key authentication for secure access to endpoints 2026-03-22 11:25:04 -04:00
MayaTheShy
69041244a2 refactor: add API_KEY environment variable to server configuration 2026-03-22 11:25:00 -04:00
MayaTheShy
9a56e6b736 refactor: add cross-project integration API for inventory management and turtle state queries 2026-03-22 04:11:06 -04:00
MayaTheShy
79b50071ee refactor: add inventory dashboard link with appropriate attributes in the panel 2026-03-22 04:10:28 -04:00
MayaTheShy
9b09a59eba refactor: add styling for cross-link button with hover effects 2026-03-22 04:10:23 -04:00
MayaTheShy
6d8ec7b013 refactor: add INVENTORY_SERVER_URL environment variable to server configuration 2026-03-22 04:10:17 -04:00
MayaTheShy
90ec195497 refactor: enhance error handling for eval response to log failures and track error statistics 2026-02-20 04:40:29 -05:00
MayaTheShy
23515728e0 refactor: enhance command timeout logging and add keepalive ping handling for improved monitoring 2026-02-20 04:39:52 -05:00
MayaTheShy
a809bddd46 refactor: improve error handling in idle state fuel check to prevent infinite loop 2026-02-20 04:38:58 -05:00
MayaTheShy
5ff1f3e7f0 refactor: prevent infinite loop in idle state handling by adding retry mechanism 2026-02-20 04:38:54 -05:00
MayaTheShy
00d31698a1 refactor: filter players to display only those with valid positions and recent timestamps for improved clarity 2026-02-20 04:32:12 -05:00
MayaTheShy
af2c978185 refactor: update player marker display to show label if available for improved clarity 2026-02-20 04:32:02 -05:00
MayaTheShy
8f23aa5caa refactor: update player position handling to include label for improved tracking 2026-02-20 04:31:53 -05:00
MayaTheShy
720c6c20fb refactor: enhance player position update to include label and timestamp for improved tracking 2026-02-20 04:31:47 -05:00
MayaTheShy
f61e7ca185 refactor: include player positions in initial WebSocket state for enhanced tracking 2026-02-20 04:29:28 -05:00
MayaTheShy
ddc1b03506 refactor: enhance player data handling to include label and default timestamp for improved tracking 2026-02-20 04:29:23 -05:00
MayaTheShy
460352ec26 refactor: update player position saving to include optional label for enhanced tracking 2026-02-20 04:28:12 -05:00
MayaTheShy
3ce0e4c530 refactor: enhance player position handling to include label for improved tracking 2026-02-20 04:28:05 -05:00
MayaTheShy
38ff06eb04 refactor: enhance player update handling to include label and timestamp for improved tracking 2026-02-20 04:27:57 -05:00
MayaTheShy
cfd127dfab refactor: migrate player_positions table to add label column for enhanced data tracking 2026-02-20 04:27:46 -05:00
MayaTheShy
d2718b3287 refactor: add total steps display in TurtleDetails for better status tracking 2026-02-20 04:24:36 -05:00
MayaTheShy
8f4eeabee9 refactor: enhance ExploringState for chunk-based spiral exploration and improve navigation logic 2026-02-20 04:24:29 -05:00
MayaTheShy
3b2e00b2b4 refactor: update turnToFace method to synchronize turtle facing state with global variable 2026-02-20 04:24:24 -05:00
MayaTheShy
cb666a6a45 refactor: synchronize turtle facing state with global variable on turn commands 2026-02-20 04:24:15 -05:00
MayaTheShy
c424662c18 refactor: update color logic in MiningArea to prioritize custom area color 2026-02-20 04:22:20 -05:00
MayaTheShy
05519dc17e refactor: add color picker styles and area color dot to enhance UI 2026-02-20 04:21:21 -05:00
MayaTheShy
681b4e1fa9 refactor: enhance area creation form with color picker and display area color in the card 2026-02-20 04:21:07 -05:00
MayaTheShy
465a8bacf4 refactor: add color property to newArea state and include it in area creation 2026-02-20 04:20:52 -05:00
MayaTheShy
bfae87287a refactor: add color parameter to saveMiningArea and updateMiningArea functions; implement write-file and refresh-inventory endpoints for Turtle 2026-02-20 04:20:06 -05:00
MayaTheShy
12fc109a30 refactor: add areaName and color properties to formatMiningArea function 2026-02-20 04:19:34 -05:00
MayaTheShy
973e4be6a3 refactor: add color and name parameters to saveMiningArea and implement updateMiningArea function 2026-02-20 04:19:27 -05:00
MayaTheShy
fb84b5a554 refactor: add name and color columns to mining_areas table and handle migration 2026-02-20 04:19:03 -05:00
MayaTheShy
2e3d5b4b6b refactor: enhance refueling logic and add file writing and inventory refresh methods in Turtle class 2026-02-20 04:18:44 -05:00
MayaTheShy
9a34f72178 refactor: track steps since last refuel and total steps in Turtle class 2026-02-20 04:18:03 -05:00
MayaTheShy
88fdd1c46d refactor: add fuel efficiency tracking to Turtle class 2026-02-20 04:17:47 -05:00
MayaTheShy
e3abdb612c refactor: update Web Bridge to enhance WebSocket integration and streamline dashboard functionality 2026-02-20 04:14:57 -05:00
MayaTheShy
e84ca4cfb9 refactor: enhance WebSocket handling for bridge connections and command forwarding 2026-02-20 04:14:03 -05:00
MayaTheShy
b8cd239597 refactor: unify HTTP API and WebSocket ports in docker-compose configuration 2026-02-20 04:13:51 -05:00
MayaTheShy
5aec3df3b3 refactor: improve inventory count calculation for TurtleCard component 2026-02-20 04:05:06 -05:00
MayaTheShy
989b6f9118 refactor: update IdleState to include periodic fuel checks and adjust sleep duration 2026-02-20 04:04:01 -05:00
MayaTheShy
ec5f048d49 fix: correct function name to retrieve world blocks in chunk analysis endpoint 2026-02-20 04:03:54 -05:00
MayaTheShy
60c5b3aaba feat: add chunk analysis endpoint to compute ore density from discovered blocks 2026-02-20 04:03:34 -05:00
MayaTheShy
2c806bf994 refactor: enhance state loop error handling with consecutive error tracking for improved resilience 2026-02-20 04:03:23 -05:00
MayaTheShy
b34cc8cec0 refactor: update turtle script for improved structure and clarity, enhancing state management and communication protocols 2026-02-20 04:03:18 -05:00
MayaTheShy
cef3cdf03d refactor: update turtle script for improved server-driven functionality and code organization 2026-02-20 03:54:52 -05:00
MayaTheShy
b8a1b7c0b3 refactor: streamline safety checks and error handling in mining operation for improved reliability 2026-02-20 03:54:23 -05:00
MayaTheShy
8fcd3f44c7 refactor: improve error handling and retry logic in exploration state for enhanced stability 2026-02-20 03:54:15 -05:00
MayaTheShy
7385c258d5 refactor: implement error handling and retry logic in state loop for improved resilience 2026-02-20 03:54:07 -05:00
MayaTheShy
2686ca4697 refactor: enhance peripheral display to support multiple types and improve readability 2026-02-20 03:53:49 -05:00
MayaTheShy
53ae92a184 refactor: optimize polling interval and command transmission for improved responsiveness 2026-02-20 03:53:40 -05:00
MayaTheShy
7da9c1d0d8 refactor: remove legacy turtleData getter and update command handling for eval commands 2026-02-20 03:45:23 -05:00
MayaTheShy
2549adc49d refactor: replace legacy command handling with unified pending command queue 2026-02-20 03:44:44 -05:00
MayaTheShy
bad3b5bf13 refactor: replace legacy command queue with eval command queue in Turtle class 2026-02-20 03:44:27 -05:00
MayaTheShy
de58ec6b08 Refactor group command handling to enforce server control of turtle movement and reject legacy commands 2026-02-20 03:44:11 -05:00
MayaTheShy
f6b39808aa Refactor WebSocket command handling to enforce server-side control of turtle movement and remove legacy command support 2026-02-20 03:43:45 -05:00
MayaTheShy
c02bb7db68 style: Remove unused sendCommand function from turtleStore 2026-02-20 03:43:40 -05:00
MayaTheShy
885ebf698d Refactor PathRecorder to utilize server-side pathfinding for waypoint navigation 2026-02-20 03:43:36 -05:00
MayaTheShy
1522523f22 style: Remove unused sendCommand from Scene component 2026-02-20 03:43:31 -05:00
MayaTheShy
bca3cb4508 style: Refactor PathRecorder to utilize individual turtle movement functions 2026-02-20 03:42:13 -05:00
MayaTheShy
ebe4f10df5 Refactor VoiceControl to handle server-side movement and state commands directly 2026-02-20 03:42:07 -05:00
MayaTheShy
586b161da9 style: Add turnLeft and turnRight methods to Turtle class for directional control 2026-02-20 03:41:46 -05:00
MayaTheShy
2316e14b9c style: Add server-side endpoints for turtle movement and actions 2026-02-20 03:41:42 -05:00
MayaTheShy
45a4b4e7ae style: Refactor VoiceControl to utilize individual turtle command functions 2026-02-20 03:41:38 -05:00
MayaTheShy
9735fd8776 style: Implement server-side turtle actions for movement and block manipulation 2026-02-20 03:41:27 -05:00
MayaTheShy
c6ff9094ed style: Refactor TurtleDetails to use direct command functions and remove legacy command handling 2026-02-20 03:41:21 -05:00
MayaTheShy
cb2353785f style: Add middleware to rewrite requests without /api prefix for reverse proxy compatibility 2026-02-20 03:29:14 -05:00
MayaTheShy
46c0817270 style: Update SERVER_URL to direct to Docker server on LAN for local development 2026-02-20 03:16:35 -05:00
MayaTheShy
f0281ddaa5 style: Remove version declaration from docker-compose.yml for cleaner configuration 2026-02-20 03:15:42 -05:00
MayaTheShy
fc5cbae73e style: Simplify Dockerfile by copying all server code in a single command 2026-02-20 03:11:35 -05:00
MayaTheShy
9cb2939224 style: Update API fetch URL to use dynamic origin for improved flexibility 2026-02-20 03:09:53 -05:00
MayaTheShy
1a2de77ae2 style: Update API URL construction for improved endpoint clarity 2026-02-20 03:09:49 -05:00
MayaTheShy
420456c04b style: Update SERVER_URL to use reverse proxy for improved security 2026-02-20 03:09:45 -05:00
MayaTheShy
b7221327c2 style: Add shadow-receiving ground plane and update UI colors for improved consistency and depth 2026-02-20 03:05:59 -05:00
MayaTheShy
997201b139 style: Enhance lighting and shadow effects for improved visual depth and realism 2026-02-20 03:05:26 -05:00
MayaTheShy
daaf969662 style: Remove view controls for improved layout consistency and simplify app structure 2026-02-20 03:05:22 -05:00
MayaTheShy
0b61d8b2dd style: Remove view controls for improved layout consistency and responsiveness 2026-02-20 03:05:18 -05:00
MayaTheShy
586d231720 style: Update color presets in GroupsPanel for improved consistency with Minecraft theme 2026-02-20 02:59:44 -05:00
MayaTheShy
3c6ee280ba style: Update task status and priority colors for improved visibility and consistency with Minecraft theme 2026-02-20 02:58:54 -05:00
MayaTheShy
5966d4de2b style: Update StatusBadge colors for improved visibility and consistency with Minecraft theme 2026-02-20 02:58:45 -05:00
MayaTheShy
1d99ba534a style: Revamp MiningAreasPanel.css for improved aesthetics and consistency with Minecraft theme 2026-02-20 02:58:38 -05:00
MayaTheShy
b06f878ca0 style: Update VoiceControl.css for improved aesthetics and consistency with Minecraft theme 2026-02-20 02:56:27 -05:00
MayaTheShy
943cc73163 style: Enhance PathRecorder.css for improved aesthetics and consistency with Minecraft theme 2026-02-20 02:55:20 -05:00
MayaTheShy
2263fbb1de style: Revamp TaskPanel.css for improved aesthetics and consistency with Minecraft theme 2026-02-20 02:54:18 -05:00
MayaTheShy
481be70940 style: Revamp GroupsPanel.css for enhanced Minecraft theme consistency and improved aesthetics 2026-02-20 02:53:24 -05:00
MayaTheShy
60ef3b81f7 style: Update StatsPanel.css for improved aesthetics and consistency with Minecraft theme 2026-02-20 02:52:22 -05:00
MayaTheShy
997c64c40b style: Escape quotes and backslashes in usePeripheralWithSide method for safe Lua string interpolation 2026-02-20 02:50:14 -05:00
MayaTheShy
a68ddd843f style: Update block search API to accept 'name' parameter as an alternative to 'pattern' 2026-02-20 02:50:09 -05:00
MayaTheShy
fe44978f6e style: Escape quotes and backslashes in turtle place methods and connectToInventory for safe Lua string interpolation 2026-02-20 02:48:00 -05:00
MayaTheShy
c862b2816c style: Escape quotes and backslashes in turtle rename method for safe Lua string interpolation 2026-02-20 02:47:41 -05:00
MayaTheShy
73f8a21a81 style: Refactor gpsLocate method for improved position handling and code clarity 2026-02-20 02:47:36 -05:00
MayaTheShy
617310eade style: Enhance eval response handling and inventory update for improved data management 2026-02-20 02:47:29 -05:00
MayaTheShy
7dae800eed style: Adjust button layout in drawDetail function for improved UI organization 2026-02-20 02:47:16 -05:00
MayaTheShy
86a6db04ac style: Update selected inventory slot outline color for improved UI consistency 2026-02-20 02:44:49 -05:00
MayaTheShy
8e1d1f67fc style: Update error, warning, and peripheral colors for improved UI consistency 2026-02-20 02:44:26 -05:00
MayaTheShy
fa8b45b74a style: Update TurtleCard and TurtleDetails components for improved UI consistency 2026-02-20 02:44:14 -05:00
MayaTheShy
32677ecac5 style: Revamp Control Panel CSS with Minecraft-themed colors and improved UI elements 2026-02-20 02:42:08 -05:00
MayaTheShy
5f6dbce277 style: Update scrollbar colors for improved UI consistency 2026-02-20 02:38:31 -05:00
MayaTheShy
75ca027ab4 style: Update background colors and button styles for improved UI consistency 2026-02-20 02:38:27 -05:00
MayaTheShy
cfc891d164 style: Update body and code font styles for improved UI consistency 2026-02-20 02:38:08 -05:00
MayaTheShy
5b89e0432e style: Update view controls styling for improved UI consistency 2026-02-20 02:37:59 -05:00
MayaTheShy
37bd17f26a feat: Add equip left, equip right, rename, and sort buttons for turtle management 2026-02-20 02:36:39 -05:00
MayaTheShy
c0865d5196 feat: Add equipment and inventory action buttons to TurtleCard 2026-02-20 02:35:41 -05:00
MayaTheShy
a0eaeb6712 feat: Add turtle management features including rename, configuration, and GPS locate 2026-02-20 02:35:17 -05:00
MayaTheShy
d8f3d5d13c feat: Update TurtleCard to display turtle label instead of ID 2026-02-20 02:34:48 -05:00
MayaTheShy
2571865917 feat: Add turtle inventory management actions including rename, equip, select, transfer, sort, connect, drop, suck, update config, get config, explore, and GPS locate 2026-02-20 02:34:41 -05:00
MayaTheShy
0e6d4acfdd feat: Implement block deletion handling in WebSocket message processing 2026-02-20 02:34:21 -05:00
MayaTheShy
67b2d7eb2e feat: Add turtle auto-naming, state recovery, and multiple action endpoints for inventory management 2026-02-20 02:34:13 -05:00
MayaTheShy
7b63c92434 feat: Enhance DumpInventoryState with fuzzy container detection and detailed dump reporting 2026-02-20 02:33:30 -05:00
MayaTheShy
2a0a90892c feat: Add computer label handling and status broadcasting on rename command 2026-02-20 02:33:25 -05:00
MayaTheShy
11c289a3ed feat: Add Levenshtein distance utility for fuzzy matching of item names 2026-02-20 02:32:43 -05:00
MayaTheShy
ac6dd5da95 feat: Enhance Turtle class with state recovery, command chaining, and new movement methods 2026-02-20 02:30:15 -05:00
MayaTheShy
58cc909de8 feat: Add turtle state persistence functions for saving, retrieving, and deleting turtle states 2026-02-20 02:27:44 -05:00
MayaTheShy
b9023758a6 feat: Add turtle state persistence table for reconnect recovery 2026-02-20 02:26:31 -05:00
MayaTheShy
6e05efa2f0 feat: Add scan, extract, build, and autocraft buttons to turtle control interface 2026-02-20 02:21:09 -05:00
MayaTheShy
b632e14932 feat: Add chunk analysis handling and search functionality in turtle store 2026-02-20 02:20:34 -05:00
MayaTheShy
5208c38738 feat: Add new turtle commands for scanning, extraction, building, and autocrafting with error and warning displays 2026-02-20 02:19:59 -05:00
MayaTheShy
a64e9a1a51 feat: Add interaction toolbar and block count HUD to Map3D component 2026-02-20 02:19:51 -05:00
MayaTheShy
be0064aae5 feat: Implement enhanced interaction handling with move, build, and selection previews in Map3D component 2026-02-20 02:18:40 -05:00
MayaTheShy
dd2a877192 feat: Add move and build preview components with selection area for enhanced interaction 2026-02-20 02:18:16 -05:00
MayaTheShy
1ef975cbae feat: Optimize WorldBlocks rendering with chunked InstancedMesh and interaction handling 2026-02-20 02:18:08 -05:00
MayaTheShy
e62e83cf33 feat: Enhance interaction modes with detailed comments in Map3D component 2026-02-20 02:17:13 -05:00
MayaTheShy
eff33dfe09 feat: Add real-time inventory and peripheral event handling for turtles 2026-02-20 02:15:54 -05:00
MayaTheShy
f38a219ad0 feat: Add real-time inventory and peripheral event handling for Turtle 2026-02-20 02:15:45 -05:00
MayaTheShy
0bf5590343 feat: Export additional states for scanning, extraction, building, and autocrafting in Turtle state machine 2026-02-20 02:15:36 -05:00
MayaTheShy
bf7db42384 feat: Add additional states for scanning, extracting, building, and autocrafting in Turtle state machine 2026-02-20 02:15:31 -05:00
MayaTheShy
8731da04f9 feat: Add real-time event handling and chunk analysis endpoints for turtles 2026-02-20 02:15:26 -05:00
MayaTheShy
ff58778f3f feat: Add additional properties to Turtle state for enhanced status reporting 2026-02-20 02:13:10 -05:00
MayaTheShy
668d4a3685 feat: Enhance Turtle class with new properties and real-time event handling for peripherals and inventory 2026-02-20 02:12:56 -05:00
MayaTheShy
e401c39bb3 feat: Implement AutocraftState for automated crafting with workbench integration 2026-02-20 02:12:29 -05:00
MayaTheShy
d4e6b469df feat: Add BuildingState for autonomous block placement with material management 2026-02-20 02:11:45 -05:00
MayaTheShy
150cc4b41a feat: Implement ScanState for systematic scanning with multiple scanner types 2026-02-20 02:11:04 -05:00
MayaTheShy
220f2a90d4 feat: Implement ExtractionState for smart ore extraction with area scanning and mining logic 2026-02-20 02:10:58 -05:00
MayaTheShy
ba11d2c9d2 feat: Add chunk analysis table and enhance world_blocks schema for block state and tags 2026-02-20 02:10:50 -05:00
MayaTheShy
dca14643c6 feat: Refactor TurtleCard and TurtleDetails to implement active state management and enhance command handling 2026-02-20 02:03:47 -05:00
MayaTheShy
67498b93bf feat: Add setTurtleState and execOnTurtle functions for REST API integration 2026-02-20 02:02:40 -05:00
MayaTheShy
df00a6be70 feat: Add handling for 'blocks_discovered' message to update worldBlocks in turtle store 2026-02-20 02:02:04 -05:00
MayaTheShy
f23ebcb05a feat: Enhance multi-face texture handling for Minecraft blocks in Map3D component 2026-02-20 02:01:53 -05:00
MayaTheShy
08281d88fe feat: Expand texture mapping for Minecraft blocks and enhance multi-face block handling 2026-02-20 02:01:00 -05:00
MayaTheShy
882dc75762 feat: Add state management and modes for turtle commands in Pocket Computer 2026-02-20 01:59:37 -05:00
MayaTheShy
4c774ac306 feat: Enhance turtle command handling with legacy support and eval response endpoints 2026-02-20 01:57:09 -05:00
MayaTheShy
e38551dfc2 feat: Update command polling endpoint to support combined eval and legacy commands for turtles 2026-02-20 01:54:59 -05:00
MayaTheShy
87c103ea9e feat: Update WebSocket handling for turtle commands and status updates, transitioning to Turtle instance methods for state management 2026-02-20 01:54:43 -05:00
MayaTheShy
836e7b61c7 feat: Enhance turtle instance management with improved state handling and event forwarding 2026-02-20 01:54:15 -05:00
MayaTheShy
f8baadce3a feat: Implement eval command protocol and response handling in web bridge 2026-02-20 01:53:43 -05:00
MayaTheShy
a5aec5800e Refactor turtle.lua for v4: Implement eval/response protocol, streamline state management, enhance GPS and movement functions, and improve command handling. 2026-02-20 01:50:55 -05:00
MayaTheShy
0e41ee12c5 feat: Add Turtle class to manage state machine and command execution for server-side turtles 2026-02-20 01:46:39 -05:00
MayaTheShy
bdc4dade9f feat: Implement RefuelingState class for turtle state machine to manage fuel replenishment 2026-02-20 01:46:09 -05:00
MayaTheShy
e8ef1d669b feat: Implement MiningState class for turtle state machine to autonomously mine and explore 2026-02-20 01:46:05 -05:00
MayaTheShy
3dfc4237c6 feat: Add index.js to export state classes for turtle state machine 2026-02-20 01:46:01 -05:00
MayaTheShy
1492f59de7 feat: Implement GoHomeState class for turtle state machine to navigate back to home position 2026-02-20 01:45:57 -05:00
MayaTheShy
d2185ac493 feat: Implement FarmingState class for turtle state machine to automate farming operations 2026-02-20 01:45:46 -05:00
MayaTheShy
fa98e86055 feat: Implement ExploringState class for turtle state machine to autonomously explore and discover the world map 2026-02-20 01:45:39 -05:00
MayaTheShy
1b08777f88 feat: Implement DumpInventoryState class for turtle state machine to manage inventory dumping 2026-02-20 01:45:29 -05:00
MayaTheShy
68d4ee52c9 feat: Implement MovingState class for turtle state machine to navigate to target positions 2026-02-20 01:42:16 -05:00
MayaTheShy
6f73fdd0ed feat: Implement IdleState class for turtle state machine to handle idle behavior 2026-02-20 01:42:06 -05:00
MayaTheShy
0f22c8e49b feat: Add BaseState class for turtle state machine with async generator support 2026-02-20 01:41:58 -05:00
52 changed files with 10148 additions and 2992 deletions

34
.package Normal file
View File

@@ -0,0 +1,34 @@
{
required = {
'platform',
},
title = "RemoteTurtle",
description = "Web-based remote control for CC:Tweaked turtles with 3D visualization, D* Lite pathfinding, and state-machine AI. Includes turtle controller, GPS host, web bridge, and pocket computer programs.",
repository = "gitea://git.spatulaa.com/MayaTheShy/remoteturtle/master/",
exclude = {
"^server/", "^client/", "^__tests__/",
"^startup_", "^start%.",
"%.md$", "%.yml$", "%.json$", "%.bat$", "%.sh$",
"^Dockerfile", "^%.git", "^LICENSE$", "^node_modules/",
},
install = [[
local pkgDir = fs.combine("packages", "remoteturtle")
-- Web Bridge config
print("")
print("-- RemoteTurtle Web Bridge Setup --")
print("")
write("Server URL (e.g. http://192.168.1.10:4200): ")
local serverUrl = read()
if serverUrl and #serverUrl > 0 then
local wsUrl = serverUrl:gsub("^http", "ws") .. "/ws/bridge"
local cfg = textutils.serialiseJSON({ serverUrl = serverUrl, wsUrl = wsUrl })
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
f.write(cfg)
f.close()
print("Saved web bridge config.")
else
print("Skipped — edit .webbridge_config later.")
end
]],
}

View File

@@ -64,6 +64,36 @@ You should see:
**GPS not working?**
- Set up 4 GPS host computers at high altitude
- Run `gps host X Y Z` on each (with their coordinates)
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
turtle can self-build a complete GPS constellation (replaces 4 host computers)
## Pathfinding
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
You can trigger it from the web UI via eval commands:
```lua
-- Navigate to coordinates (avoids obstacles)
_pathfind.goto(100, 65, -200)
-- Navigate with block digging enabled
_pathfind.goto(100, 65, -200, { dig = true })
-- Go home
_pathfind.goHome()
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
_pathfind.face(3)
-- Get current heading name
_pathfind.headingName() -- "east"
```
The pathfinder:
- Uses GPS for initial position, then tracks movement locally
- Auto-detects heading on startup via GPS triangulation
- Handles obstacles by trying to go over/around them
- Supports optional block digging for clearing paths
## Next Steps

View File

@@ -4,37 +4,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-controls {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background: #1e293b;
border-bottom: 2px solid #334155;
}
.view-controls button {
padding: 0.5rem 1rem;
border: none;
background: #334155;
color: #e2e8f0;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.view-controls button:hover {
background: #475569;
transform: translateY(-1px);
}
.view-controls button.active {
background: #3b82f6;
color: white;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
background: #2c2c2c;
}
.app-content {
@@ -57,21 +27,13 @@
width: 50%;
}
.app-content.map .panel-container {
display: none;
}
.app-content.panel .map-container {
display: none;
}
.map-container {
position: relative;
background: #0a0e1a;
background: #2c2c2c;
}
.panel-container {
background: #0f172a;
background: #2c2c2c;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -81,34 +43,62 @@
display: flex;
gap: 0.25rem;
padding: 0.5rem;
background: #1e293b;
border-bottom: 2px solid #334155;
background: #3b3b3b;
border-bottom: 3px solid #1a1a1a;
overflow-x: auto;
flex-shrink: 0;
}
.panel-tabs button {
padding: 0.5rem 1rem;
border: none;
background: #334155;
color: #94a3b8;
border-radius: 0.375rem;
border: 2px solid #1a1a1a;
background: #5a5a5a;
color: #b0b0b0;
border-radius: 0;
font-size: 0.875rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
}
.panel-tabs button:hover {
background: #475569;
background: #6b6b6b;
color: #e5e7eb;
}
.panel-tabs button.active {
background: #3b82f6;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
/* === Cross-link Button === */
.cross-link-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
border: 2px solid #1a1a1a;
background: #2e4a8b;
color: #55ffff;
font-size: 0.875rem;
font-weight: 700;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
text-decoration: none;
cursor: pointer;
transition: all 0.1s;
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
white-space: nowrap;
}
.cross-link-btn:hover {
background: #3e5a9b;
color: white;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
}
.panel-content-wrapper {
@@ -125,16 +115,16 @@
}
::-webkit-scrollbar-track {
background: #1e293b;
background: #2c2c2c;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
background: #5a5a5a;
border: 1px solid #1a1a1a;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
background: #6b6b6b;
}
/* Mobile-Responsive Design */
@@ -149,28 +139,9 @@
width: 100%;
flex: 1;
}
.view-controls {
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
/* Mobile: Force single view mode with tabs */
.view-controls {
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.view-controls button {
flex: 1;
min-width: 0;
padding: 0.75rem 0.5rem;
font-size: 0.8125rem;
}
.app-content.split {
flex-direction: column;
}
@@ -191,17 +162,6 @@
}
@media (max-width: 480px) {
/* Small mobile: Optimize spacing */
.view-controls {
padding: 0.5rem;
gap: 0.375rem;
}
.view-controls button {
padding: 0.625rem 0.375rem;
font-size: 0.75rem;
}
.app-content.split .map-container {
height: 40vh;
}
@@ -270,10 +230,6 @@
/* High contrast mode */
@media (prefers-contrast: high) {
.view-controls button {
border: 2px solid currentColor;
}
.turtle-card {
border-width: 3px;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import Map3D from './components/Map3D';
import ControlPanel from './components/ControlPanel';
import VoiceControl from './components/VoiceControl';
@@ -12,17 +12,18 @@ import './App.css';
function App() {
const connect = useTurtleStore((state) => state.connect);
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
useEffect(() => {
connect();
}, [connect]);
const renderPanelContent = () => {
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
switch (panelTab) {
@@ -47,36 +48,21 @@ function App() {
return (
<div className="app">
<div className="view-controls">
<button
className={view === 'split' ? 'active' : ''}
onClick={() => setView('split')}
>
📊 Split View
</button>
<button
className={view === 'map' ? 'active' : ''}
onClick={() => setView('map')}
>
🗺 Map Only
</button>
<button
className={view === 'panel' ? 'active' : ''}
onClick={() => setView('panel')}
>
🎮 Control Only
</button>
</div>
<div className={`app-content ${view}`}>
{(view === 'split' || view === 'map') && (
<div className="map-container">
<Map3D />
</div>
)}
{(view === 'split' || view === 'panel') && (
<div className="panel-container">
<div className="app-content split">
<div className="map-container">
<Map3D />
</div>
<div className="panel-container">
<div className="panel-tabs">
<a
href={inventoryDashboardUrl}
className="cross-link-btn"
title="Open Inventory Manager Dashboard"
target="_blank"
rel="noopener noreferrer"
>
📦 Inventory
</a>
<button
className={panelTab === 'control' ? 'active' : ''}
onClick={() => setPanelTab('control')}
@@ -131,7 +117,6 @@ function App() {
{renderPanelContent()}
</div>
</div>
)}
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,44 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import { useTurtleStore } from '../store/turtleStore';
import './ControlPanel.css';
function TurtleCard({ turtle, isSelected, onSelect }) {
const mode = turtle.mode || 'unknown';
const activeState = turtle.state || turtle.mode || 'idle';
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
const inventoryCount = turtle.inventory?.length || 0;
const inventoryCount = Array.isArray(turtle.inventory)
? turtle.inventory.length
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
const modeColors = {
mining: '#4ade80',
exploring: '#60a5fa',
returning: '#f59e0b',
idle: '#9ca3af',
manual: '#a78bfa',
unknown: '#6b7280'
mining: '#55ff55',
exploring: '#55ffff',
returning: '#ffaa00',
goHome: '#ffaa00',
idle: '#aaaaaa',
manual: '#ff55ff',
refueling: '#ff5555',
farming: '#55ff55',
dumpInventory: '#aa00aa',
dumping: '#aa00aa',
moving: '#55ffff',
scan: '#5555ff',
extraction: '#ffaa00',
building: '#00aaaa',
autocraft: '#ff55ff',
unknown: '#555555'
};
return (
<div
className={`turtle-card ${isSelected ? 'selected' : ''}`}
onClick={onSelect}
style={{ borderColor: modeColors[mode] }}
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
>
<div className="turtle-header">
<h3>Turtle {turtle.turtleID}</h3>
<span className="mode-badge" style={{ background: modeColors[mode] }}>
{mode}
<h3>{displayName}</h3>
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
{activeState}
</span>
</div>
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
}
function TurtleDetails({ turtle }) {
const sendCommand = useTurtleStore((state) => state.sendCommand);
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
const equipLeft = useTurtleStore((state) => state.equipLeft);
const equipRight = useTurtleStore((state) => state.equipRight);
const sortInventory = useTurtleStore((state) => state.sortInventory);
const selectSlot = useTurtleStore((state) => state.selectSlot);
const dropItems = useTurtleStore((state) => state.dropItems);
const suckItems = useTurtleStore((state) => state.suckItems);
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
const moveForward = useTurtleStore((state) => state.moveForward);
const moveBack = useTurtleStore((state) => state.moveBack);
const moveUp = useTurtleStore((state) => state.moveUp);
const moveDown = useTurtleStore((state) => state.moveDown);
const turnLeft = useTurtleStore((state) => state.turnLeft);
const turnRight = useTurtleStore((state) => state.turnRight);
const digBlock = useTurtleStore((state) => state.digBlock);
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
const placeBlock = useTurtleStore((state) => state.placeBlock);
const [renameValue, setRenameValue] = useState('');
const [showConfig, setShowConfig] = useState(false);
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
if (!turtle) {
return (
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
);
}
const handleCommand = (command, param = null) => {
sendCommand(turtle.turtleID, command, param);
const handleStateChange = (stateName, data = {}) => {
setTurtleState(turtle.turtleID, stateName, data);
};
const handleRename = async () => {
if (renameValue.trim()) {
await renameTurtle(turtle.turtleID, renameValue.trim());
setRenameValue('');
}
};
const handleSlotClick = async (slotIndex) => {
await selectSlot(turtle.turtleID, slotIndex + 1);
};
const handleConfigSave = async () => {
await updateTurtleConfig(turtle.turtleID, configValues);
setShowConfig(false);
};
const activeState = turtle.state || turtle.mode || 'idle';
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
return (
<div className="turtle-details">
<h2>Turtle {turtle.turtleID} Control</h2>
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
{/* Rename + Config bar */}
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
placeholder="Rename turtle..."
className="rename-input"
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
/>
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}> Config</button>
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
</div>
{/* Config Modal */}
{showConfig && (
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
<h3> Configuration</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Max Distance:</span>
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
</label>
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Auto Refuel:</span>
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
</label>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
</div>
</div>
</div>
)}
<div className="detail-section">
<h3>Status</h3>
<div className="status-grid">
<div className="status-item">
<span className="label">Mode:</span>
<span className="value">{turtle.mode || 'unknown'}</span>
<span className="label">State:</span>
<span className="value">{activeState}</span>
</div>
{turtle.stateDescription && (
<div className="status-item">
<span className="label">Activity:</span>
<span className="value">{turtle.stateDescription}</span>
</div>
)}
<div className="status-item">
<span className="label">Fuel:</span>
<span className="value">
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
</span>
</div>
{turtle.totalSteps > 0 && (
<div className="status-item">
<span className="label">Steps:</span>
<span className="value">
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
</span>
</div>
)}
<div className="status-item">
<span className="label">Position:</span>
<span className="value">
@@ -115,67 +224,168 @@ function TurtleDetails({ turtle }) {
}
</span>
</div>
{turtle.error && (
<div className="status-item" style={{ color: '#ff5555' }}>
<span className="label">Error:</span>
<span className="value">{turtle.error}</span>
</div>
)}
{turtle.warning && (
<div className="status-item" style={{ color: '#ffaa00' }}>
<span className="label">Warning:</span>
<span className="value">{turtle.warning}</span>
</div>
)}
</div>
</div>
{/* Peripherals section */}
{turtle.peripherals && Object.keys(turtle.peripherals).length > 0 && (
<div className="detail-section">
<h3>Peripherals</h3>
<div className="status-grid">
{Object.entries(turtle.peripherals).map(([side, info]) => (
<div key={side} className="status-item">
<span className="label" style={{ textTransform: 'capitalize' }}>{side}:</span>
<span className="value" style={{ color: '#ff55ff' }}>
{typeof info === 'string' ? info : (info?.types?.join(', ') || 'unknown')}
</span>
</div>
))}
</div>
</div>
)}
<div className="detail-section">
<h3>Commands</h3>
<h3>State Machine</h3>
<div className="command-grid">
<button
className="command-btn explore"
onClick={() => handleCommand('explore')}
title="Start autonomous exploration and mining"
<button
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
onClick={() => handleStateChange('idle')}
title="Stop and go idle"
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
>
Idle
</button>
<button
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
onClick={() => handleStateChange('exploring')}
title="Autonomous exploration"
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
>
🔍 Explore
</button>
<button
className="command-btn mine"
onClick={() => handleCommand('mine')}
title="Start mining mode"
<button
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
onClick={() => handleStateChange('mining')}
title="Mining with ore priority"
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
>
Mine
</button>
<button
className="command-btn return"
onClick={() => handleCommand('returnHome')}
title="Navigate back to home position"
<button
className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
onClick={() => handleStateChange('farming')}
title="Automated farming"
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
>
🏠 Return Home
🌾 Farm
</button>
<button
className="command-btn stop"
onClick={() => handleCommand('stop')}
title="Stop all autonomous actions"
<button
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
onClick={() => handleStateChange('goHome')}
title="Navigate home"
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
>
Stop
🏠 Go Home
</button>
<button
className="command-btn manual"
onClick={() => handleCommand('manual')}
title="Switch to manual control mode"
>
🎮 Manual Mode
</button>
<button
className="command-btn setHome"
onClick={() => handleCommand('setHome')}
title="Set current position as home"
>
📍 Set Home
</button>
<button
className="command-btn refuel"
onClick={() => handleCommand('refuel')}
title="Consume fuel from inventory"
<button
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
onClick={() => handleStateChange('refueling')}
title="Auto-refuel from inventory"
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
>
Refuel
</button>
<button
className="command-btn status"
onClick={() => handleCommand('status')}
title="Request status update"
<button
className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
onClick={() => handleStateChange('dumpInventory')}
title="Dump inventory into nearby container"
style={activeState === 'dumpInventory' || activeState === 'dumping' ? { outline: '2px solid #fff' } : {}}
>
📊 Status
📦 Dump
</button>
<button
className={`command-btn ${activeState === 'moving' ? 'active' : ''}`}
onClick={() => handleStateChange('moving')}
title="Navigate to target"
style={activeState === 'moving' ? { outline: '2px solid #fff' } : {}}
>
🧭 Move To
</button>
<button
className={`command-btn ${activeState === 'scan' ? 'active' : ''}`}
onClick={() => handleStateChange('scan')}
title="Scan surroundings with peripheral scanner"
style={activeState === 'scan' ? { outline: '2px solid #fff' } : {}}
>
📡 Scan
</button>
<button
className={`command-btn ${activeState === 'extraction' ? 'active' : ''}`}
onClick={() => {
const area = prompt('Enter extraction area (startX,startY,startZ,endX,endY,endZ):');
if (area) {
const [sx,sy,sz,ex,ey,ez] = area.split(',').map(Number);
if (!isNaN(sx) && !isNaN(sy) && !isNaN(sz) && !isNaN(ex) && !isNaN(ey) && !isNaN(ez)) {
const points = [];
for (let x = sx; x <= ex; x++) for (let y = sy; y <= ey; y++) for (let z = sz; z <= ez; z++) {
points.push({x, y, z});
}
handleStateChange('extraction', { area: points });
}
}
}}
title="Smart ore extraction in an area"
style={activeState === 'extraction' ? { outline: '2px solid #fff' } : {}}
>
💎 Extract
</button>
<button
className={`command-btn ${activeState === 'building' ? 'active' : ''}`}
onClick={() => {
const input = prompt('Enter blueprint JSON (array of {x,y,z,name} blocks):');
if (input) {
try {
const blocks = JSON.parse(input);
handleStateChange('building', { blocks });
} catch (e) {
alert('Invalid JSON blueprint');
}
}
}}
title="Build from blueprint"
style={activeState === 'building' ? { outline: '2px solid #fff' } : {}}
>
🏗 Build
</button>
<button
className={`command-btn ${activeState === 'autocraft' ? 'active' : ''}`}
onClick={() => {
const input = prompt('Enter recipe JSON (array of 16 slot items, null for empty):');
if (input) {
try {
const recipe = JSON.parse(input);
handleStateChange('autocraft', { recipe });
} catch (e) {
alert('Invalid JSON recipe');
}
}
}}
title="Automated crafting with workbench"
style={activeState === 'autocraft' ? { outline: '2px solid #fff' } : {}}
>
🔨 Autocraft
</button>
</div>
</div>
@@ -184,16 +394,16 @@ function TurtleDetails({ turtle }) {
<h3>Movement</h3>
<div className="movement-controls">
<div className="movement-row">
<button onClick={() => handleCommand('forward')} title="Move forward"></button>
<button onClick={() => moveForward(turtle.turtleID)} title="Move forward"></button>
</div>
<div className="movement-row">
<button onClick={() => handleCommand('turnLeft')} title="Turn left"></button>
<button onClick={() => handleCommand('back')} title="Move backward"></button>
<button onClick={() => handleCommand('turnRight')} title="Turn right"></button>
<button onClick={() => turnLeft(turtle.turtleID)} title="Turn left"></button>
<button onClick={() => moveBack(turtle.turtleID)} title="Move backward"></button>
<button onClick={() => turnRight(turtle.turtleID)} title="Turn right"></button>
</div>
<div className="movement-row vertical">
<button onClick={() => handleCommand('up')} title="Move up"> Up</button>
<button onClick={() => handleCommand('down')} title="Move down"> Down</button>
<button onClick={() => moveUp(turtle.turtleID)} title="Move up"> Up</button>
<button onClick={() => moveDown(turtle.turtleID)} title="Move down"> Down</button>
</div>
</div>
</div>
@@ -203,28 +413,28 @@ function TurtleDetails({ turtle }) {
<div className="action-grid">
<button
className="action-btn dig"
onClick={() => handleCommand('dig')}
onClick={() => digBlock(turtle.turtleID)}
title="Dig block in front"
>
Dig
</button>
<button
className="action-btn digup"
onClick={() => handleCommand('digUp')}
onClick={() => digBlockUp(turtle.turtleID)}
title="Dig block above"
>
Dig Up
</button>
<button
className="action-btn digdown"
onClick={() => handleCommand('digDown')}
onClick={() => digBlockDown(turtle.turtleID)}
title="Dig block below"
>
Dig Down
</button>
<button
className="action-btn place"
onClick={() => handleCommand('place')}
onClick={() => placeBlock(turtle.turtleID)}
title="Place block from inventory"
>
🧱 Place
@@ -232,17 +442,86 @@ function TurtleDetails({ turtle }) {
</div>
</div>
{/* Equipment & Inventory Actions */}
<div className="detail-section">
<h3>Equipment & Inventory</h3>
<div className="action-grid">
<button
className="action-btn"
onClick={() => equipLeft(turtle.turtleID)}
title="Equip selected item on left side"
>
🛡 Equip L
</button>
<button
className="action-btn"
onClick={() => equipRight(turtle.turtleID)}
title="Equip selected item on right side"
>
🗡 Equip R
</button>
<button
className="action-btn"
onClick={() => sortInventory(turtle.turtleID)}
title="Sort and compact inventory"
>
📋 Sort
</button>
<button
className="action-btn"
onClick={() => dropItems(turtle.turtleID, 'front')}
title="Drop items forward"
>
📤 Drop
</button>
<button
className="action-btn"
onClick={() => dropItems(turtle.turtleID, 'up')}
title="Drop items upward"
>
Drop Up
</button>
<button
className="action-btn"
onClick={() => dropItems(turtle.turtleID, 'down')}
title="Drop items downward"
>
Drop Down
</button>
<button
className="action-btn"
onClick={() => suckItems(turtle.turtleID, 'front')}
title="Suck items from front"
>
📥 Suck
</button>
<button
className="action-btn"
onClick={() => {
const side = prompt('Enter peripheral side (front/top/bottom/left/right):');
if (side) connectToInventory(turtle.turtleID, side);
}}
title="Read adjacent inventory peripheral contents"
>
📦 Read Inv
</button>
</div>
</div>
{turtle.inventory && turtle.inventory.length > 0 && (
<div className="detail-section">
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16)</h3>
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) Slot: {turtle.selectedSlot || 1}</h3>
<div className="inventory-grid">
{Array.from({ length: 16 }, (_, slotIndex) => {
const item = turtle.inventory[slotIndex];
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
return (
<div
key={slotIndex}
className={`inventory-slot ${item ? 'filled' : 'empty'}`}
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count})` : 'Empty'}
className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count}) — Click to select` : `Slot ${slotIndex + 1} — Click to select`}
onClick={() => handleSlotClick(slotIndex)}
style={isSelected ? { outline: '2px solid #55ffff', outlineOffset: '-2px' } : {}}
>
{item ? (
<>

View File

@@ -1,9 +1,13 @@
/* ============================================
Minecraft-Themed Groups Panel
============================================ */
.groups-panel {
padding: 1.5rem;
background: #0f172a;
border-radius: 0.5rem;
background: #2c2c2c;
height: 100%;
overflow-y: auto;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.groups-header {
@@ -16,19 +20,20 @@
.groups-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 2px 2px 0 #1a1a1a;
}
.group-count {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
font-weight: 600;
}
.message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 2px solid;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 600;
@@ -36,15 +41,15 @@
}
.message.success {
background: #10b98133;
color: #10b981;
border: 1px solid #10b981;
background: #2d6b1a33;
color: #55ff55;
border-color: #55ff55;
}
.message.error {
background: #ef444433;
color: #ef4444;
border: 1px solid #ef4444;
background: #6b1a1a33;
color: #ff5555;
border-color: #ff5555;
}
@keyframes slideIn {
@@ -60,17 +65,19 @@
/* Create Group Section */
.create-group-section {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.create-group-section h3 {
font-size: 1rem;
font-weight: 600;
color: #f9fafb;
color: #ffaa00;
margin: 0 0 1rem 0;
text-shadow: 1px 1px 0 #1a1a1a;
}
.create-group-form {
@@ -81,16 +88,16 @@
.create-group-form input {
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e5e7eb;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.875rem;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.create-group-form input:focus {
outline: none;
border-color: #3b82f6;
border-color: #55ffff;
}
.create-group-form input:disabled {
@@ -107,11 +114,11 @@
.color-option {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.375rem;
border: 2px solid transparent;
border: 3px solid #1a1a1a;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
position: relative;
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.3), inset 0 2px 0 rgba(255,255,255,0.2);
}
.color-option:hover {
@@ -119,25 +126,26 @@
}
.color-option.active {
border-color: #e5e7eb;
box-shadow: 0 0 0 2px #0f172a, 0 0 0 4px currentColor;
border-color: #ffff55;
box-shadow: 0 0 0 2px #1a1a1a, 0 0 0 4px #ffff55;
}
.create-group-form button[type="submit"] {
padding: 0.75rem 1.5rem;
background: #3b82f6;
border: none;
border-radius: 0.375rem;
background: #4a8c2a;
border: 2px solid #1a1a1a;
color: white;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.create-group-form button[type="submit"]:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
.create-group-form button[type="submit"]:disabled {
@@ -153,15 +161,15 @@
}
.group-card {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
overflow: hidden;
transition: all 0.2s;
transition: all 0.1s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.group-card:hover {
background: #334155;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: #4b4b4b;
}
.group-header {
@@ -182,24 +190,23 @@
.group-color-indicator {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid #0f172a;
border: 2px solid #1a1a1a;
}
.group-title h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #e0e0e0;
margin: 0;
}
.member-count {
font-size: 0.75rem;
color: #94a3b8;
color: #a0a0a0;
font-weight: 600;
padding: 0.25rem 0.5rem;
background: #0f172a;
border-radius: 0.25rem;
background: #1a1a1a;
border: 2px solid #4b4b4b;
}
.delete-group-btn {
@@ -209,7 +216,7 @@
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
transition: all 0.2s;
transition: all 0.1s;
}
.delete-group-btn:hover {
@@ -225,7 +232,7 @@
.group-members h4 {
font-size: 0.875rem;
font-weight: 600;
color: #94a3b8;
color: #a0a0a0;
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -243,13 +250,14 @@
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #0f172a;
border-radius: 0.375rem;
transition: all 0.2s;
background: #2c2c2c;
border: 2px solid #1a1a1a;
transition: all 0.1s;
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
}
.member-item:hover {
background: #1e293b;
background: #4b4b4b;
}
.member-info {
@@ -266,7 +274,7 @@
.member-name {
font-size: 0.875rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
}
.member-status {
@@ -278,11 +286,11 @@
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: #ef4444;
color: #ff5555;
font-size: 1rem;
cursor: pointer;
opacity: 0.6;
transition: all 0.2s;
transition: all 0.1s;
}
.remove-member-btn:hover {
@@ -293,7 +301,7 @@
.no-members {
text-align: center;
padding: 2rem 1rem;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
font-style: italic;
}
@@ -305,34 +313,34 @@
.add-member-section select {
width: 100%;
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e5e7eb;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.add-member-section select:hover {
border-color: #3b82f6;
border-color: #55ffff;
}
.add-member-section select:focus {
outline: none;
border-color: #3b82f6;
border-color: #55ffff;
}
/* Group Commands */
.group-commands {
padding: 1rem 1.5rem 1.5rem 1.5rem;
border-top: 1px solid #334155;
border-top: 2px solid #4b4b4b;
}
.group-commands h4 {
font-size: 0.875rem;
font-weight: 600;
color: #94a3b8;
color: #a0a0a0;
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -346,21 +354,21 @@
.command-buttons button {
padding: 0.75rem 1rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e5e7eb;
background: #6b6b6b;
border: 2px solid #1a1a1a;
color: #e0e0e0;
font-size: 0.875rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
}
.command-buttons button:hover {
background: #1e293b;
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
background: #7b7b7b;
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
}
/* Empty State */
@@ -378,13 +386,13 @@
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.5rem;
}
.empty-text {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
}
/* Mobile Responsive */

View File

@@ -10,14 +10,14 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
const [message, setMessage] = useState(null);
const colorPresets = [
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Green', value: '#10b981' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Yellow', value: '#f59e0b' },
{ name: 'Purple', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' },
{ name: 'Cyan', value: '#06b6d4' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Blue', value: '#345ec3' },
{ name: 'Green', value: '#4a8c2a' },
{ name: 'Red', value: '#aa0000' },
{ name: 'Yellow', value: '#ffaa00' },
{ name: 'Purple', value: '#7b2fbe' },
{ name: 'Pink', value: '#d4658a' },
{ name: 'Cyan', value: '#55ffff' },
{ name: 'Orange', value: '#c97a2a' },
];
useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
/* ============================================
Minecraft-Themed Mining Areas Panel
============================================ */
.mining-areas-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #0f172a;
color: #e2e8f0;
background: #2c2c2c;
color: #e0e0e0;
overflow: hidden;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.panel-header {
@@ -12,79 +17,86 @@
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: #1e293b;
border-bottom: 2px solid #334155;
background: #6b4e28;
border-bottom: 3px solid #1a1a1a;
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
}
.panel-header h2 {
margin: 0;
font-size: 1.5rem;
color: #f8fafc;
color: #ffff55;
text-shadow: 2px 2px 0 #1a1a1a;
}
.btn-create {
padding: 0.5rem 1rem;
background: #3b82f6;
background: #4a8c2a;
color: white;
border: none;
border-radius: 6px;
border: 2px solid #1a1a1a;
font-size: 0.9rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.btn-create:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
/* Filter buttons */
.filter-buttons {
display: flex;
gap: 0.5rem;
gap: 0.25rem;
padding: 1rem 1.5rem;
background: #1e293b;
border-bottom: 1px solid #334155;
background: #3b3b3b;
border-bottom: 2px solid #1a1a1a;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
background: #334155;
color: #94a3b8;
border: none;
border-radius: 6px;
background: #6b6b6b;
color: #e0e0e0;
border: 2px solid #1a1a1a;
font-size: 0.875rem;
font-weight: 500;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
}
.filter-btn:hover {
background: #475569;
color: #e2e8f0;
background: #7b7b7b;
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
}
.filter-btn.active {
background: #3b82f6;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
/* Create form */
.create-form {
padding: 1.5rem;
background: #1e293b;
border-bottom: 1px solid #334155;
background: #3b3b3b;
border-bottom: 2px solid #1a1a1a;
max-height: 60vh;
overflow-y: auto;
}
.create-form h3 {
margin: 0 0 1rem 0;
color: #f8fafc;
color: #ffaa00;
text-shadow: 1px 1px 0 #1a1a1a;
}
.form-group {
@@ -94,28 +106,27 @@
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #cbd5e1;
color: #e0e0e0;
font-size: 0.875rem;
font-weight: 500;
font-weight: 600;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.625rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.875rem;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: #55ffff;
}
.coordinates-section {
@@ -130,31 +141,34 @@
}
.coordinates-header label {
color: #cbd5e1;
color: #e0e0e0;
font-size: 0.875rem;
font-weight: 500;
font-weight: 600;
}
.btn-use-position {
padding: 0.375rem 0.75rem;
background: #10b981;
background: #4a8c2a;
color: white;
border: none;
border-radius: 4px;
border: 2px solid #1a1a1a;
font-size: 0.75rem;
font-weight: 500;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.btn-use-position:hover:not(:disabled) {
background: #059669;
background: #5a9c3a;
}
.btn-use-position:disabled {
background: #334155;
color: #64748b;
background: #4b4b4b;
color: #7b7b7b;
cursor: not-allowed;
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
}
.coordinate-inputs {
@@ -170,21 +184,22 @@
.btn-submit {
width: 100%;
padding: 0.75rem;
background: #3b82f6;
background: #4a8c2a;
color: white;
border: none;
border-radius: 6px;
border: 2px solid #1a1a1a;
font-size: 1rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
margin-top: 1rem;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.btn-submit:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
/* Areas list */
@@ -197,7 +212,7 @@
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
color: #7b7b7b;
}
.empty-state p {
@@ -206,22 +221,21 @@
/* Area card */
.area-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s;
transition: all 0.1s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.area-card:hover {
border-color: #475569;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: #4b4b4b;
}
.area-card.has-conflict {
border-color: #ef4444;
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
border-color: #ff5555;
background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
}
.area-header {
@@ -230,23 +244,24 @@
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #334155;
border-bottom: 2px solid #4b4b4b;
}
.area-header h3 {
margin: 0;
color: #f8fafc;
color: #e0e0e0;
font-size: 1.125rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
border: 2px solid #1a1a1a;
font-size: 0.75rem;
font-weight: 600;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: white;
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
}
/* Area info */
@@ -259,7 +274,7 @@
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #334155;
border-bottom: 2px solid #4b4b4b;
}
.info-row:last-child {
@@ -267,32 +282,32 @@
}
.info-label {
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
font-weight: 500;
font-weight: 600;
}
.info-value {
color: #e2e8f0;
color: #e0e0e0;
font-size: 0.875rem;
font-weight: 600;
text-align: right;
}
.coordinate-value {
font-family: 'Courier New', monospace;
font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.75rem;
word-break: break-all;
color: #55ffff;
}
/* Conflict warning */
.conflict-warning {
background: #7f1d1d;
border: 1px solid #ef4444;
border-radius: 6px;
background: #6b1a1a44;
border: 2px solid #ff5555;
padding: 0.75rem;
margin-bottom: 1rem;
color: #fca5a5;
color: #ff5555;
font-size: 0.875rem;
}
@@ -316,46 +331,45 @@
flex: 1;
min-width: 120px;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
border: 2px solid #1a1a1a;
font-size: 0.875rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
}
.btn-start {
background: #10b981;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.btn-start:hover {
background: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
.btn-complete {
background: #3b82f6;
background: #345ec3;
color: white;
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
}
.btn-complete:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #4a6ed3;
}
.btn-delete {
background: #ef4444;
background: #aa0000;
color: white;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
}
.btn-delete:hover {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
background: #cc0000;
}
/* Scrollbar styling */
@@ -366,18 +380,18 @@
.areas-list::-webkit-scrollbar-track,
.create-form::-webkit-scrollbar-track {
background: #0f172a;
background: #2c2c2c;
}
.areas-list::-webkit-scrollbar-thumb,
.create-form::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
background: #5a5a5a;
border: 1px solid #1a1a1a;
}
.areas-list::-webkit-scrollbar-thumb:hover,
.create-form::-webkit-scrollbar-thumb:hover {
background: #475569;
background: #6b6b6b;
}
/* Responsive design */
@@ -432,3 +446,62 @@
text-align: left;
}
}
/* Color picker row */
.color-picker-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.color-input {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid #1a1a1a;
background: none;
cursor: pointer;
border-radius: 0;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input::-webkit-color-swatch {
border: none;
}
.color-swatch {
width: 24px;
height: 24px;
border: 2px solid #1a1a1a;
cursor: pointer;
padding: 0;
transition: transform 0.1s;
}
.color-swatch:hover {
transform: scale(1.2);
}
.color-swatch.active {
border-color: #ffff55;
box-shadow: 0 0 4px #ffff55;
transform: scale(1.15);
}
/* Color dot in area card */
.area-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.area-color-dot {
width: 14px;
height: 14px;
border: 2px solid #1a1a1a;
flex-shrink: 0;
}

View File

@@ -6,6 +6,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
const [showCreateForm, setShowCreateForm] = useState(false);
const [newArea, setNewArea] = useState({
areaName: '',
color: '#4a8c2a',
startX: '',
startY: '',
startZ: '',
@@ -66,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
endX: Number(newArea.endX),
endY: Number(newArea.endY),
endZ: Number(newArea.endZ),
color: newArea.color,
status: 'planned'
})
});
@@ -74,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
setShowCreateForm(false);
setNewArea({
areaName: '',
color: '#4a8c2a',
startX: '',
startY: '',
startZ: '',
@@ -192,12 +195,12 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
// Status badge component
const StatusBadge = ({ status }) => {
const colors = {
planned: '#6366f1',
mining: '#f59e0b',
completed: '#10b981'
planned: '#345ec3',
mining: '#ffaa00',
completed: '#4a8c2a'
};
return (
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}>
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
{status}
</span>
);
@@ -259,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
/>
</div>
<div className="form-group">
<label>Area Color:</label>
<div className="color-picker-row">
<input
type="color"
value={newArea.color}
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
className="color-input"
/>
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
<button
key={c}
type="button"
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
style={{ backgroundColor: c }}
onClick={() => setNewArea({ ...newArea, color: c })}
/>
))}
</div>
</div>
<div className="form-group">
<label>Assign Turtle:</label>
<select
@@ -364,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
return (
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
<div className="area-header">
<h3>{area.areaName}</h3>
<div className="area-title-row">
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
<h3>{area.areaName}</h3>
</div>
<StatusBadge status={area.status} />
</div>

View File

@@ -1,9 +1,13 @@
/* ============================================
Minecraft-Themed Path Recorder
============================================ */
.path-recorder {
padding: 1.5rem;
background: #0f172a;
border-radius: 0.5rem;
background: #2c2c2c;
height: 100%;
overflow-y: auto;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.recorder-header {
@@ -18,23 +22,23 @@
.recorder-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 2px 2px 0 #1a1a1a;
}
.selected-turtle-badge {
padding: 0.5rem 1rem;
background: #1e293b;
border-radius: 0.5rem;
color: #10b981;
background: #2d6b1a33;
border: 2px solid #55ff55;
color: #55ff55;
font-weight: 600;
font-size: 0.875rem;
border: 1px solid #10b981;
}
.message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 2px solid;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 600;
@@ -42,21 +46,21 @@
}
.message.success {
background: #10b98133;
color: #10b981;
border: 1px solid #10b981;
background: #2d6b1a33;
color: #55ff55;
border-color: #55ff55;
}
.message.error {
background: #ef444433;
color: #ef4444;
border: 1px solid #ef4444;
background: #6b1a1a33;
color: #ff5555;
border-color: #ff5555;
}
.message.info {
background: #3b82f633;
color: #3b82f6;
border: 1px solid #3b82f6;
background: #1a4a6b33;
color: #55ffff;
border-color: #55ffff;
}
@keyframes slideIn {
@@ -72,17 +76,19 @@
/* Recording Section */
.recording-section {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.recording-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #e0e0e0;
margin: 0 0 1rem 0;
text-shadow: 1px 1px 0 #1a1a1a;
}
.record-form {
@@ -94,36 +100,36 @@
.record-form input,
.record-form textarea {
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e5e7eb;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.875rem;
font-family: inherit;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.record-form input:focus,
.record-form textarea:focus {
outline: none;
border-color: #3b82f6;
border-color: #55ffff;
}
.record-btn {
padding: 0.875rem 1.5rem;
background: #ef4444;
border: none;
border-radius: 0.5rem;
background: #aa0000;
border: 2px solid #1a1a1a;
color: white;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
}
.record-btn:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
background: #cc0000;
box-shadow: inset 0 2px 0 #ff4444, inset 0 -2px 0 #880000;
}
.record-btn:disabled {
@@ -141,7 +147,8 @@
.waypoint-counter {
font-size: 1.5rem;
font-weight: 700;
color: #ef4444;
color: #ff5555;
text-shadow: 1px 1px 0 #1a1a1a;
animation: pulse 2s infinite;
}
@@ -157,51 +164,53 @@
.recording-info {
text-align: center;
padding: 1rem;
background: #0f172a;
border-radius: 0.375rem;
background: #2c2c2c;
border: 2px solid #1a1a1a;
width: 100%;
}
.recording-info p {
margin: 0.25rem 0;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
}
.recording-info strong {
color: #e5e7eb;
color: #e0e0e0;
}
.stop-btn {
padding: 0.875rem 2rem;
background: #3b82f6;
border: none;
border-radius: 0.5rem;
background: #4a8c2a;
border: 2px solid #1a1a1a;
color: white;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.stop-btn:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
/* Paths Section */
.paths-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #55ffff;
margin-bottom: 1rem;
text-shadow: 1px 1px 0 #1a1a1a;
}
.loading {
text-align: center;
padding: 2rem;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
}
@@ -212,15 +221,15 @@
}
.path-card {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
transition: all 0.2s;
transition: all 0.1s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.path-card:hover {
background: #334155;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: #4b4b4b;
}
.path-card-header {
@@ -230,13 +239,13 @@
.path-info h4 {
font-size: 1rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin: 0 0 0.5rem 0;
}
.path-description {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
margin: 0.5rem 0;
font-style: italic;
}
@@ -246,7 +255,7 @@
gap: 1rem;
flex-wrap: wrap;
font-size: 0.75rem;
color: #64748b;
color: #7b7b7b;
margin-top: 0.75rem;
}
@@ -263,44 +272,45 @@
.path-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
border: 2px solid #1a1a1a;
font-size: 0.875rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
}
.view-btn {
background: #3b82f6;
background: #345ec3;
color: white;
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
}
.view-btn:hover {
background: #2563eb;
transform: translateY(-2px);
background: #4a6ed3;
}
.play-btn {
background: #10b981;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.play-btn:hover {
background: #059669;
transform: translateY(-2px);
background: #5a9c3a;
}
.delete-btn {
background: #ef4444;
background: #aa0000;
color: white;
padding: 0.5rem;
margin-left: auto;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
}
.delete-btn:hover {
background: #dc2626;
transform: scale(1.1);
background: #cc0000;
}
/* Empty State */
@@ -318,13 +328,13 @@
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.5rem;
}
.empty-text {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
}
/* Path Details Modal */
@@ -334,7 +344,7 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
@@ -353,13 +363,14 @@
}
.modal-content {
background: #1e293b;
border-radius: 0.75rem;
background: #3b3b3b;
border: 3px solid #1a1a1a;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
@keyframes slideUp {
@@ -378,30 +389,34 @@
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #334155;
border-bottom: 2px solid #4b4b4b;
background: #6b4e28;
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 700;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 2px 2px 0 #1a1a1a;
}
.close-btn {
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: #94a3b8;
font-size: 1.5rem;
background: #aa0000;
border: 2px solid #1a1a1a;
color: white;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
line-height: 1;
font-weight: 700;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
}
.close-btn:hover {
color: #e5e7eb;
transform: scale(1.1);
background: #cc0000;
}
.modal-body {
@@ -409,7 +424,7 @@
}
.modal-description {
color: #94a3b8;
color: #a0a0a0;
font-style: italic;
margin-bottom: 1.5rem;
}
@@ -418,8 +433,9 @@
.path-visualization h4 {
font-size: 1rem;
font-weight: 600;
color: #f9fafb;
color: #ffaa00;
margin: 0 0 1rem 0;
text-shadow: 1px 1px 0 #1a1a1a;
}
.waypoints-grid {
@@ -434,8 +450,8 @@
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #0f172a;
border-radius: 0.375rem;
background: #2c2c2c;
border: 2px solid #1a1a1a;
font-size: 0.75rem;
}
@@ -445,21 +461,21 @@
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: #3b82f6;
background: #4a8c2a;
color: white;
border-radius: 50%;
font-weight: 700;
font-size: 0.7rem;
border: 2px solid #1a1a1a;
}
.waypoint-coords {
color: #e5e7eb;
font-family: 'Courier New', monospace;
color: #e0e0e0;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.waypoint-action {
margin-left: auto;
color: #10b981;
color: #55ff55;
font-size: 0.7rem;
}
@@ -471,25 +487,26 @@
.stat {
padding: 1rem;
background: #0f172a;
border-radius: 0.375rem;
background: #2c2c2c;
border: 2px solid #1a1a1a;
}
.stat-label {
.stat .stat-label {
display: block;
font-size: 0.75rem;
color: #94a3b8;
color: #a0a0a0;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
.stat .stat-value {
display: block;
font-size: 1rem;
font-weight: 600;
color: #3b82f6;
font-family: 'Courier New', monospace;
color: #55ffff;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
}
/* Mobile Responsive */

View File

@@ -13,7 +13,7 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
const [loading, setLoading] = useState(false);
const [playingBack, setPlayingBack] = useState(false);
const sendCommand = useTurtleStore((state) => state.sendCommand);
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
useEffect(() => {
loadPaths();
@@ -150,44 +150,23 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
}
setPlayingBack(true);
showMessage(`Playing back ${waypoints.length} waypoints...`, 'info');
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
const turtleId = selectedTurtle.turtleID;
// Determine movement commands between consecutive waypoints
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1];
const curr = waypoints[i];
// Use server-side pathfinding to navigate to each waypoint sequentially
for (let i = 0; i < waypoints.length; i++) {
const wp = waypoints[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const dz = curr.z - prev.z;
// Vertical movement
if (dy > 0) {
for (let j = 0; j < dy; j++) sendCommand(turtleId, 'up');
} else if (dy < 0) {
for (let j = 0; j < Math.abs(dy); j++) sendCommand(turtleId, 'down');
}
// Horizontal movement - send as forward movements with turns
if (dx > 0) {
for (let j = 0; j < dx; j++) sendCommand(turtleId, 'forward');
} else if (dx < 0) {
for (let j = 0; j < Math.abs(dx); j++) sendCommand(turtleId, 'forward');
}
if (dz > 0) {
for (let j = 0; j < dz; j++) sendCommand(turtleId, 'forward');
} else if (dz < 0) {
for (let j = 0; j < Math.abs(dz); j++) sendCommand(turtleId, 'forward');
}
// Small delay between waypoint groups to avoid flooding
await new Promise(resolve => setTimeout(resolve, 200));
// Navigate to each waypoint using the server's moving state
await setTurtleState(turtleId, 'moving', { target: { x: wp.x, y: wp.y, z: wp.z } });
// Wait for turtle to arrive (poll position or just add delay)
await new Promise(resolve => setTimeout(resolve, 500));
}
setPlayingBack(false);
showMessage('Playback complete! Commands queued for turtle.', 'success');
showMessage('Playback complete! Turtle navigating via server.', 'success');
};
const showMessage = (text, type) => {

View File

@@ -1,9 +1,13 @@
/* ============================================
Minecraft-Themed Stats Panel
============================================ */
.stats-panel {
padding: 1.5rem;
background: #0f172a;
border-radius: 0.5rem;
background: #2c2c2c;
height: 100%;
overflow-y: auto;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.stats-header {
@@ -18,43 +22,47 @@
.stats-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 2px 2px 0 #1a1a1a;
}
.time-filter {
display: flex;
gap: 0.5rem;
background: #1e293b;
gap: 0.25rem;
background: #3b3b3b;
padding: 0.25rem;
border-radius: 0.5rem;
border: 2px solid #1a1a1a;
}
.time-filter button {
padding: 0.5rem 1rem;
background: transparent;
border: none;
border-radius: 0.375rem;
color: #94a3b8;
font-weight: 600;
background: #6b6b6b;
border: 2px solid #1a1a1a;
color: #e0e0e0;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
}
.time-filter button:hover {
color: #e5e7eb;
background: #334155;
background: #7b7b7b;
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
}
.time-filter button.active {
background: #3b82f6;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.loading {
text-align: center;
padding: 2rem;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
}
@@ -66,33 +74,36 @@
.turtle-stats h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #55ff55;
margin-bottom: 1rem;
text-shadow: 1px 1px 0 #1a1a1a;
}
.stat-section {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.total-mined {
text-align: center;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #334155;
border-bottom: 2px solid #4b4b4b;
}
.stat-value {
font-size: 3rem;
font-weight: 700;
color: #3b82f6;
color: #55ffff;
line-height: 1;
text-shadow: 2px 2px 0 #1a1a1a;
}
.stat-label {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
margin-top: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -101,7 +112,7 @@
.blocks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
gap: 0.75rem;
}
.block-stat {
@@ -109,14 +120,15 @@
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #0f172a;
border-radius: 0.375rem;
transition: all 0.2s;
background: #2c2c2c;
border: 2px solid #1a1a1a;
transition: all 0.1s;
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
}
.block-stat:hover {
background: #1e293b;
transform: translateY(-2px);
background: #4b4b4b;
box-shadow: inset 0 -1px 0 #333, inset 0 1px 0 #666;
}
.block-emoji {
@@ -131,13 +143,13 @@
.block-name {
font-size: 0.875rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.25rem;
}
.block-count {
font-size: 0.75rem;
color: #10b981;
color: #55ff55;
font-weight: 600;
}
@@ -149,14 +161,16 @@
.leaderboard h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #ffaa00;
margin-bottom: 1rem;
text-shadow: 1px 1px 0 #1a1a1a;
}
.leaderboard-list {
background: #1e293b;
border-radius: 0.5rem;
padding: 1rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 0.75rem;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.leaderboard-item {
@@ -165,9 +179,10 @@
gap: 1rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #0f172a;
border-radius: 0.375rem;
transition: all 0.2s;
background: #2c2c2c;
border: 2px solid #1a1a1a;
transition: all 0.1s;
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
}
.leaderboard-item:last-child {
@@ -175,20 +190,19 @@
}
.leaderboard-item:hover {
background: #1e293b;
transform: translateX(4px);
background: #4b4b4b;
}
.leaderboard-item.rank-1 {
border-left: 3px solid #fbbf24;
border-left: 4px solid #ffaa00;
}
.leaderboard-item.rank-2 {
border-left: 3px solid #9ca3af;
border-left: 4px solid #a0a0a0;
}
.leaderboard-item.rank-3 {
border-left: 3px solid #cd7f32;
border-left: 4px solid #cd7f32;
}
.rank {
@@ -196,6 +210,8 @@
font-weight: 700;
min-width: 3rem;
text-align: center;
color: #ffff55;
text-shadow: 1px 1px 0 #1a1a1a;
}
.miner-info {
@@ -205,27 +221,29 @@
.miner-name {
font-size: 0.875rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.25rem;
}
.miner-stats {
font-size: 0.75rem;
color: #94a3b8;
color: #a0a0a0;
}
.miner-score {
font-size: 1.25rem;
font-weight: 700;
color: #3b82f6;
color: #55ffff;
text-shadow: 1px 1px 0 #1a1a1a;
}
/* All Turtles Overview */
.all-turtles-stats h3 {
font-size: 1.125rem;
font-weight: 600;
color: #f9fafb;
color: #55ffff;
margin-bottom: 1rem;
text-shadow: 1px 1px 0 #1a1a1a;
}
.turtles-summary-grid {
@@ -235,16 +253,16 @@
}
.turtle-summary-card {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1rem;
transition: all 0.2s;
transition: all 0.1s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.turtle-summary-card:hover {
background: #334155;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: #4b4b4b;
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
}
.turtle-summary-header {
@@ -253,19 +271,20 @@
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #334155;
border-bottom: 2px solid #4b4b4b;
}
.turtle-id {
font-size: 0.875rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
}
.turtle-total {
font-size: 1.25rem;
font-weight: 700;
color: #3b82f6;
color: #55ffff;
text-shadow: 1px 1px 0 #1a1a1a;
}
.turtle-top-blocks {
@@ -278,8 +297,8 @@
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
background: #0f172a;
border-radius: 0.25rem;
background: #2c2c2c;
border: 2px solid #1a1a1a;
flex: 1;
}
@@ -290,7 +309,7 @@
.mini-count {
font-size: 0.75rem;
font-weight: 600;
color: #10b981;
color: #55ff55;
}
/* Empty State */
@@ -308,19 +327,19 @@
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.5rem;
}
.empty-text {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
}
.no-data {
text-align: center;
padding: 2rem;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
}

View File

@@ -1,9 +1,13 @@
/* ============================================
Minecraft-Themed Task Panel
============================================ */
.task-panel {
padding: 1.5rem;
background: #0f172a;
border-radius: 0.5rem;
background: #2c2c2c;
height: 100%;
overflow-y: auto;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.task-header {
@@ -16,31 +20,33 @@
.task-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 2px 2px 0 #1a1a1a;
}
.create-task-btn {
padding: 0.75rem 1.5rem;
background: #3b82f6;
border: none;
border-radius: 0.375rem;
background: #4a8c2a;
border: 2px solid #1a1a1a;
color: white;
font-weight: 600;
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.create-task-btn:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
.message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 2px solid;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 600;
@@ -48,15 +54,15 @@
}
.message.success {
background: #10b98133;
color: #10b981;
border: 1px solid #10b981;
background: #2d6b1a33;
color: #55ff55;
border-color: #55ff55;
}
.message.error {
background: #ef444433;
color: #ef4444;
border: 1px solid #ef4444;
background: #6b1a1a33;
color: #ff5555;
border-color: #ff5555;
}
@keyframes slideIn {
@@ -72,18 +78,20 @@
/* Create Task Form */
.create-task-form {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
margin-bottom: 1.5rem;
animation: slideIn 0.3s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.create-task-form h3 {
font-size: 1rem;
font-weight: 600;
color: #f9fafb;
color: #ffaa00;
margin: 0 0 1rem 0;
text-shadow: 1px 1px 0 #1a1a1a;
}
.create-task-form form {
@@ -104,28 +112,29 @@
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
}
.create-task-form select,
.create-task-form input[type="text"],
.create-task-form input[type="number"] {
padding: 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e5e7eb;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.875rem;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.create-task-form select:focus,
.create-task-form input:focus {
outline: none;
border-color: #3b82f6;
border-color: #55ffff;
}
.create-task-form input[type="range"] {
width: 100%;
accent-color: #4a8c2a;
}
.priority-value {
@@ -137,7 +146,7 @@
.coordinates-section h4 {
font-size: 0.875rem;
font-weight: 600;
color: #94a3b8;
color: #a0a0a0;
margin: 0 0 0.75rem 0;
}
@@ -156,64 +165,68 @@
.coord-group span {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
color: #a0a0a0;
text-transform: uppercase;
}
.coord-group input {
padding: 0.5rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.25rem;
color: #e5e7eb;
background: #1a1a1a;
border: 2px solid #4b4b4b;
color: #e0e0e0;
font-size: 0.75rem;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background: #10b981;
border: none;
border-radius: 0.375rem;
background: #4a8c2a;
border: 2px solid #1a1a1a;
color: white;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
.submit-btn:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
background: #5a9c3a;
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
}
/* Task Filters */
.task-filters {
display: flex;
gap: 0.5rem;
gap: 0.25rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.task-filters button {
padding: 0.5rem 1rem;
background: #1e293b;
border: none;
border-radius: 0.375rem;
color: #94a3b8;
font-weight: 600;
background: #6b6b6b;
border: 2px solid #1a1a1a;
color: #e0e0e0;
font-weight: 700;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
}
.task-filters button:hover {
color: #e5e7eb;
background: #334155;
background: #7b7b7b;
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
}
.task-filters button.active {
background: #3b82f6;
background: #4a8c2a;
color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
}
/* Tasks List */
@@ -226,20 +239,20 @@
.loading {
text-align: center;
padding: 2rem;
color: #94a3b8;
color: #a0a0a0;
font-size: 0.875rem;
}
.task-card {
background: #1e293b;
border-radius: 0.5rem;
background: #3b3b3b;
border: 2px solid #1a1a1a;
padding: 1.5rem;
transition: all 0.2s;
transition: all 0.1s;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.task-card:hover {
background: #334155;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: #4b4b4b;
}
.task-card-header {
@@ -264,7 +277,7 @@
.task-type {
font-size: 1rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
}
.task-badges {
@@ -275,41 +288,42 @@
.priority-badge,
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
border: 2px solid #1a1a1a;
font-size: 0.75rem;
font-weight: 700;
color: white;
text-transform: uppercase;
letter-spacing: 0.05em;
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
}
.task-assignment {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: #0f172a;
border-radius: 0.25rem;
background: #2c2c2c;
border: 2px solid #1a1a1a;
}
.task-parameters {
font-size: 0.75rem;
color: #94a3b8;
color: #a0a0a0;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: #0f172a;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
background: #2c2c2c;
border: 2px solid #1a1a1a;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.task-result {
font-size: 0.875rem;
color: #10b981;
color: #55ff55;
margin-bottom: 0.75rem;
padding: 0.75rem;
background: #10b98120;
border-radius: 0.25rem;
border-left: 3px solid #10b981;
background: #2d6b1a20;
border: 2px solid #55ff55;
border-left: 4px solid #55ff55;
}
.task-actions {
@@ -320,25 +334,26 @@
.task-actions button {
padding: 0.5rem 1rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.25rem;
color: #e5e7eb;
background: #6b6b6b;
border: 2px solid #1a1a1a;
color: #e0e0e0;
font-size: 0.75rem;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
}
.task-actions button:hover {
background: #1e293b;
border-color: #3b82f6;
transform: translateY(-2px);
background: #7b7b7b;
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
}
.task-timestamp {
font-size: 0.75rem;
color: #64748b;
color: #7b7b7b;
font-style: italic;
}
@@ -357,13 +372,13 @@
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.5rem;
}
.empty-text {
font-size: 0.875rem;
color: #94a3b8;
color: #a0a0a0;
}
/* Mobile Responsive */

View File

@@ -157,19 +157,19 @@ const TaskPanel = ({ turtles, apiUrl }) => {
const getStatusColor = (status) => {
switch (status) {
case 'pending': return '#94a3b8';
case 'in_progress': return '#3b82f6';
case 'completed': return '#10b981';
case 'failed': return '#ef4444';
default: return '#94a3b8';
case 'pending': return '#a0a0a0';
case 'in_progress': return '#345ec3';
case 'completed': return '#4a8c2a';
case 'failed': return '#aa0000';
default: return '#a0a0a0';
}
};
const getPriorityLabel = (priority) => {
if (priority >= 8) return { label: 'Critical', color: '#ef4444' };
if (priority >= 6) return { label: 'High', color: '#f59e0b' };
if (priority >= 4) return { label: 'Medium', color: '#3b82f6' };
return { label: 'Low', color: '#94a3b8' };
if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
if (priority >= 6) return { label: 'High', color: '#ffaa00' };
if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
return { label: 'Low', color: '#a0a0a0' };
};
return (

View File

@@ -1,18 +1,23 @@
/* ============================================
Minecraft-Themed Voice Control
============================================ */
.voice-control {
padding: 1rem;
background: #1e293b;
border-radius: 0.5rem;
border: 1px solid #334155;
background: #3b3b3b;
border: 2px solid #1a1a1a;
font-family: 'Silkscreen', 'Courier New', monospace;
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
}
.voice-control.unsupported {
text-align: center;
padding: 2rem;
color: #ef4444;
color: #ff5555;
}
.voice-control.unsupported small {
color: #9ca3af;
color: #a0a0a0;
display: block;
margin-top: 0.5rem;
}
@@ -27,62 +32,67 @@
.voice-header h3 {
font-size: 1rem;
font-weight: 600;
color: #f9fafb;
color: #ffff55;
margin: 0;
text-shadow: 1px 1px 0 #1a1a1a;
}
.selected-turtle {
font-size: 0.875rem;
color: #10b981;
color: #55ff55;
font-weight: 600;
}
.no-selection {
font-size: 0.875rem;
color: #9ca3af;
color: #a0a0a0;
}
.voice-button {
width: 100%;
padding: 1.5rem;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 0.75rem;
background: #345ec3;
border: 3px solid #1a1a1a;
color: white;
font-weight: 600;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.3s;
transition: all 0.1s;
position: relative;
overflow: hidden;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
box-shadow: inset 0 2px 0 #5577dd, inset 0 -3px 0 #223399;
}
.voice-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
background: #4a6ed3;
box-shadow: inset 0 2px 0 #6688ee, inset 0 -3px 0 #3344aa;
}
.voice-button:disabled {
background: #374151;
background: #4b4b4b;
cursor: not-allowed;
opacity: 0.5;
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
}
.voice-button.listening {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
background: #aa0000;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000;
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 20px rgba(255, 85, 85, 0.5);
}
50% {
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 40px rgba(255, 85, 85, 0.8);
}
}
@@ -95,7 +105,6 @@
width: 80px;
height: 80px;
border: 3px solid rgba(255, 255, 255, 0.6);
border-radius: 50%;
animation: pulse-ring 1.5s infinite;
}
@@ -113,29 +122,28 @@
.voice-feedback {
margin-top: 1rem;
padding: 1rem;
background: #0f172a;
border-radius: 0.5rem;
border: 1px solid #1e293b;
background: #2c2c2c;
border: 2px solid #1a1a1a;
}
.transcript {
color: #e5e7eb;
color: #e0e0e0;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.transcript strong {
color: #60a5fa;
color: #55ffff;
}
.last-command {
color: #10b981;
color: #55ff55;
font-size: 0.875rem;
font-family: 'Courier New', monospace;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.last-command strong {
color: #34d399;
color: #55ff55;
}
.voice-commands-help {
@@ -143,23 +151,22 @@
}
.voice-commands-help details {
background: #0f172a;
border-radius: 0.5rem;
border: 1px solid #1e293b;
background: #2c2c2c;
border: 2px solid #1a1a1a;
}
.voice-commands-help summary {
padding: 0.75rem;
cursor: pointer;
font-weight: 600;
color: #94a3b8;
color: #a0a0a0;
-webkit-user-select: none;
user-select: none;
transition: all 0.2s;
transition: all 0.1s;
}
.voice-commands-help summary:hover {
color: #e5e7eb;
color: #e0e0e0;
}
.commands-grid {
@@ -171,11 +178,12 @@
.command-category h4 {
font-size: 0.75rem;
color: #60a5fa;
color: #ffaa00;
text-transform: uppercase;
margin: 0 0 0.5rem 0;
font-weight: 700;
letter-spacing: 0.05em;
text-shadow: 1px 1px 0 #1a1a1a;
}
.command-category ul {
@@ -186,14 +194,14 @@
.command-category li {
font-size: 0.75rem;
color: #9ca3af;
color: #a0a0a0;
padding: 0.25rem 0;
font-family: 'Courier New', monospace;
font-family: 'Silkscreen', 'Courier New', monospace;
}
.command-category li::before {
content: "▸ ";
color: #3b82f6;
color: #55ff55;
font-weight: bold;
}

View File

@@ -10,7 +10,18 @@ export default function VoiceControl() {
const [recognition, setRecognition] = useState(null);
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
const sendCommand = useTurtleStore((state) => state.sendCommand);
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
const moveForward = useTurtleStore((state) => state.moveForward);
const moveBack = useTurtleStore((state) => state.moveBack);
const moveUp = useTurtleStore((state) => state.moveUp);
const moveDown = useTurtleStore((state) => state.moveDown);
const turnLeft = useTurtleStore((state) => state.turnLeft);
const turnRight = useTurtleStore((state) => state.turnRight);
const digBlock = useTurtleStore((state) => state.digBlock);
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
const placeBlock = useTurtleStore((state) => state.placeBlock);
const refuelTurtle = useTurtleStore((state) => state.refuelTurtle);
useEffect(() => {
// Check if speech recognition is supported
@@ -49,52 +60,68 @@ export default function VoiceControl() {
}
const turtleId = selectedTurtle.turtleID;
let action = null;
let param = null;
let actionName = null;
// Parse voice commands
// Movement commands (server-side)
if (command.includes('forward') || command.includes('go ahead')) {
action = 'forward';
moveForward(turtleId);
actionName = 'forward';
} else if (command.includes('back') || command.includes('backward')) {
action = 'back';
moveBack(turtleId);
actionName = 'back';
} else if (command.includes('turn left') || command.includes('left')) {
action = 'turnLeft';
turnLeft(turtleId);
actionName = 'turn left';
} else if (command.includes('turn right') || command.includes('right')) {
action = 'turnRight';
turnRight(turtleId);
actionName = 'turn right';
} else if (command.includes('go up') || command.includes('move up')) {
action = 'up';
moveUp(turtleId);
actionName = 'up';
} else if (command.includes('go down') || command.includes('move down')) {
action = 'down';
moveDown(turtleId);
actionName = 'down';
} else if (command.includes('dig')) {
if (command.includes('up')) {
action = 'digUp';
digBlockUp(turtleId);
actionName = 'dig up';
} else if (command.includes('down')) {
action = 'digDown';
digBlockDown(turtleId);
actionName = 'dig down';
} else {
action = 'dig';
digBlock(turtleId);
actionName = 'dig';
}
} else if (command.includes('place') || command.includes('build')) {
action = 'place';
placeBlock(turtleId);
actionName = 'place';
// State machine commands (server-side)
} else if (command.includes('explore') || command.includes('start exploring')) {
action = 'explore';
setTurtleState(turtleId, 'exploring');
actionName = 'explore';
} else if (command.includes('mine') || command.includes('start mining')) {
action = 'mine';
setTurtleState(turtleId, 'mining');
actionName = 'mine';
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
action = 'returnHome';
setTurtleState(turtleId, 'goHome');
actionName = 'go home';
} else if (command.includes('stop')) {
action = 'stop';
} else if (command.includes('set home') || command.includes('mark home')) {
action = 'setHome';
setTurtleState(turtleId, 'idle');
actionName = 'idle';
} else if (command.includes('refuel')) {
action = 'refuel';
} else if (command.includes('status') || command.includes('report')) {
action = 'status';
setTurtleState(turtleId, 'refueling');
actionName = 'refuel';
} else if (command.includes('farm')) {
setTurtleState(turtleId, 'farming');
actionName = 'farm';
} else if (command.includes('dump')) {
setTurtleState(turtleId, 'dumpInventory');
actionName = 'dump inventory';
}
if (action) {
sendCommand(turtleId, action, param);
setLastCommand(`${action}${param ? ` ${param}` : ''}`);
speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`);
if (actionName) {
setLastCommand(actionName);
speak(`Sending ${actionName} command`);
} else {
speak('Command not recognized');
}

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
* {
margin: 0;
padding: 0;
@@ -5,13 +7,12 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #0a0e1a;
color: #e0e0e0;
font-family: 'Silkscreen', 'Courier New', monospace;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
image-rendering: pixelated;
background: #2c2c2c;
color: #d4d4d4;
overflow: hidden;
}
@@ -21,5 +22,5 @@ body {
}
code {
font-family: 'Courier New', monospace;
font-family: 'Silkscreen', 'Courier New', monospace;
}

View File

@@ -12,6 +12,7 @@ export const useTurtleStore = create((set, get) => ({
turtles: {},
players: {},
worldBlocks: [],
chunkAnalyses: {},
selectedTurtleId: null,
connected: false,
ws: null,
@@ -34,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
data.turtles.forEach(turtle => {
turtlesMap[turtle.turtleID] = turtle;
});
const playersMap = {};
if (data.players && Array.isArray(data.players)) {
data.players.forEach(player => {
playersMap[player.playerID] = player;
});
}
set({
turtles: turtlesMap,
players: playersMap,
worldBlocks: data.blocks || []
});
} else if (data.type === 'turtle_update') {
@@ -73,7 +81,8 @@ export const useTurtleStore = create((set, get) => ({
[data.playerID]: {
playerID: data.playerID,
position: data.position,
timestamp: data.timestamp
label: data.label || null,
timestamp: data.timestamp || Date.now()
}
}
}));
@@ -86,6 +95,63 @@ export const useTurtleStore = create((set, get) => ({
return { worldBlocks: Array.from(blockMap.values()) };
});
}
} else if (data.type === 'blocks_discovered') {
if (data.blocks && Array.isArray(data.blocks)) {
set(state => {
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
data.blocks.forEach(block => {
const key = `${block.x},${block.y},${block.z}`;
blockMap.set(key, block);
});
return { worldBlocks: Array.from(blockMap.values()) };
});
}
} else if (data.type === 'turtle_event') {
// Handle live turtle events (inventory, peripherals)
const turtleId = data.turtleID;
if (turtleId && data.eventType === 'inventory_update' && data.inventory) {
set(state => {
const turtle = state.turtles[turtleId];
if (!turtle) return state;
return {
turtles: {
...state.turtles,
[turtleId]: { ...turtle, inventory: data.inventory }
}
};
});
} else if (turtleId && (data.eventType === 'peripheral_attached' || data.eventType === 'peripheral_detached')) {
set(state => {
const turtle = state.turtles[turtleId];
if (!turtle) return state;
return {
turtles: {
...state.turtles,
[turtleId]: { ...turtle, peripherals: data.peripherals || turtle.peripherals }
}
};
});
}
} else if (data.type === 'chunk_analysis') {
// Store chunk analysis results
if (data.chunk) {
set(state => ({
chunkAnalyses: {
...state.chunkAnalyses,
[`${data.chunk.x},${data.chunk.z}`]: data.chunk
}
}));
}
} else if (data.type === 'block_deleted') {
// Remove a block from the world map (turtle moved into it)
if (data.x !== undefined && data.y !== undefined && data.z !== undefined) {
set(state => {
const key = `${data.x},${data.y},${data.z}`;
return {
worldBlocks: state.worldBlocks.filter(b => `${b.x},${b.y},${b.z}` !== key)
};
});
}
}
} catch (error) {
console.error('Error processing message:', error);
@@ -158,35 +224,288 @@ export const useTurtleStore = create((set, get) => ({
set({ worldBlocks: Array.from(blockMap.values()) });
},
sendCommand: async (turtleId, command, param = null) => {
const { ws } = get();
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${param})` : '');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'command',
turtleID: turtleId,
command,
param
}));
console.log(' ✅ Sent via WebSocket');
} else {
// Fallback to REST API
console.log(' ⚠️ WebSocket not connected, using REST API fallback');
try {
await fetch(`${API_URL}/turtle/${turtleId}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, param })
});
console.log(' ✅ Sent via REST API');
} catch (error) {
console.error(' ❌ Error sending command:', error);
// Set turtle state machine state via REST API
setTurtleState: async (turtleId, stateName, stateData = {}) => {
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: stateName, data: stateData })
});
const result = await response.json();
console.log(' ✅ State set:', result);
return result;
} catch (error) {
console.error(' ❌ Error setting state:', error);
// Fallback: send via WebSocket as a command
const { ws } = get();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'command',
turtleID: turtleId,
command: 'set_state',
param: { state: stateName, data: stateData }
}));
}
}
},
// Execute arbitrary Lua code on a turtle
execOnTurtle: async (turtleId, code) => {
console.log(`💻 Exec on turtle ${turtleId}:`, code);
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/exec`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
return await response.json();
} catch (error) {
console.error(' ❌ Error executing:', error);
return { success: false, error: error.message };
}
},
// ========== Server-Side Movement & Actions ==========
// All movement/actions are routed through the server's Turtle.js exec() pipeline
_turtleAction: async (turtleId, action, body = {}) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return await response.json();
} catch (error) {
console.error(` ❌ Error ${action}:`, error);
return { success: false, error: error.message };
}
},
moveForward: async (turtleId) => get()._turtleAction(turtleId, 'forward'),
moveBack: async (turtleId) => get()._turtleAction(turtleId, 'back'),
moveUp: async (turtleId) => get()._turtleAction(turtleId, 'up'),
moveDown: async (turtleId) => get()._turtleAction(turtleId, 'down'),
turnLeft: async (turtleId) => get()._turtleAction(turtleId, 'turnLeft'),
turnRight: async (turtleId) => get()._turtleAction(turtleId, 'turnRight'),
digBlock: async (turtleId) => get()._turtleAction(turtleId, 'dig'),
digBlockUp: async (turtleId) => get()._turtleAction(turtleId, 'digUp'),
digBlockDown: async (turtleId) => get()._turtleAction(turtleId, 'digDown'),
placeBlock: async (turtleId, text) => get()._turtleAction(turtleId, 'place', { text }),
placeBlockUp: async (turtleId, text) => get()._turtleAction(turtleId, 'placeUp', { text }),
placeBlockDown: async (turtleId, text) => get()._turtleAction(turtleId, 'placeDown', { text }),
refuelTurtle: async (turtleId, count) => get()._turtleAction(turtleId, 'refuel-action', { count }),
// Fetch chunk analyses from server
fetchChunkAnalyses: async () => {
try {
const response = await fetch(`${API_URL}/chunks`);
if (response.ok) {
const data = await response.json();
const analyses = {};
data.forEach(c => { analyses[`${c.x},${c.z}`] = c; });
set({ chunkAnalyses: analyses });
}
} catch (error) {
console.error(' ❌ Error fetching chunks:', error);
}
},
// Request chunk analysis for a specific chunk
analyzeChunk: async (chunkX, chunkZ) => {
try {
const response = await fetch(`${API_URL}/chunks/${chunkX}/${chunkZ}/analyze`, {
method: 'POST'
});
if (response.ok) {
const data = await response.json();
set(state => ({
chunkAnalyses: {
...state.chunkAnalyses,
[`${chunkX},${chunkZ}`]: data
}
}));
return data;
}
} catch (error) {
console.error(' ❌ Error analyzing chunk:', error);
}
},
// Search blocks by name pattern
searchBlocks: async (namePattern) => {
try {
const response = await fetch(`${API_URL}/world/blocks/search?name=${encodeURIComponent(namePattern)}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error(' ❌ Error searching blocks:', error);
}
return [];
},
// Rename a turtle
renameTurtle: async (turtleId, name) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return await response.json();
} catch (error) {
console.error(' ❌ Error renaming turtle:', error);
return { success: false, error: error.message };
}
},
// Equip left
equipLeft: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-left`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Equip right
equipRight: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-right`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Select inventory slot
selectSlot: async (turtleId, slot) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Transfer items between slots
transferItems: async (turtleId, fromSlot, toSlot, count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/transfer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fromSlot, toSlot, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Sort/compact inventory
sortInventory: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/sort`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Connect to adjacent inventory
connectToInventory: async (turtleId, side) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/connect-inventory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ side })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Drop items
dropItems: async (turtleId, direction = 'front', count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/drop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Suck items
suckItems: async (turtleId, direction = 'front', count) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/suck`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction, count })
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Update turtle config
updateTurtleConfig: async (turtleId, config) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Get turtle config
getTurtleConfig: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`);
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Explore (batched 3-direction inspect)
exploreTurtle: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/explore`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// GPS locate
gpsLocateTurtle: async (turtleId) => {
try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/gps`, { method: 'POST' });
return await response.json();
} catch (error) {
return { success: false, error: error.message };
}
},
// Helper getters
getTurtleArray: () => {
return Object.values(get().turtles);

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# Backend server
server:
@@ -8,12 +6,12 @@ services:
dockerfile: Dockerfile
container_name: turtle-server
ports:
- "4200:3001" # HTTP API
- "3002:3002" # WebSocket
- "4200:3001" # HTTP API + WebSocket (unified)
environment:
- NODE_ENV=production
- PORT=3001
- WS_PORT=3002
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
- API_KEY=${API_KEY:-}
restart: unless-stopped
networks:
- turtle-network

36
etc/apps.db Normal file
View File

@@ -0,0 +1,36 @@
{
[ "rt_turtle_controller" ] = {
title = "Turtle Controller",
category = "RemoteTurtle",
run = "turtle.lua",
requires = "turtle",
},
[ "rt_gps_host" ] = {
title = "GPS Host",
category = "RemoteTurtle",
run = "gpshost.lua",
},
[ "rt_web_bridge" ] = {
title = "Web Bridge",
category = "RemoteTurtle",
run = "webbridge.lua",
},
[ "rt_pocket_control" ] = {
title = "Pocket Control",
category = "RemoteTurtle",
run = "pocketcontrol.lua",
requires = "pocket",
},
[ "rt_pocket_remote" ] = {
title = "Pocket Remote",
category = "RemoteTurtle",
run = "pocketremote.lua",
requires = "pocket",
},
[ "rt_pocket_gps" ] = {
title = "Pocket GPS",
category = "RemoteTurtle",
run = "pocketgps.lua",
requires = "pocket",
},
}

View File

@@ -2,10 +2,12 @@
-- Combines turtle control, GPS tracking, server management, and webbridge control
-- Communicates wirelessly with webbridge - NO direct HTTP calls
local CHANNEL_SEND = 100
local CHANNEL_RECEIVE = 101
local STATUS_CHANNEL = 102
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
local Channels = require('platform.channels')
local CHANNEL_SEND = Channels.get('remoteturtle.command')
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
-- Find modem
local modem = peripheral.find("modem")
@@ -19,9 +21,12 @@ if pocket then
modem = peripheral.find("modem")
end
modem.open(CHANNEL_RECEIVE)
modem.open(STATUS_CHANNEL)
modem.open(POCKET_CHANNEL)
local WebBridge = require('platform.webbridge')
WebBridge.openChannels(modem, {
'remoteturtle.response',
'remoteturtle.status',
'remoteturtle.pocket',
})
local w, h = term.getSize()
@@ -98,10 +103,10 @@ local function updateMyPosition()
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
type = "player_position",
playerID = os.getComputerID(),
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
position = myPosition,
timestamp = os.epoch("utc")
})
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
return true
else
addLog("GPS: Failed to locate", colors.red)
@@ -556,7 +561,7 @@ parallel.waitForAny(
function()
-- GPS update loop
while true do
sleep(5)
sleep(2)
updateMyPosition()
end
end,
@@ -574,7 +579,9 @@ parallel.waitForAny(
while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
if channel == STATUS_CHANNEL and type(message) == "table" then
-- Uses Channels.match() for dual-mode safety: accepts messages on
-- both legacy (102/103) and target (4212/4213) channels during migration.
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
if message.type == "status" then
-- Update turtle list
local found = false
@@ -590,7 +597,7 @@ parallel.waitForAny(
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
end
end
elseif channel == POCKET_CHANNEL and type(message) == "table" then
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
-- Handle responses from webbridge
if message.type == "webbridge_status" then
webbridgeStatus = message.data

View File

@@ -1,7 +1,10 @@
-- Live GPS Tracker for Pocket Computer
-- Shows your current location in real-time
local STATUS_CHANNEL = 102
local Channels = require('platform.channels')
local WebBridge = require('platform.webbridge')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
-- Setup modem
local modem = peripheral.find("modem")
@@ -15,7 +18,7 @@ if pocket then
modem = peripheral.find("modem")
end
modem.open(STATUS_CHANNEL)
WebBridge.openChannels(modem, { 'remoteturtle.status' })
local w, h = term.getSize()
local myID = os.getComputerID()
@@ -228,7 +231,9 @@ local function main()
local replyChannel = param3
local message = param4
if channel == STATUS_CHANNEL and type(message) == "table" then
-- Uses Channels.match() for dual-mode safety: accepts messages on
-- both legacy (102) and target (4212) channels during migration.
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
handleStatus(message)
end

View File

@@ -1,9 +1,12 @@
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
-- Monitor and control autonomous mining turtles
local CHANNEL_SEND = 100
local CHANNEL_RECEIVE = 101
local STATUS_CHANNEL = 102
local Channels = require('platform.channels')
local WebBridge = require('platform.webbridge')
local CHANNEL_SEND = Channels.get('remoteturtle.command')
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
local modem = peripheral.find("modem")
if not modem then
@@ -15,15 +18,17 @@ if pocket then
modem = peripheral.find("modem")
end
modem.open(CHANNEL_RECEIVE)
modem.open(STATUS_CHANNEL)
WebBridge.openChannels(modem, {
'remoteturtle.response',
'remoteturtle.status',
})
local w, h = term.getSize()
-- Tracked turtles
local turtles = {}
local selectedTurtle = nil
local viewMode = "overview" -- overview, detail, manual
local viewMode = "overview" -- overview, detail, manual, modes
-- Button system
local buttons = {}
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
})
end
-- Send state change command to turtle (new protocol)
local function sendStateCommand(turtleID, stateName, stateData)
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "state_change",
state = stateName,
data = stateData or {},
target = turtleID
})
end
-- Helper function to format fuel display
local function formatFuel(fuel)
if not fuel then
@@ -127,7 +142,7 @@ local function drawOverview()
-- Turtle info box
term.setCursorPos(1, y)
term.setTextColor(selected and colors.lime or colors.white)
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
if turtle.position then
print(string.format(" %d,%d,%d",
@@ -197,7 +212,7 @@ local function drawDetail()
term.setTextColor(colors.white)
print("")
print("Mode: " .. (turtle.mode or "unknown"))
print("State: " .. (turtle.state or turtle.mode or "idle"))
print("Fuel: " .. formatFuel(turtle.fuel))
if turtle.position then
@@ -234,8 +249,8 @@ local function drawDetail()
print(" Empty")
end
-- Action buttons
local btnY = h - 7
-- Action buttons (row 1: explore/home/stop)
local btnY = h - 10
addButton(1, btnY, 8, 2, "EXPLORE", function()
sendCommand(turtle.turtleID, "explore")
end, colors.green)
@@ -248,16 +263,60 @@ local function drawDetail()
sendCommand(turtle.turtleID, "stop")
end, colors.red)
btnY = h - 4
addButton(1, btnY, 12, 2, "MANUAL", function()
-- Row 2: manual/modes/setHome
btnY = h - 7
addButton(1, btnY, 8, 2, "MANUAL", function()
viewMode = "manual"
sendCommand(turtle.turtleID, "manual")
end, colors.purple)
addButton(14, btnY, 12, 2, "SET HOME", function()
addButton(10, btnY, 8, 2, "MODES", function()
viewMode = "modes"
end, colors.cyan)
addButton(19, btnY, 7, 2, "HOME*", function()
sendCommand(turtle.turtleID, "setHome")
end, colors.blue)
-- Row 3: equip/rename
btnY = h - 4
addButton(1, btnY, 8, 2, "EQUIP L", function()
-- Send eval to equip left
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "eval",
uuid = tostring(math.random(100000, 999999)),
code = "return turtle.equipLeft()",
target = turtle.turtleID
})
end, colors.purple)
addButton(10, btnY, 8, 2, "EQUIP R", function()
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "eval",
uuid = tostring(math.random(100000, 999999)),
code = "return turtle.equipRight()",
target = turtle.turtleID
})
end, colors.purple)
addButton(19, btnY, 7, 2, "RENAME", function()
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.yellow)
print("Enter new name:")
term.setTextColor(colors.white)
local name = read()
if name and #name > 0 then
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "rename",
name = name,
target = turtle.turtleID
})
end
draw()
end, colors.lightBlue)
addButton(1, h - 1, 12, 2, "< BACK", function()
viewMode = "overview"
end, colors.gray)
@@ -349,9 +408,37 @@ local function drawManual()
sendCommand(turtle.turtleID, "refuel")
end, colors.lime)
addButton(19, h - 3, 7, 2, "INFO", function()
sendCommand(turtle.turtleID, "status")
end, colors.lightBlue)
addButton(19, h - 3, 7, 2, "SORT", function()
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "eval",
uuid = tostring(math.random(100000, 999999)),
code = [[
local moved = 0
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
for target = 1, slot - 1 do
local ti = turtle.getItemDetail(target)
if not ti then
turtle.select(slot)
turtle.transferTo(target)
moved = moved + 1
break
elseif ti.name == item.name and ti.count < 64 then
turtle.select(slot)
turtle.transferTo(target)
moved = moved + 1
if turtle.getItemCount(slot) == 0 then break end
end
end
end
end
turtle.select(1)
return moved
]],
target = turtle.turtleID
})
end, colors.cyan)
addButton(1, h, 12, 1, "< BACK", function()
viewMode = "detail"
@@ -369,6 +456,98 @@ local function drawManual()
term.setTextColor(colors.white)
end
local function drawModes()
if not selectedTurtle or not turtles[selectedTurtle] then
viewMode = "overview"
return
end
clearButtons()
local turtle = turtles[selectedTurtle]
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
term.setTextColor(colors.cyan)
print("=STATE MACHINE=")
term.setTextColor(colors.white)
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
-- State buttons
local btnY = 4
local btnW = 12
addButton(1, btnY, btnW, 2, "IDLE", function()
sendStateCommand(turtle.turtleID, "idle")
sendCommand(turtle.turtleID, "stop")
end, colors.gray)
addButton(14, btnY, btnW, 2, "EXPLORE", function()
sendStateCommand(turtle.turtleID, "exploring")
sendCommand(turtle.turtleID, "explore")
end, colors.green)
btnY = btnY + 3
addButton(1, btnY, btnW, 2, "MINE", function()
sendStateCommand(turtle.turtleID, "mining")
sendCommand(turtle.turtleID, "explore")
end, colors.orange)
addButton(14, btnY, btnW, 2, "FARM", function()
sendStateCommand(turtle.turtleID, "farming")
end, colors.lime)
btnY = btnY + 3
addButton(1, btnY, btnW, 2, "GO HOME", function()
sendStateCommand(turtle.turtleID, "goHome")
sendCommand(turtle.turtleID, "returnHome")
end, colors.yellow)
addButton(14, btnY, btnW, 2, "REFUEL", function()
sendStateCommand(turtle.turtleID, "refueling")
sendCommand(turtle.turtleID, "refuel")
end, colors.red)
btnY = btnY + 3
addButton(1, btnY, btnW, 2, "DUMP INV", function()
sendStateCommand(turtle.turtleID, "dumpInventory")
end, colors.brown)
addButton(14, btnY, btnW, 2, "MOVE TO", function()
-- Could prompt for coordinates, for now just sends moving state
sendStateCommand(turtle.turtleID, "moving")
end, colors.lightBlue)
btnY = btnY + 3
addButton(1, btnY, btnW, 2, "SCAN", function()
sendStateCommand(turtle.turtleID, "scan")
end, colors.purple)
addButton(14, btnY, btnW, 2, "EXTRACT", function()
sendStateCommand(turtle.turtleID, "extraction")
end, colors.magenta)
btnY = btnY + 3
addButton(1, btnY, btnW, 2, "BUILD", function()
sendStateCommand(turtle.turtleID, "building")
end, colors.lightBlue)
addButton(14, btnY, btnW, 2, "AUTOCRAFT", function()
sendStateCommand(turtle.turtleID, "autocraft")
end, colors.pink)
-- Back button
addButton(1, h - 1, 12, 2, "< BACK", function()
viewMode = "detail"
end, colors.gray)
for _, btn in ipairs(buttons) do
drawButton(btn)
end
term.setTextColor(colors.white)
end
local function draw()
if viewMode == "overview" then
drawOverview()
@@ -376,6 +555,8 @@ local function draw()
drawDetail()
elseif viewMode == "manual" then
drawManual()
elseif viewMode == "modes" then
drawModes()
end
end
@@ -450,14 +631,20 @@ parallel.waitForAny(
end,
function()
-- Status receiver
-- Uses Channels.match() for dual-mode safety: accepts status on
-- both legacy (102) and target (4212) channels during migration.
while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
-- Update or add turtle
local found = false
for i, t in ipairs(turtles) do
if t.turtleID == message.turtleID then
-- Preserve state if not in message
if not message.state then
message.state = t.state or message.mode or "idle"
end
turtles[i] = message
found = true
break
@@ -465,6 +652,9 @@ parallel.waitForAny(
end
if not found then
if not message.state then
message.state = message.mode or "idle"
end
table.insert(turtles, message)
if not selectedTurtle then
selectedTurtle = 1
@@ -472,8 +662,16 @@ parallel.waitForAny(
end
draw()
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
-- Response from turtle
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
-- State change confirmation or other response
if message.type == "state_changed" and message.turtleID then
for i, t in ipairs(turtles) do
if t.turtleID == message.turtleID then
turtles[i].state = message.state
break
end
end
end
draw()
end
end

View File

@@ -1,17 +1,31 @@
# Node.js backend
# Stage 1: Fetch platform server package from git
FROM alpine:3.20 AS platform
RUN apk add --no-cache git
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
ARG PLATFORM_BRANCH=master
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
&& rm -rf /src/server/node_modules /src/.git
# Stage 2: Node.js backend
FROM node:18-alpine
WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Copy package files
COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
# Install dependencies
RUN npm install --omit=dev
# Copy server code
COPY server.js ./
COPY database.js ./
# Copy all server code
COPY . .
# Expose ports
EXPOSE 3001 3002

View File

@@ -1,14 +1,29 @@
# Development Dockerfile with hot reload
# Stage 1: Fetch platform server package from git
FROM alpine:3.20 AS platform
RUN apk add --no-cache git
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
ARG PLATFORM_BRANCH=master
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
&& rm -rf /src/server/node_modules /src/.git
# Stage 2: Development with hot reload
FROM node:18-alpine
WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Install nodemon for hot reload
RUN npm install -g nodemon
# Copy package files
COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
# Install all dependencies (including dev)
RUN npm install

387
server/TaskDispatcher.js Normal file
View File

@@ -0,0 +1,387 @@
/**
* TaskDispatcher — Automatic task queue dispatcher for RemoteTurtle
*
* Periodically polls the task_queue for pending tasks, matches them to
* available (idle + connected) turtles, maps task_type to state machine
* states, and drives the turtle state machine. Handles turtle disconnection
* mid-task (re-queues) and completion callbacks.
*
* Task type → State machine mapping:
* mine_area → mining (requires bounds in task_data)
* explore → exploring (requires target/area in task_data)
* gather → extracting (requires blockName in task_data)
* build → building (requires plan in task_data)
* transport → moving (requires target position)
* clear_area → mining (same as mine_area)
* scan → scanning (requires area/range)
* farm → farming (requires area)
* autocraft → autocrafting (requires recipe)
*/
// Map task_type values from the TaskPanel UI to turtle state machine state names + data mappers
const TASK_TYPE_MAP = {
mine_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
explore: { state: 'exploring', mapData: d => ({ target: d.target || d, ...d }) },
gather: { state: 'extracting', mapData: d => ({ blockName: d.blockName, count: d.count, ...d }) },
build: { state: 'building', mapData: d => ({ plan: d.plan, origin: d.origin, ...d }) },
transport: { state: 'moving', mapData: d => ({ target: d.target || d.destination || d, ...d }) },
clear_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
scan: { state: 'scanning', mapData: d => ({ area: d.area, ...d }) },
farm: { state: 'farming', mapData: d => ({ area: d.area, ...d }) },
autocraft: { state: 'autocrafting', mapData: d => ({ recipe: d.recipe, count: d.count, ...d }) },
};
export class TaskDispatcher {
/**
* @param {Object} opts
* @param {Map<number, import('./Turtle.js').Turtle>} opts.turtles - Live turtle map
* @param {Object} opts.db - Database module (server/database.js)
* @param {Function} opts.broadcastToClients - WebSocket broadcaster
* @param {number} [opts.pollInterval=5000] - ms between dispatch cycles
*/
constructor({ turtles, db, broadcastToClients, pollInterval = 5000 }) {
this._turtles = turtles;
this._db = db;
this._broadcast = broadcastToClients;
this._pollInterval = pollInterval;
this._timer = null;
this._enabled = true;
// Track which tasks are actively being executed by which turtles
// turtleId -> { taskId, taskType }
this._activeTasks = new Map();
// Reverse lookup: taskId -> turtleId
this._taskToTurtle = new Map();
}
/** Start the dispatch loop */
start() {
if (this._timer) return;
console.log(`🚀 TaskDispatcher started (poll every ${this._pollInterval}ms)`);
this._timer = setInterval(() => this._tick(), this._pollInterval);
// Run immediately on start
this._tick();
}
/** Stop the dispatch loop */
stop() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
console.log('🛑 TaskDispatcher stopped');
}
}
/** Enable/disable automatic dispatching (tasks still tracked when disabled) */
set enabled(val) {
this._enabled = !!val;
console.log(`TaskDispatcher: auto-dispatch ${this._enabled ? 'enabled' : 'disabled'}`);
}
get enabled() {
return this._enabled;
}
/** Get dispatcher status for the API */
status() {
return {
enabled: this._enabled,
activeTasks: Array.from(this._activeTasks.entries()).map(([turtleId, info]) => ({
turtleId,
...info,
})),
pollInterval: this._pollInterval,
};
}
// ========== Internal ==========
/**
* One dispatch cycle:
* 1. Reconcile active tasks (detect turtle disconnects, state completions)
* 2. If enabled, find pending tasks and assign to idle turtles
*/
_tick() {
try {
this._reconcile();
if (this._enabled) {
this._dispatch();
}
} catch (err) {
console.error('[TaskDispatcher] tick error:', err.message);
}
}
/**
* Reconcile: check if turtles executing tasks have finished or disconnected.
*/
_reconcile() {
for (const [turtleId, info] of this._activeTasks) {
const turtle = this._turtles.get(turtleId);
// Turtle removed from server
if (!turtle) {
this._handleTaskFailure(info.taskId, turtleId, 'Turtle no longer exists');
continue;
}
// Turtle disconnected mid-task
if (!turtle.connected) {
this._handleTaskFailure(info.taskId, turtleId, 'Turtle disconnected');
continue;
}
// Turtle transitioned to idle → task completed
if (turtle.stateName === 'idle') {
this._handleTaskCompletion(info.taskId, turtleId);
continue;
}
// Turtle errored out
if (turtle._error) {
this._handleTaskFailure(info.taskId, turtleId, turtle._error);
continue;
}
}
}
/**
* Dispatch: find pending (unassigned or assigned-but-not-started) tasks,
* match to available turtles, and start them.
*/
_dispatch() {
// Get all pending tasks (sorted by priority DESC, created_at ASC via DB)
let pendingTasks;
try {
pendingTasks = this._db.getAllTasks('pending');
} catch (e) {
return;
}
if (!pendingTasks || pendingTasks.length === 0) return;
// Find idle, connected turtles not already executing a task
const availableTurtles = this._getAvailableTurtles();
if (availableTurtles.length === 0) return;
for (const task of pendingTasks) {
if (availableTurtles.length === 0) break;
const mapping = TASK_TYPE_MAP[task.task_type];
if (!mapping) {
console.warn(`[TaskDispatcher] Unknown task type: ${task.task_type} (task #${task.id})`);
continue;
}
// If task has a specific turtle assignment, respect it
let turtle = null;
if (task.assigned_turtle_id) {
turtle = this._turtles.get(task.assigned_turtle_id);
if (!turtle || !turtle.connected || turtle.stateName !== 'idle') {
// Assigned turtle not available — skip this task for now
continue;
}
// Remove from available pool
const idx = availableTurtles.indexOf(turtle);
if (idx !== -1) availableTurtles.splice(idx, 1);
} else {
// Pick the best available turtle (closest to target if we have coords, else first)
turtle = this._pickBestTurtle(availableTurtles, task.task_data);
const idx = availableTurtles.indexOf(turtle);
if (idx !== -1) availableTurtles.splice(idx, 1);
}
if (!turtle) continue;
// Dispatch!
this._startTask(task, turtle, mapping);
}
}
/**
* Start a task on a turtle.
*/
_startTask(task, turtle, mapping) {
const taskId = task.id;
const turtleId = turtle.id;
console.log(`[TaskDispatcher] Assigning task #${taskId} (${task.task_type}) → Turtle #${turtleId}`);
// Map task_data to state data
const stateData = mapping.mapData(task.task_data || {});
// Update DB: assign + set in_progress
try {
this._db.assignTask(taskId, turtleId);
this._db.updateTaskStatus(taskId, 'in_progress');
} catch (e) {
console.error(`[TaskDispatcher] DB error assigning task #${taskId}:`, e.message);
return;
}
// Track
this._activeTasks.set(turtleId, { taskId, taskType: task.task_type });
this._taskToTurtle.set(taskId, turtleId);
// Broadcast updates
this._broadcast({ type: 'task_assigned', taskId, turtleId });
this._broadcast({ type: 'task_updated', taskId, status: 'in_progress' });
// Set the turtle's state machine
turtle.setState(mapping.state, stateData);
}
/**
* Handle task completion (turtle went back to idle after executing).
*/
_handleTaskCompletion(taskId, turtleId) {
console.log(`[TaskDispatcher] Task #${taskId} completed by Turtle #${turtleId}`);
try {
this._db.completeTask(taskId);
} catch (e) {
console.error(`[TaskDispatcher] DB error completing task #${taskId}:`, e.message);
}
this._activeTasks.delete(turtleId);
this._taskToTurtle.delete(taskId);
this._broadcast({ type: 'task_completed', taskId });
}
/**
* Handle task failure (turtle disconnected, errored, etc.).
* Re-queues the task as pending so another turtle can pick it up.
*/
_handleTaskFailure(taskId, turtleId, reason) {
console.warn(`[TaskDispatcher] Task #${taskId} failed on Turtle #${turtleId}: ${reason}`);
try {
// Re-queue: set back to pending, clear assignment
this._db.updateTaskStatus(taskId, 'pending', reason);
this._db.assignTask(taskId, null);
} catch (e) {
console.error(`[TaskDispatcher] DB error re-queuing task #${taskId}:`, e.message);
}
this._activeTasks.delete(turtleId);
this._taskToTurtle.delete(taskId);
this._broadcast({ type: 'task_updated', taskId, status: 'pending' });
}
/**
* Manually cancel a running task (called via API).
*/
cancelTask(taskId) {
const turtleId = this._taskToTurtle.get(taskId);
if (turtleId !== undefined) {
const turtle = this._turtles.get(turtleId);
if (turtle) {
turtle.setState('idle');
}
this._activeTasks.delete(turtleId);
this._taskToTurtle.delete(taskId);
}
try {
this._db.updateTaskStatus(taskId, 'cancelled');
} catch (e) {
console.error(`[TaskDispatcher] DB error cancelling task #${taskId}:`, e.message);
}
this._broadcast({ type: 'task_updated', taskId, status: 'cancelled' });
}
/**
* Check if a specific task is currently being executed.
*/
isTaskActive(taskId) {
return this._taskToTurtle.has(taskId);
}
// ========== Helpers ==========
/**
* Get connected, idle turtles not currently executing a dispatched task.
*/
_getAvailableTurtles() {
const available = [];
for (const [id, turtle] of this._turtles) {
if (
turtle.connected &&
turtle.stateName === 'idle' &&
!this._activeTasks.has(id)
) {
available.push(turtle);
}
}
return available;
}
/**
* Pick the best turtle for a task.
* If task_data has coordinates, pick the closest turtle with a known position.
* Otherwise, pick the turtle with the most fuel.
*/
_pickBestTurtle(candidates, taskData) {
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// Try to extract a target position from task data
const target = this._extractTarget(taskData);
if (target) {
// Sort by Manhattan distance to target
let bestTurtle = candidates[0];
let bestDist = Infinity;
for (const t of candidates) {
if (t.position) {
const dist = Math.abs(t.position.x - target.x)
+ Math.abs(t.position.y - target.y)
+ Math.abs(t.position.z - target.z);
if (dist < bestDist) {
bestDist = dist;
bestTurtle = t;
}
}
}
return bestTurtle;
}
// No target — pick turtle with highest fuel
return candidates.reduce((best, t) => (t._fuel > best._fuel ? t : best), candidates[0]);
}
/**
* Try to extract a target {x,y,z} from task_data.
*/
_extractTarget(data) {
if (!data) return null;
// Direct target
if (data.target && typeof data.target.x === 'number') return data.target;
if (data.destination && typeof data.destination.x === 'number') return data.destination;
// Bounds — use center
if (data.bounds) {
const b = data.bounds;
if (typeof b.minX === 'number' && typeof b.maxX === 'number') {
return {
x: Math.floor((b.minX + b.maxX) / 2),
y: Math.floor((b.minY + b.maxY) / 2),
z: Math.floor((b.minZ + b.maxZ) / 2),
};
}
}
// Coordinate pair (from TaskPanel)
if (typeof data.startX === 'number' && typeof data.startZ === 'number') {
return { x: data.startX, y: data.startY || 64, z: data.startZ };
}
return null;
}
}

1251
server/Turtle.js Normal file

File diff suppressed because it is too large Load Diff

226
server/WorldBlockCache.js Normal file
View File

@@ -0,0 +1,226 @@
/**
* WorldBlockCache — LRU write-through cache for world blocks
*
* Replaces the unbounded in-memory Map that previously loaded ALL blocks at
* startup. Provides the same Map-like interface (get/set/delete) expected by
* DStarLite pathfinder and Turtle._deleteBlockAtPosition, but caps memory
* usage at `maxSize` entries and falls through to SQLite on cache miss.
*
* Bulk reads (initial_state, /api/world/blocks) bypass the cache and query
* the database directly via helper methods.
*/
export class WorldBlockCache {
/**
* @param {Object} db - The database module (server/database.js)
* @param {number} maxSize - Max entries in the LRU cache (default 50 000)
*/
constructor(db, maxSize = 50_000) {
this._db = db;
this._maxSize = maxSize;
this._cache = new Map(); // key -> { value, prev, next } (LRU doubly-linked)
this._head = null; // most recently used
this._tail = null; // least recently used
// Track total block count from DB (updated lazily)
this._dbCount = null;
}
// ========== Map-compatible interface ==========
/**
* Get a block by "x,y,z" key.
* Returns the cached value or falls through to the database.
*/
get(key) {
// Cache hit
if (this._cache.has(key)) {
const node = this._cache.get(key);
this._promote(node);
return node.value;
}
// Cache miss — query DB
const [x, y, z] = key.split(',').map(Number);
let row;
try {
row = this._db.getBlock(x, y, z);
} catch (e) {
return undefined;
}
if (!row) return undefined;
const value = {
name: row.name,
metadata: row.metadata,
discoveredBy: row.discoveredBy,
timestamp: row.discovered_at,
};
this._put(key, value);
return value;
}
/**
* Store a block (write-through: updates cache + DB is handled by caller via storeBlock).
*/
set(key, value) {
this._put(key, value);
this._dbCount = null; // invalidate count cache
return this;
}
/**
* Delete a block from cache (DB deletion handled by caller).
*/
delete(key) {
if (this._cache.has(key)) {
const node = this._cache.get(key);
this._unlink(node);
this._cache.delete(key);
}
this._dbCount = null;
return true;
}
/**
* Check if a key exists (checks cache first, then DB).
*/
has(key) {
if (this._cache.has(key)) return true;
const [x, y, z] = key.split(',').map(Number);
try {
return this._db.getBlock(x, y, z) !== null;
} catch (e) {
return false;
}
}
/**
* Approximate size — returns DB count (not cache size).
*/
get size() {
if (this._dbCount === null) {
try {
this._dbCount = this._db.getWorldBlockCount();
} catch (e) {
// Fallback: count cached entries
this._dbCount = this._cache.size;
}
}
return this._dbCount;
}
/**
* Iterate all entries — queries DB directly (not bounded by cache).
* Used for initial_state and /api/world/blocks.
* Returns an iterator of [key, value] pairs.
*/
*entries() {
const rows = this._db.getWorldBlocks(100000);
for (const row of rows) {
const key = `${row.x},${row.y},${row.z}`;
yield [key, {
name: row.block_name,
metadata: row.metadata,
discoveredBy: row.discovered_by,
timestamp: row.discovered_at,
}];
}
}
// ========== Bulk helpers for API / WebSocket ==========
/**
* Get blocks in an area (for spatial queries).
*/
getBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
return this._db.getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ);
}
/**
* Get all blocks formatted for the /api/world/blocks response.
* @param {number} limit - Max rows to return
*/
getAllBlocksForAPI(limit = 100000) {
const rows = this._db.getWorldBlocks(limit);
return rows.map(row => ({
x: row.x, y: row.y, z: row.z,
name: row.block_name,
metadata: row.metadata,
discoveredBy: row.discovered_by,
timestamp: row.discovered_at,
}));
}
/**
* Warm the cache with blocks near a set of positions (e.g., turtle locations).
*/
warmArea(cx, cy, cz, radius = 32) {
const blocks = this._db.getWorldBlocksInArea(
cx - radius, cy - radius, cz - radius,
cx + radius, cy + radius, cz + radius,
);
for (const row of blocks) {
const key = `${row.x},${row.y},${row.z}`;
if (!this._cache.has(key)) {
this._put(key, {
name: row.block_name,
metadata: row.metadata,
discoveredBy: row.discovered_by,
timestamp: row.discovered_at,
});
}
}
}
// ========== Internal LRU ==========
_put(key, value) {
if (this._cache.has(key)) {
const node = this._cache.get(key);
node.value = value;
this._promote(node);
} else {
const node = { key, value, prev: null, next: null };
this._cache.set(key, node);
this._addToHead(node);
// Evict if over capacity
if (this._cache.size > this._maxSize) {
this._evict();
}
}
}
_promote(node) {
if (node === this._head) return;
this._unlink(node);
this._addToHead(node);
}
_addToHead(node) {
node.prev = null;
node.next = this._head;
if (this._head) this._head.prev = node;
this._head = node;
if (!this._tail) this._tail = node;
}
_unlink(node) {
if (node.prev) node.prev.next = node.next;
else this._head = node.next;
if (node.next) node.next.prev = node.prev;
else this._tail = node.prev;
node.prev = null;
node.next = null;
}
_evict() {
if (!this._tail) return;
const evicted = this._tail;
this._unlink(evicted);
this._cache.delete(evicted.key);
}
}

View File

@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TaskDispatcher } from '../TaskDispatcher.js';
// ========== Mock Factories ==========
function makeTurtle(id, { connected = true, state = 'idle', fuel = 1000, position = null } = {}) {
return {
id,
connected,
stateName: state,
_fuel: fuel,
_error: null,
position,
setState: vi.fn(function (name) { this.stateName = name; }),
};
}
function makeDb() {
const tasks = [];
let nextId = 1;
return {
createTask: vi.fn((type, data, priority, turtleId) => {
const id = nextId++;
tasks.push({ id, task_type: type, task_data: data, priority, assigned_turtle_id: turtleId, status: 'pending' });
return id;
}),
getAllTasks: vi.fn((status) => {
return tasks
.filter(t => !status || t.status === status)
.sort((a, b) => b.priority - a.priority || a.id - b.id);
}),
getNextTask: vi.fn(() => tasks.find(t => t.status === 'pending') || null),
assignTask: vi.fn((taskId, turtleId) => {
const t = tasks.find(x => x.id === taskId);
if (t) {
t.assigned_turtle_id = turtleId;
t.status = turtleId === null ? 'pending' : 'assigned';
}
}),
updateTaskStatus: vi.fn((taskId, status) => {
const t = tasks.find(x => x.id === taskId);
if (t) t.status = status;
}),
completeTask: vi.fn((taskId) => {
const t = tasks.find(x => x.id === taskId);
if (t) t.status = 'completed';
}),
_tasks: tasks,
};
}
// ========== Tests ==========
describe('TaskDispatcher', () => {
let turtles, db, broadcast, dispatcher;
beforeEach(() => {
turtles = new Map();
db = makeDb();
broadcast = vi.fn();
dispatcher = new TaskDispatcher({ turtles, db, broadcastToClients: broadcast, pollInterval: 60000 });
});
it('should not dispatch when no turtles are available', () => {
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
dispatcher._tick();
expect(db.assignTask).not.toHaveBeenCalled();
});
it('should assign a pending task to an idle turtle', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 } }, 5, null);
dispatcher._tick();
expect(db.assignTask).toHaveBeenCalledWith(1, 1);
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'in_progress');
expect(turtle.setState).toHaveBeenCalledWith('mining', expect.objectContaining({ bounds: expect.any(Object) }));
});
it('should respect turtle assignment on tasks', () => {
const t1 = makeTurtle(1);
const t2 = makeTurtle(2);
turtles.set(1, t1);
turtles.set(2, t2);
// Task assigned specifically to turtle 2
db.createTask('explore', { target: { x: 100, y: 64, z: 100 } }, 5, 2);
dispatcher._tick();
expect(t2.setState).toHaveBeenCalled();
expect(t1.setState).not.toHaveBeenCalled();
});
it('should skip tasks with unknown task_type', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('unknown_type', {}, 5, null);
dispatcher._tick();
expect(turtle.setState).not.toHaveBeenCalled();
});
it('should not assign to busy turtles', () => {
const turtle = makeTurtle(1, { state: 'mining' });
turtles.set(1, turtle);
db.createTask('explore', {}, 5, null);
dispatcher._tick();
expect(db.assignTask).not.toHaveBeenCalled();
});
it('should not assign to disconnected turtles', () => {
const turtle = makeTurtle(1, { connected: false });
turtles.set(1, turtle);
db.createTask('explore', {}, 5, null);
dispatcher._tick();
expect(db.assignTask).not.toHaveBeenCalled();
});
it('should complete task when turtle returns to idle', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('mine_area', { bounds: { minX: 0, minY: 0, minZ: 0, maxX: 5, maxY: 5, maxZ: 5 } }, 5, null);
// First tick: assigns
dispatcher._tick();
expect(turtle.stateName).toBe('mining');
// Simulate turtle completing
turtle.stateName = 'idle';
// Second tick: reconcile detects idle → complete
dispatcher._tick();
expect(db.completeTask).toHaveBeenCalledWith(1);
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_completed', taskId: 1 }));
});
it('should re-queue task when turtle disconnects', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('explore', {}, 5, null);
dispatcher._tick();
expect(turtle.stateName).toBe('exploring');
// Simulate disconnect
turtle.connected = false;
dispatcher._tick();
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'pending', 'Turtle disconnected');
expect(db.assignTask).toHaveBeenCalledWith(1, null);
});
it('should pick closest turtle when multiple are available', () => {
const t1 = makeTurtle(1, { position: { x: 0, y: 64, z: 0 } });
const t2 = makeTurtle(2, { position: { x: 100, y: 64, z: 100 } });
turtles.set(1, t1);
turtles.set(2, t2);
db.createTask('transport', { target: { x: 95, y: 64, z: 95 } }, 5, null);
dispatcher._tick();
// t2 is closer to (95,64,95)
expect(t2.setState).toHaveBeenCalled();
expect(t1.setState).not.toHaveBeenCalled();
});
it('should not dispatch when disabled', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('mine_area', {}, 5, null);
dispatcher.enabled = false;
dispatcher._tick();
expect(turtle.setState).not.toHaveBeenCalled();
});
it('should cancel a running task', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('mine_area', { bounds: {} }, 5, null);
dispatcher._tick();
dispatcher.cancelTask(1);
expect(turtle.stateName).toBe('idle');
expect(db.updateTaskStatus).toHaveBeenCalledWith(1, 'cancelled');
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({ type: 'task_updated', taskId: 1, status: 'cancelled' }));
});
it('should report status correctly', () => {
const turtle = makeTurtle(1);
turtles.set(1, turtle);
db.createTask('mine_area', { bounds: {} }, 5, null);
dispatcher._tick();
const status = dispatcher.status();
expect(status.enabled).toBe(true);
expect(status.activeTasks).toHaveLength(1);
expect(status.activeTasks[0].turtleId).toBe(1);
expect(status.activeTasks[0].taskId).toBe(1);
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorldBlockCache } from '../WorldBlockCache.js';
function makeDb() {
const blocks = new Map();
return {
getBlock: vi.fn((x, y, z) => {
const key = `${x},${y},${z}`;
return blocks.get(key) || null;
}),
getWorldBlocks: vi.fn((limit) => {
const all = [];
for (const [key, val] of blocks) {
const [x, y, z] = key.split(',').map(Number);
all.push({ x, y, z, block_name: val.name, metadata: val.metadata || 0, discovered_by: val.discoveredBy, discovered_at: val.timestamp });
if (all.length >= limit) break;
}
return all;
}),
getWorldBlockCount: vi.fn(() => blocks.size),
getWorldBlocksInArea: vi.fn(() => []),
// Helper for test setup
_blocks: blocks,
_addBlock(x, y, z, name) {
blocks.set(`${x},${y},${z}`, { name, metadata: 0, discoveredBy: 1, timestamp: Date.now() });
}
};
}
describe('WorldBlockCache', () => {
let db, cache;
beforeEach(() => {
db = makeDb();
cache = new WorldBlockCache(db, 5); // Small capacity for testing eviction
});
it('should return undefined for missing blocks', () => {
expect(cache.get('0,0,0')).toBeUndefined();
expect(db.getBlock).toHaveBeenCalledWith(0, 0, 0);
});
it('should cache blocks from DB on first access', () => {
db._addBlock(1, 2, 3, 'minecraft:stone');
const block = cache.get('1,2,3');
expect(block).toBeDefined();
expect(block.name).toBe('minecraft:stone');
// Second access should not hit DB
cache.get('1,2,3');
expect(db.getBlock).toHaveBeenCalledTimes(1);
});
it('should set and retrieve blocks', () => {
cache.set('5,5,5', { name: 'minecraft:dirt', metadata: 0 });
const block = cache.get('5,5,5');
expect(block.name).toBe('minecraft:dirt');
// Should not hit DB since we just set it
expect(db.getBlock).not.toHaveBeenCalled();
});
it('should delete blocks from cache', () => {
cache.set('1,1,1', { name: 'minecraft:stone' });
expect(cache.delete('1,1,1')).toBe(true);
// Now should fall through to DB
const result = cache.get('1,1,1');
expect(db.getBlock).toHaveBeenCalledWith(1, 1, 1);
});
it('should evict LRU entries when over capacity', () => {
// Fill cache to capacity (5)
for (let i = 0; i < 5; i++) {
cache.set(`${i},0,0`, { name: `block_${i}` });
}
// Access block_0 to make it recently used
cache.get('0,0,0');
// Add one more → should evict the LRU entry (block_1, since block_0 was just accessed)
cache.set('5,0,0', { name: 'block_5' });
// block_1 should have been evicted (we check internal cache size)
expect(cache._cache.size).toBe(5);
expect(cache._cache.has('1,0,0')).toBe(false);
expect(cache._cache.has('0,0,0')).toBe(true); // recently used
});
it('should report size from DB count', () => {
db._addBlock(1, 1, 1, 'stone');
db._addBlock(2, 2, 2, 'dirt');
expect(cache.size).toBe(2);
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
// Cached — no second call
const _ = cache.size;
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
});
it('should invalidate size cache on set/delete', () => {
const _ = cache.size;
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(1);
cache.set('1,1,1', { name: 'stone' });
const __ = cache.size;
expect(db.getWorldBlockCount).toHaveBeenCalledTimes(2);
});
it('should iterate all entries from DB', () => {
db._addBlock(1, 1, 1, 'stone');
db._addBlock(2, 2, 2, 'dirt');
const entries = [...cache.entries()];
expect(entries).toHaveLength(2);
expect(db.getWorldBlocks).toHaveBeenCalled();
});
it('should return blocks formatted for API', () => {
db._addBlock(3, 3, 3, 'minecraft:diamond_ore');
const blocks = cache.getAllBlocksForAPI(100);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({ x: 3, y: 3, z: 3, name: 'minecraft:diamond_ore' });
});
});

View File

@@ -7,6 +7,9 @@ const __dirname = path.dirname(__filename);
const db = new Database(path.join(__dirname, 'turtle_control.db'));
// Enable WAL journal mode for better concurrent read/write performance
db.pragma('journal_mode = WAL');
// Initialize database schema
export function initializeDatabase() {
// Turtle homes table
@@ -82,12 +85,28 @@ export function initializeDatabase() {
max_x INTEGER NOT NULL,
max_y INTEGER NOT NULL,
max_z INTEGER NOT NULL,
name TEXT,
color TEXT DEFAULT '#4a8c2a',
status TEXT DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Migrate existing mining_areas table to add name/color columns if missing
try {
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
const columns = tableInfo.map(c => c.name);
if (!columns.includes('name')) {
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
}
if (!columns.includes('color')) {
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
}
} catch (e) {
// Ignore migration errors
}
// Mining statistics table
db.exec(`
CREATE TABLE IF NOT EXISTS mining_stats (
@@ -141,10 +160,41 @@ export function initializeDatabase() {
x INTEGER NOT NULL,
y INTEGER NOT NULL,
z INTEGER NOT NULL,
label TEXT,
updated_at INTEGER NOT NULL
)
`);
// Migrate player_positions table to add label column if missing
try {
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
const columns = tableInfo.map(c => c.name);
if (!columns.includes('label')) {
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
}
} catch (e) {
// Ignore migration errors
}
// Chunk analysis table (ore density per chunk)
db.exec(`
CREATE TABLE IF NOT EXISTS chunks (
x INTEGER NOT NULL,
z INTEGER NOT NULL,
analysis TEXT DEFAULT '{}',
scanned_at INTEGER NOT NULL,
PRIMARY KEY (x, z)
)
`);
// Add block_state and block_tags columns to world_blocks if not present
try {
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_state TEXT DEFAULT '{}'`);
} catch (e) { /* column already exists */ }
try {
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_tags TEXT DEFAULT '{}'`);
} catch (e) { /* column already exists */ }
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
@@ -156,6 +206,26 @@ export function initializeDatabase() {
ON task_queue(status, priority DESC);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_world_blocks_name
ON world_blocks(block_name);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_chunks_coords
ON chunks(x, z);
`);
// Turtle state persistence table (for reconnect recovery)
db.exec(`
CREATE TABLE IF NOT EXISTS turtle_state (
turtle_id INTEGER PRIMARY KEY,
state_name TEXT NOT NULL,
state_data TEXT DEFAULT '{}',
updated_at INTEGER NOT NULL
)
`);
console.log('✅ Database initialized');
}
@@ -203,12 +273,14 @@ export function getTurtleConfig(turtleId) {
}
// World Blocks
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy) {
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy, blockState = null, blockTags = null) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at, block_state, block_tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now());
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now(),
blockState ? JSON.stringify(blockState) : '{}',
blockTags ? JSON.stringify(blockTags) : '{}');
}
export function getWorldBlocks(limit = 10000) {
@@ -216,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
return stmt.all(limit);
}
export function getWorldBlockCount() {
const row = db.prepare('SELECT COUNT(*) as cnt FROM world_blocks').get();
return row ? row.cnt : 0;
}
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
const stmt = db.prepare(`
SELECT * FROM world_blocks
@@ -303,12 +380,22 @@ export function getNextTask() {
}
export function assignTask(taskId, turtleId) {
const stmt = db.prepare(`
UPDATE task_queue
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
WHERE id = ?
`);
stmt.run(turtleId, Date.now(), taskId);
if (turtleId === null || turtleId === undefined) {
// Un-assign: clear turtle and revert to pending
const stmt = db.prepare(`
UPDATE task_queue
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
WHERE id = ?
`);
stmt.run(Date.now(), taskId);
} else {
const stmt = db.prepare(`
UPDATE task_queue
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
WHERE id = ?
`);
stmt.run(turtleId, Date.now(), taskId);
}
}
export function updateTaskStatus(taskId, status, result = null) {
@@ -350,16 +437,17 @@ export function getAllTasks(status = null) {
}
// Mining Areas
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned') {
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
const stmt = db.prepare(`
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const now = Date.now();
const result = stmt.run(
turtleId,
bounds.minX, bounds.minY, bounds.minZ,
bounds.maxX, bounds.maxY, bounds.maxZ,
areaName, color,
status, now, now
);
return result.lastInsertRowid;
@@ -379,6 +467,24 @@ export function updateMiningAreaStatus(areaId, status) {
stmt.run(status, Date.now(), areaId);
}
export function updateMiningArea(areaId, updates) {
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
const setClauses = [];
const values = [];
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
setClauses.push(`${key} = ?`);
values.push(value);
}
}
if (setClauses.length === 0) return;
setClauses.push('updated_at = ?');
values.push(Date.now());
values.push(areaId);
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
stmt.run(...values);
}
export function deleteMiningArea(areaId) {
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
return stmt.run(areaId);
@@ -522,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
}
// Player Positions
export function savePlayerPosition(playerId, position) {
export function savePlayerPosition(playerId, position, label = null) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at)
VALUES (?, ?, ?, ?, ?)
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
stmt.run(playerId, position.x, position.y, position.z, Date.now());
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
}
export function getPlayerPosition(playerId) {
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?');
return stmt.get(playerId);
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
const row = stmt.get(playerId);
if (!row) return null;
return {
playerID: row.player_id,
position: { x: row.x, y: row.y, z: row.z },
label: row.label,
lastUpdate: row.updated_at
};
}
export function getAllPlayerPositions() {
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions');
return stmt.all();
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
return stmt.all().map(row => ({
playerID: row.player_id,
position: { x: row.x, y: row.y, z: row.z },
label: row.label,
lastUpdate: row.updated_at
}));
}
// Cleanup function
@@ -545,5 +663,117 @@ export function closeDatabase() {
db.close();
}
// ========== CHUNK ANALYSIS ==========
export function saveChunkAnalysis(x, z, analysis) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO chunks (x, z, analysis, scanned_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(x, z, JSON.stringify(analysis), Date.now());
}
export function getChunkAnalysis(x, z) {
const stmt = db.prepare('SELECT * FROM chunks WHERE x = ? AND z = ?');
const row = stmt.get(x, z);
if (row) {
return { ...row, analysis: JSON.parse(row.analysis) };
}
return null;
}
export function getAllChunkAnalyses() {
const stmt = db.prepare('SELECT * FROM chunks');
return stmt.all().map(row => ({
...row,
analysis: JSON.parse(row.analysis)
}));
}
// ========== BLOCK SEARCH ==========
export function getBlocksWithNameLike(fromX, fromY, fromZ, toX, toY, toZ, namePattern) {
const stmt = db.prepare(`
SELECT x, y, z, block_name as name, metadata, block_state, block_tags
FROM world_blocks
WHERE x BETWEEN ? AND ?
AND y BETWEEN ? AND ?
AND z BETWEEN ? AND ?
AND block_name LIKE ?
`);
return stmt.all(
Math.min(fromX, toX), Math.max(fromX, toX),
Math.min(fromY, toY), Math.max(fromY, toY),
Math.min(fromZ, toZ), Math.max(fromZ, toZ),
namePattern
).map(row => ({
...row,
state: row.block_state ? JSON.parse(row.block_state) : {},
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
}));
}
export function getBlock(x, y, z) {
const stmt = db.prepare('SELECT block_name as name, metadata, block_state, block_tags FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
const row = stmt.get(x, y, z);
if (row) {
return {
...row,
state: row.block_state ? JSON.parse(row.block_state) : {},
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
};
}
return null;
}
export function deleteBlocksInArea(fromX, fromY, fromZ, toX, toY, toZ) {
const stmt = db.prepare(`
DELETE FROM world_blocks
WHERE x BETWEEN ? AND ?
AND y BETWEEN ? AND ?
AND z BETWEEN ? AND ?
`);
return stmt.run(
Math.min(fromX, toX), Math.max(fromX, toX),
Math.min(fromY, toY), Math.max(fromY, toY),
Math.min(fromZ, toZ), Math.max(fromZ, toZ)
);
}
// ========== BLOCK DELETION ==========
export function deleteBlock(x, y, z) {
const stmt = db.prepare('DELETE FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
return stmt.run(x, y, z);
}
// ========== TURTLE STATE PERSISTENCE ==========
export function saveTurtleState(turtleId, stateName, stateData = {}) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO turtle_state (turtle_id, state_name, state_data, updated_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(turtleId, stateName, JSON.stringify(stateData), Date.now());
}
export function getTurtleState(turtleId) {
const stmt = db.prepare('SELECT * FROM turtle_state WHERE turtle_id = ?');
const row = stmt.get(turtleId);
if (row) {
return {
stateName: row.state_name,
stateData: JSON.parse(row.state_data),
updatedAt: row.updated_at,
};
}
return null;
}
export function deleteTurtleState(turtleId) {
const stmt = db.prepare('DELETE FROM turtle_state WHERE turtle_id = ?');
return stmt.run(turtleId);
}
// Export database instance for custom queries if needed
export { db };

View File

@@ -0,0 +1,109 @@
/**
* Levenshtein distance utility
* Used for fuzzy matching block/item names for inventory operations.
* Inspired by runi95/turtle-control-panel utility.
*/
/**
* Compute the Levenshtein edit distance between two strings.
* @param {string} a - First string
* @param {string} b - Second string
* @returns {number} The edit distance
*/
export function levenshtein(a, b) {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
// Use a single-row approach for memory efficiency
const aLen = a.length;
const bLen = b.length;
const row = new Array(bLen + 1);
// Initialize the first row (distance from empty string to b[0..j])
for (let j = 0; j <= bLen; j++) {
row[j] = j;
}
for (let i = 1; i <= aLen; i++) {
let prev = i; // row[0] for this iteration = i
for (let j = 1; j <= bLen; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const val = Math.min(
row[j] + 1, // deletion
prev + 1, // insertion
row[j - 1] + cost // substitution
);
row[j - 1] = prev;
prev = val;
}
row[bLen] = prev;
}
return row[bLen];
}
/**
* Find the best match for a query string among candidates using Levenshtein distance.
* @param {string} query - The search term
* @param {string[]} candidates - Array of candidate strings
* @param {number} maxDistance - Maximum acceptable distance (default: Infinity)
* @returns {{ match: string|null, distance: number }} Best match and its distance
*/
export function findBestMatch(query, candidates, maxDistance = Infinity) {
let bestMatch = null;
let bestDistance = Infinity;
const lowerQuery = query.toLowerCase();
for (const candidate of candidates) {
const lowerCandidate = candidate.toLowerCase();
// Exact substring match is distance 0
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
const dist = Math.abs(lowerCandidate.length - lowerQuery.length);
if (dist < bestDistance) {
bestDistance = dist;
bestMatch = candidate;
}
continue;
}
const dist = levenshtein(lowerQuery, lowerCandidate);
if (dist < bestDistance && dist <= maxDistance) {
bestDistance = dist;
bestMatch = candidate;
}
}
return { match: bestMatch, distance: bestDistance };
}
/**
* Find all matches within a given distance.
* @param {string} query - The search term
* @param {string[]} candidates - Array of candidate strings
* @param {number} maxDistance - Maximum acceptable distance
* @returns {Array<{match: string, distance: number}>} Matches sorted by distance
*/
export function findAllMatches(query, candidates, maxDistance = 3) {
const lowerQuery = query.toLowerCase();
const matches = [];
for (const candidate of candidates) {
const lowerCandidate = candidate.toLowerCase();
// Exact substring match
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
matches.push({ match: candidate, distance: 0 });
continue;
}
const dist = levenshtein(lowerQuery, lowerCandidate);
if (dist <= maxDistance) {
matches.push({ match: candidate, distance: dist });
}
}
return matches.sort((a, b) => a.distance - b.distance);
}

View File

@@ -6,7 +6,9 @@
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"minecraft",
@@ -17,12 +19,14 @@
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.14.2",
"@cc-platform/server": "file:../../cc-platform-core/server",
"better-sqlite3": "^9.2.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2"
"express": "^4.18.2",
"ws": "^8.14.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
"nodemon": "^3.0.1",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
/**
* AutocraftState - Automated crafting using a workbench peripheral
*
* The turtle must have a crafting table equipped as a peripheral.
* Given a recipe (16-slot inventory layout), it will:
* 1. Verify workbench peripheral is present
* 2. Arrange items in the correct slots
* 3. Pull materials from adjacent inventories
* 4. Craft continuously
* 5. Push crafted items into adjacent inventories
*/
import { BaseState } from './BaseState.js';
export class AutocraftState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
// recipe: { 1: {name, count}, 2: null, 3: {name, count}, ... } (1-16 slots)
this.recipe = data.recipe || {};
this.craftCount = 0;
this.totalCrafted = 0;
this.maxCrafts = data.maxCrafts || Infinity;
this.warning = null;
}
get name() {
return 'autocrafting';
}
get description() {
if (this.warning) return `Crafting - ${this.warning}`;
return `Crafting - ${this.totalCrafted} items crafted`;
}
async *act() {
console.log(`[${this.turtle.id}] Starting autocraft`);
// Verify workbench peripheral
const hasWorkbench = await this.exec(`
local names = peripheral.getNames()
for _, name in ipairs(names) do
local types = {peripheral.getType(name)}
for _, t in ipairs(types) do
if t == "workbench" then return true end
end
end
return false
`);
if (!hasWorkbench) {
console.log(`[${this.turtle.id}] No workbench peripheral found`);
this.warning = 'No crafting table equipped';
await this._sleep(3000);
this.turtle.setState('idle');
return;
}
yield;
// Initial craft to verify recipe works
const initialCraft = await this.exec(`
local workbench = peripheral.find("workbench")
if workbench then
local ok, msg = workbench.craft(0)
return ok
end
return false
`);
if (!initialCraft) {
console.log(`[${this.turtle.id}] Recipe validation failed`);
this.warning = 'Invalid recipe';
await this._sleep(3000);
this.turtle.setState('idle');
return;
}
yield;
// Main crafting loop
while (!this.cancelled && this.totalCrafted < this.maxCrafts) {
this.warning = null;
let recipeReady = true;
// Arrange items for recipe
for (let slot = 1; slot <= 16; slot++) {
if (this.cancelled) return;
const recipeItem = this.recipe[slot];
// Get current item in slot
const currentItem = await this.exec(`
local item = turtle.getItemDetail(${slot})
if item then return {name=item.name, count=item.count} end
return nil
`);
yield;
if (!recipeItem) {
// Slot should be empty - move item out if present
if (currentItem) {
await this._transferSlotOut(slot);
yield;
}
continue;
}
// Slot needs a specific item
if (currentItem) {
if (currentItem.name === recipeItem.name) {
continue; // Already has correct item
}
// Wrong item - move it out
await this._transferSlotOut(slot);
yield;
}
// Pull the required item into this slot
const pulled = await this._pullItemToSlot(recipeItem.name, slot);
yield;
if (!pulled) {
this.warning = `Missing: ${recipeItem.name.replace('minecraft:', '')}`;
recipeReady = false;
break;
}
}
if (!recipeReady) {
yield;
await this._sleep(5000);
yield;
continue;
}
// Craft!
const craftResult = await this.exec(`
local workbench = peripheral.find("workbench")
if workbench then
local ok, msg = workbench.craft()
if ok then return true
else return false
end
end
return false
`);
if (craftResult) {
this.totalCrafted++;
this.craftCount++;
console.log(`[${this.turtle.id}] Crafted item #${this.totalCrafted}`);
// Push crafted items out to adjacent inventory
await this._pushCraftedItems();
yield;
} else {
this.warning = 'Craft failed';
await this._sleep(2000);
}
yield;
await this._sleep(500);
}
console.log(`[${this.turtle.id}] Autocraft finished: ${this.totalCrafted} total`);
this.turtle.setState('idle');
}
async _transferSlotOut(slot) {
// Try to drop the item in the given slot into an adjacent inventory
await this.exec(`
turtle.select(${slot})
if not turtle.dropDown() then
if not turtle.dropUp() then
turtle.drop()
end
end
turtle.select(1)
`);
}
async _pullItemToSlot(itemName, targetSlot) {
// Try to pull item from adjacent inventories
// First try suckDown, then suckUp, then suck
const result = await this.exec(`
turtle.select(${targetSlot})
-- Try each direction
for _, suckFn in ipairs({turtle.suckDown, turtle.suckUp, turtle.suck}) do
suckFn(1)
local item = turtle.getItemDetail(${targetSlot})
if item and item.name == "${itemName}" then
return true
elseif item then
-- Wrong item, put it back
for _, dropFn in ipairs({turtle.dropDown, turtle.dropUp, turtle.drop}) do
if dropFn() then break end
end
end
end
turtle.select(1)
return false
`);
return result === true;
}
async _pushCraftedItems() {
// Push all items out of inventory
await this.exec(`
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
turtle.select(slot)
if not turtle.dropDown() then
if not turtle.dropUp() then
turtle.drop()
end
end
end
end
turtle.select(1)
`);
}
}

356
server/states/BaseState.js Normal file
View File

@@ -0,0 +1,356 @@
/**
* BaseState - Abstract base class for turtle state machine
*
* Uses async generator pattern inspired by runi95/turtle-control-panel.
* Each state implements *act() which yields control back periodically,
* allowing the state machine to be cooperative and interruptible.
*/
export class BaseState {
/**
* @param {import('../Turtle.js').Turtle} turtle - The turtle instance
* @param {Object} data - State-specific data for initialization/recovery
*/
constructor(turtle, data = {}) {
this.turtle = turtle;
this.data = data;
this.cancelled = false;
this.startedAt = Date.now();
}
/**
* The name of this state (used for logging and UI)
*/
get name() {
return 'base';
}
/**
* Get a human-readable description of what the state is doing
*/
get description() {
return 'Idle';
}
/**
* The async generator that drives the state's behavior.
* Subclasses MUST override this method.
*
* Yield to give up control temporarily (cooperative multitasking).
* Return to signal that the state is complete.
* Throw to signal an error.
*
* @yields {void}
*/
async *act() {
// Base state does nothing - just idles
while (!this.cancelled) {
yield;
await this._sleep(1000);
}
}
/**
* Cancel this state. The act() generator should check this.cancelled
* and clean up gracefully.
*/
cancel() {
this.cancelled = true;
}
/**
* Get recovery data for persisting state across reconnections
*/
getRecoveryData() {
return {
stateName: this.name,
data: this.data,
startedAt: this.startedAt,
};
}
// ========== Helper Methods for Subclasses ==========
/**
* Execute a Lua command on the turtle and wait for the result
*/
async exec(luaCode) {
return this.turtle.exec(luaCode);
}
/**
* Move the turtle forward, digging if necessary
*/
async moveForward(dig = true) {
if (dig) {
const result = await this.exec('turtle.dig(); return turtle.forward()');
if (result) this.turtle.updatePositionForward();
return result;
}
const result = await this.exec('return turtle.forward()');
if (result) this.turtle.updatePositionForward();
return result;
}
/**
* Move the turtle up, digging if necessary
*/
async moveUp(dig = true) {
if (dig) {
const result = await this.exec('turtle.digUp(); return turtle.up()');
if (result) this.turtle.updatePositionUp();
return result;
}
const result = await this.exec('return turtle.up()');
if (result) this.turtle.updatePositionUp();
return result;
}
/**
* Move the turtle down, digging if necessary
*/
async moveDown(dig = true) {
if (dig) {
const result = await this.exec('turtle.digDown(); return turtle.down()');
if (result) this.turtle.updatePositionDown();
return result;
}
const result = await this.exec('return turtle.down()');
if (result) this.turtle.updatePositionDown();
return result;
}
/**
* Turn the turtle to face a specific direction
* @param {number} targetFacing - 0=North, 1=East, 2=South, 3=West
*/
async turnToFace(targetFacing) {
const currentFacing = this.turtle.facing;
if (currentFacing === targetFacing) return true;
const diff = (targetFacing - currentFacing + 4) % 4;
if (diff === 1) {
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing;
} else if (diff === 2) {
await this.exec(`turtle.turnRight(); turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing;
} else if (diff === 3) {
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing;
}
return true;
}
/**
* Move to an adjacent point (one of the 6 neighbors)
* Handles turning and digging automatically
*/
async moveToAdjacent(targetPoint) {
const pos = this.turtle.position;
if (!pos) return false;
const dx = targetPoint.x - pos.x;
const dy = targetPoint.y - pos.y;
const dz = targetPoint.z - pos.z;
if (dy === 1) return this.moveUp();
if (dy === -1) return this.moveDown();
// Horizontal movement - determine facing
let targetFacing;
if (dx === 1) targetFacing = 1; // East
else if (dx === -1) targetFacing = 3; // West
else if (dz === 1) targetFacing = 2; // South
else if (dz === -1) targetFacing = 0; // North
else return false; // Not adjacent
await this.turnToFace(targetFacing);
return this.moveForward();
}
/**
* Navigate to a target point using the D* Lite pathfinder
* This is a generator that yields after each step
*/
async *navigateTo(targetPoint, options = {}) {
const { Point, DStarLite } = await import('../pathfinding/index.js');
const start = Point.from(this.turtle.position);
const goal = Point.from(targetPoint);
if (!start || !goal) {
console.error('Cannot navigate: missing position');
return false;
}
if (start.equals(goal)) return true;
const pathfinder = new DStarLite(start, goal, this.turtle.server.worldBlocks, {
canMine: options.canMine !== false,
maxSteps: options.maxSteps || 50000,
});
let currentPos = start;
let attempts = 0;
const maxAttempts = options.maxAttempts || 5000;
while (!currentPos.equals(goal) && attempts < maxAttempts && !this.cancelled) {
attempts++;
const nextStep = pathfinder.getNextStep();
if (!nextStep) {
console.log(`[${this.turtle.id}] No path to ${goal} from ${currentPos}`);
return false;
}
// Scan surroundings before moving
const scanResult = await this.exec(`
local results = {}
local hasBlock, data
hasBlock, data = turtle.inspect()
if hasBlock then results.forward = data end
hasBlock, data = turtle.inspectUp()
if hasBlock then results.up = data end
hasBlock, data = turtle.inspectDown()
if hasBlock then results.down = data end
return results
`);
// Update pathfinder with discovered blocks
if (scanResult && typeof scanResult === 'object') {
this.turtle.processScanResults(scanResult);
// Update pathfinder with new information
for (const [dir, blockData] of Object.entries(scanResult)) {
const blockPos = this.turtle.getBlockPositionInDirection(dir);
if (blockPos) {
pathfinder.updateBlock(Point.from(blockPos), blockData);
}
}
}
// Try to move to the next step
const success = await this.moveToAdjacent(nextStep);
if (success) {
currentPos = Point.from(this.turtle.position);
pathfinder.updateStart(currentPos);
} else {
// Movement failed - update the blocked node and replan
pathfinder.updateBlock(nextStep, { name: 'minecraft:bedrock' }); // Treat as blocked
if (!pathfinder.replan()) {
console.log(`[${this.turtle.id}] Replanning failed - no path exists`);
return false;
}
}
yield; // Cooperative yield
}
return currentPos.equals(goal);
}
/**
* Scan all 6 directions around the turtle
*/
async scanSurroundings() {
const result = await this.exec(`
local results = {}
local facing = _G._turtleFacing or 0
-- Scan up/down
local hasBlock, data
hasBlock, data = turtle.inspectUp()
if hasBlock then results.up = {name=data.name, metadata=data.metadata or 0} end
hasBlock, data = turtle.inspectDown()
if hasBlock then results.down = {name=data.name, metadata=data.metadata or 0} end
-- Scan all 4 horizontal directions
for i = 0, 3 do
hasBlock, data = turtle.inspect()
if hasBlock then
local dir = (facing + i) % 4
results["h" .. dir] = {name=data.name, metadata=data.metadata or 0}
end
if i < 3 then turtle.turnRight(); facing = (facing + 1) % 4 end
end
-- Turn back to original facing
turtle.turnRight()
facing = (facing + 1) % 4
_G._turtleFacing = facing
return results
`);
if (result) {
this.turtle.processScanResults(result);
}
return result;
}
/**
* Check fuel level
*/
async checkFuel() {
const fuel = await this.exec('return turtle.getFuelLevel()');
if (fuel !== null) this.turtle.fuel = fuel;
return fuel;
}
/**
* Get inventory contents
*/
async getInventory() {
const inv = await this.exec(`
local items = {}
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
items[slot] = {name=item.name, count=item.count, damage=item.damage}
end
end
return items
`);
if (inv) this.turtle.inventory = inv;
return inv;
}
/**
* Check if inventory is full
*/
async isInventoryFull() {
const result = await this.exec(`
for slot = 1, 16 do
if turtle.getItemCount(slot) == 0 then return false end
end
return true
`);
return result === true;
}
/**
* Try to refuel from inventory
*/
async tryRefuel() {
return this.exec(`
for slot = 1, 16 do
turtle.select(slot)
if turtle.refuel(0) then
turtle.refuel(1)
turtle.select(1)
return true
end
end
turtle.select(1)
return false
`);
}
/**
* Sleep for a duration (non-blocking)
*/
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,311 @@
/**
* BuildingState - Blueprint-based autonomous block placement
*
* Given a list of blocks (x, y, z, name, state), the turtle will:
* 1. Calculate required materials
* 2. Go home to collect materials from nearby inventories
* 3. Navigate to each block position and place it
* 4. Handle facing-aware placement (stairs, logs, etc.)
* 5. Track failed placements and retry later
*/
import { BaseState } from './BaseState.js';
export class BuildingState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
// blocks: [{x, y, z, name, state?}]
this.blocks = data.blocks || [];
this.placedCount = 0;
this.failedPlacements = new Map(); // "x,y,z" -> retry count
this.maxRetries = 3;
// Group blocks by column (x,z) for efficient placement
this.columns = new Map(); // "x,z" -> [{x,y,z,name,state}] sorted by y ascending
this._organizeBlocks();
}
get name() {
return 'building';
}
get description() {
return `Building - ${this.placedCount}/${this.blocks.length} placed`;
}
_organizeBlocks() {
// Filter out blocks that might already be placed (we can't check DB here,
// but we organize for efficient traversal)
for (const block of this.blocks) {
const key = `${block.x},${block.z}`;
if (!this.columns.has(key)) {
this.columns.set(key, []);
}
this.columns.get(key).push(block);
}
// Sort each column by Y ascending (place from bottom up)
for (const [key, column] of this.columns) {
column.sort((a, b) => a.y - b.y);
}
}
async *act() {
console.log(`[${this.turtle.id}] Starting build: ${this.blocks.length} blocks to place`);
while (!this.cancelled) {
// Check if we have any blocks left to place
const remainingBlocks = this._getRemainingBlocks();
if (remainingBlocks.length === 0) {
console.log(`[${this.turtle.id}] Build complete: ${this.placedCount} blocks placed`);
this.turtle.setState('idle');
return;
}
// Safety checks
const fuel = await this.checkFuel();
if (fuel !== 'unlimited' && fuel < 500) {
const refueled = await this.tryRefuel();
if (!refueled) {
console.log(`[${this.turtle.id}] Low fuel during build, going home`);
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'building', returnData: this.data });
return;
}
}
yield;
// Calculate required materials
const requiredMaterials = this._calculateMaterials(remainingBlocks);
// Check if we have the materials
const inventory = await this.getInventory();
const hasMaterials = this._hasRequiredMaterials(requiredMaterials, inventory);
yield;
if (!hasMaterials) {
// Go home to collect materials
const home = this.turtle.homePosition;
if (!home) {
console.log(`[${this.turtle.id}] No home set, cannot collect materials`);
this.turtle.setState('idle');
return;
}
console.log(`[${this.turtle.id}] Going home to collect building materials`);
// Navigate home
const reachedHome = yield* this.navigateTo(home);
if (!reachedHome) {
console.log(`[${this.turtle.id}] Cannot reach home`);
await this._sleep(5000);
yield;
continue;
}
yield;
// Dump current inventory into nearby chests
await this._dumpInventory();
yield;
// Try to pull required materials
const gotMaterials = await this._pullMaterials(requiredMaterials);
yield;
if (!gotMaterials) {
console.log(`[${this.turtle.id}] Cannot get required materials, waiting...`);
await this._sleep(10000);
yield;
continue;
}
}
// Find the next block to place (closest to turtle)
const pos = this.turtle.position;
if (!pos) {
await this._sleep(1000);
yield;
continue;
}
const nextBlock = this._findClosestBlock(remainingBlocks, pos);
if (!nextBlock) {
await this._sleep(1000);
yield;
continue;
}
// Navigate to one block above the target (to place down)
const placePos = { x: nextBlock.x, y: nextBlock.y + 1, z: nextBlock.z };
const reached = yield* this.navigateTo(placePos);
yield;
if (!reached) {
// Mark as failed
const key = `${nextBlock.x},${nextBlock.y},${nextBlock.z}`;
const retries = (this.failedPlacements.get(key) || 0) + 1;
this.failedPlacements.set(key, retries);
if (retries >= this.maxRetries) {
console.log(`[${this.turtle.id}] Cannot reach ${key}, skipping permanently`);
}
yield;
continue;
}
// Handle facing - turn turtle to face correct direction before placing
if (nextBlock.state && nextBlock.state.facing) {
await this._turnForPlacement(nextBlock.state.facing);
yield;
}
// Select the correct item
const selected = await this._selectItem(nextBlock.name);
if (!selected) {
console.log(`[${this.turtle.id}] Don't have ${nextBlock.name} to place`);
yield;
continue;
}
// Place the block
const placed = await this.exec('return turtle.placeDown()');
yield;
if (placed) {
this.placedCount++;
// Remove from our list
this._markBlockPlaced(nextBlock);
console.log(`[${this.turtle.id}] Placed ${nextBlock.name} at ${nextBlock.x},${nextBlock.y},${nextBlock.z} (${this.placedCount}/${this.blocks.length})`);
} else {
const key = `${nextBlock.x},${nextBlock.y},${nextBlock.z}`;
const retries = (this.failedPlacements.get(key) || 0) + 1;
this.failedPlacements.set(key, retries);
}
yield;
await this._sleep(100);
}
}
_getRemainingBlocks() {
const remaining = [];
for (const [key, column] of this.columns) {
for (const block of column) {
const blockKey = `${block.x},${block.y},${block.z}`;
const retries = this.failedPlacements.get(blockKey) || 0;
if (retries < this.maxRetries) {
remaining.push(block);
}
}
}
return remaining;
}
_calculateMaterials(blocks) {
const materials = new Map(); // name -> count
for (const block of blocks) {
materials.set(block.name, (materials.get(block.name) || 0) + 1);
}
return materials;
}
_hasRequiredMaterials(required, inventory) {
if (!inventory) return false;
const available = new Map();
for (const [slot, item] of Object.entries(inventory)) {
if (item && item.name) {
available.set(item.name, (available.get(item.name) || 0) + item.count);
}
}
for (const [name, count] of required) {
if ((available.get(name) || 0) < Math.min(count, 1)) {
return false; // Need at least 1 of each material
}
}
return true;
}
_findClosestBlock(blocks, from) {
let closest = null;
let minDist = Infinity;
for (const block of blocks) {
const dist = Math.abs(block.x - from.x) + Math.abs(block.y - from.y) + Math.abs(block.z - from.z);
if (dist < minDist) {
minDist = dist;
closest = block;
}
}
return closest;
}
_markBlockPlaced(block) {
const key = `${block.x},${block.z}`;
const column = this.columns.get(key);
if (column) {
const idx = column.findIndex(b => b.x === block.x && b.y === block.y && b.z === block.z);
if (idx >= 0) column.splice(idx, 1);
if (column.length === 0) this.columns.delete(key);
}
}
async _turnForPlacement(facing) {
// When placing blocks with facing, the turtle should face the opposite direction
// because placed blocks face toward the placer
const facingMap = { north: 2, south: 0, east: 3, west: 1 };
const targetFacing = facingMap[facing];
if (targetFacing !== undefined) {
await this.turnToFace(targetFacing);
}
}
async _selectItem(blockName) {
const result = await this.exec(`
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item and item.name == "${blockName}" then
turtle.select(slot)
return true
end
end
return false
`);
return result === true;
}
async _dumpInventory() {
await this.exec(`
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
turtle.select(slot)
turtle.dropDown()
end
end
turtle.select(1)
`);
}
async _pullMaterials(required) {
// Try to suck items from chests below (at home position)
let gotAny = false;
for (const [name, count] of required) {
const result = await this.exec(`
local needed = "${name}"
for slot = 1, 16 do
if turtle.getItemCount(slot) == 0 then
turtle.select(slot)
turtle.suckDown(64)
local item = turtle.getItemDetail(slot)
if item and item.name == needed then
return true
elseif item then
turtle.dropDown()
end
end
end
turtle.select(1)
return false
`);
if (result) gotAny = true;
}
return gotAny;
}
}

View File

@@ -0,0 +1,209 @@
/**
* DumpInventoryState - Dump inventory into nearby containers
* Keeps fuel items, dumps everything else.
*
* Improvements over v1:
* - Uses Levenshtein distance for fuzzy container name matching
* - Detects containers via peripheral inspection on all sides
* - Tracks dump success/failure per direction
* - Reports detailed dump stats
* - Optionally navigates home before dumping
*/
import { BaseState } from './BaseState.js';
import { findBestMatch } from '../helpers/levenshtein.js';
// Items to keep (fuel sources and tools)
const KEEP_ITEMS = new Set([
'minecraft:coal', 'minecraft:charcoal', 'minecraft:coal_block',
'minecraft:torch', 'minecraft:lava_bucket',
]);
// Known container block names (used for fuzzy matching)
const CONTAINER_NAMES = [
'chest', 'barrel', 'shulker_box', 'hopper', 'dropper', 'dispenser',
'trapped_chest', 'ender_chest', 'furnace', 'blast_furnace', 'smoker',
];
/**
* Check if a block name is likely a container using fuzzy matching
*/
function isContainerBlock(blockName) {
if (!blockName) return false;
const shortName = blockName.replace(/^[^:]+:/, '').toLowerCase();
// Exact substring match first
for (const container of CONTAINER_NAMES) {
if (shortName.includes(container)) return true;
}
// Fuzzy match as fallback
const { match, distance } = findBestMatch(shortName, CONTAINER_NAMES, 3);
return match !== null && distance <= 2;
}
export class DumpInventoryState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.returnState = data.returnState || null;
this.returnData = data.returnData || {};
this.navigateHome = data.navigateHome || false;
this.keepItems = data.keepItems
? new Set([...KEEP_ITEMS, ...data.keepItems])
: KEEP_ITEMS;
}
get name() {
return 'dumping';
}
get description() {
return this.navigateHome ? 'Returning home to dump inventory' : 'Dumping inventory';
}
async *act() {
console.log(`[${this.turtle.id}] Starting inventory dump`);
// Optionally navigate home first
if (this.navigateHome && this.turtle.homePosition) {
console.log(`[${this.turtle.id}] Navigating home before dumping`);
yield* this.navigateTo(this.turtle.homePosition);
yield;
}
// First, detect containers by inspecting all directions
const containerDirections = await this._detectContainers();
if (containerDirections.length === 0) {
console.log(`[${this.turtle.id}] No containers found nearby, trying blind dump`);
containerDirections.push('front', 'up', 'down');
} else {
console.log(`[${this.turtle.id}] Found containers: ${containerDirections.join(', ')}`);
}
// Build keep items set for Lua
const keepItemsList = [...this.keepItems].map(n => `"${n}"`).join(', ');
// Map direction names to Lua drop functions
const dropFnMap = {
front: 'turtle.drop',
up: 'turtle.dropUp',
down: 'turtle.dropDown',
};
// Build the drop functions array for only detected container directions
const dropFnEntries = containerDirections
.filter(d => dropFnMap[d])
.map(d => dropFnMap[d]);
if (dropFnEntries.length === 0) {
console.log(`[${this.turtle.id}] No valid drop directions`);
this._transitionOut();
return;
}
const result = await this.exec(`
local keepItems = {${keepItemsList}}
local keepSet = {}
for _, name in ipairs(keepItems) do keepSet[name] = true end
local dropFns = {${dropFnEntries.join(', ')}}
local dumpedCount = 0
local failedCount = 0
for _, dropFn in ipairs(dropFns) do
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item and not keepSet[item.name] then
turtle.select(slot)
local prevCount = item.count
if dropFn() then
local remaining = turtle.getItemCount(slot)
dumpedCount = dumpedCount + (prevCount - remaining)
else
failedCount = failedCount + 1
end
end
end
end
turtle.select(1)
return {dumped = dumpedCount, failed = failedCount}
`);
if (result) {
console.log(`[${this.turtle.id}] Dumped ${result.dumped} items (${result.failed} drops failed)`);
if (result.failed > 0) {
this.turtle.warning = `Dump: ${result.failed} drops failed (container full?)`;
}
}
yield;
// Update inventory after dumping
await this.getInventory();
// Refresh any adjacent inventory peripherals
for (const dir of containerDirections) {
if (this.turtle._peripherals?.[dir]?.types?.includes('inventory')) {
try {
await this.turtle.connectToInventory(dir);
} catch (e) { /* ignore */ }
}
}
this._transitionOut();
}
/**
* Detect which directions have containers by inspecting blocks
*/
async _detectContainers() {
const result = await this.exec(`
local dirs = {}
local h, d
h, d = turtle.inspect()
if h then dirs.front = d.name end
h, d = turtle.inspectUp()
if h then dirs.up = d.name end
h, d = turtle.inspectDown()
if h then dirs.down = d.name end
return dirs
`);
if (!result || typeof result !== 'object') return [];
const containerDirs = [];
for (const [dir, blockName] of Object.entries(result)) {
if (isContainerBlock(blockName)) {
containerDirs.push(dir);
}
}
return containerDirs;
}
/**
* Transition to the next state (return state or idle)
*/
_transitionOut() {
if (this.returnState) {
this.turtle.setState(this.returnState, this.returnData);
} else {
this.turtle.setState('idle');
}
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
returnState: this.returnState,
returnData: this.returnData,
navigateHome: this.navigateHome,
keepItems: this.keepItems !== KEEP_ITEMS
? [...this.keepItems].filter(i => !KEEP_ITEMS.has(i))
: undefined,
},
};
}
}

View File

@@ -0,0 +1,380 @@
/**
* ExploringState - Chunk-based spiral exploration to discover the world map
*
* Instead of random walking, this systematically explores in a spiral pattern
* outward from the starting position, chunk by chunk (16x16 areas).
* Within each chunk, it does a strip-mine pattern to scan all blocks.
*
* Inspired by runi95/turtle-control-panel's exploration pattern.
*/
import { BaseState } from './BaseState.js';
export class ExploringState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.maxDistance = data.maxDistance || 200;
this.minFuel = data.minFuel || 500;
this.yLevel = data.yLevel || null; // null = stay at current Y
this.blocksDiscovered = 0;
this.chunksExplored = 0;
// Spiral state
this.spiralRing = data.spiralRing || 0;
this.spiralSide = data.spiralSide || 0;
this.spiralStep = data.spiralStep || 0;
this.spiralChunkX = data.spiralChunkX ?? null;
this.spiralChunkZ = data.spiralChunkZ ?? null;
this.startChunkX = data.startChunkX ?? null;
this.startChunkZ = data.startChunkZ ?? null;
this.exploredChunks = new Set(data.exploredChunks || []);
}
get name() {
return 'exploring';
}
get description() {
return `Exploring - ${this.blocksDiscovered} blocks, ${this.chunksExplored} chunks (ring ${this.spiralRing})`;
}
async *act() {
const pos = this.turtle.position;
if (!pos) {
console.log(`[${this.turtle.id}] No position, trying GPS...`);
await this.turtle.gpsLocate();
if (!this.turtle.position) {
console.error(`[${this.turtle.id}] Cannot explore without position`);
this.turtle.setState('idle');
return;
}
}
// Initialize start chunk from current position
if (this.startChunkX === null) {
this.startChunkX = Math.floor(this.turtle.position.x / 16);
this.startChunkZ = Math.floor(this.turtle.position.z / 16);
this.spiralChunkX = this.startChunkX;
this.spiralChunkZ = this.startChunkZ;
}
// Set Y level for exploration
if (!this.yLevel) {
this.yLevel = this.turtle.position.y;
}
console.log(`[${this.turtle.id}] Starting chunk-based spiral exploration from chunk (${this.startChunkX}, ${this.startChunkZ}), Y=${this.yLevel}`);
while (!this.cancelled) {
try {
// Safety: check fuel
const fuel = await this.checkFuel();
if (fuel !== 'unlimited' && fuel < this.minFuel) {
const refueled = await this.tryRefuel();
if (!refueled) {
console.log(`[${this.turtle.id}] Low fuel, going home`);
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'exploring', returnData: this.getRecoveryData().data });
return;
}
}
// Safety: check inventory
const isFull = await this.isInventoryFull();
if (isFull) {
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring', returnData: this.getRecoveryData().data });
return;
}
// Safety: check distance
if (this._isTooFar()) {
console.log(`[${this.turtle.id}] Too far from home, returning`);
this.turtle.setState('goHome', { reason: 'too_far' });
return;
}
// Get the current target chunk
const chunkKey = `${this.spiralChunkX},${this.spiralChunkZ}`;
if (!this.exploredChunks.has(chunkKey)) {
// Explore this chunk
console.log(`[${this.turtle.id}] Exploring chunk (${this.spiralChunkX}, ${this.spiralChunkZ}) [ring ${this.spiralRing}]`);
yield* this._exploreChunk(this.spiralChunkX, this.spiralChunkZ);
this.exploredChunks.add(chunkKey);
this.chunksExplored++;
}
// Advance spiral to next chunk
this._advanceSpiral();
yield;
} catch (error) {
const isTimeout = error.message?.includes('timed out');
if (isTimeout) {
console.warn(`[${this.turtle.id}] Exploration timeout, retrying...`);
await this._sleep(3000);
} else {
throw error;
}
}
}
}
/**
* Explore a single chunk by navigating to it and doing a strip-mine scan pattern.
* Scans every 3rd row at the exploration Y level for good coverage.
*/
async *_exploreChunk(chunkX, chunkZ) {
const startX = chunkX * 16;
const startZ = chunkZ * 16;
// Navigate to the start corner of the chunk
const chunkStart = { x: startX, y: this.yLevel, z: startZ };
yield* this._navigateToSafe(chunkStart);
// Strip-mine scan pattern across the chunk
// Every 3 blocks gives good coverage with turtle.inspect() range
let forward = true;
for (let row = 0; row < 16; row += 3) {
if (this.cancelled) return;
const rowZ = startZ + row;
const rowStartX = forward ? startX : startX + 15;
// Navigate to row start
yield* this._navigateToSafe({ x: rowStartX, y: this.yLevel, z: rowZ });
// Walk along the row, scanning as we go
const direction = forward ? 1 : 3; // East or West
await this.turnToFace(direction);
for (let step = 0; step < 15; step++) {
if (this.cancelled) return;
// 3-direction scan
const scanResult = await this.exec(`
local results = {}
local h, d
h, d = turtle.inspect()
if h then results.forward = d end
h, d = turtle.inspectUp()
if h then results.up = d end
h, d = turtle.inspectDown()
if h then results.down = d end
return results
`);
if (scanResult && typeof scanResult === 'object') {
this.turtle.processScanResults(scanResult);
this.blocksDiscovered += Object.keys(scanResult).length;
}
// Mine any valuable ores we find
yield* this._checkAndMineOres();
// Move forward (digging through obstacles)
const moved = await this.moveForward(true);
if (!moved) {
// Stuck - try to go over
await this.moveUp(true);
const movedOver = await this.moveForward(true);
if (movedOver) {
await this.moveDown(true);
} else {
// Really stuck, skip rest of row
break;
}
}
yield;
await this._sleep(100);
}
// Alternate direction for serpentine pattern
forward = !forward;
}
}
/**
* Navigate to a target position safely using pathfinding with simple fallback
*/
async *_navigateToSafe(target) {
try {
const success = yield* this.navigateTo(target, { canMine: true, maxAttempts: 500 });
if (success) return;
} catch (error) {
// Pathfinding failed, use simple navigation
}
// Fallback: simple direct navigation
yield* this._simpleNavigate(target);
}
/**
* Simple direct navigation (X -> Z -> Y)
*/
async *_simpleNavigate(target) {
const maxAttempts = 300;
let attempts = 0;
let stuckCount = 0;
while (attempts < maxAttempts && !this.cancelled) {
const pos = this.turtle.position;
if (!pos) return;
const dx = target.x - pos.x;
const dy = target.y - pos.y;
const dz = target.z - pos.z;
// Close enough (within 2 blocks)
if (Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && Math.abs(dz) <= 1) return;
attempts++;
let moved = false;
// Move in X direction
if (Math.abs(dx) > 1) {
const facing = dx > 0 ? 1 : 3;
await this.turnToFace(facing);
moved = await this.moveForward(true);
}
// Then Z direction
else if (Math.abs(dz) > 1) {
const facing = dz > 0 ? 2 : 0;
await this.turnToFace(facing);
moved = await this.moveForward(true);
}
// Then Y direction
else if (dy > 0) {
moved = await this.moveUp(true);
} else if (dy < 0) {
moved = await this.moveDown(true);
}
if (!moved) {
stuckCount++;
if (stuckCount > 5) {
// Try going up and over
await this.moveUp(true);
await this.moveUp(true);
stuckCount = 0;
}
} else {
stuckCount = 0;
}
yield;
}
}
async *_checkAndMineOres() {
const valuableOres = new Set([
'minecraft:diamond_ore', 'minecraft:emerald_ore',
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
'minecraft:gold_ore', 'minecraft:deepslate_gold_ore',
'minecraft:iron_ore', 'minecraft:deepslate_iron_ore',
'minecraft:lapis_ore', 'minecraft:deepslate_lapis_ore',
'minecraft:redstone_ore', 'minecraft:deepslate_redstone_ore',
'minecraft:copper_ore', 'minecraft:deepslate_copper_ore',
'minecraft:coal_ore', 'minecraft:deepslate_coal_ore',
'minecraft:ancient_debris',
]);
const directions = [
{ check: 'turtle.inspect()', dig: 'turtle.dig()' },
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()' },
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()' },
];
for (const { check, dig } of directions) {
if (this.cancelled) return;
const result = await this.exec(`
local hasBlock, data = ${check}
if hasBlock then return {name = data.name} end
return nil
`);
if (result && valuableOres.has(result.name)) {
await this.exec(dig);
this.turtle.emit('blockMined', { blockType: result.name });
yield;
}
}
}
/**
* Advance the spiral to the next chunk position.
* Standard clockwise spiral: start at center, go E, then S, W, N with increasing lengths.
* Ring 0: just center
* Ring 1: side lengths = 1,2,2,1 (total 6 chunks)
* Ring R: 8*R chunks around the ring
*/
_advanceSpiral() {
if (this.spiralRing === 0) {
// Center done, start ring 1 going East
this.spiralRing = 1;
this.spiralSide = 0;
this.spiralStep = 0;
this.spiralChunkX = this.startChunkX + 1;
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
return;
}
// Side directions: S, W, N, E
const sideDirs = [
{ dx: 0, dz: 1 }, // South
{ dx: -1, dz: 0 }, // West
{ dx: 0, dz: -1 }, // North
{ dx: 1, dz: 0 }, // East
];
this.spiralStep++;
const sideLength = this.spiralRing * 2;
if (this.spiralStep >= sideLength) {
this.spiralStep = 0;
this.spiralSide++;
if (this.spiralSide >= 4) {
// Done with this ring, move to next
this.spiralRing++;
this.spiralSide = 0;
// Jump to start of new ring (one East, one North from current)
this.spiralChunkX = this.startChunkX + this.spiralRing;
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
return;
}
}
// Move in current side direction
const dir = sideDirs[this.spiralSide];
this.spiralChunkX += dir.dx;
this.spiralChunkZ += dir.dz;
}
_isTooFar() {
const pos = this.turtle.position;
const home = this.turtle.homePosition;
if (!pos || !home) return false;
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
return dist > this.maxDistance;
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
maxDistance: this.maxDistance,
minFuel: this.minFuel,
yLevel: this.yLevel,
spiralRing: this.spiralRing,
spiralSide: this.spiralSide,
spiralStep: this.spiralStep,
spiralChunkX: this.spiralChunkX,
spiralChunkZ: this.spiralChunkZ,
startChunkX: this.startChunkX,
startChunkZ: this.startChunkZ,
exploredChunks: [...this.exploredChunks],
},
};
}
}

View File

@@ -0,0 +1,321 @@
/**
* ExtractionState - Smart ore extraction in a defined area
*
* Uses scanner peripherals to find ores, navigates to them,
* mines them, and returns home when full or low on fuel.
* Much more targeted than basic MiningState.
*/
import { BaseState } from './BaseState.js';
const ORE_BLOCKS = new Set([
'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:gold_ore',
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:redstone_ore',
'minecraft:lapis_ore', 'minecraft:copper_ore',
'minecraft:deepslate_coal_ore', 'minecraft:deepslate_iron_ore',
'minecraft:deepslate_gold_ore', 'minecraft:deepslate_diamond_ore',
'minecraft:deepslate_emerald_ore', 'minecraft:deepslate_redstone_ore',
'minecraft:deepslate_lapis_ore', 'minecraft:deepslate_copper_ore',
'minecraft:nether_gold_ore', 'minecraft:nether_quartz_ore',
'minecraft:ancient_debris',
]);
export class ExtractionState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
// Area bounds for extraction
this.area = data.area || []; // [{x,y,z}, ...] coordinates to scan/mine
this.minY = data.minY ?? -64;
this.maxY = data.maxY ?? 320;
this.oreTargets = []; // Known ore positions to mine
this.scanPositions = []; // Positions still needing scanning
this.remainingPositions = []; // Ore positions still to mine
this.blocksMined = 0;
this.oresExtracted = 0;
this.scannerType = null;
this.hasInitialized = false;
// If area not specified, generate from bounds
if (this.area.length === 0 && data.bounds) {
this._generateAreaFromBounds(data.bounds);
}
}
get name() {
return 'extracting';
}
get description() {
return `Extracting - ${this.oresExtracted} ores, ${this.remainingPositions.length} remaining`;
}
_generateAreaFromBounds(bounds) {
const { minX, minY, minZ, maxX, maxY, maxZ } = bounds;
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
for (let z = minZ; z <= maxZ; z++) {
this.area.push({ x, y, z });
}
}
}
}
async *act() {
console.log(`[${this.turtle.id}] Starting extraction (${this.area.length} area blocks)`);
// Detect scanner
this.scannerType = await this._detectScanner();
yield;
// Initialize: query DB for known ores in area, compute scan positions
if (!this.hasInitialized) {
await this._initializeScanPlan();
this.hasInitialized = true;
}
yield;
while (!this.cancelled) {
// Done if no more scan positions and no remaining ore positions
if (this.scanPositions.length === 0 && this.remainingPositions.length === 0) {
console.log(`[${this.turtle.id}] Extraction complete: ${this.oresExtracted} ores extracted`);
this.turtle.setState('idle');
return;
}
// Safety: check fuel
const fuel = await this.checkFuel();
if (fuel !== 'unlimited' && fuel < 500) {
const refueled = await this.tryRefuel();
if (!refueled) {
console.log(`[${this.turtle.id}] Low fuel, going home`);
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'extracting', returnData: this.data });
return;
}
}
yield;
// Check inventory fullness
const isFull = await this.isInventoryFull();
if (isFull) {
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'extracting', returnData: this.data });
return;
}
yield;
const pos = this.turtle.position;
if (!pos) {
await this._sleep(1000);
yield;
continue;
}
// Priority 1: Navigate to scan positions and scan
if (this.scanPositions.length > 0) {
const scanTarget = this._findClosest(this.scanPositions, pos);
if (scanTarget) {
// Navigate to scan position
const success = yield* this.navigateTo(scanTarget);
if (success) {
// Perform scan
const scannedBlocks = await this._performScan();
yield;
// Process results - find ores
if (scannedBlocks) {
for (const block of scannedBlocks) {
if (this._isOre(block.name)) {
this.remainingPositions.push({ x: block.x, y: block.y, z: block.z });
}
}
}
// Remove this scan position
const idx = this.scanPositions.findIndex(p => p.x === scanTarget.x && p.y === scanTarget.y && p.z === scanTarget.z);
if (idx >= 0) this.scanPositions.splice(idx, 1);
}
}
yield;
continue;
}
// Priority 2: Navigate to and mine ore positions
if (this.remainingPositions.length > 0) {
const oreTarget = this._findClosest(this.remainingPositions, pos);
if (oreTarget) {
// Navigate adjacent to ore
const success = yield* this.navigateTo(oreTarget);
yield;
if (success) {
// Mine the ore (we should be at or adjacent to it)
await this._mineAt(oreTarget);
this.oresExtracted++;
}
// Remove from remaining
const idx = this.remainingPositions.findIndex(p => p.x === oreTarget.x && p.y === oreTarget.y && p.z === oreTarget.z);
if (idx >= 0) this.remainingPositions.splice(idx, 1);
}
yield;
}
await this._sleep(200);
yield;
}
}
async _detectScanner() {
const result = await this.exec(`
local names = peripheral.getNames()
for _, name in ipairs(names) do
local types = {peripheral.getType(name)}
for _, t in ipairs(types) do
if t == "geoScanner" then return "geoScanner"
elseif t == "universal_scanner" then return "universal_scanner"
elseif t == "plethora:scanner" then return "plethora:scanner"
end
end
end
return nil
`);
return result || 'native';
}
async _initializeScanPlan() {
// Query the DB for known ores in the area
if (this.area.length > 0) {
const bounds = this._getBoundingBox();
try {
const knownOres = this.turtle.server.db.getBlocksWithNameLike(
bounds.minX, bounds.minY, bounds.minZ,
bounds.maxX, bounds.maxY, bounds.maxZ,
'%_ore'
);
for (const ore of knownOres) {
this.remainingPositions.push({ x: ore.x, y: ore.y, z: ore.z });
}
} catch (e) {
console.log(`[${this.turtle.id}] DB ore query failed:`, e.message);
}
}
// Compute scan positions (evenly spaced grid across area)
if (this.scannerType !== 'native' && this.area.length > 0) {
const bounds = this._getBoundingBox();
const scanSpacing = this.scannerType === 'plethora:scanner' ? 16 : 32;
for (let x = bounds.minX; x <= bounds.maxX; x += scanSpacing) {
for (let z = bounds.minZ; z <= bounds.maxZ; z += scanSpacing) {
const y = Math.floor((bounds.minY + bounds.maxY) / 2);
this.scanPositions.push({ x, y, z });
}
}
}
}
_getBoundingBox() {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const p of this.area) {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
}
return { minX, minY, minZ, maxX, maxY, maxZ };
}
_findClosest(positions, from) {
let closest = null;
let minDist = Infinity;
for (const pos of positions) {
const dist = Math.abs(pos.x - from.x) + Math.abs(pos.y - from.y) + Math.abs(pos.z - from.z);
if (dist < minDist) {
minDist = dist;
closest = pos;
}
}
return closest;
}
_isOre(name) {
return ORE_BLOCKS.has(name) || (name && name.endsWith('_ore'));
}
async _performScan() {
const pos = this.turtle.position;
if (!pos) return null;
let rawBlocks = null;
if (this.scannerType === 'geoScanner') {
rawBlocks = await this.exec(`
local scanner = peripheral.find("geoScanner")
if scanner then
local ok, blocks = pcall(scanner.scan, 16)
if ok then return blocks end
end
return nil
`);
} else if (this.scannerType === 'universal_scanner') {
rawBlocks = await this.exec(`
local scanner = peripheral.find("universal_scanner")
if scanner then
local ok, blocks = pcall(scanner.scan, "block", 16)
if ok then return blocks end
end
return nil
`);
} else if (this.scannerType === 'plethora:scanner') {
rawBlocks = await this.exec(`
local scanner = peripheral.find("plethora:scanner")
if scanner then
local ok, blocks = pcall(scanner.scan)
if ok then return blocks end
end
return nil
`);
}
if (!rawBlocks || !Array.isArray(rawBlocks)) return null;
// Convert relative positions to absolute
return rawBlocks
.filter(b => b && b.name && b.name !== 'minecraft:air')
.map(b => ({
x: pos.x + (b.x || 0),
y: pos.y + (b.y || 0),
z: pos.z + (b.z || 0),
name: b.name,
}));
}
async _mineAt(target) {
const pos = this.turtle.position;
if (!pos) return;
const dx = target.x - pos.x;
const dy = target.y - pos.y;
const dz = target.z - pos.z;
// We should be at or adjacent - mine in appropriate direction
if (dy === 1 || (dy === 0 && dx === 0 && dz === 0)) {
await this.exec('turtle.digUp()');
} else if (dy === -1) {
await this.exec('turtle.digDown()');
} else {
// Face the block and dig
let targetFacing;
if (dx === 1) targetFacing = 1;
else if (dx === -1) targetFacing = 3;
else if (dz === 1) targetFacing = 2;
else if (dz === -1) targetFacing = 0;
else targetFacing = this.turtle.facing;
await this.turnToFace(targetFacing);
await this.exec('turtle.dig()');
}
this.blocksMined++;
this.turtle.emit('blockMined', { blockType: 'ore' });
}
}

View File

@@ -0,0 +1,124 @@
/**
* FarmingState - Automated farming (plant, harvest, replant)
* Works with wheat, carrots, potatoes, beetroot, etc.
*/
import { BaseState } from './BaseState.js';
const CROP_SEEDS = {
'minecraft:wheat': 'minecraft:wheat_seeds',
'minecraft:carrots': 'minecraft:carrot',
'minecraft:potatoes': 'minecraft:potato',
'minecraft:beetroots': 'minecraft:beetroot_seeds',
};
export class FarmingState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.area = data.area || null; // {minX, minZ, maxX, maxZ, y}
this.harvestCount = 0;
}
get name() {
return 'farming';
}
get description() {
return `Farming - ${this.harvestCount} harvested`;
}
async *act() {
console.log(`[${this.turtle.id}] Starting farming operation`);
if (!this.area) {
console.log(`[${this.turtle.id}] No farming area defined`);
this.turtle.setState('idle');
return;
}
// Farm in a zigzag pattern over the area
const { minX, minZ, maxX, maxZ, y } = this.area;
let direction = 1; // 1 = positive Z, -1 = negative Z
for (let x = minX; x <= maxX; x++) {
const zStart = direction === 1 ? minZ : maxZ;
const zEnd = direction === 1 ? maxZ : minZ;
const zStep = direction;
for (let z = zStart; direction === 1 ? z <= zEnd : z >= zEnd; z += zStep) {
if (this.cancelled) return;
// Navigate to position above the crop
const target = { x, y: y + 1, z };
const navGen = this.navigateTo(target);
for await (const _ of navGen) {
if (this.cancelled) return;
yield;
}
// Check the block below
const blockBelow = await this.exec(`
local hasBlock, data = turtle.inspectDown()
if hasBlock then
return {name = data.name, state = data.state}
end
return nil
`);
if (blockBelow) {
// Check if crop is mature (age = 7 for most crops)
const isMature = blockBelow.state && blockBelow.state.age === 7;
if (isMature) {
// Harvest
await this.exec('turtle.digDown()');
this.harvestCount++;
// Replant
const seedName = CROP_SEEDS[blockBelow.name];
if (seedName) {
await this.exec(`
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item and item.name == "${seedName}" then
turtle.select(slot)
turtle.placeDown()
turtle.select(1)
return true
end
end
return false
`);
}
}
}
yield;
}
direction *= -1; // Zigzag
}
// Done farming one pass - check inventory
const isFull = await this.isInventoryFull();
if (isFull) {
this.turtle.setState('goHome', {
reason: 'inventory_full',
returnState: 'farming',
returnData: this.data
});
} else {
// Start another pass
console.log(`[${this.turtle.id}] Farm pass complete, ${this.harvestCount} harvested`);
yield* this.act(); // Loop
}
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
area: this.area,
},
};
}
}

View File

@@ -0,0 +1,89 @@
/**
* GoHomeState - Navigate the turtle back to its home position
* After arrival, optionally transitions to dump inventory or another state
*/
import { BaseState } from './BaseState.js';
export class GoHomeState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.reason = data.reason || 'manual'; // 'low_fuel', 'inventory_full', 'too_far', 'manual'
this.returnState = data.returnState || null; // State to return to after going home
this.returnData = data.returnData || {};
}
get name() {
return 'returning';
}
get description() {
return `Returning home (${this.reason})`;
}
async *act() {
const home = this.turtle.homePosition;
if (!home) {
console.log(`[${this.turtle.id}] No home position set, going idle`);
this.turtle.setState('idle');
return;
}
console.log(`[${this.turtle.id}] Going home: reason=${this.reason}`);
// Navigate to home using D* Lite
const navGen = this.navigateTo(home, { canMine: true });
for await (const _ of navGen) {
if (this.cancelled) return;
// Check fuel during return
const fuel = await this.checkFuel();
if (fuel !== 'unlimited' && fuel < 100) {
// Emergency refuel
await this.tryRefuel();
}
yield;
}
// Arrived home
console.log(`[${this.turtle.id}] Arrived home`);
// If we came home to dump inventory, do so
if (this.reason === 'inventory_full') {
this.turtle.setState('dumpInventory', {
returnState: this.returnState,
returnData: this.returnData,
});
return;
}
// If we came home to refuel, try refueling
if (this.reason === 'low_fuel') {
this.turtle.setState('refueling', {
returnState: this.returnState,
returnData: this.returnData,
});
return;
}
// If there's a return state, go to it
if (this.returnState) {
this.turtle.setState(this.returnState, this.returnData);
return;
}
// Default: go idle
this.turtle.setState('idle');
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
reason: this.reason,
returnState: this.returnState,
returnData: this.returnData,
},
};
}
}

View File

@@ -0,0 +1,32 @@
/**
* IdleState - Turtle is idle, waiting for commands
*/
import { BaseState } from './BaseState.js';
export class IdleState extends BaseState {
get name() {
return 'idle';
}
get description() {
return 'Idle - waiting for commands';
}
async *act() {
// Periodic fuel check while idle (no scanning — avoids rotating turtle)
// Errors are caught here so they don't bubble up and trigger
// the _runStateLoop retry mechanism (which would create an
// infinite idle→timeout→idle loop).
while (!this.cancelled) {
try {
await this.checkFuel();
} catch (e) {
// Fuel check failed (likely command timeout) — not critical in idle
// Just wait longer before trying again
await this._sleep(30000);
}
yield;
await this._sleep(10000);
}
}
}

View File

@@ -0,0 +1,232 @@
/**
* MiningState - Autonomous mining with intelligent exploration
* Mines ores, explores caves, manages inventory
*/
import { BaseState } from './BaseState.js';
const VALUABLE_BLOCKS = new Set([
'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:gold_ore',
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:redstone_ore',
'minecraft:lapis_ore', 'minecraft:copper_ore',
'minecraft:deepslate_coal_ore', 'minecraft:deepslate_iron_ore',
'minecraft:deepslate_gold_ore', 'minecraft:deepslate_diamond_ore',
'minecraft:deepslate_emerald_ore', 'minecraft:deepslate_redstone_ore',
'minecraft:deepslate_lapis_ore', 'minecraft:deepslate_copper_ore',
'minecraft:nether_gold_ore', 'minecraft:nether_quartz_ore',
'minecraft:ancient_debris',
]);
export class MiningState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.maxDistance = data.maxDistance || 200;
this.minFuel = data.minFuel || 500;
this.blocksMined = 0;
this.oresFound = 0;
this.stuckCounter = 0;
this.visitedPositions = new Set();
this.miningArea = data.miningArea || null; // Optional bounded area
}
get name() {
return 'mining';
}
get description() {
return `Mining - ${this.blocksMined} mined, ${this.oresFound} ores found`;
}
async *act() {
console.log(`[${this.turtle.id}] Starting mining operation`);
while (!this.cancelled) {
try {
// Safety checks
const fuel = await this.checkFuel();
if (fuel !== 'unlimited' && fuel < this.minFuel) {
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
const refueled = await this.tryRefuel();
if (!refueled) {
console.log(`[${this.turtle.id}] Cannot refuel, going home`);
this.turtle.setState('goHome', { reason: 'low_fuel' });
return;
}
}
// Check inventory
const isFull = await this.isInventoryFull();
if (isFull) {
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'mining', returnData: this.data });
return;
}
// Check distance from home
if (this._isTooFar()) {
console.log(`[${this.turtle.id}] Too far from home, returning`);
this.turtle.setState('goHome', { reason: 'too_far' });
return;
}
// Mark current position as visited
const pos = this.turtle.position;
if (pos) {
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
}
// Scan surroundings for ores
const scanResult = await this.scanSurroundings();
// Mine any ores found in surroundings
yield* this._mineAdjacentOres();
// Explore step - try to find new areas
yield* this._exploreStep();
} catch (error) {
const isTimeout = error.message?.includes('timed out');
if (isTimeout) {
console.warn(`[${this.turtle.id}] Mining exec timeout, will retry next iteration`);
await this._sleep(3000);
} else {
throw error;
}
}
yield;
await this._sleep(200);
}
}
/**
* Mine ores in adjacent positions
*/
async *_mineAdjacentOres() {
// Check all 6 directions for ores
const directions = [
{ check: 'turtle.inspect()', dig: 'turtle.dig()', dir: 'forward' },
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()', dir: 'up' },
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()', dir: 'down' },
];
for (const { check, dig, dir } of directions) {
if (this.cancelled) return;
const result = await this.exec(`
local hasBlock, data = ${check}
if hasBlock then
return {name = data.name, metadata = data.metadata or 0}
end
return nil
`);
if (result && VALUABLE_BLOCKS.has(result.name)) {
console.log(`[${this.turtle.id}] Found ore: ${result.name} (${dir})`);
await this.exec(dig);
this.blocksMined++;
this.oresFound++;
// Report mined block to server
this.turtle.emit('blockMined', { blockType: result.name, direction: dir });
yield;
}
}
}
/**
* Explore step - move to unvisited positions, favor horizontal movement
*/
async *_exploreStep() {
const pos = this.turtle.position;
if (!pos) return;
const r = Math.random() * 100;
// 75%: horizontal exploration
if (r < 75) {
// Try to find an unvisited forward direction
let moved = false;
// Try current facing first
const forwardPos = this.turtle.getBlockPositionInDirection('forward');
if (forwardPos && !this.visitedPositions.has(`${forwardPos.x},${forwardPos.y},${forwardPos.z}`)) {
const success = await this.moveForward(true);
if (success) {
this.stuckCounter = 0;
moved = true;
}
}
// If forward is visited or blocked, try other directions
if (!moved) {
for (let i = 0; i < 4; i++) {
await this.exec('turtle.turnRight()');
this.turtle.facing = (this.turtle.facing + 1) % 4;
const newForwardPos = this.turtle.getBlockPositionInDirection('forward');
if (newForwardPos && !this.visitedPositions.has(`${newForwardPos.x},${newForwardPos.y},${newForwardPos.z}`)) {
const success = await this.moveForward(true);
if (success) {
this.stuckCounter = 0;
moved = true;
break;
}
}
}
}
if (!moved) {
// All directions visited, just move forward
const success = await this.moveForward(true);
if (!success) {
this.stuckCounter++;
await this.exec('turtle.turnRight()');
this.turtle.facing = (this.turtle.facing + 1) % 4;
}
}
// 15%: go down (if not too deep)
} else if (r < 90 && pos.y > -50) {
const downPos = { x: pos.x, y: pos.y - 1, z: pos.z };
if (!this.visitedPositions.has(`${downPos.x},${downPos.y},${downPos.z}`)) {
await this.moveDown(true);
}
// 10%: go up
} else {
const upPos = { x: pos.x, y: pos.y + 1, z: pos.z };
if (!this.visitedPositions.has(`${upPos.x},${upPos.y},${upPos.z}`)) {
await this.moveUp(true);
}
}
// Handle being stuck
if (this.stuckCounter > 5) {
console.log(`[${this.turtle.id}] Stuck! Trying to escape`);
await this.moveUp(true);
this.stuckCounter = 0;
}
yield;
}
_isTooFar() {
const pos = this.turtle.position;
const home = this.turtle.homePosition;
if (!pos || !home) return false;
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
return dist > this.maxDistance;
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
maxDistance: this.maxDistance,
minFuel: this.minFuel,
miningArea: this.miningArea,
},
};
}
}

View File

@@ -0,0 +1,78 @@
/**
* MovingState - Navigate the turtle to a target position using D* Lite
*/
import { BaseState } from './BaseState.js';
export class MovingState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.target = data.target; // {x, y, z}
this.onArrival = data.onArrival || null; // Callback/next state when arrived
this.canMine = data.canMine !== false;
}
get name() {
return 'moving';
}
get description() {
const pos = this.target;
if (pos) {
return `Moving to (${pos.x}, ${pos.y}, ${pos.z})`;
}
return 'Moving to target';
}
async *act() {
if (!this.target) {
console.log(`[${this.turtle.id}] MovingState: No target set`);
this.turtle.setState('idle');
return;
}
console.log(`[${this.turtle.id}] Moving to ${this.target.x},${this.target.y},${this.target.z}`);
// Use the navigateTo generator from BaseState
const nav = this.navigateTo(this.target, { canMine: this.canMine });
for await (const _ of nav) {
if (this.cancelled) return;
yield;
}
// Check if we actually arrived
const pos = this.turtle.position;
if (pos) {
const { Point } = await import('../pathfinding/index.js');
const current = Point.from(pos);
const goal = Point.from(this.target);
if (current.distanceTo(goal) <= 1) {
console.log(`[${this.turtle.id}] Arrived at destination`);
if (this.onArrival === 'idle') {
this.turtle.setState('idle');
} else if (this.onArrival === 'mine') {
this.turtle.setState('mining', this.data);
} else {
this.turtle.setState('idle');
}
return;
}
}
console.log(`[${this.turtle.id}] Failed to reach destination`);
this.turtle.setState('idle');
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
target: this.target,
onArrival: this.onArrival,
canMine: this.canMine,
},
};
}
}

View File

@@ -0,0 +1,126 @@
/**
* RefuelingState - Refuel the turtle from inventory or nearby containers
* Prioritizes fuel sources: lava buckets > coal blocks > coal > logs > sticks
*/
import { BaseState } from './BaseState.js';
const FUEL_PRIORITY = [
'minecraft:lava_bucket',
'minecraft:coal_block',
'minecraft:charcoal',
'minecraft:coal',
'minecraft:oak_log', 'minecraft:birch_log', 'minecraft:spruce_log',
'minecraft:dark_oak_log', 'minecraft:jungle_log', 'minecraft:acacia_log',
'minecraft:mangrove_log', 'minecraft:cherry_log',
'minecraft:oak_planks', 'minecraft:birch_planks', 'minecraft:spruce_planks',
'minecraft:stick',
];
export class RefuelingState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.returnState = data.returnState || null;
this.returnData = data.returnData || {};
this.targetFuel = data.targetFuel || 5000;
}
get name() {
return 'refueling';
}
get description() {
return 'Refueling turtle';
}
async *act() {
console.log(`[${this.turtle.id}] Starting refueling, target: ${this.targetFuel}`);
// First try to refuel from own inventory using priority order
const refueled = await this.exec(`
local fuelPriority = ${this._luaFuelTable()}
local targetFuel = ${this.targetFuel}
local refueled = false
for _, fuelName in ipairs(fuelPriority) do
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item and item.name == fuelName then
turtle.select(slot)
turtle.refuel()
refueled = true
if turtle.getFuelLevel() >= targetFuel then
turtle.select(1)
return {success = true, fuel = turtle.getFuelLevel()}
end
end
end
end
turtle.select(1)
return {success = refueled, fuel = turtle.getFuelLevel()}
`);
if (refueled) {
this.turtle.fuel = refueled.fuel;
console.log(`[${this.turtle.id}] Refueled to ${refueled.fuel}`);
}
yield;
// Try to refuel from nearby containers (chests, barrels)
if (!refueled || !refueled.success || refueled.fuel < this.targetFuel) {
console.log(`[${this.turtle.id}] Trying nearby containers for fuel`);
const containerResult = await this.exec(`
local directions = {"front", "top", "bottom"}
local suckFns = {turtle.suck, turtle.suckUp, turtle.suckDown}
for i, dir in ipairs(directions) do
-- Try to suck items from container
for attempt = 1, 16 do
if suckFns[i]() then
-- Check if it's fuel
local slot = turtle.getSelectedSlot()
if turtle.refuel(0) then
turtle.refuel()
end
else
break
end
end
end
return {fuel = turtle.getFuelLevel()}
`);
if (containerResult) {
this.turtle.fuel = containerResult.fuel;
}
}
yield;
// Transition to next state
if (this.returnState) {
this.turtle.setState(this.returnState, this.returnData);
} else {
this.turtle.setState('idle');
}
}
_luaFuelTable() {
const items = FUEL_PRIORITY.map(name => `"${name}"`).join(', ');
return `{${items}}`;
}
getRecoveryData() {
return {
...super.getRecoveryData(),
data: {
returnState: this.returnState,
returnData: this.returnData,
targetFuel: this.targetFuel,
},
};
}
}

184
server/states/ScanState.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* ScanState - Systematic scanning of surroundings using peripheral scanners
*
* Supports multiple scanner types:
* - geoScanner (Advanced Peripherals)
* - universal_scanner
* - plethora:scanner (Plethora mod)
* - Native turtle.inspect() fallback (6-directional scan)
*/
import { BaseState } from './BaseState.js';
export class ScanState extends BaseState {
constructor(turtle, data = {}) {
super(turtle, data);
this.scanRadius = data.scanRadius || 16;
this.blocksScanned = 0;
this.scannerType = null;
}
get name() {
return 'scanning';
}
get description() {
return `Scanning (${this.scannerType || 'detecting'}) - ${this.blocksScanned} blocks`;
}
async *act() {
console.log(`[${this.turtle.id}] Starting scan`);
// Detect available scanner peripheral
this.scannerType = await this._detectScanner();
yield;
if (this.scannerType === 'geoScanner') {
yield* this._geoScan();
} else if (this.scannerType === 'universal_scanner') {
yield* this._universalScan();
} else if (this.scannerType === 'plethora:scanner') {
yield* this._plethuraScan();
} else {
// Fallback to native inspect-based scanning
yield* this._nativeScan();
}
console.log(`[${this.turtle.id}] Scan complete: ${this.blocksScanned} blocks`);
this.turtle.setState('idle');
}
async _detectScanner() {
// Check for geoScanner
const geoResult = await this.exec(`
local names = peripheral.getNames()
for _, name in ipairs(names) do
local types = {peripheral.getType(name)}
for _, t in ipairs(types) do
if t == "geoScanner" then return "geoScanner" end
end
end
return nil
`);
if (geoResult) return geoResult;
// Check for universal_scanner
const uniResult = await this.exec(`
local names = peripheral.getNames()
for _, name in ipairs(names) do
local types = {peripheral.getType(name)}
for _, t in ipairs(types) do
if t == "universal_scanner" then return "universal_scanner" end
end
end
return nil
`);
if (uniResult) return uniResult;
// Check for plethora scanner
const plResult = await this.exec(`
local names = peripheral.getNames()
for _, name in ipairs(names) do
local types = {peripheral.getType(name)}
for _, t in ipairs(types) do
if t == "plethora:scanner" then return "plethora:scanner" end
end
end
return nil
`);
if (plResult) return plResult;
return 'native';
}
async *_geoScan() {
// Wait for cooldown
yield;
await this._sleep(500);
const result = await this.exec(`
local scanner = peripheral.find("geoScanner")
if not scanner then return nil end
local ok, blocks = pcall(scanner.scan, ${this.scanRadius})
if ok then return blocks end
return nil
`);
if (result && Array.isArray(result)) {
this._processScannedBlocks(result);
}
yield;
}
async *_universalScan() {
yield;
await this._sleep(500);
const result = await this.exec(`
local scanner = peripheral.find("universal_scanner")
if not scanner then return nil end
local ok, blocks = pcall(scanner.scan, "block", ${this.scanRadius})
if ok then return blocks end
return nil
`);
if (result && Array.isArray(result)) {
this._processScannedBlocks(result);
}
yield;
}
async *_plethuraScan() {
yield;
const result = await this.exec(`
local scanner = peripheral.find("plethora:scanner")
if not scanner then return nil end
local ok, blocks = pcall(scanner.scan)
if ok then return blocks end
return nil
`);
if (result && Array.isArray(result)) {
this._processScannedBlocks(result);
}
yield;
}
async *_nativeScan() {
// Fallback: scan all 6 directions using turtle.inspect
const scanResult = await this.scanSurroundings();
if (scanResult) {
this.blocksScanned += Object.keys(scanResult).length;
}
yield;
}
_processScannedBlocks(blocks) {
const pos = this.turtle.position;
if (!pos) return;
const discoveredBlocks = [];
for (const block of blocks) {
if (!block || !block.name) continue;
if (block.name === 'minecraft:air') continue;
if (block.x === 0 && block.y === 0 && block.z === 0) continue;
discoveredBlocks.push({
x: pos.x + (block.x || 0),
y: pos.y + (block.y || 0),
z: pos.z + (block.z || 0),
name: block.name,
metadata: block.metadata || 0,
state: block.state || {},
tags: block.tags || {},
discoveredBy: this.turtle.id,
});
}
this.blocksScanned = discoveredBlocks.length;
if (discoveredBlocks.length > 0) {
this.turtle.emit('blocksDiscovered', discoveredBlocks);
}
}
}

13
server/states/index.js Normal file
View File

@@ -0,0 +1,13 @@
export { BaseState } from './BaseState.js';
export { IdleState } from './IdleState.js';
export { MovingState } from './MovingState.js';
export { MiningState } from './MiningState.js';
export { ExploringState } from './ExploringState.js';
export { GoHomeState } from './GoHomeState.js';
export { RefuelingState } from './RefuelingState.js';
export { DumpInventoryState } from './DumpInventoryState.js';
export { FarmingState } from './FarmingState.js';
export { ScanState } from './ScanState.js';
export { ExtractionState } from './ExtractionState.js';
export { BuildingState } from './BuildingState.js';
export { AutocraftState } from './AutocraftState.js';

1480
turtle.lua

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff