Compare commits

..

217 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
MayaTheShy
9796f73e64 feat: Implement PriorityQueue class for D* Lite pathfinding with min-heap functionality 2026-02-20 01:41:01 -05:00
MayaTheShy
6da90fc560 feat: Add pathfinding module exports for Point, Node, PriorityQueue, and DStarLite 2026-02-20 01:40:53 -05:00
MayaTheShy
afc4c1f97a feat: Implement D* Lite pathfinding algorithm with node management and dynamic replanning 2026-02-20 01:40:48 -05:00
MayaTheShy
9a294f0c98 feat: Add Point class to represent 3D coordinates with distance calculations and neighbor retrieval 2026-02-20 01:40:39 -05:00
MayaTheShy
1f803fbde6 feat: Implement Node class for D* Lite pathfinding algorithm with traversal cost calculation 2026-02-20 01:40:32 -05:00
MayaTheShy
207f6901d6 fix: Update mining stats and top miners endpoints to return structured data for single and multiple turtles 2026-02-20 01:32:35 -05:00
MayaTheShy
d2e0deb945 fix: Add UNIQUE constraint to mining blocks table for turtle_id, block_type, and session_start 2026-02-20 01:32:01 -05:00
MayaTheShy
9fd9180087 fix: Update loadTasks function to handle server responses with flat arrays or single task objects 2026-02-20 01:31:56 -05:00
MayaTheShy
558fb92c41 fix: Update miningStats and topMiners loading logic to handle single object responses and ensure correct state setting 2026-02-20 01:31:49 -05:00
MayaTheShy
bd68a79cd5 feat: Enhance PathRecorder component; implement path playback functionality and improve path loading logic 2026-02-20 01:31:45 -05:00
MayaTheShy
f0afbca74b fix: Handle server response for mining areas; ensure areas state is set correctly for non-array responses 2026-02-20 01:31:40 -05:00
MayaTheShy
35c76cbdca fix: Ensure groups state is set correctly by handling non-array responses in loadGroups function 2026-02-20 01:31:36 -05:00
MayaTheShy
7a3b30bbbf feat: Enhance mining area API; add formatting helper, support for multiple input formats, and new endpoints for updating and deleting areas 2026-02-20 01:29:29 -05:00
MayaTheShy
d7433b8bcc feat: Enhance path and task management APIs; add detailed responses and optional filters 2026-02-20 01:29:03 -05:00
MayaTheShy
3c0f72acf1 feat: Enhance database functions for turtle paths and mining areas; add support for optional parameters and improve data handling 2026-02-20 01:28:54 -05:00
MayaTheShy
75b2088b4f feat: Update API and WebSocket URLs to use environment variables for better configuration 2026-02-20 01:28:41 -05:00
MayaTheShy
0eac9497de feat: Implement block discovery endpoint and update worldBlocks state on discovery 2026-02-20 01:23:05 -05:00
MayaTheShy
855874576d fix: Update turtle state references to use mode instead; adjust command transmission channels 2026-02-20 01:22:58 -05:00
MayaTheShy
0c925036d9 feat: Add action buttons for turtle commands in control mode 2026-02-20 01:22:51 -05:00
MayaTheShy
68e21d9c82 feat: Update installation instructions in README; add auto-update system details and wireless control interface 2026-02-20 01:15:37 -05:00
MayaTheShy
1cccfb5baa feat: Update README for Docker installation; add setup instructions and commands 2026-02-20 01:13:07 -05:00
MayaTheShy
4bd999d394 feat: Forward discovered blocks to server; log block details and handle errors 2026-02-20 01:06:09 -05:00
MayaTheShy
8d66ef637e feat: Implement comprehensive block scanning; report discovered blocks to server 2026-02-20 01:05:18 -05:00
MayaTheShy
5a4fd000fe feat: Enhance debugging output; log open channels and modem messages in the main event loop 2026-02-20 01:00:01 -05:00
MayaTheShy
86250deba3 feat: Improve command processing output; list command names instead of serializing the commands table 2026-02-20 00:59:19 -05:00
59 changed files with 11333 additions and 2927 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?** **GPS not working?**
- Set up 4 GPS host computers at high altitude - Set up 4 GPS host computers at high altitude
- Run `gps host X Y Z` on each (with their coordinates) - 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 ## Next Steps

130
README.md
View File

@@ -52,54 +52,118 @@ A comprehensive full-stack web application for monitoring and controlling Comput
### Prerequisites ### Prerequisites
- Node.js 18+ installed on your computer - **Docker and Docker Compose** installed on your computer
- Minecraft with ComputerCraft mod installed - **Minecraft** with ComputerCraft mod installed
- At least one turtle with a wireless modem - At least one **turtle with a wireless modem**
- A computer in Minecraft to run the bridge script - A **computer in Minecraft** to run the bridge script
### Installation ### Installation (Docker - Recommended)
1. **Clone or download this project** 1. **Clone or download this project**
2. **Install Server Dependencies**
```bash ```bash
cd server git clone <repository-url>
npm install cd remoteturtle
``` ```
3. **Install Client Dependencies** 2. **Start with Docker Compose**
```bash ```bash
cd client docker-compose up -d
npm install
``` ```
This will automatically:
- Build and start the backend server on port 3001
- Build and start the frontend on port 3000
- Create a persistent SQLite database volume
- Set up networking between containers
4. **Start the Backend Server** 3. **Access the Web Interface**
```bash - Frontend: http://localhost:3000
cd server - Backend API: http://localhost:3001
npm start
```
Server will run on:
- HTTP: http://localhost:3001
- WebSocket: ws://localhost:3002 - WebSocket: ws://localhost:3002
5. **Start the React Frontend** 4. **Set up Minecraft Bridge Computer**
```bash
cd client In Minecraft, place a computer with a wireless modem and run:
npm run dev ```lua
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
reboot
``` ```
Frontend will open at: http://localhost:3000
This installs an auto-update system that:
6. **Set up Minecraft Bridge** - Downloads the latest `webbridge.lua` on boot
- In Minecraft, place a computer with a wireless modem - Falls back to cached version if download fails
- Automatically connects to your server
Or manually:
- Copy `webbridge.lua` to the computer - Copy `webbridge.lua` to the computer
- Edit the `SERVER_URL` in webbridge.lua to point to your server - Edit the `SERVER_URL` to point to your Docker host
(If running locally, use `http://localhost:3001`) - If Minecraft is on the same machine: `http://host.docker.internal:3001`
- If on different machine: `http://<your-ip>:3001`
- Run: `webbridge` - Run: `webbridge`
7. **Deploy Turtles** 5. **Deploy Auto-Updating Turtles**
- Copy `turtle.lua` to your turtles
- Equip wireless modems on turtles On each turtle with a wireless modem, run:
- Run: `turtle` ```lua
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
reboot
```
This installs an auto-update system that:
- Downloads the latest `turtle.lua` on boot
- Falls back to cached version if download fails
- Keeps turtles updated automatically
6. **Optional: Deploy Pocket Computer Control**
For wireless pocket computer control:
```lua
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
reboot
```
This provides a wireless control interface for managing turtles from anywhere in-game
### Docker Commands
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild after code changes
docker-compose up -d --build
# Stop and remove volumes (resets database)
docker-compose down -v
```
### Alternative: Manual Installation (Without Docker)
If you prefer not to use Docker:
1. **Install Dependencies**
```bash
cd server && npm install
cd ../client && npm install
```
2. **Start Backend**
```bash
cd server && npm start
```
3. **Start Frontend**
```bash
cd client && npm run dev
```
4. Follow steps 4-6 from the Docker installation above
## 📁 Project Structure ## 📁 Project Structure

View File

@@ -4,37 +4,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} background: #2c2c2c;
.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);
} }
.app-content { .app-content {
@@ -57,21 +27,13 @@
width: 50%; width: 50%;
} }
.app-content.map .panel-container {
display: none;
}
.app-content.panel .map-container {
display: none;
}
.map-container { .map-container {
position: relative; position: relative;
background: #0a0e1a; background: #2c2c2c;
} }
.panel-container { .panel-container {
background: #0f172a; background: #2c2c2c;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -81,34 +43,62 @@
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
padding: 0.5rem; padding: 0.5rem;
background: #1e293b; background: #3b3b3b;
border-bottom: 2px solid #334155; border-bottom: 3px solid #1a1a1a;
overflow-x: auto; overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
} }
.panel-tabs button { .panel-tabs button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: 2px solid #1a1a1a;
background: #334155; background: #5a5a5a;
color: #94a3b8; color: #b0b0b0;
border-radius: 0.375rem; border-radius: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.1s;
white-space: nowrap; 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 { .panel-tabs button:hover {
background: #475569; background: #6b6b6b;
color: #e5e7eb; color: #e5e7eb;
} }
.panel-tabs button.active { .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; color: white;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
} }
.panel-content-wrapper { .panel-content-wrapper {
@@ -125,16 +115,16 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #1e293b; background: #2c2c2c;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #475569; background: #5a5a5a;
border-radius: 4px; border: 1px solid #1a1a1a;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #64748b; background: #6b6b6b;
} }
/* Mobile-Responsive Design */ /* Mobile-Responsive Design */
@@ -149,28 +139,9 @@
width: 100%; width: 100%;
flex: 1; flex: 1;
} }
.view-controls {
flex-wrap: wrap;
}
} }
@media (max-width: 768px) { @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 { .app-content.split {
flex-direction: column; flex-direction: column;
} }
@@ -191,17 +162,6 @@
} }
@media (max-width: 480px) { @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 { .app-content.split .map-container {
height: 40vh; height: 40vh;
} }
@@ -270,10 +230,6 @@
/* High contrast mode */ /* High contrast mode */
@media (prefers-contrast: high) { @media (prefers-contrast: high) {
.view-controls button {
border: 2px solid currentColor;
}
.turtle-card { .turtle-card {
border-width: 3px; 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 Map3D from './components/Map3D';
import ControlPanel from './components/ControlPanel'; import ControlPanel from './components/ControlPanel';
import VoiceControl from './components/VoiceControl'; import VoiceControl from './components/VoiceControl';
@@ -12,18 +12,19 @@ import './App.css';
function App() { function App() {
const connect = useTurtleStore((state) => state.connect); 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 [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
const turtles = useTurtleStore((state) => state.getTurtleArray()); const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle()); const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
useEffect(() => { useEffect(() => {
connect(); connect();
}, [connect]); }, [connect]);
const renderPanelContent = () => { const renderPanelContent = () => {
const apiUrl = 'http://localhost:3001'; const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
const wsUrl = 'ws://localhost:3002'; const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
switch (panelTab) { switch (panelTab) {
case 'control': case 'control':
@@ -47,36 +48,21 @@ function App() {
return ( return (
<div className="app"> <div className="app">
<div className="view-controls"> <div className="app-content split">
<button <div className="map-container">
className={view === 'split' ? 'active' : ''} <Map3D />
onClick={() => setView('split')} </div>
> <div className="panel-container">
📊 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="panel-tabs"> <div className="panel-tabs">
<a
href={inventoryDashboardUrl}
className="cross-link-btn"
title="Open Inventory Manager Dashboard"
target="_blank"
rel="noopener noreferrer"
>
📦 Inventory
</a>
<button <button
className={panelTab === 'control' ? 'active' : ''} className={panelTab === 'control' ? 'active' : ''}
onClick={() => setPanelTab('control')} onClick={() => setPanelTab('control')}
@@ -131,7 +117,6 @@ function App() {
{renderPanelContent()} {renderPanelContent()}
</div> </div>
</div> </div>
)}
</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 { useTurtleStore } from '../store/turtleStore';
import './ControlPanel.css'; import './ControlPanel.css';
function TurtleCard({ turtle, isSelected, onSelect }) { 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 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 = { const modeColors = {
mining: '#4ade80', mining: '#55ff55',
exploring: '#60a5fa', exploring: '#55ffff',
returning: '#f59e0b', returning: '#ffaa00',
idle: '#9ca3af', goHome: '#ffaa00',
manual: '#a78bfa', idle: '#aaaaaa',
unknown: '#6b7280' manual: '#ff55ff',
refueling: '#ff5555',
farming: '#55ff55',
dumpInventory: '#aa00aa',
dumping: '#aa00aa',
moving: '#55ffff',
scan: '#5555ff',
extraction: '#ffaa00',
building: '#00aaaa',
autocraft: '#ff55ff',
unknown: '#555555'
}; };
return ( return (
<div <div
className={`turtle-card ${isSelected ? 'selected' : ''}`} className={`turtle-card ${isSelected ? 'selected' : ''}`}
onClick={onSelect} onClick={onSelect}
style={{ borderColor: modeColors[mode] }} style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
> >
<div className="turtle-header"> <div className="turtle-header">
<h3>Turtle {turtle.turtleID}</h3> <h3>{displayName}</h3>
<span className="mode-badge" style={{ background: modeColors[mode] }}> <span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
{mode} {activeState}
</span> </span>
</div> </div>
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
} }
function TurtleDetails({ turtle }) { 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) { if (!turtle) {
return ( return (
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
); );
} }
const handleCommand = (command, param = null) => { const handleStateChange = (stateName, data = {}) => {
sendCommand(turtle.turtleID, command, param); 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 ( return (
<div className="turtle-details"> <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"> <div className="detail-section">
<h3>Status</h3> <h3>Status</h3>
<div className="status-grid"> <div className="status-grid">
<div className="status-item"> <div className="status-item">
<span className="label">Mode:</span> <span className="label">State:</span>
<span className="value">{turtle.mode || 'unknown'}</span> <span className="value">{activeState}</span>
</div> </div>
{turtle.stateDescription && (
<div className="status-item">
<span className="label">Activity:</span>
<span className="value">{turtle.stateDescription}</span>
</div>
)}
<div className="status-item"> <div className="status-item">
<span className="label">Fuel:</span> <span className="label">Fuel:</span>
<span className="value"> <span className="value">
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel} {turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
</span> </span>
</div> </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"> <div className="status-item">
<span className="label">Position:</span> <span className="label">Position:</span>
<span className="value"> <span className="value">
@@ -115,67 +224,168 @@ function TurtleDetails({ turtle }) {
} }
</span> </span>
</div> </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>
</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"> <div className="detail-section">
<h3>Commands</h3> <h3>State Machine</h3>
<div className="command-grid"> <div className="command-grid">
<button <button
className="command-btn explore" className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
onClick={() => handleCommand('explore')} onClick={() => handleStateChange('idle')}
title="Start autonomous exploration and mining" 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 🔍 Explore
</button> </button>
<button <button
className="command-btn mine" className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
onClick={() => handleCommand('mine')} onClick={() => handleStateChange('mining')}
title="Start mining mode" title="Mining with ore priority"
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
> >
Mine Mine
</button> </button>
<button <button
className="command-btn return" className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
onClick={() => handleCommand('returnHome')} onClick={() => handleStateChange('farming')}
title="Navigate back to home position" title="Automated farming"
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
> >
🏠 Return Home 🌾 Farm
</button> </button>
<button <button
className="command-btn stop" className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
onClick={() => handleCommand('stop')} onClick={() => handleStateChange('goHome')}
title="Stop all autonomous actions" title="Navigate home"
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
> >
Stop 🏠 Go Home
</button> </button>
<button <button
className="command-btn manual" className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
onClick={() => handleCommand('manual')} onClick={() => handleStateChange('refueling')}
title="Switch to manual control mode" title="Auto-refuel from inventory"
> style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
🎮 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"
> >
Refuel Refuel
</button> </button>
<button <button
className="command-btn status" className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
onClick={() => handleCommand('status')} onClick={() => handleStateChange('dumpInventory')}
title="Request status update" 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> </button>
</div> </div>
</div> </div>
@@ -184,16 +394,16 @@ function TurtleDetails({ turtle }) {
<h3>Movement</h3> <h3>Movement</h3>
<div className="movement-controls"> <div className="movement-controls">
<div className="movement-row"> <div className="movement-row">
<button onClick={() => handleCommand('forward')} title="Move forward"></button> <button onClick={() => moveForward(turtle.turtleID)} title="Move forward"></button>
</div> </div>
<div className="movement-row"> <div className="movement-row">
<button onClick={() => handleCommand('turnLeft')} title="Turn left"></button> <button onClick={() => turnLeft(turtle.turtleID)} title="Turn left"></button>
<button onClick={() => handleCommand('back')} title="Move backward"></button> <button onClick={() => moveBack(turtle.turtleID)} title="Move backward"></button>
<button onClick={() => handleCommand('turnRight')} title="Turn right"></button> <button onClick={() => turnRight(turtle.turtleID)} title="Turn right"></button>
</div> </div>
<div className="movement-row vertical"> <div className="movement-row vertical">
<button onClick={() => handleCommand('up')} title="Move up"> Up</button> <button onClick={() => moveUp(turtle.turtleID)} title="Move up"> Up</button>
<button onClick={() => handleCommand('down')} title="Move down"> Down</button> <button onClick={() => moveDown(turtle.turtleID)} title="Move down"> Down</button>
</div> </div>
</div> </div>
</div> </div>
@@ -203,28 +413,28 @@ function TurtleDetails({ turtle }) {
<div className="action-grid"> <div className="action-grid">
<button <button
className="action-btn dig" className="action-btn dig"
onClick={() => handleCommand('dig')} onClick={() => digBlock(turtle.turtleID)}
title="Dig block in front" title="Dig block in front"
> >
Dig Dig
</button> </button>
<button <button
className="action-btn digup" className="action-btn digup"
onClick={() => handleCommand('digUp')} onClick={() => digBlockUp(turtle.turtleID)}
title="Dig block above" title="Dig block above"
> >
Dig Up Dig Up
</button> </button>
<button <button
className="action-btn digdown" className="action-btn digdown"
onClick={() => handleCommand('digDown')} onClick={() => digBlockDown(turtle.turtleID)}
title="Dig block below" title="Dig block below"
> >
Dig Down Dig Down
</button> </button>
<button <button
className="action-btn place" className="action-btn place"
onClick={() => handleCommand('place')} onClick={() => placeBlock(turtle.turtleID)}
title="Place block from inventory" title="Place block from inventory"
> >
🧱 Place 🧱 Place
@@ -232,17 +442,86 @@ function TurtleDetails({ turtle }) {
</div> </div>
</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 && ( {turtle.inventory && turtle.inventory.length > 0 && (
<div className="detail-section"> <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"> <div className="inventory-grid">
{Array.from({ length: 16 }, (_, slotIndex) => { {Array.from({ length: 16 }, (_, slotIndex) => {
const item = turtle.inventory[slotIndex]; const item = turtle.inventory[slotIndex];
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
return ( return (
<div <div
key={slotIndex} key={slotIndex}
className={`inventory-slot ${item ? 'filled' : 'empty'}`} className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count})` : 'Empty'} 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 ? ( {item ? (
<> <>

View File

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

View File

@@ -10,14 +10,14 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const colorPresets = [ const colorPresets = [
{ name: 'Blue', value: '#3b82f6' }, { name: 'Blue', value: '#345ec3' },
{ name: 'Green', value: '#10b981' }, { name: 'Green', value: '#4a8c2a' },
{ name: 'Red', value: '#ef4444' }, { name: 'Red', value: '#aa0000' },
{ name: 'Yellow', value: '#f59e0b' }, { name: 'Yellow', value: '#ffaa00' },
{ name: 'Purple', value: '#8b5cf6' }, { name: 'Purple', value: '#7b2fbe' },
{ name: 'Pink', value: '#ec4899' }, { name: 'Pink', value: '#d4658a' },
{ name: 'Cyan', value: '#06b6d4' }, { name: 'Cyan', value: '#55ffff' },
{ name: 'Orange', value: '#f97316' }, { name: 'Orange', value: '#c97a2a' },
]; ];
useEffect(() => { useEffect(() => {
@@ -33,7 +33,7 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
const response = await fetch(`${apiUrl}/api/groups`); const response = await fetch(`${apiUrl}/api/groups`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setGroups(data); setGroups(Array.isArray(data) ? data : (data.groups || []));
} }
} catch (error) { } catch (error) {
console.error('Failed to load groups:', error); console.error('Failed to load groups:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
/* ============================================
Minecraft-Themed Mining Areas Panel
============================================ */
.mining-areas-panel { .mining-areas-panel {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0f172a; background: #2c2c2c;
color: #e2e8f0; color: #e0e0e0;
overflow: hidden; overflow: hidden;
font-family: 'Silkscreen', 'Courier New', monospace;
} }
.panel-header { .panel-header {
@@ -12,79 +17,86 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
background: #1e293b; background: #6b4e28;
border-bottom: 2px solid #334155; border-bottom: 3px solid #1a1a1a;
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
} }
.panel-header h2 { .panel-header h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #f8fafc; color: #ffff55;
text-shadow: 2px 2px 0 #1a1a1a;
} }
.btn-create { .btn-create {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #3b82f6; background: #4a8c2a;
color: white; color: white;
border: none; border: 2px solid #1a1a1a;
border-radius: 6px;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 700;
cursor: pointer; 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 { .btn-create:hover {
background: #2563eb; background: #5a9c3a;
transform: translateY(-1px); box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
} }
/* Filter buttons */ /* Filter buttons */
.filter-buttons { .filter-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.25rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
background: #1e293b; background: #3b3b3b;
border-bottom: 1px solid #334155; border-bottom: 2px solid #1a1a1a;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filter-btn { .filter-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #334155; background: #6b6b6b;
color: #94a3b8; color: #e0e0e0;
border: none; border: 2px solid #1a1a1a;
border-radius: 6px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.1s;
white-space: nowrap; 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 { .filter-btn:hover {
background: #475569; background: #7b7b7b;
color: #e2e8f0; box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
} }
.filter-btn.active { .filter-btn.active {
background: #3b82f6; background: #4a8c2a;
color: white; color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
} }
/* Create form */ /* Create form */
.create-form { .create-form {
padding: 1.5rem; padding: 1.5rem;
background: #1e293b; background: #3b3b3b;
border-bottom: 1px solid #334155; border-bottom: 2px solid #1a1a1a;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
} }
.create-form h3 { .create-form h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #f8fafc; color: #ffaa00;
text-shadow: 1px 1px 0 #1a1a1a;
} }
.form-group { .form-group {
@@ -94,28 +106,27 @@
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #cbd5e1; color: #e0e0e0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 600;
} }
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 0.625rem; padding: 0.625rem;
background: #0f172a; background: #1a1a1a;
border: 1px solid #334155; border: 2px solid #4b4b4b;
border-radius: 6px; color: #e0e0e0;
color: #e2e8f0;
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s; transition: all 0.1s;
font-family: 'Silkscreen', 'Courier New', monospace;
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: #55ffff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
.coordinates-section { .coordinates-section {
@@ -130,31 +141,34 @@
} }
.coordinates-header label { .coordinates-header label {
color: #cbd5e1; color: #e0e0e0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 600;
} }
.btn-use-position { .btn-use-position {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: #10b981; background: #4a8c2a;
color: white; color: white;
border: none; border: 2px solid #1a1a1a;
border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 700;
cursor: pointer; 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) { .btn-use-position:hover:not(:disabled) {
background: #059669; background: #5a9c3a;
} }
.btn-use-position:disabled { .btn-use-position:disabled {
background: #334155; background: #4b4b4b;
color: #64748b; color: #7b7b7b;
cursor: not-allowed; cursor: not-allowed;
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
} }
.coordinate-inputs { .coordinate-inputs {
@@ -170,21 +184,22 @@
.btn-submit { .btn-submit {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: #3b82f6; background: #4a8c2a;
color: white; color: white;
border: none; border: 2px solid #1a1a1a;
border-radius: 6px;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.1s;
margin-top: 1rem; 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 { .btn-submit:hover {
background: #2563eb; background: #5a9c3a;
transform: translateY(-1px); box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
} }
/* Areas list */ /* Areas list */
@@ -197,7 +212,7 @@
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
color: #64748b; color: #7b7b7b;
} }
.empty-state p { .empty-state p {
@@ -206,22 +221,21 @@
/* Area card */ /* Area card */
.area-card { .area-card {
background: #1e293b; background: #3b3b3b;
border: 1px solid #334155; border: 2px solid #1a1a1a;
border-radius: 8px;
padding: 1rem; padding: 1rem;
margin-bottom: 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 { .area-card:hover {
border-color: #475569; background: #4b4b4b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.area-card.has-conflict { .area-card.has-conflict {
border-color: #ef4444; border-color: #ff5555;
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%); background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
} }
.area-header { .area-header {
@@ -230,23 +244,24 @@
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid #334155; border-bottom: 2px solid #4b4b4b;
} }
.area-header h3 { .area-header h3 {
margin: 0; margin: 0;
color: #f8fafc; color: #e0e0e0;
font-size: 1.125rem; font-size: 1.125rem;
} }
.status-badge { .status-badge {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border: 2px solid #1a1a1a;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: white; color: white;
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
} }
/* Area info */ /* Area info */
@@ -259,7 +274,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 1px solid #334155; border-bottom: 2px solid #4b4b4b;
} }
.info-row:last-child { .info-row:last-child {
@@ -267,32 +282,32 @@
} }
.info-label { .info-label {
color: #94a3b8; color: #a0a0a0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 600;
} }
.info-value { .info-value {
color: #e2e8f0; color: #e0e0e0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
} }
.coordinate-value { .coordinate-value {
font-family: 'Courier New', monospace; font-family: 'Silkscreen', 'Courier New', monospace;
font-size: 0.75rem; font-size: 0.75rem;
word-break: break-all; word-break: break-all;
color: #55ffff;
} }
/* Conflict warning */ /* Conflict warning */
.conflict-warning { .conflict-warning {
background: #7f1d1d; background: #6b1a1a44;
border: 1px solid #ef4444; border: 2px solid #ff5555;
border-radius: 6px;
padding: 0.75rem; padding: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #fca5a5; color: #ff5555;
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -316,46 +331,45 @@
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: 2px solid #1a1a1a;
border-radius: 6px;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.1s;
white-space: nowrap; white-space: nowrap;
font-family: 'Silkscreen', 'Courier New', monospace;
text-shadow: 1px 1px 0 #1a1a1a;
} }
.btn-start { .btn-start {
background: #10b981; background: #4a8c2a;
color: white; color: white;
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
} }
.btn-start:hover { .btn-start:hover {
background: #059669; background: #5a9c3a;
transform: translateY(-1px); box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
} }
.btn-complete { .btn-complete {
background: #3b82f6; background: #345ec3;
color: white; color: white;
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
} }
.btn-complete:hover { .btn-complete:hover {
background: #2563eb; background: #4a6ed3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
} }
.btn-delete { .btn-delete {
background: #ef4444; background: #aa0000;
color: white; color: white;
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
} }
.btn-delete:hover { .btn-delete:hover {
background: #dc2626; background: #cc0000;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -366,18 +380,18 @@
.areas-list::-webkit-scrollbar-track, .areas-list::-webkit-scrollbar-track,
.create-form::-webkit-scrollbar-track { .create-form::-webkit-scrollbar-track {
background: #0f172a; background: #2c2c2c;
} }
.areas-list::-webkit-scrollbar-thumb, .areas-list::-webkit-scrollbar-thumb,
.create-form::-webkit-scrollbar-thumb { .create-form::-webkit-scrollbar-thumb {
background: #334155; background: #5a5a5a;
border-radius: 4px; border: 1px solid #1a1a1a;
} }
.areas-list::-webkit-scrollbar-thumb:hover, .areas-list::-webkit-scrollbar-thumb:hover,
.create-form::-webkit-scrollbar-thumb:hover { .create-form::-webkit-scrollbar-thumb:hover {
background: #475569; background: #6b6b6b;
} }
/* Responsive design */ /* Responsive design */
@@ -432,3 +446,62 @@
text-align: left; 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 [showCreateForm, setShowCreateForm] = useState(false);
const [newArea, setNewArea] = useState({ const [newArea, setNewArea] = useState({
areaName: '', areaName: '',
color: '#4a8c2a',
startX: '', startX: '',
startY: '', startY: '',
startZ: '', startZ: '',
@@ -28,7 +29,8 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
const response = await fetch(`${apiUrl}/api/mining-areas`); const response = await fetch(`${apiUrl}/api/mining-areas`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setAreas(data); // Server returns a flat array of formatted areas
setAreas(Array.isArray(data) ? data : (data.areas || []));
} }
} catch (error) { } catch (error) {
console.error('Failed to load mining areas:', error); console.error('Failed to load mining areas:', error);
@@ -65,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
endX: Number(newArea.endX), endX: Number(newArea.endX),
endY: Number(newArea.endY), endY: Number(newArea.endY),
endZ: Number(newArea.endZ), endZ: Number(newArea.endZ),
color: newArea.color,
status: 'planned' status: 'planned'
}) })
}); });
@@ -73,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
setShowCreateForm(false); setShowCreateForm(false);
setNewArea({ setNewArea({
areaName: '', areaName: '',
color: '#4a8c2a',
startX: '', startX: '',
startY: '', startY: '',
startZ: '', startZ: '',
@@ -191,12 +195,12 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
// Status badge component // Status badge component
const StatusBadge = ({ status }) => { const StatusBadge = ({ status }) => {
const colors = { const colors = {
planned: '#6366f1', planned: '#345ec3',
mining: '#f59e0b', mining: '#ffaa00',
completed: '#10b981' completed: '#4a8c2a'
}; };
return ( return (
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}> <span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
{status} {status}
</span> </span>
); );
@@ -258,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
/> />
</div> </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"> <div className="form-group">
<label>Assign Turtle:</label> <label>Assign Turtle:</label>
<select <select
@@ -363,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
return ( return (
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}> <div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
<div className="area-header"> <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} /> <StatusBadge status={area.status} />
</div> </div>

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTurtleStore } from '../store/turtleStore';
import './PathRecorder.css'; import './PathRecorder.css';
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => { const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
@@ -10,6 +11,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
const [selectedPath, setSelectedPath] = useState(null); const [selectedPath, setSelectedPath] = useState(null);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [playingBack, setPlayingBack] = useState(false);
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
useEffect(() => { useEffect(() => {
loadPaths(); loadPaths();
@@ -21,7 +25,7 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
const response = await fetch(`${apiUrl}/api/paths`); const response = await fetch(`${apiUrl}/api/paths`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setPaths(data); setPaths(Array.isArray(data) ? data : []);
} }
} catch (error) { } catch (error) {
console.error('Failed to load paths:', error); console.error('Failed to load paths:', error);
@@ -59,31 +63,18 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
} }
try { try {
// Create the path const response = await fetch(`${apiUrl}/api/paths`, {
const pathResponse = await fetch(`${apiUrl}/api/paths`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: currentPath.name,
turtleId: currentPath.turtleId, turtleId: currentPath.turtleId,
description: currentPath.description pathName: currentPath.name,
pathData: currentPath.waypoints
}) })
}); });
if (!pathResponse.ok) { if (!response.ok) {
throw new Error('Failed to create path'); throw new Error('Failed to save path');
}
const pathData = await pathResponse.json();
const pathId = pathData.pathId;
// Add all waypoints
for (const waypoint of currentPath.waypoints) {
await fetch(`${apiUrl}/api/paths/${pathId}/waypoints`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(waypoint)
});
} }
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success'); showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
@@ -132,9 +123,50 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
} }
}; };
const playbackPath = (path) => { const playbackPath = async (path) => {
showMessage(`Playback not yet implemented for path: ${path.name}`, 'info'); if (!selectedTurtle) {
// TODO: Implement playback by sending commands to turtle showMessage('Please select a turtle to play back this path', 'error');
return;
}
// Load full path data if we don't have waypoints
let waypoints = path.pathData || path.waypoints;
if (!waypoints || waypoints.length === 0) {
try {
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
if (response.ok) {
const data = await response.json();
waypoints = data.waypoints;
}
} catch (error) {
showMessage('Failed to load path data', 'error');
return;
}
}
if (!waypoints || waypoints.length < 2) {
showMessage('Path has insufficient waypoints for playback', 'error');
return;
}
setPlayingBack(true);
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
const turtleId = selectedTurtle.turtleID;
// Use server-side pathfinding to navigate to each waypoint sequentially
for (let i = 0; i < waypoints.length; i++) {
const wp = waypoints[i];
// 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! Turtle navigating via server.', 'success');
}; };
const showMessage = (text, type) => { const showMessage = (text, type) => {
@@ -276,8 +308,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
onClick={() => playbackPath(path)} onClick={() => playbackPath(path)}
className="play-btn" className="play-btn"
title="Playback path" title="Playback path"
disabled={playingBack}
> >
Play {playingBack ? '⏳ Playing...' : '▶️ Play'}
</button> </button>
<button <button
onClick={() => deletePath(path.pathId)} onClick={() => deletePath(path.pathId)}

View File

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

View File

@@ -30,6 +30,7 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// Response is either a single object (per turtle) or an array (all turtles)
setMiningStats(Array.isArray(data) ? data : [data]); setMiningStats(Array.isArray(data) ? data : [data]);
} }
} catch (error) { } catch (error) {
@@ -44,7 +45,8 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`); const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setTopMiners(data); // Response is now a flat array of {turtleId, totalBlocks, uniqueTypes}
setTopMiners(Array.isArray(data) ? data : (data.topMiners || []));
} }
} catch (error) { } catch (error) {
console.error('Failed to load top miners:', error); console.error('Failed to load top miners:', error);

View File

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

View File

@@ -44,7 +44,8 @@ const TaskPanel = ({ turtles, apiUrl }) => {
const response = await fetch(url); const response = await fetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setTasks(data); // Server returns flat array of formatted tasks
setTasks(Array.isArray(data) ? data : (data.tasks || []));
} }
} catch (error) { } catch (error) {
console.error('Failed to load tasks:', error); console.error('Failed to load tasks:', error);
@@ -156,19 +157,19 @@ const TaskPanel = ({ turtles, apiUrl }) => {
const getStatusColor = (status) => { const getStatusColor = (status) => {
switch (status) { switch (status) {
case 'pending': return '#94a3b8'; case 'pending': return '#a0a0a0';
case 'in_progress': return '#3b82f6'; case 'in_progress': return '#345ec3';
case 'completed': return '#10b981'; case 'completed': return '#4a8c2a';
case 'failed': return '#ef4444'; case 'failed': return '#aa0000';
default: return '#94a3b8'; default: return '#a0a0a0';
} }
}; };
const getPriorityLabel = (priority) => { const getPriorityLabel = (priority) => {
if (priority >= 8) return { label: 'Critical', color: '#ef4444' }; if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
if (priority >= 6) return { label: 'High', color: '#f59e0b' }; if (priority >= 6) return { label: 'High', color: '#ffaa00' };
if (priority >= 4) return { label: 'Medium', color: '#3b82f6' }; if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
return { label: 'Low', color: '#94a3b8' }; return { label: 'Low', color: '#a0a0a0' };
}; };
return ( return (

View File

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

View File

@@ -10,7 +10,18 @@ export default function VoiceControl() {
const [recognition, setRecognition] = useState(null); const [recognition, setRecognition] = useState(null);
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle()); 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(() => { useEffect(() => {
// Check if speech recognition is supported // Check if speech recognition is supported
@@ -49,52 +60,68 @@ export default function VoiceControl() {
} }
const turtleId = selectedTurtle.turtleID; const turtleId = selectedTurtle.turtleID;
let action = null; let actionName = null;
let param = null;
// Parse voice commands // Movement commands (server-side)
if (command.includes('forward') || command.includes('go ahead')) { if (command.includes('forward') || command.includes('go ahead')) {
action = 'forward'; moveForward(turtleId);
actionName = 'forward';
} else if (command.includes('back') || command.includes('backward')) { } else if (command.includes('back') || command.includes('backward')) {
action = 'back'; moveBack(turtleId);
actionName = 'back';
} else if (command.includes('turn left') || command.includes('left')) { } 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')) { } 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')) { } 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')) { } else if (command.includes('go down') || command.includes('move down')) {
action = 'down'; moveDown(turtleId);
actionName = 'down';
} else if (command.includes('dig')) { } else if (command.includes('dig')) {
if (command.includes('up')) { if (command.includes('up')) {
action = 'digUp'; digBlockUp(turtleId);
actionName = 'dig up';
} else if (command.includes('down')) { } else if (command.includes('down')) {
action = 'digDown'; digBlockDown(turtleId);
actionName = 'dig down';
} else { } else {
action = 'dig'; digBlock(turtleId);
actionName = 'dig';
} }
} else if (command.includes('place') || command.includes('build')) { } 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')) { } 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')) { } 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')) { } 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')) { } else if (command.includes('stop')) {
action = 'stop'; setTurtleState(turtleId, 'idle');
} else if (command.includes('set home') || command.includes('mark home')) { actionName = 'idle';
action = 'setHome';
} else if (command.includes('refuel')) { } else if (command.includes('refuel')) {
action = 'refuel'; setTurtleState(turtleId, 'refueling');
} else if (command.includes('status') || command.includes('report')) { actionName = 'refuel';
action = 'status'; } else if (command.includes('farm')) {
setTurtleState(turtleId, 'farming');
actionName = 'farm';
} else if (command.includes('dump')) {
setTurtleState(turtleId, 'dumpInventory');
actionName = 'dump inventory';
} }
if (action) { if (actionName) {
sendCommand(turtleId, action, param); setLastCommand(actionName);
setLastCommand(`${action}${param ? ` ${param}` : ''}`); speak(`Sending ${actionName} command`);
speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`);
} else { } else {
speak('Command not recognized'); 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; margin: 0;
padding: 0; padding: 0;
@@ -5,13 +7,12 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: 'Silkscreen', 'Courier New', monospace;
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', -webkit-font-smoothing: none;
sans-serif; -moz-osx-font-smoothing: unset;
-webkit-font-smoothing: antialiased; image-rendering: pixelated;
-moz-osx-font-smoothing: grayscale; background: #2c2c2c;
background: #0a0e1a; color: #d4d4d4;
color: #e0e0e0;
overflow: hidden; overflow: hidden;
} }
@@ -21,5 +22,5 @@ body {
} }
code { 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: {}, turtles: {},
players: {}, players: {},
worldBlocks: [], worldBlocks: [],
chunkAnalyses: {},
selectedTurtleId: null, selectedTurtleId: null,
connected: false, connected: false,
ws: null, ws: null,
@@ -34,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
data.turtles.forEach(turtle => { data.turtles.forEach(turtle => {
turtlesMap[turtle.turtleID] = turtle; turtlesMap[turtle.turtleID] = turtle;
}); });
const playersMap = {};
if (data.players && Array.isArray(data.players)) {
data.players.forEach(player => {
playersMap[player.playerID] = player;
});
}
set({ set({
turtles: turtlesMap, turtles: turtlesMap,
players: playersMap,
worldBlocks: data.blocks || [] worldBlocks: data.blocks || []
}); });
} else if (data.type === 'turtle_update') { } else if (data.type === 'turtle_update') {
@@ -73,10 +81,77 @@ export const useTurtleStore = create((set, get) => ({
[data.playerID]: { [data.playerID]: {
playerID: data.playerID, playerID: data.playerID,
position: data.position, position: data.position,
timestamp: data.timestamp label: data.label || null,
timestamp: data.timestamp || Date.now()
} }
} }
})); }));
} else if (data.type === 'block_discovered') {
if (data.block) {
set(state => {
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
const key = `${data.block.x},${data.block.y},${data.block.z}`;
blockMap.set(key, data.block);
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) { } catch (error) {
console.error('Error processing message:', error); console.error('Error processing message:', error);
@@ -149,35 +224,288 @@ export const useTurtleStore = create((set, get) => ({
set({ worldBlocks: Array.from(blockMap.values()) }); set({ worldBlocks: Array.from(blockMap.values()) });
}, },
sendCommand: async (turtleId, command, param = null) => { // Set turtle state machine state via REST API
const { ws } = get(); setTurtleState: async (turtleId, stateName, stateData = {}) => {
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${param})` : ''); try {
const response = await fetch(`${API_URL}/turtle/${turtleId}/state`, {
if (ws && ws.readyState === WebSocket.OPEN) { method: 'POST',
ws.send(JSON.stringify({ headers: { 'Content-Type': 'application/json' },
type: 'command', body: JSON.stringify({ state: stateName, data: stateData })
turtleID: turtleId, });
command, const result = await response.json();
param console.log(' ✅ State set:', result);
})); return result;
console.log(' ✅ Sent via WebSocket'); } catch (error) {
} else { console.error(' ❌ Error setting state:', error);
// Fallback to REST API // Fallback: send via WebSocket as a command
console.log(' ⚠️ WebSocket not connected, using REST API fallback'); const { ws } = get();
try { if (ws && ws.readyState === WebSocket.OPEN) {
await fetch(`${API_URL}/turtle/${turtleId}/command`, { ws.send(JSON.stringify({
method: 'POST', type: 'command',
headers: { 'Content-Type': 'application/json' }, turtleID: turtleId,
body: JSON.stringify({ command, param }) command: 'set_state',
}); param: { state: stateName, data: stateData }
console.log(' ✅ Sent via REST API'); }));
} catch (error) {
console.error(' ❌ Error sending command:', error);
} }
} }
}, },
// 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 // Helper getters
getTurtleArray: () => { getTurtleArray: () => {
return Object.values(get().turtles); return Object.values(get().turtles);

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
# Backend server # Backend server
server: server:
@@ -8,12 +6,12 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: turtle-server container_name: turtle-server
ports: ports:
- "4200:3001" # HTTP API - "4200:3001" # HTTP API + WebSocket (unified)
- "3002:3002" # WebSocket
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3001 - PORT=3001
- WS_PORT=3002 - INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
- API_KEY=${API_KEY:-}
restart: unless-stopped restart: unless-stopped
networks: networks:
- turtle-network - 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 -- Combines turtle control, GPS tracking, server management, and webbridge control
-- Communicates wirelessly with webbridge - NO direct HTTP calls -- Communicates wirelessly with webbridge - NO direct HTTP calls
local CHANNEL_SEND = 100 local Channels = require('platform.channels')
local CHANNEL_RECEIVE = 101
local STATUS_CHANNEL = 102 local CHANNEL_SEND = Channels.get('remoteturtle.command')
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
-- Find modem -- Find modem
local modem = peripheral.find("modem") local modem = peripheral.find("modem")
@@ -19,9 +21,12 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(CHANNEL_RECEIVE) local WebBridge = require('platform.webbridge')
modem.open(STATUS_CHANNEL) WebBridge.openChannels(modem, {
modem.open(POCKET_CHANNEL) 'remoteturtle.response',
'remoteturtle.status',
'remoteturtle.pocket',
})
local w, h = term.getSize() local w, h = term.getSize()
@@ -98,10 +103,10 @@ local function updateMyPosition()
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, { modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
type = "player_position", type = "player_position",
playerID = os.getComputerID(), playerID = os.getComputerID(),
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
position = myPosition, position = myPosition,
timestamp = os.epoch("utc") timestamp = os.epoch("utc")
}) })
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
return true return true
else else
addLog("GPS: Failed to locate", colors.red) addLog("GPS: Failed to locate", colors.red)
@@ -292,8 +297,25 @@ local function drawControl()
sendCommand(turtle.turtleID, "up") sendCommand(turtle.turtleID, "up")
end, colors.green) end, colors.green)
-- Action buttons (bottom row) -- Action buttons (bottom rows)
local btnY = h - 3 local btnY = h - 5
addButton(2, btnY, 6, 1, "EXPLR", function()
sendCommand(turtle.turtleID, "explore")
end, colors.cyan)
addButton(9, btnY, 6, 1, "MINE", function()
sendCommand(turtle.turtleID, "mine")
end, colors.orange)
addButton(16, btnY, 6, 1, "HOME", function()
sendCommand(turtle.turtleID, "returnHome")
end, colors.yellow)
addButton(23, btnY, 6, 1, "STOP", function()
sendCommand(turtle.turtleID, "stop")
end, colors.red)
btnY = h - 3
addButton(2, btnY, 6, 1, "DOWN", function() addButton(2, btnY, 6, 1, "DOWN", function()
sendCommand(turtle.turtleID, "down") sendCommand(turtle.turtleID, "down")
end, colors.green) end, colors.green)
@@ -302,14 +324,6 @@ local function drawControl()
sendCommand(turtle.turtleID, "dig") sendCommand(turtle.turtleID, "dig")
end, colors.red) end, colors.red)
addButton(16, btnY, 6, 1, "MINE", function()
sendCommand(turtle.turtleID, "mineStart")
end, colors.orange)
addButton(23, btnY, 6, 1, "HOME", function()
sendCommand(turtle.turtleID, "returnHome")
end, colors.yellow)
for i = #buttons - 8, #buttons do for i = #buttons - 8, #buttons do
if buttons[i] then if buttons[i] then
drawButton(buttons[i], false) drawButton(buttons[i], false)
@@ -547,7 +561,7 @@ parallel.waitForAny(
function() function()
-- GPS update loop -- GPS update loop
while true do while true do
sleep(5) sleep(2)
updateMyPosition() updateMyPosition()
end end
end, end,
@@ -565,7 +579,9 @@ parallel.waitForAny(
while true do while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message") 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 if message.type == "status" then
-- Update turtle list -- Update turtle list
local found = false local found = false
@@ -581,7 +597,7 @@ parallel.waitForAny(
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime) addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
end end
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 -- Handle responses from webbridge
if message.type == "webbridge_status" then if message.type == "webbridge_status" then
webbridgeStatus = message.data webbridgeStatus = message.data

View File

@@ -1,7 +1,10 @@
-- Live GPS Tracker for Pocket Computer -- Live GPS Tracker for Pocket Computer
-- Shows your current location in real-time -- 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 -- Setup modem
local modem = peripheral.find("modem") local modem = peripheral.find("modem")
@@ -15,7 +18,7 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(STATUS_CHANNEL) WebBridge.openChannels(modem, { 'remoteturtle.status' })
local w, h = term.getSize() local w, h = term.getSize()
local myID = os.getComputerID() local myID = os.getComputerID()
@@ -228,7 +231,9 @@ local function main()
local replyChannel = param3 local replyChannel = param3
local message = param4 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) handleStatus(message)
end end

View File

@@ -1,9 +1,12 @@
-- Touch-Enabled Command Center for Pocket Computer (FIXED) -- Touch-Enabled Command Center for Pocket Computer (FIXED)
-- Monitor and control autonomous mining turtles -- Monitor and control autonomous mining turtles
local CHANNEL_SEND = 100 local Channels = require('platform.channels')
local CHANNEL_RECEIVE = 101 local WebBridge = require('platform.webbridge')
local STATUS_CHANNEL = 102
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") local modem = peripheral.find("modem")
if not modem then if not modem then
@@ -15,15 +18,17 @@ if pocket then
modem = peripheral.find("modem") modem = peripheral.find("modem")
end end
modem.open(CHANNEL_RECEIVE) WebBridge.openChannels(modem, {
modem.open(STATUS_CHANNEL) 'remoteturtle.response',
'remoteturtle.status',
})
local w, h = term.getSize() local w, h = term.getSize()
-- Tracked turtles -- Tracked turtles
local turtles = {} local turtles = {}
local selectedTurtle = nil local selectedTurtle = nil
local viewMode = "overview" -- overview, detail, manual local viewMode = "overview" -- overview, detail, manual, modes
-- Button system -- Button system
local buttons = {} local buttons = {}
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
}) })
end 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 -- Helper function to format fuel display
local function formatFuel(fuel) local function formatFuel(fuel)
if not fuel then if not fuel then
@@ -127,7 +142,7 @@ local function drawOverview()
-- Turtle info box -- Turtle info box
term.setCursorPos(1, y) term.setCursorPos(1, y)
term.setTextColor(selected and colors.lime or colors.white) 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 if turtle.position then
print(string.format(" %d,%d,%d", print(string.format(" %d,%d,%d",
@@ -197,7 +212,7 @@ local function drawDetail()
term.setTextColor(colors.white) term.setTextColor(colors.white)
print("") print("")
print("Mode: " .. (turtle.mode or "unknown")) print("State: " .. (turtle.state or turtle.mode or "idle"))
print("Fuel: " .. formatFuel(turtle.fuel)) print("Fuel: " .. formatFuel(turtle.fuel))
if turtle.position then if turtle.position then
@@ -234,8 +249,8 @@ local function drawDetail()
print(" Empty") print(" Empty")
end end
-- Action buttons -- Action buttons (row 1: explore/home/stop)
local btnY = h - 7 local btnY = h - 10
addButton(1, btnY, 8, 2, "EXPLORE", function() addButton(1, btnY, 8, 2, "EXPLORE", function()
sendCommand(turtle.turtleID, "explore") sendCommand(turtle.turtleID, "explore")
end, colors.green) end, colors.green)
@@ -248,16 +263,60 @@ local function drawDetail()
sendCommand(turtle.turtleID, "stop") sendCommand(turtle.turtleID, "stop")
end, colors.red) end, colors.red)
btnY = h - 4 -- Row 2: manual/modes/setHome
addButton(1, btnY, 12, 2, "MANUAL", function() btnY = h - 7
addButton(1, btnY, 8, 2, "MANUAL", function()
viewMode = "manual" viewMode = "manual"
sendCommand(turtle.turtleID, "manual") sendCommand(turtle.turtleID, "manual")
end, colors.purple) 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") sendCommand(turtle.turtleID, "setHome")
end, colors.blue) 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() addButton(1, h - 1, 12, 2, "< BACK", function()
viewMode = "overview" viewMode = "overview"
end, colors.gray) end, colors.gray)
@@ -349,9 +408,37 @@ local function drawManual()
sendCommand(turtle.turtleID, "refuel") sendCommand(turtle.turtleID, "refuel")
end, colors.lime) end, colors.lime)
addButton(19, h - 3, 7, 2, "INFO", function() addButton(19, h - 3, 7, 2, "SORT", function()
sendCommand(turtle.turtleID, "status") modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
end, colors.lightBlue) 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() addButton(1, h, 12, 1, "< BACK", function()
viewMode = "detail" viewMode = "detail"
@@ -369,6 +456,98 @@ local function drawManual()
term.setTextColor(colors.white) term.setTextColor(colors.white)
end 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() local function draw()
if viewMode == "overview" then if viewMode == "overview" then
drawOverview() drawOverview()
@@ -376,6 +555,8 @@ local function draw()
drawDetail() drawDetail()
elseif viewMode == "manual" then elseif viewMode == "manual" then
drawManual() drawManual()
elseif viewMode == "modes" then
drawModes()
end end
end end
@@ -450,14 +631,20 @@ parallel.waitForAny(
end, end,
function() function()
-- Status receiver -- Status receiver
-- Uses Channels.match() for dual-mode safety: accepts status on
-- both legacy (102) and target (4212) channels during migration.
while true do while true do
local event, side, channel, replyChannel, message = os.pullEvent("modem_message") 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 -- Update or add turtle
local found = false local found = false
for i, t in ipairs(turtles) do for i, t in ipairs(turtles) do
if t.turtleID == message.turtleID then 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 turtles[i] = message
found = true found = true
break break
@@ -465,6 +652,9 @@ parallel.waitForAny(
end end
if not found then if not found then
if not message.state then
message.state = message.mode or "idle"
end
table.insert(turtles, message) table.insert(turtles, message)
if not selectedTurtle then if not selectedTurtle then
selectedTurtle = 1 selectedTurtle = 1
@@ -472,8 +662,16 @@ parallel.waitForAny(
end end
draw() draw()
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
-- Response from turtle -- 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() draw()
end end
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 FROM node:18-alpine
WORKDIR /app WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
# Install dependencies # Install dependencies
RUN npm install --omit=dev RUN npm install --omit=dev
# Copy server code # Copy all server code
COPY server.js ./ COPY . .
COPY database.js ./
# Expose ports # Expose ports
EXPOSE 3001 3002 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 FROM node:18-alpine
WORKDIR /app WORKDIR /app
# Copy platform server package from the git-clone stage
COPY --from=platform /src/server /app/platform-server/
# Install nodemon for hot reload # Install nodemon for hot reload
RUN npm install -g nodemon RUN npm install -g nodemon
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Rewrite file: dependency to use the local copy inside the container
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
&& rm -f package-lock.json
# Install all dependencies (including dev) # Install all dependencies (including dev)
RUN npm install 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')); 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 // Initialize database schema
export function initializeDatabase() { export function initializeDatabase() {
// Turtle homes table // Turtle homes table
@@ -82,12 +85,28 @@ export function initializeDatabase() {
max_x INTEGER NOT NULL, max_x INTEGER NOT NULL,
max_y INTEGER NOT NULL, max_y INTEGER NOT NULL,
max_z INTEGER NOT NULL, max_z INTEGER NOT NULL,
name TEXT,
color TEXT DEFAULT '#4a8c2a',
status TEXT DEFAULT 'active', status TEXT DEFAULT 'active',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_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 // Mining statistics table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS mining_stats ( CREATE TABLE IF NOT EXISTS mining_stats (
@@ -96,7 +115,8 @@ export function initializeDatabase() {
block_type TEXT NOT NULL, block_type TEXT NOT NULL,
count INTEGER DEFAULT 1, count INTEGER DEFAULT 1,
session_start INTEGER NOT NULL, session_start INTEGER NOT NULL,
last_mined INTEGER NOT NULL last_mined INTEGER NOT NULL,
UNIQUE(turtle_id, block_type, session_start)
) )
`); `);
@@ -140,10 +160,41 @@ export function initializeDatabase() {
x INTEGER NOT NULL, x INTEGER NOT NULL,
y INTEGER NOT NULL, y INTEGER NOT NULL,
z INTEGER NOT NULL, z INTEGER NOT NULL,
label TEXT,
updated_at INTEGER NOT NULL 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 // Create indexes for better performance
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
@@ -155,6 +206,26 @@ export function initializeDatabase() {
ON task_queue(status, priority DESC); 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'); console.log('✅ Database initialized');
} }
@@ -202,12 +273,14 @@ export function getTurtleConfig(turtleId) {
} }
// World Blocks // 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(` const stmt = db.prepare(`
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at) INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at, block_state, block_tags)
VALUES (?, ?, ?, ?, ?, ?, ?) 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) { export function getWorldBlocks(limit = 10000) {
@@ -215,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
return stmt.all(limit); 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) { export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT * FROM world_blocks SELECT * FROM world_blocks
@@ -239,15 +317,33 @@ export function savePath(turtleId, pathName, pathData) {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`); `);
const now = Date.now(); const now = Date.now();
stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now); const result = stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
return result.lastInsertRowid;
} }
export function getPaths(turtleId) { export function getPaths(turtleId = null) {
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC'); if (turtleId) {
return stmt.all(turtleId).map(row => ({ const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
...row, return stmt.all(turtleId).map(row => ({
path_data: JSON.parse(row.path_data) ...row,
})); path_data: JSON.parse(row.path_data)
}));
} else {
const stmt = db.prepare('SELECT * FROM turtle_paths ORDER BY created_at DESC');
return stmt.all().map(row => ({
...row,
path_data: JSON.parse(row.path_data)
}));
}
}
export function getPath(pathId) {
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE id = ?');
const row = stmt.get(pathId);
if (row) {
return { ...row, path_data: JSON.parse(row.path_data) };
}
return null;
} }
export function deletePath(pathId) { export function deletePath(pathId) {
@@ -256,13 +352,13 @@ export function deletePath(pathId) {
} }
// Task Queue // Task Queue
export function createTask(taskType, taskData, priority = 0) { export function createTask(taskType, taskData, priority = 0, assignedTurtleId = null) {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO task_queue (task_type, task_data, priority, status, created_at, updated_at) INSERT INTO task_queue (task_type, task_data, assigned_turtle_id, priority, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?) VALUES (?, ?, ?, ?, 'pending', ?, ?)
`); `);
const now = Date.now(); const now = Date.now();
const result = stmt.run(taskType, JSON.stringify(taskData), priority, now, now); const result = stmt.run(taskType, JSON.stringify(taskData), assignedTurtleId, priority, now, now);
return result.lastInsertRowid; return result.lastInsertRowid;
} }
@@ -284,12 +380,31 @@ export function getNextTask() {
} }
export function assignTask(taskId, turtleId) { export function assignTask(taskId, turtleId) {
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) {
const stmt = db.prepare(` const stmt = db.prepare(`
UPDATE task_queue UPDATE task_queue
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ? SET status = ?, task_data = CASE WHEN ? IS NOT NULL THEN json_set(task_data, '$.result', ?) ELSE task_data END, updated_at = ?
WHERE id = ? WHERE id = ?
`); `);
stmt.run(turtleId, Date.now(), taskId); stmt.run(status, result, result, Date.now(), taskId);
} }
export function completeTask(taskId) { export function completeTask(taskId) {
@@ -301,7 +416,19 @@ export function completeTask(taskId) {
stmt.run(Date.now(), taskId); stmt.run(Date.now(), taskId);
} }
export function getAllTasks() { export function deleteTask(taskId) {
const stmt = db.prepare('DELETE FROM task_queue WHERE id = ?');
return stmt.run(taskId);
}
export function getAllTasks(status = null) {
if (status) {
const stmt = db.prepare('SELECT * FROM task_queue WHERE status = ? ORDER BY priority DESC, created_at DESC');
return stmt.all(status).map(row => ({
...row,
task_data: JSON.parse(row.task_data)
}));
}
const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC'); const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC');
return stmt.all().map(row => ({ return stmt.all().map(row => ({
...row, ...row,
@@ -310,25 +437,59 @@ export function getAllTasks() {
} }
// Mining Areas // Mining Areas
export function saveMiningArea(turtleId, bounds) { export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
const stmt = db.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const now = Date.now(); const now = Date.now();
stmt.run( const result = stmt.run(
turtleId, turtleId,
bounds.minX, bounds.minY, bounds.minZ, bounds.minX, bounds.minY, bounds.minZ,
bounds.maxX, bounds.maxY, bounds.maxZ, bounds.maxX, bounds.maxY, bounds.maxZ,
now, now areaName, color,
status, now, now
); );
return result.lastInsertRowid;
} }
export function getMiningAreas() { export function getMiningAreas(statusFilter = null) {
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = \'active\''); if (statusFilter) {
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = ? ORDER BY created_at DESC');
return stmt.all(statusFilter);
}
const stmt = db.prepare('SELECT * FROM mining_areas ORDER BY created_at DESC');
return stmt.all(); return stmt.all();
} }
export function updateMiningAreaStatus(areaId, status) {
const stmt = db.prepare('UPDATE mining_areas SET status = ?, updated_at = ? WHERE id = ?');
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);
}
export function closeMiningArea(areaId) { export function closeMiningArea(areaId) {
const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?'); const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?');
stmt.run(Date.now(), areaId); stmt.run(Date.now(), areaId);
@@ -373,7 +534,7 @@ export function getMiningStats(turtleId = null, days = 7) {
export function getTopMiners(limit = 10) { export function getTopMiners(limit = 10) {
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT turtle_id, SUM(count) as total_blocks SELECT turtle_id, SUM(count) as total_blocks, COUNT(DISTINCT block_type) as unique_types
FROM mining_stats FROM mining_stats
GROUP BY turtle_id GROUP BY turtle_id
ORDER BY total_blocks DESC ORDER BY total_blocks DESC
@@ -467,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
} }
// Player Positions // Player Positions
export function savePlayerPosition(playerId, position) { export function savePlayerPosition(playerId, position, label = null) {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at) INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
VALUES (?, ?, ?, ?, ?) 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) { export function getPlayerPosition(playerId) {
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?'); const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
return stmt.get(playerId); 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() { export function getAllPlayerPositions() {
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions'); const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
return stmt.all(); 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 // Cleanup function
@@ -490,5 +663,117 @@ export function closeDatabase() {
db.close(); 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 database instance for custom queries if needed
export { db }; 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", "type": "module",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js" "dev": "nodemon server.js",
"test": "vitest run",
"test:watch": "vitest"
}, },
"keywords": [ "keywords": [
"minecraft", "minecraft",
@@ -17,12 +19,14 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "@cc-platform/server": "file:../../cc-platform-core/server",
"ws": "^8.14.2", "better-sqlite3": "^9.2.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"better-sqlite3": "^9.2.2" "express": "^4.18.2",
"ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1",
"vitest": "^3.2.4"
} }
} }

View File

@@ -0,0 +1,361 @@
/**
* D* Lite Pathfinding Algorithm
*
* An incremental heuristic search algorithm that efficiently replans
* when the environment changes (new blocks discovered, blocks mined, etc.)
*
* Based on Koenig & Likhachev (2002) and adapted from
* runi95/turtle-control-panel implementation.
*/
import { Point } from './Point.js';
import { Node } from './Node.js';
import { PriorityQueue } from './PriorityQueue.js';
// Unbreakable blocks that cannot be mined
const UNBREAKABLE_BLOCKS = new Set([
'minecraft:bedrock',
'minecraft:barrier',
'minecraft:command_block',
'minecraft:chain_command_block',
'minecraft:repeating_command_block',
'minecraft:structure_block',
'minecraft:jigsaw',
'minecraft:end_portal_frame',
'minecraft:end_portal',
'minecraft:nether_portal',
'minecraft:spawner',
'minecraft:reinforced_deepslate',
]);
// Blocks that are liquid/hazardous - avoid unless necessary
const HAZARDOUS_BLOCKS = new Set([
'minecraft:lava',
'minecraft:water',
'minecraft:flowing_lava',
'minecraft:flowing_water',
]);
export class DStarLite {
/**
* @param {Point} start - Starting position
* @param {Point} goal - Goal position
* @param {Map} worldBlocks - Map of "x,y,z" -> blockData from server
* @param {Object} options - Configuration options
*/
constructor(start, goal, worldBlocks, options = {}) {
this.start = start;
this.goal = goal;
this.worldBlocks = worldBlocks;
this.km = 0;
this.nodes = new Map(); // key -> Node
this.queue = new PriorityQueue();
// Options
this.maxSteps = options.maxSteps || 50000;
this.canMine = options.canMine !== false; // Default: true - can mine through blocks
this.boundaryMin = options.boundaryMin || null; // Point - min boundary
this.boundaryMax = options.boundaryMax || null; // Point - max boundary
this.avoidHazards = options.avoidHazards !== false; // Default: true
// Initialize
this._initialize();
}
/**
* Get or create a node for a point
*/
getNode(point) {
const key = point.toKey();
if (!this.nodes.has(key)) {
const node = new Node(point);
// Check world blocks for this position
const blockData = this.worldBlocks.get(key);
if (blockData) {
node.blockData = blockData;
if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
node.blocked = true;
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
node.blocked = true;
} else if (blockData.name && blockData.name !== 'minecraft:air') {
// There's a block here - it's mineable
node.mineable = true;
}
}
// Check boundary constraints
if (this.boundaryMin && this.boundaryMax) {
if (point.x < this.boundaryMin.x || point.x > this.boundaryMax.x ||
point.y < this.boundaryMin.y || point.y > this.boundaryMax.y ||
point.z < this.boundaryMin.z || point.z > this.boundaryMax.z) {
node.blocked = true;
}
}
// Don't go below bedrock
if (point.y < -64) node.blocked = true;
if (point.y > 320) node.blocked = true;
this.nodes.set(key, node);
}
return this.nodes.get(key);
}
/**
* Initialize the D* Lite algorithm
*/
_initialize() {
const goalNode = this.getNode(this.goal);
goalNode.rhs = 0;
const key = goalNode.calculateKey(this.start, this.km);
this.queue.insertOrUpdate(goalNode, key);
}
/**
* Compute the shortest path
* Returns true if a path exists, false otherwise
*/
computeShortestPath() {
const startNode = this.getNode(this.start);
let steps = 0;
while (
!this.queue.isEmpty() &&
(PriorityQueue.compareKeys(this.queue.topKey(), startNode.calculateKey(this.start, this.km)) < 0 ||
startNode.rhs !== startNode.g)
) {
steps++;
if (steps > this.maxSteps) {
console.warn(`D* Lite: Max steps (${this.maxSteps}) exceeded`);
return false;
}
const oldKey = this.queue.topKey();
const u = this.queue.pop();
if (!u) break;
const newKey = u.calculateKey(this.start, this.km);
if (PriorityQueue.compareKeys(oldKey, newKey) < 0) {
// Key has changed, reinsert with new key
this.queue.insertOrUpdate(u, newKey);
} else if (u.g > u.rhs) {
// Overconsistent - decrease g
u.g = u.rhs;
// Update predecessors
const neighbors = u.point.getNeighbors();
for (const neighborPoint of neighbors) {
const neighborNode = this.getNode(neighborPoint);
this._updateNode(neighborNode);
}
} else {
// Underconsistent - increase g
u.g = Infinity;
// Update u and its predecessors
this._updateNode(u);
const neighbors = u.point.getNeighbors();
for (const neighborPoint of neighbors) {
const neighborNode = this.getNode(neighborPoint);
this._updateNode(neighborNode);
}
}
}
return startNode.g !== Infinity;
}
/**
* Update a node's rhs value and queue status
*/
_updateNode(node) {
if (!node.point.equals(this.goal)) {
// rhs = min over all successors of (cost(node, succ) + succ.g)
let minRhs = Infinity;
const neighbors = node.point.getNeighbors();
for (const neighborPoint of neighbors) {
const neighborNode = this.getNode(neighborPoint);
const cost = this._cost(node, neighborNode);
const val = cost + neighborNode.g;
if (val < minRhs) {
minRhs = val;
}
}
node.rhs = minRhs;
}
// Remove from queue if present
if (this.queue.contains(node)) {
this.queue.remove(node);
}
// Re-insert if inconsistent
if (node.g !== node.rhs) {
const key = node.calculateKey(this.start, this.km);
this.queue.insertOrUpdate(node, key);
}
}
/**
* Get the cost to traverse from one node to an adjacent node
*/
_cost(from, to) {
return to.getTraversalCost();
}
/**
* Get the computed path from start to goal
* Returns array of Points, or null if no path exists
*/
getPath() {
if (!this.computeShortestPath()) {
return null;
}
const path = [this.start];
let current = this.start;
let maxPathLength = 10000;
while (!current.equals(this.goal) && maxPathLength > 0) {
maxPathLength--;
const currentNode = this.getNode(current);
if (currentNode.g === Infinity) {
return null; // No path
}
// Find the best neighbor to move to
let bestNeighbor = null;
let bestCost = Infinity;
const neighbors = current.getNeighbors();
for (const neighborPoint of neighbors) {
const neighborNode = this.getNode(neighborPoint);
const cost = this._cost(currentNode, neighborNode) + neighborNode.g;
if (cost < bestCost) {
bestCost = cost;
bestNeighbor = neighborPoint;
}
}
if (!bestNeighbor || bestCost === Infinity) {
return null; // No path
}
path.push(bestNeighbor);
current = bestNeighbor;
}
return path;
}
/**
* Update the algorithm when the turtle moves to a new position
* This is the key D* Lite optimization - it adjusts km to avoid full recomputation
*/
updateStart(newStart) {
this.km += this.start.euclideanDistanceTo(newStart);
this.start = newStart;
}
/**
* Notify the algorithm that a block has changed at a position
* This triggers efficient replanning
* @param {Point} point - The position that changed
* @param {Object|null} blockData - New block data, or null if block was removed
*/
updateBlock(point, blockData) {
const node = this.getNode(point);
const oldBlocked = node.blocked;
const oldMineable = node.mineable;
// Update node state
node.blockData = blockData;
if (!blockData || blockData.name === 'minecraft:air') {
node.blocked = false;
node.mineable = false;
} else if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
node.blocked = true;
node.mineable = false;
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
node.blocked = true;
node.mineable = false;
} else {
node.blocked = false;
node.mineable = true;
}
// Only replan if the traversability changed
if (node.blocked !== oldBlocked || node.mineable !== oldMineable) {
// Update this node and all its neighbors
this._updateNode(node);
const neighbors = point.getNeighbors();
for (const neighborPoint of neighbors) {
if (this.nodes.has(neighborPoint.toKey())) {
const neighborNode = this.getNode(neighborPoint);
this._updateNode(neighborNode);
}
}
}
}
/**
* Get the next step the turtle should take from current position
* Returns the next Point to move to, or null if at goal or no path
*/
getNextStep() {
if (this.start.equals(this.goal)) {
return null; // Already at goal
}
if (!this.computeShortestPath()) {
return null; // No path
}
const startNode = this.getNode(this.start);
if (startNode.g === Infinity) {
return null;
}
let bestNeighbor = null;
let bestCost = Infinity;
const neighbors = this.start.getNeighbors();
for (const neighborPoint of neighbors) {
const neighborNode = this.getNode(neighborPoint);
const cost = this._cost(startNode, neighborNode) + neighborNode.g;
if (cost < bestCost) {
bestCost = cost;
bestNeighbor = neighborPoint;
}
}
return bestNeighbor;
}
/**
* Replan the path (called after block updates)
*/
replan() {
return this.computeShortestPath();
}
/**
* Get statistics about the pathfinding
*/
getStats() {
return {
nodesExplored: this.nodes.size,
queueSize: this.queue.size,
startG: this.getNode(this.start).g,
goalRhs: this.getNode(this.goal).rhs,
km: this.km,
};
}
}

View File

@@ -0,0 +1,61 @@
/**
* Node - Represents a pathfinding node in the D* Lite algorithm
* Each node wraps a Point and stores pathfinding metadata
*/
import { Point } from './Point.js';
export class Node {
constructor(point) {
this.point = point;
this.key = point.toKey();
// D* Lite values
this.g = Infinity; // Cost from start to this node
this.rhs = Infinity; // One-step lookahead cost
// Whether this node is blocked (wall, unbreakable block, etc.)
this.blocked = false;
// Whether this node contains a mineable block (costs more to traverse but possible)
this.mineable = false;
// The block data at this position (if known)
this.blockData = null;
}
/**
* Calculate the D* Lite key pair for priority queue ordering
* @param {Point} start - The current start position
* @param {number} km - The key modifier (updated on robot movement)
*/
calculateKey(start, km = 0) {
const minVal = Math.min(this.g, this.rhs);
return [
minVal + start.euclideanDistanceTo(this.point) + km,
minVal
];
}
/**
* Check if this node is consistent (g === rhs)
*/
isConsistent() {
return this.g === this.rhs;
}
/**
* Get the traversal cost to move to this node
* - Blocked nodes: Infinity
* - Mineable blocks: 2 (higher cost to prefer open paths)
* - Open space: 1
*/
getTraversalCost() {
if (this.blocked) return Infinity;
if (this.mineable) return 2;
return 1;
}
toString() {
return `Node(${this.point}, g=${this.g}, rhs=${this.rhs}, blocked=${this.blocked})`;
}
}

100
server/pathfinding/Point.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* Point - Represents a 3D coordinate in the Minecraft world
* Inspired by runi95/turtle-control-panel Point class
*/
export class Point {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
/**
* Returns the Manhattan distance to another point
*/
distanceTo(other) {
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y) + Math.abs(this.z - other.z);
}
/**
* Returns the Euclidean distance to another point (used for D* Lite heuristic)
*/
euclideanDistanceTo(other) {
const dx = this.x - other.x;
const dy = this.y - other.y;
const dz = this.z - other.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* Check equality with another point
*/
equals(other) {
if (!other) return false;
return this.x === other.x && this.y === other.y && this.z === other.z;
}
/**
* Get all 6 neighboring points (up, down, north, south, east, west)
*/
getNeighbors() {
return [
new Point(this.x + 1, this.y, this.z), // East
new Point(this.x - 1, this.y, this.z), // West
new Point(this.x, this.y + 1, this.z), // Up
new Point(this.x, this.y - 1, this.z), // Down
new Point(this.x, this.y, this.z + 1), // South
new Point(this.x, this.y, this.z - 1), // North
];
}
/**
* Get the cardinal direction needed to move from this point to another adjacent point
* Returns: 'forward', 'back', 'up', 'down', or facing adjustment needed
*/
directionTo(other) {
const dx = other.x - this.x;
const dy = other.y - this.y;
const dz = other.z - this.z;
if (dy === 1) return 'up';
if (dy === -1) return 'down';
if (dx === 1) return { facing: 1 }; // East
if (dx === -1) return { facing: 3 }; // West
if (dz === 1) return { facing: 2 }; // South
if (dz === -1) return { facing: 0 }; // North
return null;
}
/**
* Unique string key for maps
*/
toKey() {
return `${this.x},${this.y},${this.z}`;
}
/**
* Create Point from key string
*/
static fromKey(key) {
const [x, y, z] = key.split(',').map(Number);
return new Point(x, y, z);
}
/**
* Create Point from object with x,y,z properties
*/
static from(obj) {
if (!obj) return null;
return new Point(obj.x, obj.y, obj.z);
}
toString() {
return `(${this.x}, ${this.y}, ${this.z})`;
}
toJSON() {
return { x: this.x, y: this.y, z: this.z };
}
}

View File

@@ -0,0 +1,175 @@
/**
* PriorityQueue - Min-heap priority queue for D* Lite
* Uses two-element key pairs [k1, k2] for ordering
*/
export class PriorityQueue {
constructor() {
this.heap = [];
this.keyMap = new Map(); // key string -> index in heap
}
get size() {
return this.heap.length;
}
isEmpty() {
return this.heap.length === 0;
}
/**
* Compare two key pairs
* Returns negative if a < b, positive if a > b, 0 if equal
*/
static compareKeys(a, b) {
if (a[0] !== b[0]) return a[0] - b[0];
return a[1] - b[1];
}
/**
* Get the minimum key without removing
*/
topKey() {
if (this.heap.length === 0) return [Infinity, Infinity];
return this.heap[0].priority;
}
/**
* Insert or update a node with a priority
*/
insertOrUpdate(node, priority) {
const key = node.key;
if (this.keyMap.has(key)) {
// Update existing entry
const index = this.keyMap.get(key);
const oldPriority = this.heap[index].priority;
this.heap[index].priority = priority;
this.heap[index].node = node;
// Determine whether to bubble up or sift down
if (PriorityQueue.compareKeys(priority, oldPriority) < 0) {
this._bubbleUp(index);
} else {
this._siftDown(index);
}
} else {
// Insert new entry
const entry = { node, priority };
this.heap.push(entry);
const index = this.heap.length - 1;
this.keyMap.set(key, index);
this._bubbleUp(index);
}
}
/**
* Remove and return the minimum node
*/
pop() {
if (this.heap.length === 0) return null;
const min = this.heap[0];
this.keyMap.delete(min.node.key);
const last = this.heap.pop();
if (this.heap.length > 0) {
this.heap[0] = last;
this.keyMap.set(last.node.key, 0);
this._siftDown(0);
}
return min.node;
}
/**
* Remove a specific node by key
*/
remove(node) {
const key = node.key;
if (!this.keyMap.has(key)) return;
const index = this.keyMap.get(key);
this.keyMap.delete(key);
const last = this.heap.pop();
if (index < this.heap.length) {
this.heap[index] = last;
this.keyMap.set(last.node.key, index);
this._bubbleUp(index);
this._siftDown(index);
}
}
/**
* Check if a node is in the queue
*/
contains(node) {
return this.keyMap.has(node.key);
}
/**
* Clear the queue
*/
clear() {
this.heap = [];
this.keyMap.clear();
}
// --- Internal heap operations ---
_parent(i) {
return Math.floor((i - 1) / 2);
}
_leftChild(i) {
return 2 * i + 1;
}
_rightChild(i) {
return 2 * i + 2;
}
_swap(i, j) {
const temp = this.heap[i];
this.heap[i] = this.heap[j];
this.heap[j] = temp;
this.keyMap.set(this.heap[i].node.key, i);
this.keyMap.set(this.heap[j].node.key, j);
}
_bubbleUp(i) {
while (i > 0) {
const parent = this._parent(i);
if (PriorityQueue.compareKeys(this.heap[i].priority, this.heap[parent].priority) < 0) {
this._swap(i, parent);
i = parent;
} else {
break;
}
}
}
_siftDown(i) {
const n = this.heap.length;
while (true) {
let smallest = i;
const left = this._leftChild(i);
const right = this._rightChild(i);
if (left < n && PriorityQueue.compareKeys(this.heap[left].priority, this.heap[smallest].priority) < 0) {
smallest = left;
}
if (right < n && PriorityQueue.compareKeys(this.heap[right].priority, this.heap[smallest].priority) < 0) {
smallest = right;
}
if (smallest !== i) {
this._swap(i, smallest);
i = smallest;
} else {
break;
}
}
}
}

View File

@@ -0,0 +1,4 @@
export { Point } from './Point.js';
export { Node } from './Node.js';
export { PriorityQueue } from './PriorityQueue.js';
export { DStarLite } from './DStarLite.js';

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';

1382
turtle.lua

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff