Compare commits

...

139 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
41 changed files with 4502 additions and 2603 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

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,17 +12,18 @@ 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 = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`; const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`; const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
switch (panelTab) { switch (panelTab) {
@@ -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

@@ -5,26 +5,28 @@ import './ControlPanel.css';
function TurtleCard({ turtle, isSelected, onSelect }) { function TurtleCard({ turtle, isSelected, onSelect }) {
const activeState = turtle.state || turtle.mode || 'idle'; 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 displayName = turtle.label || `Turtle ${turtle.turtleID}`;
const modeColors = { const modeColors = {
mining: '#4ade80', mining: '#55ff55',
exploring: '#60a5fa', exploring: '#55ffff',
returning: '#f59e0b', returning: '#ffaa00',
goHome: '#f59e0b', goHome: '#ffaa00',
idle: '#9ca3af', idle: '#aaaaaa',
manual: '#a78bfa', manual: '#ff55ff',
refueling: '#ef4444', refueling: '#ff5555',
farming: '#22c55e', farming: '#55ff55',
dumpInventory: '#a855f7', dumpInventory: '#aa00aa',
dumping: '#a855f7', dumping: '#aa00aa',
moving: '#06b6d4', moving: '#55ffff',
scan: '#8b5cf6', scan: '#5555ff',
extraction: '#f97316', extraction: '#ffaa00',
building: '#14b8a6', building: '#00aaaa',
autocraft: '#ec4899', autocraft: '#ff55ff',
unknown: '#6b7280' unknown: '#555555'
}; };
return ( return (
@@ -77,8 +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 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 (
@@ -88,19 +114,70 @@ function TurtleDetails({ turtle }) {
); );
} }
const handleCommand = (command, param = null) => {
sendCommand(turtle.turtleID, command, param);
};
const handleStateChange = (stateName, data = {}) => { const handleStateChange = (stateName, data = {}) => {
setTurtleState(turtle.turtleID, stateName, data); setTurtleState(turtle.turtleID, stateName, data);
}; };
const handleRename = async () => {
if (renameValue.trim()) {
await renameTurtle(turtle.turtleID, renameValue.trim());
setRenameValue('');
}
};
const handleSlotClick = async (slotIndex) => {
await selectSlot(turtle.turtleID, slotIndex + 1);
};
const handleConfigSave = async () => {
await updateTurtleConfig(turtle.turtleID, configValues);
setShowConfig(false);
};
const activeState = turtle.state || turtle.mode || 'idle'; const 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>
@@ -121,6 +198,14 @@ function TurtleDetails({ turtle }) {
{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">
@@ -140,13 +225,13 @@ function TurtleDetails({ turtle }) {
</span> </span>
</div> </div>
{turtle.error && ( {turtle.error && (
<div className="status-item" style={{ color: '#ef4444' }}> <div className="status-item" style={{ color: '#ff5555' }}>
<span className="label">Error:</span> <span className="label">Error:</span>
<span className="value">{turtle.error}</span> <span className="value">{turtle.error}</span>
</div> </div>
)} )}
{turtle.warning && ( {turtle.warning && (
<div className="status-item" style={{ color: '#f59e0b' }}> <div className="status-item" style={{ color: '#ffaa00' }}>
<span className="label">Warning:</span> <span className="label">Warning:</span>
<span className="value">{turtle.warning}</span> <span className="value">{turtle.warning}</span>
</div> </div>
@@ -159,10 +244,12 @@ function TurtleDetails({ turtle }) {
<div className="detail-section"> <div className="detail-section">
<h3>Peripherals</h3> <h3>Peripherals</h3>
<div className="status-grid"> <div className="status-grid">
{Object.entries(turtle.peripherals).map(([side, type]) => ( {Object.entries(turtle.peripherals).map(([side, info]) => (
<div key={side} className="status-item"> <div key={side} className="status-item">
<span className="label" style={{ textTransform: 'capitalize' }}>{side}:</span> <span className="label" style={{ textTransform: 'capitalize' }}>{side}:</span>
<span className="value" style={{ color: '#a78bfa' }}>{type}</span> <span className="value" style={{ color: '#ff55ff' }}>
{typeof info === 'string' ? info : (info?.types?.join(', ') || 'unknown')}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -174,7 +261,7 @@ function TurtleDetails({ turtle }) {
<div className="command-grid"> <div className="command-grid">
<button <button
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`} className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
onClick={() => { handleStateChange('idle'); handleCommand('stop'); }} onClick={() => handleStateChange('idle')}
title="Stop and go idle" title="Stop and go idle"
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}} style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
> >
@@ -182,7 +269,7 @@ function TurtleDetails({ turtle }) {
</button> </button>
<button <button
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`} className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
onClick={() => { handleStateChange('exploring'); handleCommand('explore'); }} onClick={() => handleStateChange('exploring')}
title="Autonomous exploration" title="Autonomous exploration"
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}} style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
> >
@@ -190,7 +277,7 @@ function TurtleDetails({ turtle }) {
</button> </button>
<button <button
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`} className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
onClick={() => { handleStateChange('mining'); handleCommand('explore'); }} onClick={() => handleStateChange('mining')}
title="Mining with ore priority" title="Mining with ore priority"
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}} style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
> >
@@ -206,7 +293,7 @@ function TurtleDetails({ turtle }) {
</button> </button>
<button <button
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`} className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
onClick={() => { handleStateChange('goHome'); handleCommand('returnHome'); }} onClick={() => handleStateChange('goHome')}
title="Navigate home" title="Navigate home"
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}} style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
> >
@@ -214,7 +301,7 @@ function TurtleDetails({ turtle }) {
</button> </button>
<button <button
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`} className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
onClick={() => { handleStateChange('refueling'); handleCommand('refuel'); }} onClick={() => handleStateChange('refueling')}
title="Auto-refuel from inventory" title="Auto-refuel from inventory"
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}} style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
> >
@@ -303,82 +390,20 @@ function TurtleDetails({ turtle }) {
</div> </div>
</div> </div>
<div className="detail-section">
<h3>Legacy Commands</h3>
<div className="command-grid">
<button
className="command-btn explore"
onClick={() => handleCommand('explore')}
title="Start autonomous exploration and mining"
>
🔍 Explore
</button>
<button
className="command-btn mine"
onClick={() => handleCommand('mine')}
title="Start mining mode"
>
Mine
</button>
<button
className="command-btn return"
onClick={() => handleCommand('returnHome')}
title="Navigate back to home position"
>
🏠 Return Home
</button>
<button
className="command-btn stop"
onClick={() => handleCommand('stop')}
title="Stop all autonomous actions"
>
Stop
</button>
<button
className="command-btn manual"
onClick={() => handleCommand('manual')}
title="Switch to manual control mode"
>
🎮 Manual Mode
</button>
<button
className="command-btn setHome"
onClick={() => handleCommand('setHome')}
title="Set current position as home"
>
📍 Set Home
</button>
<button
className="command-btn refuel"
onClick={() => handleCommand('refuel')}
title="Consume fuel from inventory"
>
Refuel
</button>
<button
className="command-btn status"
onClick={() => handleCommand('status')}
title="Request status update"
>
📊 Status
</button>
</div>
</div>
<div className="detail-section"> <div className="detail-section">
<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>
@@ -388,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
@@ -417,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(() => {

View File

@@ -736,6 +736,8 @@ function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures,
<mesh <mesh
key={blockKey} key={blockKey}
position={[block.x, block.y, block.z]} position={[block.x, block.y, block.z]}
castShadow
receiveShadow
onPointerOver={(e) => { e.stopPropagation(); setHoveredBlock(block); }} onPointerOver={(e) => { e.stopPropagation(); setHoveredBlock(block); }}
onPointerOut={() => setHoveredBlock(null)} onPointerOut={() => setHoveredBlock(null)}
onClick={(e) => { onClick={(e) => {
@@ -759,11 +761,9 @@ function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures,
map={faceTexture} map={faceTexture}
color="#ffffff" color="#ffffff"
emissive={appearance.emissive || '#000000'} emissive={appearance.emissive || '#000000'}
emissiveIntensity={isHovered ? 0.25 : (appearance.intensity || 0.02)} emissiveIntensity={isHovered ? 0.3 : (appearance.intensity || 0)}
roughness={0.8} roughness={0.85}
metalness={0.0} metalness={0.0}
transparent
opacity={isHovered ? 0.98 : 0.9}
/> />
))} ))}
</mesh> </mesh>
@@ -777,6 +777,8 @@ function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures,
<instancedMesh <instancedMesh
ref={meshRef} ref={meshRef}
args={[null, null, blockList.length]} args={[null, null, blockList.length]}
castShadow
receiveShadow
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerOut={handlePointerOut} onPointerOut={handlePointerOut}
onClick={handleClick} onClick={handleClick}
@@ -785,12 +787,10 @@ function BlockTypeInstances({ blockName, blockList, textures, multiFaceTextures,
<meshStandardMaterial <meshStandardMaterial
map={texture} map={texture}
color={texture ? '#ffffff' : appearance.color} color={texture ? '#ffffff' : appearance.color}
emissive={appearance.emissive || appearance.color} emissive={appearance.emissive || '#000000'}
emissiveIntensity={appearance.intensity || 0.05} emissiveIntensity={appearance.intensity || 0}
roughness={appearance.pattern === 'ore' ? 0.4 : 0.8} roughness={appearance.pattern === 'ore' ? 0.5 : 0.9}
metalness={appearance.pattern === 'ore' ? 0.2 : 0.0} metalness={appearance.pattern === 'ore' ? 0.15 : 0.0}
transparent
opacity={0.9}
/> />
</instancedMesh> </instancedMesh>
); );
@@ -858,8 +858,9 @@ function MiningArea({ area, turtle, isSelected, onClick }) {
const centerY = (area.startY + area.endY) / 2; const centerY = (area.startY + area.endY) / 2;
const centerZ = (area.startZ + area.endZ) / 2; const centerZ = (area.startZ + area.endZ) / 2;
// Color based on turtle or status // Color based on area's custom color, status fallback
const getColor = () => { const getColor = () => {
if (area.color) return area.color;
if (area.status === 'completed') return '#10b981'; // green if (area.status === 'completed') return '#10b981'; // green
if (area.status === 'mining') return '#f59e0b'; // orange if (area.status === 'mining') return '#f59e0b'; // orange
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
@@ -977,7 +978,7 @@ function PlayerMarker({ player }) {
outlineWidth={0.05} outlineWidth={0.05}
outlineColor="#000000" outlineColor="#000000"
> >
Player {player.playerID} {player.label || `Player ${player.playerID}`}
</Text> </Text>
</group> </group>
); );
@@ -1101,7 +1102,6 @@ function Scene({ interactionMode, onInteraction }) {
const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId); const selectedTurtleId = useTurtleStore((state) => state.selectedTurtleId);
const selectTurtle = useTurtleStore((state) => state.selectTurtle); const selectTurtle = useTurtleStore((state) => state.selectTurtle);
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []); const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
const sendCommand = useTurtleStore((state) => state.sendCommand);
const setTurtleState = useTurtleStore((state) => state.setTurtleState); const setTurtleState = useTurtleStore((state) => state.setTurtleState);
// Mining areas state // Mining areas state
@@ -1160,7 +1160,7 @@ function Scene({ interactionMode, onInteraction }) {
useEffect(() => { useEffect(() => {
const fetchMiningAreas = async () => { const fetchMiningAreas = async () => {
try { try {
const response = await fetch('http://localhost:3001/api/mining-areas'); const response = await fetch(`${window.location.origin}/api/mining-areas`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setMiningAreas(data); setMiningAreas(data);
@@ -1200,20 +1200,52 @@ function Scene({ interactionMode, onInteraction }) {
return ( return (
<> <>
<ambientLight intensity={0.6} /> {/* Soft ambient base - prevents pure black shadows */}
<pointLight position={[10, 10, 10]} intensity={1.2} /> <ambientLight intensity={0.3} color="#b0c4ff" />
<pointLight position={[-10, -10, -10]} intensity={0.6} />
<pointLight position={[0, 20, 0]} intensity={0.8} color="#ffffff" /> {/* Hemisphere light - sky blue from above, ground brown from below */}
<hemisphereLight
args={['#87ceeb', '#3b2f1e', 0.4]}
/>
{/* Main directional sunlight with shadows */}
<directionalLight
position={[30, 50, 20]}
intensity={1.8}
color="#fff5e6"
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-camera-far={150}
shadow-camera-left={-60}
shadow-camera-right={60}
shadow-camera-top={60}
shadow-camera-bottom={-60}
shadow-bias={-0.0005}
/>
{/* Subtle fill light from opposite side to soften shadows */}
<directionalLight
position={[-20, 10, -15]}
intensity={0.3}
color="#8ec5fc"
/>
{/* Shadow-receiving ground plane */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
<planeGeometry args={[200, 200]} />
<shadowMaterial transparent opacity={0.3} />
</mesh>
{/* Grid */} {/* Grid */}
<Grid <Grid
args={[100, 100]} args={[100, 100]}
cellSize={1} cellSize={1}
cellThickness={0.5} cellThickness={0.5}
cellColor="#1e293b" cellColor="#2a2a3a"
sectionSize={5} sectionSize={5}
sectionThickness={1} sectionThickness={1}
sectionColor="#334155" sectionColor="#3b3b4b"
fadeDistance={50} fadeDistance={50}
fadeStrength={1} fadeStrength={1}
followCamera={false} followCamera={false}
@@ -1265,9 +1297,11 @@ function Scene({ interactionMode, onInteraction }) {
))} ))}
{/* Players */} {/* Players */}
{players.map((player) => ( {players
<PlayerMarker key={player.playerID} player={player} /> .filter((p) => p.position && Date.now() - (p.timestamp || 0) < 30000)
))} .map((player) => (
<PlayerMarker key={player.playerID} player={player} />
))}
{/* Camera controls */} {/* Camera controls */}
<OrbitControls <OrbitControls
@@ -1299,12 +1333,12 @@ function InteractionToolbar({ mode, setMode, lastInteraction }) {
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
display: 'flex', display: 'flex',
gap: '4px', gap: '4px',
background: 'rgba(15, 23, 42, 0.9)', background: '#3b3b3b',
padding: '6px', padding: '6px',
borderRadius: '12px', border: '2px solid #1a1a1a',
border: '1px solid rgba(100, 116, 139, 0.3)', boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a, 0 4px 12px rgba(0,0,0,0.5)',
zIndex: 10, zIndex: 10,
backdropFilter: 'blur(10px)', fontFamily: "'Silkscreen', 'Courier New', monospace",
}}> }}>
{modes.map(m => ( {modes.map(m => (
<button <button
@@ -1313,16 +1347,20 @@ function InteractionToolbar({ mode, setMode, lastInteraction }) {
title={m.desc} title={m.desc}
style={{ style={{
padding: '8px 14px', padding: '8px 14px',
borderRadius: '8px', border: `2px solid ${mode === m.key ? '#4a8c2a' : '#1a1a1a'}`,
border: mode === m.key ? '2px solid #3b82f6' : '1px solid transparent', background: mode === m.key ? '#4a8c2a' : '#6b6b6b',
background: mode === m.key ? 'rgba(59, 130, 246, 0.2)' : 'rgba(30, 41, 59, 0.8)', color: mode === m.key ? '#ffffff' : '#e0e0e0',
color: mode === m.key ? '#60a5fa' : '#94a3b8',
cursor: 'pointer', cursor: 'pointer',
fontSize: '13px', fontSize: '12px',
fontFamily: "'Silkscreen', 'Courier New', monospace",
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '6px', gap: '6px',
transition: 'all 0.15s ease', transition: 'all 0.1s',
textShadow: '1px 1px 0 #1a1a1a',
boxShadow: mode === m.key
? 'inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a'
: 'inset 0 2px 0 #888, inset 0 -2px 0 #444',
}} }}
> >
<span style={{ fontSize: '16px' }}>{m.icon}</span> <span style={{ fontSize: '16px' }}>{m.icon}</span>
@@ -1338,12 +1376,14 @@ function InteractionToolbar({ mode, setMode, lastInteraction }) {
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
marginBottom: '8px', marginBottom: '8px',
padding: '6px 12px', padding: '6px 12px',
background: 'rgba(15, 23, 42, 0.95)', background: '#3b3b3b',
borderRadius: '8px', border: '2px solid #1a1a1a',
border: '1px solid rgba(100, 116, 139, 0.3)', boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a',
color: '#94a3b8', color: '#e0e0e0',
fontSize: '12px', fontSize: '11px',
fontFamily: "'Silkscreen', 'Courier New', monospace",
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textShadow: '1px 1px 0 #1a1a1a',
}}> }}>
{lastInteraction.mode === 'move' && `🎯 Moving turtle to (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`} {lastInteraction.mode === 'move' && `🎯 Moving turtle to (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
{lastInteraction.mode === 'build' && `🧱 Build at (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`} {lastInteraction.mode === 'build' && `🧱 Build at (${lastInteraction.target.x}, ${lastInteraction.target.y}, ${lastInteraction.target.z})`}
@@ -1370,11 +1410,13 @@ function BlockCountHUD({ blocks }) {
top: '12px', top: '12px',
right: '12px', right: '12px',
padding: '8px 12px', padding: '8px 12px',
background: 'rgba(15, 23, 42, 0.85)', background: '#3b3b3b',
borderRadius: '8px', border: '2px solid #1a1a1a',
border: '1px solid rgba(100, 116, 139, 0.2)', boxShadow: 'inset 0 2px 0 #555, inset 0 -2px 0 #2a2a2a, 0 2px 8px rgba(0,0,0,0.4)',
color: '#94a3b8', color: '#55ffff',
fontSize: '12px', fontSize: '11px',
fontFamily: "'Silkscreen', 'Courier New', monospace",
textShadow: '1px 1px 0 #1a1a1a',
zIndex: 10, zIndex: 10,
}}> }}>
🧊 {count.toLocaleString()} blocks · 📦 {chunkCount} chunks 🧊 {count.toLocaleString()} blocks · 📦 {chunkCount} chunks
@@ -1395,11 +1437,13 @@ export default function Map3D() {
}, []); }, []);
return ( return (
<div style={{ width: '100%', height: '100%', background: '#0a0e1a', position: 'relative' }}> <div style={{ width: '100%', height: '100%', background: '#1a1a2e', position: 'relative' }}>
<Canvas <Canvas
camera={{ position: [15, 15, 15], fov: 60 }} camera={{ position: [15, 15, 15], fov: 60 }}
style={{ background: '#0a0e1a' }} shadows
style={{ background: '#1a1a2e' }}
> >
<fog attach="fog" args={['#1a1a2e', 60, 120]} />
<Scene interactionMode={interactionMode} onInteraction={handleInteraction} /> <Scene interactionMode={interactionMode} onInteraction={handleInteraction} />
</Canvas> </Canvas>

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: '',
@@ -66,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'
}) })
}); });
@@ -74,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: '',
@@ -192,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>
); );
@@ -259,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
@@ -364,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

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

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

@@ -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

@@ -157,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

@@ -35,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') {
@@ -74,7 +81,8 @@ 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()
} }
} }
})); }));
@@ -216,35 +224,6 @@ export const useTurtleStore = create((set, get) => ({
set({ worldBlocks: Array.from(blockMap.values()) }); set({ worldBlocks: Array.from(blockMap.values()) });
}, },
sendCommand: async (turtleId, command, param = null) => {
const { ws } = get();
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${JSON.stringify(param)})` : '');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'command',
turtleID: turtleId,
command,
param
}));
console.log(' ✅ Sent via WebSocket');
} else {
// Fallback to REST API
console.log(' ⚠️ WebSocket not connected, using REST API fallback');
try {
await fetch(`${API_URL}/turtle/${turtleId}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, param })
});
console.log(' ✅ Sent via REST API');
} catch (error) {
console.error(' ❌ Error sending command:', error);
}
}
},
// Set turtle state machine state via REST API // Set turtle state machine state via REST API
setTurtleState: async (turtleId, stateName, stateData = {}) => { setTurtleState: async (turtleId, stateName, stateData = {}) => {
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData); console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
@@ -288,6 +267,37 @@ export const useTurtleStore = create((set, get) => ({
} }
}, },
// ========== 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 // Fetch chunk analyses from server
fetchChunkAnalyses: async () => { fetchChunkAnalyses: async () => {
try { try {

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)
@@ -556,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,
@@ -574,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
@@ -590,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,8 +18,10 @@ 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()
@@ -244,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)
@@ -258,7 +263,8 @@ 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
btnY = h - 7
addButton(1, btnY, 8, 2, "MANUAL", function() addButton(1, btnY, 8, 2, "MANUAL", function()
viewMode = "manual" viewMode = "manual"
sendCommand(turtle.turtleID, "manual") sendCommand(turtle.turtleID, "manual")
@@ -272,6 +278,45 @@ local function drawDetail()
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)
@@ -363,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"
@@ -558,10 +631,12 @@ 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
@@ -587,7 +662,7 @@ parallel.waitForAny(
end end
draw() draw()
elseif channel == CHANNEL_RECEIVE and type(message) == "table" then elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
-- State change confirmation or other response -- State change confirmation or other response
if message.type == "state_changed" and message.turtleID then if message.type == "state_changed" and message.turtleID then
for i, t in ipairs(turtles) do for i, t in ipairs(turtles) do

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

View File

@@ -98,13 +98,18 @@ export class Turtle extends EventEmitter {
this._messageIndex = 0; this._messageIndex = 0;
this._lastPromise = Promise.resolve(); // Promise chain for sequential exec this._lastPromise = Promise.resolve(); // Promise chain for sequential exec
// Fuel efficiency tracking
this._stepsSinceLastRefuel = 0;
this._totalSteps = 0;
this._totalFuelUsed = 0;
// Connection tracking // Connection tracking
this.connected = false; this.connected = false;
this.lastUpdate = Date.now(); this.lastUpdate = Date.now();
this.lastStatusBroadcast = 0; this.lastStatusBroadcast = 0;
// Legacy command queue (for pocket computer compatibility) // Command queue for eval commands sent to webbridge
this.pendingLegacyCommands = []; this.pendingCommands = [];
// Start in idle state // Start in idle state
this.setState('idle'); this.setState('idle');
@@ -288,8 +293,9 @@ export class Turtle extends EventEmitter {
/** /**
* Run the current state's act() generator in a loop * Run the current state's act() generator in a loop
*/ */
async _runStateLoop() { async _runStateLoop(consecutiveErrors = 0) {
const state = this._state; const state = this._state;
const MAX_CONSECUTIVE_ERRORS = 5;
try { try {
const generator = state.act(); const generator = state.act();
@@ -299,11 +305,35 @@ export class Turtle extends EventEmitter {
if (this._state !== state) { if (this._state !== state) {
return; // State changed, stop this generator return; // State changed, stop this generator
} }
// Reset error counter on successful iteration
consecutiveErrors = 0;
} }
} catch (error) { } catch (error) {
if (this._state === state) { if (this._state !== state) return;
consecutiveErrors++;
const isTimeout = error.message?.includes('timed out');
if (isTimeout && consecutiveErrors < MAX_CONSECUTIVE_ERRORS) {
console.warn(`[Turtle ${this.id}] Timeout in ${state.name} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}), retrying...`);
// Wait a bit then restart the state loop, passing the error count
await new Promise(r => setTimeout(r, 2000));
if (this._state === state) {
this._runStateLoop(consecutiveErrors);
}
} else {
console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message); console.error(`[Turtle ${this.id}] State error in ${state.name}:`, error.message);
this.setState('idle'); // Don't transition idle→idle (causes infinite loop)
if (state.name !== 'idle') {
this.setState('idle');
} else {
// Already idle and erroring — just wait and retry the idle loop
console.log(`[Turtle ${this.id}] Idle loop paused, will retry in 60s`);
await new Promise(r => setTimeout(r, 60000));
if (this._state === state) {
this._runStateLoop(0);
}
}
} }
} }
} }
@@ -328,6 +358,10 @@ export class Turtle extends EventEmitter {
// Set up timeout // Set up timeout
const timer = setTimeout(() => { const timer = setTimeout(() => {
const pending = this._pendingCommands.get(uuid);
if (pending) {
console.warn(`[Turtle ${this.id}] ⏰ Command timed out uuid:${uuid.substring(0, 8)} (pending: ${this._pendingCommands.size}, code: ${luaCode.substring(0, 50)})`);
}
this._pendingCommands.delete(uuid); this._pendingCommands.delete(uuid);
reject(new Error(`Command timed out after ${timeout}ms`)); reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout); }, timeout);
@@ -386,20 +420,48 @@ export class Turtle extends EventEmitter {
const turn = (direction - this._facing + 4) % 4; const turn = (direction - this._facing + 4) % 4;
if (turn === 1) { if (turn === 1) {
await this.exec('return turtle.turnRight()'); await this.exec(`turtle.turnRight(); _G._turtleFacing = ${direction}`);
this._facing = direction; this._facing = direction;
this._emitUpdate(); this._emitUpdate();
} else if (turn === 2) { } else if (turn === 2) {
await this.exec('turtle.turnLeft(); return turtle.turnLeft()'); await this.exec(`turtle.turnLeft(); turtle.turnLeft(); _G._turtleFacing = ${direction}`);
this._facing = direction; this._facing = direction;
this._emitUpdate(); this._emitUpdate();
} else if (turn === 3) { } else if (turn === 3) {
await this.exec('return turtle.turnLeft()'); await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${direction}`);
this._facing = direction; this._facing = direction;
this._emitUpdate(); this._emitUpdate();
} }
} }
/**
* Turn the turtle left
*/
async turnLeft() {
const result = await this.exec('return turtle.turnLeft()');
if (result === true || (Array.isArray(result) && result[0] === true)) {
this._facing = (this._facing + 3) % 4;
// Sync facing on turtle side
this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {});
this._emitUpdate();
}
return result;
}
/**
* Turn the turtle right
*/
async turnRight() {
const result = await this.exec('return turtle.turnRight()');
if (result === true || (Array.isArray(result) && result[0] === true)) {
this._facing = (this._facing + 1) % 4;
// Sync facing on turtle side
this.exec(`_G._turtleFacing = ${this._facing}`).catch(() => {});
this._emitUpdate();
}
return result;
}
/** /**
* Move the turtle forward. Updates position and deletes block at destination. * Move the turtle forward. Updates position and deletes block at destination.
*/ */
@@ -408,6 +470,8 @@ export class Turtle extends EventEmitter {
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
this.updatePositionForward(); this.updatePositionForward();
this._deleteBlockAtPosition(this._position); this._deleteBlockAtPosition(this._position);
this._stepsSinceLastRefuel++;
this._totalSteps++;
} }
return result; return result;
} }
@@ -428,6 +492,8 @@ export class Turtle extends EventEmitter {
this.position = pos; this.position = pos;
this._deleteBlockAtPosition(pos); this._deleteBlockAtPosition(pos);
} }
this._stepsSinceLastRefuel++;
this._totalSteps++;
} }
return result; return result;
} }
@@ -440,6 +506,8 @@ export class Turtle extends EventEmitter {
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
this.updatePositionUp(); this.updatePositionUp();
this._deleteBlockAtPosition(this._position); this._deleteBlockAtPosition(this._position);
this._stepsSinceLastRefuel++;
this._totalSteps++;
} }
return result; return result;
} }
@@ -452,6 +520,8 @@ export class Turtle extends EventEmitter {
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
this.updatePositionDown(); this.updatePositionDown();
this._deleteBlockAtPosition(this._position); this._deleteBlockAtPosition(this._position);
this._stepsSinceLastRefuel++;
this._totalSteps++;
} }
return result; return result;
} }
@@ -588,7 +658,7 @@ export class Turtle extends EventEmitter {
* Place block in front * Place block in front
*/ */
async place(text) { async place(text) {
const cmd = text ? `return turtle.place("${text}")` : 'return turtle.place()'; const cmd = text ? `return turtle.place("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.place()';
const result = await this.exec(cmd); const result = await this.exec(cmd);
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
await this.inspect(); // Auto-inspect to update world map await this.inspect(); // Auto-inspect to update world map
@@ -600,7 +670,7 @@ export class Turtle extends EventEmitter {
* Place block above * Place block above
*/ */
async placeUp(text) { async placeUp(text) {
const cmd = text ? `return turtle.placeUp("${text}")` : 'return turtle.placeUp()'; const cmd = text ? `return turtle.placeUp("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeUp()';
const result = await this.exec(cmd); const result = await this.exec(cmd);
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
await this.inspectUp(); await this.inspectUp();
@@ -612,7 +682,7 @@ export class Turtle extends EventEmitter {
* Place block below * Place block below
*/ */
async placeDown(text) { async placeDown(text) {
const cmd = text ? `return turtle.placeDown("${text}")` : 'return turtle.placeDown()'; const cmd = text ? `return turtle.placeDown("${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")` : 'return turtle.placeDown()';
const result = await this.exec(cmd); const result = await this.exec(cmd);
if (result === true || (Array.isArray(result) && result[0] === true)) { if (result === true || (Array.isArray(result) && result[0] === true)) {
await this.inspectDown(); await this.inspectDown();
@@ -683,10 +753,15 @@ export class Turtle extends EventEmitter {
*/ */
async refuel(count) { async refuel(count) {
const cmd = count != null ? `return turtle.refuel(${count})` : 'return turtle.refuel()'; const cmd = count != null ? `return turtle.refuel(${count})` : 'return turtle.refuel()';
const fuelBefore = this._fuel;
const result = await this.exec(cmd); const result = await this.exec(cmd);
// Update fuel level // Update fuel level
const fuelLevel = await this.exec('return turtle.getFuelLevel()'); const fuelLevel = await this.exec('return turtle.getFuelLevel()');
if (fuelLevel != null) this._fuel = fuelLevel; if (fuelLevel != null) {
this._totalFuelUsed += (fuelLevel - fuelBefore);
this._fuel = fuelLevel;
this._stepsSinceLastRefuel = 0;
}
return result; return result;
} }
@@ -720,7 +795,9 @@ export class Turtle extends EventEmitter {
* Rename the turtle (set computer label) * Rename the turtle (set computer label)
*/ */
async rename(name) { async rename(name) {
await this.exec(`os.setComputerLabel("${name}")`); // Escape quotes and backslashes for safe Lua string interpolation
const safeName = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
await this.exec(`os.setComputerLabel("${safeName}")`);
this._label = name; this._label = name;
this._emitUpdate(); this._emitUpdate();
} }
@@ -745,26 +822,98 @@ export class Turtle extends EventEmitter {
* GPS locate * GPS locate
*/ */
async gpsLocate() { async gpsLocate() {
const result = await this.exec('return gps.locate(5)'); const result = await this.exec('local x,y,z = gps.locate(5); if x then return {x=x, y=y, z=z} else return nil end');
if (result && typeof result === 'object' && result[1] != null) { if (result && result.x != null) {
this.position = { x: Math.floor(result[1]), y: Math.floor(result[2]), z: Math.floor(result[3]) }; this.position = { x: Math.floor(result.x), y: Math.floor(result.y), z: Math.floor(result.z) };
} else if (typeof result === 'number') {
// Some versions return x,y,z as separate values
const pos = await this.exec('local x,y,z = gps.locate(5); return {x=x, y=y, z=z}');
if (pos && pos.x != null) {
this.position = { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) };
}
} }
return this._position; return this._position;
} }
/**
* Write content to a file on the turtle's filesystem
* @param {string} path - The file path on the turtle (e.g., 'log.txt', 'scripts/miner.lua')
* @param {string} content - The content to write
* @param {boolean} append - Whether to append instead of overwrite
*/
async writeToFile(path, content, append = false) {
const safePath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// Encode content as base64-like hex to avoid Lua string escaping issues
const mode = append ? 'a' : 'w';
// Split content into chunks to avoid oversized eval commands
const chunkSize = 4000;
const chunks = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push(content.substring(i, i + chunkSize));
}
if (chunks.length <= 1) {
// Single write for small files
const safeContent = (chunks[0] || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
return this.exec(`
local f = fs.open("${safePath}", "${mode}")
if f then
f.write("${safeContent}")
f.close()
return true
end
return false, "Cannot open file"
`);
}
// Multi-chunk write for large files
for (let i = 0; i < chunks.length; i++) {
const safeChunk = chunks[i].replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '');
const writeMode = i === 0 ? mode : 'a';
const result = await this.exec(`
local f = fs.open("${safePath}", "${writeMode}")
if f then
f.write("${safeChunk}")
f.close()
return true
end
return false, "Cannot open file"
`);
if (result !== true) return result;
}
return true;
}
/**
* Refresh detailed inventory state for all 16 slots.
* Gets name, count, damage, and maxCount for each slot.
*/
async refreshInventoryState() {
const result = await this.exec(`
local inv = {}
for slot = 1, 16 do
local item = turtle.getItemDetail(slot)
if item then
inv[tostring(slot)] = {
name = item.name,
count = item.count,
damage = item.damage or 0,
maxCount = turtle.getItemSpace(slot) + item.count
}
end
end
return inv
`);
if (result && typeof result === 'object') {
this._inventory = result;
this._emitUpdate();
}
return result;
}
/** /**
* Connect to an adjacent inventory peripheral and read its contents * Connect to an adjacent inventory peripheral and read its contents
*/ */
async connectToInventory(side) { async connectToInventory(side) {
const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const result = await this.exec(` const result = await this.exec(`
local size = peripheral.call("${side}", "size") local size = peripheral.call("${safeSide}", "size")
local content = peripheral.call("${side}", "list") local content = peripheral.call("${safeSide}", "list")
if next(content) == nil then content = nil end if next(content) == nil then content = nil end
return {size = size, content = content} return {size = size, content = content}
`); `);
@@ -810,13 +959,15 @@ export class Turtle extends EventEmitter {
* Use a peripheral method by side * Use a peripheral method by side
*/ */
async usePeripheralWithSide(side, method, ...args) { async usePeripheralWithSide(side, method, ...args) {
const safeSide = side.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const safeMethod = method.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const argsStr = args.map(a => { const argsStr = args.map(a => {
if (a === null) return 'nil'; if (a === null) return 'nil';
if (typeof a === 'string') return `"${a}"`; if (typeof a === 'string') return `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return String(a); return String(a);
}).join(', '); }).join(', ');
return this.exec(`return peripheral.call("${side}", "${method}"${argsStr ? ', ' + argsStr : ''})`); return this.exec(`return peripheral.call("${safeSide}", "${safeMethod}"${argsStr ? ', ' + argsStr : ''})`);
} }
/** /**
@@ -1060,6 +1211,9 @@ export class Turtle extends EventEmitter {
peripherals: this._peripherals, peripherals: this._peripherals,
error: this._error, error: this._error,
warning: this._warning, warning: this._warning,
stepsSinceLastRefuel: this._stepsSinceLastRefuel,
totalSteps: this._totalSteps,
totalFuelUsed: this._totalFuelUsed,
}; };
} }

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

@@ -85,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 (
@@ -144,10 +160,22 @@ 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) // Chunk analysis table (ore density per chunk)
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS chunks ( CREATE TABLE IF NOT EXISTS chunks (
@@ -260,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
@@ -347,12 +380,22 @@ export function getNextTask() {
} }
export function assignTask(taskId, turtleId) { export function assignTask(taskId, turtleId) {
const stmt = db.prepare(` if (turtleId === null || turtleId === undefined) {
UPDATE task_queue // Un-assign: clear turtle and revert to pending
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ? const stmt = db.prepare(`
WHERE id = ? UPDATE task_queue
`); SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
stmt.run(turtleId, Date.now(), taskId); 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) { export function updateTaskStatus(taskId, status, result = null) {
@@ -394,16 +437,17 @@ export function getAllTasks(status = null) {
} }
// Mining Areas // Mining Areas
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned') { export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
const stmt = db.prepare(` 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const now = Date.now(); const now = Date.now();
const result = 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,
areaName, color,
status, now, now status, now, now
); );
return result.lastInsertRowid; return result.lastInsertRowid;
@@ -423,6 +467,24 @@ export function updateMiningAreaStatus(areaId, status) {
stmt.run(status, Date.now(), areaId); 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) { export function deleteMiningArea(areaId) {
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?'); const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
return stmt.run(areaId); return stmt.run(areaId);
@@ -566,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

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

@@ -1,16 +1,34 @@
import express from 'express';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import cors from 'cors'; import { createRequire } from 'module';
import { createServer } from 'http';
import * as db from './database.js'; import * as db from './database.js';
import { Turtle } from './Turtle.js'; import { Turtle } from './Turtle.js';
import { TaskDispatcher } from './TaskDispatcher.js';
import { WorldBlockCache } from './WorldBlockCache.js';
const app = express(); const require = createRequire(import.meta.url);
const PORT = 3001; const {
const WS_PORT = 3002; createPlatformServer,
setupGracefulShutdown,
createProxyEndpoint,
} = require('@cc-platform/server');
app.use(cors()); // ========== Platform Server Setup ==========
app.use(express.json({ limit: '5mb' })); const { app, server, auth, start, port } = createPlatformServer({
serviceName: 'turtle-control',
port: 3001,
cors: true,
});
const { requireAuth } = auth;
const API_KEY = process.env.API_KEY || '';
// Rewrite requests that arrive without /api prefix (from reverse proxy stripping it)
app.use((req, res, next) => {
if (!req.path.startsWith('/api') && req.path !== '/' && req.path !== '/health') {
req.url = '/api' + req.url;
}
next();
});
// Initialize database // Initialize database
db.initializeDatabase(); db.initializeDatabase();
@@ -18,48 +36,33 @@ db.initializeDatabase();
// Load persisted data from database // Load persisted data from database
console.log('📂 Loading persisted data from database...'); console.log('📂 Loading persisted data from database...');
const savedHomes = db.getAllTurtleHomes(); const savedHomes = db.getAllTurtleHomes();
const savedBlocks = db.getWorldBlocks(); const blockCount = db.getWorldBlockCount();
console.log(` Loaded ${savedHomes.length} turtle homes`); console.log(` Loaded ${savedHomes.length} turtle homes`);
console.log(` Loaded ${savedBlocks.length} world blocks`); console.log(` ${blockCount} world blocks in database (demand-loaded)`);
// Store connected web clients and turtle data // Store connected web clients and turtle data
const webClients = new Set(); const webClients = new Set();
const turtles = new Map(); // turtleID -> Turtle instance const turtles = new Map(); // turtleID -> Turtle instance
const worldBlocks = new Map(); // "x,y,z" -> {name, metadata, discoveredBy, timestamp} const worldBlocks = new WorldBlockCache(db, parseInt(process.env.BLOCK_CACHE_SIZE || '50000', 10));
const turtleHomes = new Map(); // turtleID -> {x, y, z} home position const turtleHomes = new Map(); // turtleID -> {x, y, z} home position
const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc} const turtleConfig = new Map(); // turtleID -> {maxDistance, facing, etc}
// Legacy compat: turtleData getter (returns serialized Turtle data)
const turtleData = {
has(id) { return turtles.has(id); },
get(id) { const t = turtles.get(id); return t ? t.toJSON() : undefined; },
set(id, _val) { /* no-op, use getOrCreateTurtle */ },
delete(id) { return turtles.delete(id); },
get size() { return turtles.size; },
entries() { return Array.from(turtles.entries()).map(([id, t]) => [id, t.toJSON()])[Symbol.iterator](); },
values() { return Array.from(turtles.values()).map(t => t.toJSON())[Symbol.iterator](); },
keys() { return turtles.keys(); },
};
// Load saved homes into memory // Load saved homes into memory
for (const home of savedHomes) { for (const home of savedHomes) {
turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z }); turtleHomes.set(home.turtle_id, { x: home.x, y: home.y, z: home.z });
} }
// Load saved blocks into memory
for (const block of savedBlocks) {
const key = `${block.x},${block.y},${block.z}`;
worldBlocks.set(key, {
name: block.block_name,
metadata: block.metadata,
discoveredBy: block.discovered_by,
timestamp: block.discovered_at
});
}
// Timeout for considering turtles offline (30 seconds) // Timeout for considering turtles offline (30 seconds)
const TURTLE_TIMEOUT = 30000; const TURTLE_TIMEOUT = 30000;
// Task dispatcher — automatically assigns pending tasks to idle turtles
const taskDispatcher = new TaskDispatcher({
turtles,
db,
broadcastToClients: (data) => broadcastToClients(data),
pollInterval: parseInt(process.env.DISPATCH_INTERVAL || '5000', 10),
});
// Broadcast to all web clients // Broadcast to all web clients
function broadcastToClients(data) { function broadcastToClients(data) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
@@ -103,13 +106,9 @@ function getOrCreateTurtle(turtleID) {
// ---- Event handlers ---- // ---- Event handlers ----
// Forward eval commands to webbridge via pending command queue // Forward eval commands to webbridge via WebSocket (or fallback to poll queue)
turtle.on('sendCommand', (command) => { turtle.on('sendCommand', (command) => {
// Queue eval command for webbridge to poll pushCommandToBridge(turtleID, command);
turtle.pendingLegacyCommands.push({
...command,
timestamp: Date.now(),
});
}); });
// Store discovered blocks // Store discovered blocks
@@ -202,32 +201,179 @@ function getBlockPosition(turtlePos, facing, direction) {
return pos; return pos;
} }
// Create HTTP server // WebSocket server for web clients AND bridge connections
const server = createServer(app); const wss = new WebSocketServer({ server });
// WebSocket server for web clients // Track bridge connections separately
const wss = new WebSocketServer({ port: WS_PORT }); const bridgeClients = new Set();
console.log(`🚀 Turtle Control Server starting...`); /**
console.log(`📡 HTTP Server: http://localhost:${PORT}`); * Push a command to the webbridge for a specific turtle.
console.log(`🔌 WebSocket Server: ws://localhost:${WS_PORT}`); * If a bridge is connected via WebSocket, send instantly.
* Otherwise, queue for HTTP polling (fallback).
*/
function pushCommandToBridge(turtleID, command) {
let sent = false;
for (const bridge of bridgeClients) {
if (bridge.readyState === 1) { // OPEN
bridge.send(JSON.stringify({
type: 'command',
turtleID,
command,
}));
sent = true;
}
}
if (!sent) {
// Fallback: queue for HTTP polling (backward compatible)
const turtle = turtles.get(turtleID);
if (turtle) {
turtle.pendingCommands.push({ ...command, timestamp: Date.now() });
console.log(`[Bridge] Queued command for T#${turtleID} via HTTP poll (no WS bridge, ${bridgeClients.size} bridges)`);
}
}
}
// WebSocket connection handler // WebSocket connection handler
wss.on('connection', (ws) => { wss.on('connection', (ws, req) => {
const url = req.url || '';
// ---- Bridge WebSocket connection ----
if (url.startsWith('/ws/bridge')) {
console.log('🌉 Webbridge connected via WebSocket');
bridgeClients.add(ws);
// Send list of known turtle IDs so bridge knows who to listen for
ws.send(JSON.stringify({
type: 'init',
turtleIDs: Array.from(turtles.keys()),
}));
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw);
// Handle keepalive pings from bridge
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (data.type === 'status') {
// Turtle status update forwarded from bridge
const turtleID = data.turtleID;
if (!turtleID) return;
const turtle = getOrCreateTurtle(turtleID);
turtle.updateFromStatus(data);
// Store home position if provided
if (data.homePosition) {
turtleHomes.set(turtleID, data.homePosition);
db.saveTurtleHome(turtleID, data.homePosition);
}
} else if (data.type === 'eval_response') {
// Eval response from turtle via bridge
const turtle = turtles.get(data.turtleID);
if (turtle) {
const handled = turtle.handleResponse(data.uuid, data.result, data.error);
if (handled) {
console.log(`📩 Eval response T#${data.turtleID} uuid:${(data.uuid || '').substring(0, 8)} - resolved`);
}
}
} else if (data.type === 'event') {
// Real-time events (inventory, peripheral)
const turtle = turtles.get(data.turtleID);
if (turtle) {
turtle.handleEvent(data.eventType, data.message);
}
} else if (data.type === 'request_home') {
// Turtle requesting home position
const home = turtleHomes.get(data.turtleID);
ws.send(JSON.stringify({
type: 'home_position',
turtleID: data.turtleID,
homePosition: home || null,
}));
} else if (data.type === 'set_home') {
// Turtle setting home position
const pos = data.position;
if (pos && data.turtleID) {
turtleHomes.set(data.turtleID, pos);
db.saveTurtleHome(data.turtleID, pos);
const turtle = turtles.get(data.turtleID);
if (turtle) turtle.homePosition = pos;
ws.send(JSON.stringify({
type: 'home_set_confirm',
turtleID: data.turtleID,
homePosition: pos,
}));
}
} else if (data.type === 'blocks_discovered') {
// Batch block discovery from turtle
if (data.blocks && Array.isArray(data.blocks)) {
for (const block of data.blocks) {
storeBlock(block.x, block.y, block.z, { name: block.name, metadata: block.metadata || 0 }, data.turtleID || block.discoveredBy);
}
broadcastToClients({
type: 'blocks_discovered',
blocks: data.blocks.map(b => ({
x: b.x, y: b.y, z: b.z,
name: b.name, metadata: b.metadata || 0,
discoveredBy: data.turtleID || b.discoveredBy,
timestamp: Date.now(),
})),
});
}
} else if (data.type === 'player_update') {
// Player position from pocket computer
if (data.playerID && data.position) {
db.savePlayerPosition(data.playerID, data.position, data.label || null);
broadcastToClients({
type: 'player_update',
playerID: data.playerID,
position: data.position,
label: data.label || null,
timestamp: Date.now(),
});
}
}
} catch (error) {
console.error('❌ Bridge WS message error:', error.message);
}
});
ws.on('close', () => {
console.log('🌉 Webbridge disconnected');
bridgeClients.delete(ws);
});
ws.on('error', (error) => {
console.error('❌ Bridge WS error:', error);
bridgeClients.delete(ws);
});
return;
}
// ---- Web client WebSocket connection ----
console.log('🌐 New web client connected'); console.log('🌐 New web client connected');
webClients.add(ws); webClients.add(ws);
// Send current turtle data and world blocks to new client // Send current turtle data and world blocks to new client
const blocks = []; const blocks = worldBlocks.getAllBlocksForAPI();
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({ x, y, z, ...blockData });
}
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'initial_state', type: 'initial_state',
turtles: Array.from(turtles.values()).map(t => t.toJSON()), turtles: Array.from(turtles.values()).map(t => t.toJSON()),
blocks: blocks blocks: blocks,
players: db.getAllPlayerPositions()
})); }));
ws.on('message', (message) => { ws.on('message', (message) => {
@@ -241,20 +387,14 @@ wss.on('connection', (ws) => {
const turtle = turtles.get(turtleID); const turtle = turtles.get(turtleID);
if (turtle) { if (turtle) {
// Check for state change commands // All commands are state changes — server controls all turtle movement
if (data.command === 'set_state' || data.command === 'setState') { if (data.command === 'set_state' || data.command === 'setState') {
const stateName = data.param?.state || data.param; const stateName = data.param?.state || data.param;
const stateData = data.param?.data || {}; const stateData = data.param?.data || {};
turtle.setState(stateName, stateData); turtle.setState(stateName, stateData);
console.log(`✅ State set for turtle ${turtleID}: ${stateName}`); console.log(`✅ State set for turtle ${turtleID}: ${stateName}`);
} else { } else {
// Legacy command - queue for webbridge polling console.log(`⚠️ Unrecognized command from web client: ${data.command} — use state machine or server-side action routes`);
turtle.pendingLegacyCommands.push({
command: data.command,
param: data.param,
timestamp: Date.now()
});
console.log(`✅ Command queued for turtle ${turtleID}: ${data.command}`, data.param ? `(param: ${JSON.stringify(data.param)})` : '');
} }
} else { } else {
console.log(`❌ Turtle ${turtleID} not found`); console.log(`❌ Turtle ${turtleID} not found`);
@@ -338,25 +478,23 @@ app.get('/api/turtle/:id/commands', (req, res) => {
const turtle = turtles.get(turtleID); const turtle = turtles.get(turtleID);
if (turtle) { if (turtle) {
// Combine eval commands + legacy commands // Get pending eval commands for webbridge
const commands = turtle.pendingLegacyCommands || []; const commands = turtle.pendingCommands || [];
// Clean up old commands (older than 30 seconds) // Clean up old commands (older than 30 seconds)
const now = Date.now(); const now = Date.now();
turtle.pendingLegacyCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000); turtle.pendingCommands = commands.filter(cmd => (now - cmd.timestamp) < 30000);
if (turtle.pendingLegacyCommands.length > 0) { if (turtle.pendingCommands.length > 0) {
console.log(`📤 Sending ${turtle.pendingLegacyCommands.length} command(s) to turtle ${turtleID}`); console.log(`📤 Sending ${turtle.pendingCommands.length} eval command(s) to turtle ${turtleID}`);
turtle.pendingLegacyCommands.forEach(cmd => { turtle.pendingCommands.forEach(cmd => {
if (cmd.type === 'eval') { if (cmd.type === 'eval') {
console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`); console.log(` - EVAL ${(cmd.uuid || '').substring(0, 8)}`);
} else {
console.log(` - ${cmd.command}`, cmd.param ? `(${JSON.stringify(cmd.param)})` : '');
} }
}); });
} }
res.json({ commands: turtle.pendingLegacyCommands }); res.json({ commands: turtle.pendingCommands });
} else { } else {
res.json({ commands: [] }); res.json({ commands: [] });
} }
@@ -373,11 +511,11 @@ app.post('/api/turtle/:id/commands/ack', (req, res) => {
const turtle = turtles.get(turtleID); const turtle = turtles.get(turtleID);
if (turtle) { if (turtle) {
const clearedCount = (turtle.pendingLegacyCommands || []).length; const clearedCount = (turtle.pendingCommands || []).length;
if (clearedCount > 0) { if (clearedCount > 0) {
console.log(`✅ Turtle ${turtleID} acknowledged ${clearedCount} command(s)`); console.log(`✅ Turtle ${turtleID} acknowledged ${clearedCount} command(s)`);
turtle.pendingLegacyCommands = []; turtle.pendingCommands = [];
} }
res.json({ success: true, cleared: clearedCount }); res.json({ success: true, cleared: clearedCount });
@@ -472,14 +610,7 @@ app.get('/api/turtle/:id/home', (req, res) => {
// Get world blocks for map visualization // Get world blocks for map visualization
app.get('/api/world/blocks', (req, res) => { app.get('/api/world/blocks', (req, res) => {
const blocks = []; const blocks = worldBlocks.getAllBlocksForAPI();
for (const [key, blockData] of worldBlocks.entries()) {
const [x, y, z] = key.split(',').map(Number);
blocks.push({
x, y, z,
...blockData
});
}
res.json({ blocks }); res.json({ blocks });
}); });
@@ -516,22 +647,17 @@ app.post('/api/turtle/:id/command', (req, res) => {
const turtle = turtles.get(turtleID); const turtle = turtles.get(turtleID);
if (turtle) { if (turtle) {
// Check for state change commands // All commands are state changes — server controls all turtle movement
if (command === 'set_state' || command === 'setState') { if (command === 'set_state' || command === 'setState') {
const stateName = param?.state || param; const stateName = param?.state || param;
const stateData = param?.data || {}; const stateData = param?.data || {};
turtle.setState(stateName, stateData); turtle.setState(stateName, stateData);
console.log(`📤 State set for turtle ${turtleID}: ${stateName}`); console.log(`📤 State set for turtle ${turtleID}: ${stateName}`);
res.json({ success: true });
} else { } else {
// Legacy command - queue for webbridge console.log(`⚠️ Legacy command rejected for turtle ${turtleID}: ${command} — use state machine or server-side action routes`);
turtle.pendingLegacyCommands.push({ res.status(400).json({ error: 'Legacy commands are no longer supported. Use state machine or action routes.' });
command,
param,
timestamp: Date.now()
});
console.log(`📤 Command queued for turtle ${turtleID}:`, command);
} }
res.json({ success: true });
} else { } else {
res.status(404).json({ error: 'Turtle not found' }); res.status(404).json({ error: 'Turtle not found' });
} }
@@ -541,6 +667,46 @@ app.post('/api/turtle/:id/command', (req, res) => {
} }
}); });
// ========== SERVER-SIDE MOVEMENT & ACTION ENDPOINTS ==========
// Generic helper for turtle action routes
function turtleAction(routePath, actionFn) {
app.post(`/api/turtle/:id/${routePath}`, async (req, res) => {
try {
const turtleID = parseInt(req.params.id);
const turtle = turtles.get(turtleID);
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
const result = await actionFn(turtle, req.body);
res.json({ success: true, result });
} catch (error) {
console.error(`❌ Error in /${routePath} for turtle ${req.params.id}:`, error.message);
res.json({ success: false, error: error.message });
}
});
}
// Movement
turtleAction('forward', (t) => t.forward());
turtleAction('back', (t) => t.back());
turtleAction('up', (t) => t.up());
turtleAction('down', (t) => t.down());
turtleAction('turnLeft', (t) => t.turnLeft());
turtleAction('turnRight', (t) => t.turnRight());
// Digging
turtleAction('dig', (t) => t.dig());
turtleAction('digUp', (t) => t.digUp());
turtleAction('digDown', (t) => t.digDown());
// Placing
turtleAction('place', (t, body) => t.place(body?.text));
turtleAction('placeUp', (t, body) => t.placeUp(body?.text));
turtleAction('placeDown', (t, body) => t.placeDown(body?.text));
// Refuel (via server)
turtleAction('refuel-action', (t, body) => t.refuel(body?.count));
// ========== EVAL PROTOCOL ENDPOINTS ========== // ========== EVAL PROTOCOL ENDPOINTS ==========
// Receive eval response from webbridge (turtle -> webbridge -> server) // Receive eval response from webbridge (turtle -> webbridge -> server)
@@ -884,6 +1050,31 @@ app.post('/api/tasks/:taskId/complete', (req, res) => {
} }
}); });
// Cancel a running task (stops turtle + marks cancelled)
app.post('/api/tasks/:taskId/cancel', (req, res) => {
try {
const taskId = parseInt(req.params.taskId);
taskDispatcher.cancelTask(taskId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== TASK DISPATCHER ENDPOINTS ==========
// Get dispatcher status
app.get('/api/dispatcher/status', (req, res) => {
res.json(taskDispatcher.status());
});
// Enable/disable auto-dispatch
app.post('/api/dispatcher/toggle', (req, res) => {
const { enabled } = req.body;
taskDispatcher.enabled = enabled !== undefined ? enabled : !taskDispatcher.enabled;
res.json({ enabled: taskDispatcher.enabled });
});
// ========== MINING AREA ENDPOINTS ========== // ========== MINING AREA ENDPOINTS ==========
// Helper to format mining area for API response // Helper to format mining area for API response
@@ -891,6 +1082,8 @@ function formatMiningArea(area) {
return { return {
areaID: area.id, areaID: area.id,
turtleID: area.turtle_id, turtleID: area.turtle_id,
areaName: area.name || `Area #${area.id}`,
color: area.color || '#4a8c2a',
startX: area.min_x, startX: area.min_x,
startY: area.min_y, startY: area.min_y,
startZ: area.min_z, startZ: area.min_z,
@@ -906,7 +1099,7 @@ function formatMiningArea(area) {
// Save a mining area // Save a mining area
app.post('/api/mining-areas', (req, res) => { app.post('/api/mining-areas', (req, res) => {
try { try {
const { turtleId, turtleID, bounds, startX, startY, startZ, endX, endY, endZ, areaName, status } = req.body; const { turtleId, turtleID, bounds, startX, startY, startZ, endX, endY, endZ, areaName, status, color } = req.body;
const tid = turtleId || turtleID; const tid = turtleId || turtleID;
// Support both {turtleId, bounds} and flat {startX,startY,...} formats // Support both {turtleId, bounds} and flat {startX,startY,...} formats
@@ -919,7 +1112,7 @@ app.post('/api/mining-areas', (req, res) => {
maxZ: Math.max(Number(startZ), Number(endZ)) maxZ: Math.max(Number(startZ), Number(endZ))
}; };
db.saveMiningArea(tid, areaBounds, areaName || null, status || 'planned'); db.saveMiningArea(tid, areaBounds, areaName || null, status || 'planned', color || '#4a8c2a');
const areas = db.getMiningAreas(); const areas = db.getMiningAreas();
broadcastToClients({ broadcastToClients({
@@ -943,12 +1136,21 @@ app.get('/api/mining-areas', (req, res) => {
} }
}); });
// Update mining area status // Update mining area
app.put('/api/mining-areas/:areaId', (req, res) => { app.put('/api/mining-areas/:areaId', (req, res) => {
try { try {
const areaId = parseInt(req.params.areaId); const areaId = parseInt(req.params.areaId);
const { status } = req.body; const { status, name, color } = req.body;
db.updateMiningAreaStatus(areaId, status);
// Build updates object with only provided fields
const updates = {};
if (status !== undefined) updates.status = status;
if (name !== undefined) updates.name = name;
if (color !== undefined) updates.color = color;
if (Object.keys(updates).length > 0) {
db.updateMiningArea(areaId, updates);
}
const areas = db.getMiningAreas(); const areas = db.getMiningAreas();
broadcastToClients({ broadcastToClients({
@@ -1033,17 +1235,63 @@ app.post('/api/chunks', (req, res) => {
} }
}); });
// Analyze a chunk (compute ore density from discovered blocks)
app.post('/api/chunks/:x/:z/analyze', (req, res) => {
try {
const chunkX = parseInt(req.params.x);
const chunkZ = parseInt(req.params.z);
// Chunk bounds in world coordinates (16x16 columns, full Y range)
const minX = chunkX * 16;
const maxX = minX + 15;
const minZ = chunkZ * 16;
const maxZ = minZ + 15;
// Get all blocks in the chunk from the DB
const blocks = db.getWorldBlocksInArea(minX, -64, minZ, maxX, 320, maxZ);
// Count ores
const oreCounts = {};
let totalBlocks = 0;
if (blocks && Array.isArray(blocks)) {
for (const block of blocks) {
totalBlocks++;
if (block.block_name && block.block_name.includes('ore')) {
oreCounts[block.block_name] = (oreCounts[block.block_name] || 0) + 1;
}
}
}
const analysis = {
x: chunkX,
z: chunkZ,
totalBlocks,
ores: oreCounts,
scannedAt: Date.now(),
};
// Save the analysis
db.saveChunkAnalysis(chunkX, chunkZ, analysis);
res.json(analysis);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Search blocks by name pattern in area // Search blocks by name pattern in area
app.get('/api/world/blocks/search', (req, res) => { app.get('/api/world/blocks/search', (req, res) => {
try { try {
const { fromX, fromY, fromZ, toX, toY, toZ, pattern } = req.query; const { fromX, fromY, fromZ, toX, toY, toZ, pattern, name } = req.query;
if (!pattern) { const searchPattern = pattern || name;
return res.status(400).json({ error: 'Missing pattern parameter' }); if (!searchPattern) {
return res.status(400).json({ error: 'Missing pattern or name parameter' });
} }
const blocks = db.getBlocksWithNameLike( const blocks = db.getBlocksWithNameLike(
parseInt(fromX) || -1000, parseInt(fromY) || -64, parseInt(fromZ) || -1000, parseInt(fromX) || -1000, parseInt(fromY) || -64, parseInt(fromZ) || -1000,
parseInt(toX) || 1000, parseInt(toY) || 320, parseInt(toZ) || 1000, parseInt(toX) || 1000, parseInt(toY) || 320, parseInt(toZ) || 1000,
pattern searchPattern
); );
res.json({ blocks }); res.json({ blocks });
} catch (error) { } catch (error) {
@@ -1265,18 +1513,15 @@ app.post('/api/groups/:groupId/command', (req, res) => {
for (const member of members) { for (const member of members) {
const turtle = turtles.get(member.turtle_id); const turtle = turtles.get(member.turtle_id);
if (turtle) { if (turtle) {
// All group commands are state changes — server controls all movement
if (command === 'set_state' || command === 'setState') { if (command === 'set_state' || command === 'setState') {
const stateName = param?.state || param; const stateName = param?.state || param;
const stateData = param?.data || {}; const stateData = param?.data || {};
turtle.setState(stateName, stateData); turtle.setState(stateName, stateData);
successCount++;
} else { } else {
turtle.pendingLegacyCommands.push({ console.log(`⚠️ Legacy group command rejected: ${command}`);
command,
param,
timestamp: Date.now()
});
} }
successCount++;
} }
} }
@@ -1289,19 +1534,20 @@ app.post('/api/groups/:groupId/command', (req, res) => {
// Player position endpoints // Player position endpoints
app.post('/api/player/update', (req, res) => { app.post('/api/player/update', (req, res) => {
try { try {
const { playerID, position, timestamp } = req.body; const { playerID, position, timestamp, label } = req.body;
if (!playerID || !position) { if (!playerID || !position) {
return res.status(400).json({ error: 'Missing playerID or position' }); return res.status(400).json({ error: 'Missing playerID or position' });
} }
db.savePlayerPosition(playerID, position); db.savePlayerPosition(playerID, position, label || null);
// Broadcast to WebSocket clients // Broadcast to WebSocket clients
broadcastToClients({ broadcastToClients({
type: 'player_update', type: 'player_update',
playerID, playerID,
position, position,
label: label || null,
timestamp: timestamp || Date.now() timestamp: timestamp || Date.now()
}); });
@@ -1580,15 +1826,83 @@ app.post('/api/turtle/:id/gps', async (req, res) => {
} }
}); });
// Graceful shutdown // Write a file to turtle filesystem
process.on('SIGINT', () => { app.post('/api/turtle/:id/write-file', async (req, res) => {
console.log('\n🛑 Shutting down server...'); try {
db.closeDatabase(); const turtleID = parseInt(req.params.id);
process.exit(0); const { path, content, append } = req.body;
const turtle = turtles.get(turtleID);
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
if (!path || content === undefined) return res.status(400).json({ error: 'Missing path or content' });
const result = await turtle.writeToFile(path, content, append || false);
res.json({ success: result === true, result });
} catch (error) {
res.json({ success: false, error: error.message });
}
}); });
server.listen(PORT, () => { // Refresh detailed inventory state
console.log(`✅ Server ready!`); app.post('/api/turtle/:id/refresh-inventory', async (req, res) => {
console.log(`\nConfigured turtles to send updates to:`); try {
console.log(` http://localhost:${PORT}/api/turtle/update`); const turtleID = parseInt(req.params.id);
const turtle = turtles.get(turtleID);
if (!turtle) return res.status(404).json({ error: 'Turtle not found' });
const inventory = await turtle.refreshInventoryState();
res.json({ success: true, inventory });
} catch (error) {
res.json({ success: false, error: error.message });
}
});
// ========== Cross-Project Integration API ==========
// These endpoints allow the Inventory Manager system to query turtle state
// Get all turtle summaries (for inventory dashboard sidebar widget)
app.get('/api/integration/turtle-summary', (req, res) => {
const summaries = [];
for (const [id, turtle] of turtles) {
summaries.push({
id,
name: turtle.name || `Turtle ${id}`,
state: turtle.currentStateName || 'unknown',
fuel: turtle.fuelLevel ?? null,
position: turtle.position || null,
inventoryUsed: turtle.inventory
? turtle.inventory.filter(s => s && s.name).length
: 0,
connected: turtle.connected || false,
});
}
res.json({ turtles: summaries });
});
// Proxy to inventory server (locate items for turtle pickup)
createProxyEndpoint(app, '/api/integration/inventory-locate', 'INVENTORY_SERVER_URL', '/api/integration/locate-item');
// Proxy to inventory server (low stock alerts — turtles could auto-mine missing resources)
createProxyEndpoint(app, '/api/integration/inventory-alerts', 'INVENTORY_SERVER_URL', '/api/integration/low-stock');
// Proxy to inventory server (storage space check — should turtles keep mining?)
createProxyEndpoint(app, '/api/integration/storage-status', 'INVENTORY_SERVER_URL', '/api/integration/storage-status');
// ========== Graceful Shutdown & Start ==========
setupGracefulShutdown({
serviceName: 'turtle-control',
cleanup: [
() => taskDispatcher.stop(),
() => db.closeDatabase(),
],
});
start(() => {
console.log(`\nConfigured turtles to send updates to:`);
console.log(` http://localhost:${port}/api/turtle/update`);
if (process.env.INVENTORY_SERVER_URL) {
console.log(`📦 Inventory server integration: ${process.env.INVENTORY_SERVER_URL}`);
}
// Start task dispatcher after server is ready
taskDispatcher.start();
}); });

View File

@@ -130,13 +130,13 @@ export class BaseState {
const diff = (targetFacing - currentFacing + 4) % 4; const diff = (targetFacing - currentFacing + 4) % 4;
if (diff === 1) { if (diff === 1) {
await this.exec('return turtle.turnRight()'); await this.exec(`turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing; this.turtle.facing = targetFacing;
} else if (diff === 2) { } else if (diff === 2) {
await this.exec('turtle.turnRight(); return turtle.turnRight()'); await this.exec(`turtle.turnRight(); turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing; this.turtle.facing = targetFacing;
} else if (diff === 3) { } else if (diff === 3) {
await this.exec('return turtle.turnLeft()'); await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${targetFacing}`);
this.turtle.facing = targetFacing; this.turtle.facing = targetFacing;
} }

View File

@@ -1,6 +1,11 @@
/** /**
* ExploringState - Autonomous exploration to discover the world map * ExploringState - Chunk-based spiral exploration to discover the world map
* Similar to mining but focused on discovering blocks rather than mining them *
* 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'; import { BaseState } from './BaseState.js';
@@ -9,9 +14,19 @@ export class ExploringState extends BaseState {
super(turtle, data); super(turtle, data);
this.maxDistance = data.maxDistance || 200; this.maxDistance = data.maxDistance || 200;
this.minFuel = data.minFuel || 500; this.minFuel = data.minFuel || 500;
this.yLevel = data.yLevel || null; // null = stay at current Y
this.blocksDiscovered = 0; this.blocksDiscovered = 0;
this.stuckCounter = 0; this.chunksExplored = 0;
this.visitedPositions = new Set();
// 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() { get name() {
@@ -19,56 +34,235 @@ export class ExploringState extends BaseState {
} }
get description() { get description() {
return `Exploring - ${this.blocksDiscovered} blocks discovered`; return `Exploring - ${this.blocksDiscovered} blocks, ${this.chunksExplored} chunks (ring ${this.spiralRing})`;
} }
async *act() { async *act() {
console.log(`[${this.turtle.id}] Starting exploration`); 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) { while (!this.cancelled) {
// Safety checks try {
const fuel = await this.checkFuel(); // Safety: check fuel
if (fuel !== 'unlimited' && fuel < this.minFuel) { const fuel = await this.checkFuel();
const refueled = await this.tryRefuel(); if (fuel !== 'unlimited' && fuel < this.minFuel) {
if (!refueled) { const refueled = await this.tryRefuel();
this.turtle.setState('goHome', { reason: 'low_fuel' }); 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; 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);
} }
// Check distance // Alternate direction for serpentine pattern
if (this._isTooFar()) { forward = !forward;
this.turtle.setState('goHome', { reason: 'too_far' }); }
return; }
}
// Check inventory /**
const isFull = await this.isInventoryFull(); * Navigate to a target position safely using pathfinding with simple fallback
if (isFull) { */
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring' }); async *_navigateToSafe(target) {
return; try {
} const success = yield* this.navigateTo(target, { canMine: true, maxAttempts: 500 });
if (success) return;
} catch (error) {
// Pathfinding failed, use simple navigation
}
// Mark position // 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; const pos = this.turtle.position;
if (pos) { if (!pos) return;
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
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);
} }
// Comprehensive scan if (!moved) {
const scanResult = await this.scanSurroundings(); stuckCount++;
if (scanResult) { if (stuckCount > 5) {
this.blocksDiscovered += Object.keys(scanResult).length; // Try going up and over
await this.moveUp(true);
await this.moveUp(true);
stuckCount = 0;
}
} else {
stuckCount = 0;
} }
// Mine any valuable ores we find
yield* this._checkAndMineOres();
// Exploration movement
yield* this._exploreStep();
yield; yield;
await this._sleep(300);
} }
} }
@@ -76,6 +270,13 @@ export class ExploringState extends BaseState {
const valuableOres = new Set([ const valuableOres = new Set([
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:diamond_ore', 'minecraft:emerald_ore',
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_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 = [ const directions = [
@@ -100,61 +301,54 @@ export class ExploringState extends BaseState {
} }
} }
async *_exploreStep() { /**
const pos = this.turtle.position; * Advance the spiral to the next chunk position.
if (!pos) return; * Standard clockwise spiral: start at center, go E, then S, W, N with increasing lengths.
* Ring 0: just center
// Favor horizontal movement heavily (85%) * Ring 1: side lengths = 1,2,2,1 (total 6 chunks)
const r = Math.random() * 100; * Ring R: 8*R chunks around the ring
*/
if (r < 85) { _advanceSpiral() {
// Try to find unvisited direction if (this.spiralRing === 0) {
let moved = false; // Center done, start ring 1 going East
this.spiralRing = 1;
for (let i = 0; i < 4; i++) { this.spiralSide = 0;
const fwdPos = this.turtle.getBlockPositionInDirection('forward'); this.spiralStep = 0;
if (fwdPos && !this.visitedPositions.has(`${fwdPos.x},${fwdPos.y},${fwdPos.z}`)) { this.spiralChunkX = this.startChunkX + 1;
const canMove = await this.exec('local h = turtle.inspect(); return not h'); this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
if (canMove) { return;
await this.moveForward(false);
moved = true;
this.stuckCounter = 0;
break;
} else {
// Block in the way - dig through if exploring
const success = await this.moveForward(true);
if (success) {
moved = true;
this.stuckCounter = 0;
break;
}
}
}
await this.exec('turtle.turnRight()');
this.turtle.facing = (this.turtle.facing + 1) % 4;
}
if (!moved) {
this.stuckCounter++;
const success = await this.moveForward(true);
if (!success) {
await this.exec('turtle.turnRight()');
this.turtle.facing = (this.turtle.facing + 1) % 4;
}
}
} else if (r < 93 && pos.y > 10) {
await this.moveDown(true);
} else {
await this.moveUp(true);
} }
if (this.stuckCounter > 6) { // Side directions: S, W, N, E
await this.moveUp(true); const sideDirs = [
this.stuckCounter = 0; { 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;
}
} }
yield; // Move in current side direction
const dir = sideDirs[this.spiralSide];
this.spiralChunkX += dir.dx;
this.spiralChunkZ += dir.dz;
} }
_isTooFar() { _isTooFar() {
@@ -171,6 +365,15 @@ export class ExploringState extends BaseState {
data: { data: {
maxDistance: this.maxDistance, maxDistance: this.maxDistance,
minFuel: this.minFuel, 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

@@ -13,12 +13,20 @@ export class IdleState extends BaseState {
} }
async *act() { async *act() {
// Send periodic status updates while idle // 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) { while (!this.cancelled) {
await this.checkFuel(); try {
await this.scanSurroundings(); 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; yield;
await this._sleep(5000); await this._sleep(10000);
} }
} }
} }

View File

@@ -40,48 +40,59 @@ export class MiningState extends BaseState {
console.log(`[${this.turtle.id}] Starting mining operation`); console.log(`[${this.turtle.id}] Starting mining operation`);
while (!this.cancelled) { while (!this.cancelled) {
// Safety checks try {
const fuel = await this.checkFuel(); // Safety checks
if (fuel !== 'unlimited' && fuel < this.minFuel) { const fuel = await this.checkFuel();
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`); if (fuel !== 'unlimited' && fuel < this.minFuel) {
const refueled = await this.tryRefuel(); console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
if (!refueled) { const refueled = await this.tryRefuel();
console.log(`[${this.turtle.id}] Cannot refuel, going home`); if (!refueled) {
this.turtle.setState('goHome', { reason: 'low_fuel' }); 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; 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;
}
} }
// 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();
yield; yield;
await this._sleep(200); await this._sleep(200);
} }

View File

@@ -1,51 +1,39 @@
-- Advanced Autonomous Mining Turtle v4 (Eval/Response Protocol) -- Server-Driven Turtle v5 (Pure Eval Protocol)
-- Features: Server-driven state machine, UUID command correlation, -- All behavior is driven by the server via eval commands.
-- GPS tracking, local fallback intelligence, block scanning -- This script only handles: eval execution, status broadcasting,
-- GPS tracking, inventory/peripheral events.
local CHANNEL_RECEIVE = 100 local Channels = require('platform.channels')
local CHANNEL_SEND = 101
local STATUS_CHANNEL = 102
-- State tracking (lightweight - server drives behavior via eval) local CHANNEL_RECEIVE = Channels.get('remoteturtle.command')
local CHANNEL_SEND = Channels.get('remoteturtle.response')
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
-- State tracking (lightweight - server drives everything)
local state = { local state = {
mode = "idle",
position = nil, position = nil,
homePosition = nil, homePosition = nil,
fuel = 0, fuel = 0,
inventory = {}, inventory = {},
facing = 0, facing = 0,
lastStatusUpdate = 0, lastStatusUpdate = 0,
evalSupported = true,
} }
-- Configuration -- Configuration
local config = { local config = {
statusUpdateInterval = 5, statusUpdateInterval = 5,
valuableBlocks = {
["minecraft:coal_ore"] = 1,
["minecraft:iron_ore"] = 2,
["minecraft:gold_ore"] = 3,
["minecraft:diamond_ore"] = 5,
["minecraft:emerald_ore"] = 5,
["minecraft:redstone_ore"] = 2,
["minecraft:lapis_ore"] = 2,
["minecraft:deepslate_coal_ore"] = 1,
["minecraft:deepslate_iron_ore"] = 2,
["minecraft:deepslate_gold_ore"] = 3,
["minecraft:deepslate_diamond_ore"] = 5,
["minecraft:deepslate_emerald_ore"] = 5,
["minecraft:deepslate_redstone_ore"] = 2,
["minecraft:deepslate_lapis_ore"] = 2,
}
} }
-- Check for modem -- Check for modem
local modem = peripheral.find("modem") local WebBridge = require('platform.webbridge')
local modem, modemSide = WebBridge.findModem(true)
if not modem then if not modem then
error("No wireless modem found!") error("No wireless modem found!")
end end
modem.open(CHANNEL_RECEIVE) -- Open command channel (respects dual-mode migration)
print("Advanced Mining Turtle v4 (Eval Protocol)") WebBridge.openChannels(modem, { 'remoteturtle.command' })
print("Server-Driven Turtle v5 (Pure Eval Protocol)")
print("ID: " .. os.getComputerID()) print("ID: " .. os.getComputerID())
print("Modem opened on channel " .. CHANNEL_RECEIVE) print("Modem opened on channel " .. CHANNEL_RECEIVE)
@@ -63,69 +51,6 @@ local function updatePosition()
return false return false
end end
-- ========== Movement Functions (with position tracking) ==========
local function updateFacingAfterTurn(right)
if right then
state.facing = (state.facing + 1) % 4
else
state.facing = (state.facing - 1) % 4
end
_G._turtleFacing = state.facing
end
local function smartForward()
local success = turtle.forward()
if success and state.position then
if state.facing == 0 then state.position.z = state.position.z - 1
elseif state.facing == 1 then state.position.x = state.position.x + 1
elseif state.facing == 2 then state.position.z = state.position.z + 1
elseif state.facing == 3 then state.position.x = state.position.x - 1
end
end
return success
end
local function smartBack()
local success = turtle.back()
if success and state.position then
if state.facing == 0 then state.position.z = state.position.z + 1
elseif state.facing == 1 then state.position.x = state.position.x - 1
elseif state.facing == 2 then state.position.z = state.position.z - 1
elseif state.facing == 3 then state.position.x = state.position.x + 1
end
end
return success
end
local function smartUp()
local success = turtle.up()
if success and state.position then
state.position.y = state.position.y + 1
end
return success
end
local function smartDown()
local success = turtle.down()
if success and state.position then
state.position.y = state.position.y - 1
end
return success
end
local function smartTurnRight()
local success = turtle.turnRight()
if success then updateFacingAfterTurn(true) end
return success
end
local function smartTurnLeft()
local success = turtle.turnLeft()
if success then updateFacingAfterTurn(false) end
return success
end
-- ========== Fuel & Inventory ========== -- ========== Fuel & Inventory ==========
local function updateFuel() local function updateFuel()
@@ -133,20 +58,6 @@ local function updateFuel()
return state.fuel return state.fuel
end end
local function tryRefuel()
for slot = 1, 16 do
turtle.select(slot)
if turtle.refuel(0) then
local count = turtle.getItemCount()
if count > 0 then
turtle.refuel(1)
return true
end
end
end
return false
end
local function updateInventory() local function updateInventory()
state.inventory = {} state.inventory = {}
for slot = 1, 16 do for slot = 1, 16 do
@@ -161,97 +72,27 @@ local function updateInventory()
end end
end end
local function inventoryFull()
for slot = 1, 16 do
if turtle.getItemCount(slot) == 0 then
return false
end
end
return true
end
-- ========== Block Scanning ==========
local function scanAllDirections()
if not state.position then return {} end
local scannedBlocks = {}
local myID = os.getComputerID()
local function addBlock(relX, relY, relZ, blockData)
if blockData and blockData.name then
table.insert(scannedBlocks, {
x = state.position.x + relX,
y = state.position.y + relY,
z = state.position.z + relZ,
name = blockData.name,
metadata = blockData.metadata or 0,
discoveredBy = myID
})
end
end
local hasBlock, data
hasBlock, data = turtle.inspectUp()
if hasBlock then addBlock(0, 1, 0, data) end
hasBlock, data = turtle.inspectDown()
if hasBlock then addBlock(0, -1, 0, data) end
local originalFacing = state.facing
for i = 0, 3 do
hasBlock, data = turtle.inspect()
if hasBlock then
local relX, relZ = 0, 0
if state.facing == 0 then relZ = -1
elseif state.facing == 1 then relX = 1
elseif state.facing == 2 then relZ = 1
elseif state.facing == 3 then relX = -1
end
addBlock(relX, 0, relZ, data)
end
if i < 3 then smartTurnRight() end
end
if state.facing ~= originalFacing then
smartTurnRight()
end
return scannedBlocks
end
local function reportDiscoveredBlocks(blocks)
if #blocks == 0 then return end
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "blocks_discovered",
turtleID = os.getComputerID(),
blocks = blocks,
timestamp = os.epoch("utc")
})
end
-- ========== Status Broadcasting ========== -- ========== Status Broadcasting ==========
local function broadcastStatus() local function broadcastStatus()
updateFuel() updateFuel()
updateInventory() updateInventory()
local surroundings = {} local surroundings = {}
local hasBlock, data local hasBlock, data
hasBlock, data = turtle.inspect() hasBlock, data = turtle.inspect()
if hasBlock then surroundings.forward = {name = data.name, metadata = data.metadata or 0} end if hasBlock then surroundings.forward = {name = data.name, metadata = data.metadata or 0} end
hasBlock, data = turtle.inspectUp() hasBlock, data = turtle.inspectUp()
if hasBlock then surroundings.up = {name = data.name, metadata = data.metadata or 0} end if hasBlock then surroundings.up = {name = data.name, metadata = data.metadata or 0} end
hasBlock, data = turtle.inspectDown() hasBlock, data = turtle.inspectDown()
if hasBlock then surroundings.down = {name = data.name, metadata = data.metadata or 0} end if hasBlock then surroundings.down = {name = data.name, metadata = data.metadata or 0} end
local statusPacket = { local statusPacket = {
type = "status", type = "status",
turtleID = os.getComputerID(), turtleID = os.getComputerID(),
mode = state.mode,
position = state.position, position = state.position,
homePosition = state.homePosition, homePosition = state.homePosition,
fuel = state.fuel, fuel = state.fuel,
@@ -262,7 +103,7 @@ local function broadcastStatus()
evalSupported = true, evalSupported = true,
label = os.getComputerLabel(), label = os.getComputerLabel(),
} }
modem.transmit(STATUS_CHANNEL, CHANNEL_RECEIVE, statusPacket) modem.transmit(STATUS_CHANNEL, CHANNEL_RECEIVE, statusPacket)
end end
@@ -280,14 +121,24 @@ local function executeEval(uuid, code)
}) })
return return
end end
local ok, result = pcall(fn) local results = table.pack(pcall(fn))
local ok = results[1]
if ok then if ok then
local returnVal
if results.n <= 2 then
returnVal = results[2]
else
returnVal = {}
for i = 2, results.n do
returnVal[i - 1] = results[i]
end
end
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, { modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "eval_response", type = "eval_response",
uuid = uuid, uuid = uuid,
result = result, result = returnVal,
error = nil, error = nil,
turtleID = os.getComputerID() turtleID = os.getComputerID()
}) })
@@ -296,292 +147,42 @@ local function executeEval(uuid, code)
type = "eval_response", type = "eval_response",
uuid = uuid, uuid = uuid,
result = nil, result = nil,
error = "Runtime error: " .. tostring(result), error = "Runtime error: " .. tostring(results[2]),
turtleID = os.getComputerID() turtleID = os.getComputerID()
}) })
end end
end end
-- ========== Legacy Command Handling ==========
local legacyCommands = {
forward = function() return smartForward(), "Moved forward" end,
back = function() return smartBack(), "Moved back" end,
up = function() return smartUp(), "Moved up" end,
down = function() return smartDown(), "Moved down" end,
turnLeft = function() return smartTurnLeft(), "Turned left" end,
turnRight = function() return smartTurnRight(), "Turned right" end,
dig = function()
local hasBlock, data = turtle.inspect()
if hasBlock then
turtle.dig()
return true, "Dug: " .. (data.name or "block")
end
return false, "Nothing to dig"
end,
digUp = function()
local hasBlock, data = turtle.inspectUp()
if hasBlock then
turtle.digUp()
return true, "Dug up: " .. (data.name or "block")
end
return false, "Nothing above"
end,
digDown = function()
local hasBlock, data = turtle.inspectDown()
if hasBlock then
turtle.digDown()
return true, "Dug down: " .. (data.name or "block")
end
return false, "Nothing below"
end,
place = function() return turtle.place(), "Placed" end,
select = function(slot) return turtle.select(slot), "Selected slot " .. slot end,
refuel = function()
local success = tryRefuel()
updateFuel()
broadcastStatus()
return success, success and ("Refueled! Now: " .. state.fuel) or "No fuel"
end,
status = function()
broadcastStatus()
return true, "Status sent"
end,
setHome = function()
if not state.position then
if not updatePosition() then
return false, "No GPS"
end
end
state.homePosition = {
x = state.position.x,
y = state.position.y,
z = state.position.z
}
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "set_home",
turtleID = os.getComputerID(),
position = state.homePosition
})
return true, "Home set"
end,
explore = function()
if not state.homePosition then
if state.position then
state.homePosition = {
x = state.position.x,
y = state.position.y,
z = state.position.z
}
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "set_home",
turtleID = os.getComputerID(),
position = state.homePosition
})
else
return false, "No home or GPS"
end
end
state.mode = "exploring"
broadcastStatus()
return true, "Exploring"
end,
mine = function()
if not state.homePosition then
if state.position then
state.homePosition = {
x = state.position.x,
y = state.position.y,
z = state.position.z
}
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "set_home",
turtleID = os.getComputerID(),
position = state.homePosition
})
else
return false, "No home or GPS"
end
end
state.mode = "mining"
broadcastStatus()
return true, "Mining"
end,
returnHome = function()
state.mode = "returning"
broadcastStatus()
return true, "Returning home"
end,
stop = function()
state.mode = "idle"
broadcastStatus()
return true, "Stopped"
end,
manual = function()
state.mode = "manual"
broadcastStatus()
return true, "Manual control"
end,
}
local function processLegacyCommand(message)
if type(message) ~= "table" then return end
local myID = os.getComputerID()
if message.target and message.target ~= myID then return end
if message.command then
local handler = legacyCommands[message.command]
if handler then
local success, result = handler(message.param)
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
status = success and "ok" or "error",
message = result or "",
turtleID = myID
})
broadcastStatus()
end
end
end
-- ========== Message Processing ========== -- ========== Message Processing ==========
local function processMessage(channel, message) local function processMessage(channel, message)
if type(message) ~= "table" then return end if type(message) ~= "table" then return end
local myID = os.getComputerID() local myID = os.getComputerID()
if message.target and message.target ~= myID then return end if message.target and message.target ~= myID then return end
if message.type == "eval" and message.uuid and message.code then if message.type == "eval" and message.uuid and message.code then
print("Eval: " .. message.uuid:sub(1, 8) .. "...") print("Eval: " .. message.uuid:sub(1, 8) .. "...")
executeEval(message.uuid, message.code) executeEval(message.uuid, message.code)
if message.stateUpdate then
state.mode = message.stateUpdate
end
elseif message.type == "state_change" and message.state then
state.mode = message.state
broadcastStatus()
elseif message.type == "home_position" and message.turtleID == myID then elseif message.type == "home_position" and message.turtleID == myID then
if message.homePosition then if message.homePosition then
state.homePosition = message.homePosition state.homePosition = message.homePosition
print("Home synced from server") print("Home synced from server")
end end
elseif message.type == "home_set_confirm" and message.turtleID == myID then elseif message.type == "home_set_confirm" and message.turtleID == myID then
if message.homePosition then if message.homePosition then
state.homePosition = message.homePosition state.homePosition = message.homePosition
print("Home confirmed by server") print("Home confirmed by server")
end end
elseif message.type == "rename" and message.name then elseif message.type == "rename" and message.name then
os.setComputerLabel(message.name) os.setComputerLabel(message.name)
print("Renamed to: " .. message.name) print("Renamed to: " .. message.name)
broadcastStatus() broadcastStatus()
elseif message.command then
processLegacyCommand(message)
end end
end end
-- ========== Local Autonomous Behavior (fallback) ==========
local localStuckCounter = 0
local function getDistance(pos1, pos2)
return math.abs(pos1.x - pos2.x) + math.abs(pos1.y - pos2.y) + math.abs(pos1.z - pos2.z)
end
local function localExploreStep()
if state.position then
local blocks = scanAllDirections()
if #blocks > 0 then
reportDiscoveredBlocks(blocks)
end
end
-- Mine valuable ores
for _, direction in ipairs({"forward", "up", "down"}) do
local inspectFn = direction == "forward" and turtle.inspect
or direction == "up" and turtle.inspectUp
or turtle.inspectDown
local digFn = direction == "forward" and turtle.dig
or direction == "up" and turtle.digUp
or turtle.digDown
local hasBlock, data = inspectFn()
if hasBlock and config.valuableBlocks[data.name] then
digFn()
end
end
local r = math.random(1, 100)
if r < 75 then
local hasBlock = turtle.inspect()
if hasBlock then turtle.dig() end
if not smartForward() then
smartTurnRight()
localStuckCounter = localStuckCounter + 1
else
localStuckCounter = 0
end
elseif r < 90 and state.position and state.position.y > 15 then
local hasBlock = turtle.inspectDown()
if hasBlock then turtle.digDown() end
smartDown()
else
local hasBlock = turtle.inspectUp()
if hasBlock then turtle.digUp() end
smartUp()
end
if localStuckCounter > 5 then
turtle.digUp()
smartUp()
localStuckCounter = 0
end
end
local function localReturnHomeStep()
if not state.homePosition or not state.position then
state.mode = "idle"
return true
end
if getDistance(state.position, state.homePosition) <= 1 then
state.mode = "idle"
broadcastStatus()
return true
end
local dx = state.homePosition.x - state.position.x
local dy = state.homePosition.y - state.position.y
local dz = state.homePosition.z - state.position.z
if math.abs(dy) > 3 then
if dy > 0 then
turtle.digUp(); smartUp()
else
turtle.digDown(); smartDown()
end
else
local targetFacing
if math.abs(dx) > math.abs(dz) then
targetFacing = dx > 0 and 1 or 3
else
targetFacing = dz > 0 and 2 or 0
end
while state.facing ~= targetFacing do
smartTurnRight()
end
turtle.dig()
if not smartForward() then
smartTurnRight()
end
end
return false
end
-- ========== Sync Home ========== -- ========== Sync Home ==========
local function syncHomeWithServer() local function syncHomeWithServer()
@@ -606,14 +207,222 @@ end
syncHomeWithServer() syncHomeWithServer()
updateFuel() updateFuel()
print("Ready! Turtle " .. os.getComputerID() .. " online (v4 eval protocol)")
-- ========== Movement Wrapping (heading + position tracking) ==========
-- Heading: 0=south(+z), 1=west(-x), 2=north(-z), 3=east(+x)
local MOVE_DELTA = {
[0] = { x = 0, z = 1 }, -- south
[1] = { x = -1, z = 0 }, -- west
[2] = { x = 0, z = -1 }, -- north
[3] = { x = 1, z = 0 }, -- east
}
local _rawForward = turtle.forward
local _rawBack = turtle.back
local _rawUp = turtle.up
local _rawDown = turtle.down
local _rawTurnLeft = turtle.turnLeft
local _rawTurnRight = turtle.turnRight
turtle.forward = function()
local ok, reason = _rawForward()
if ok and state.position then
local d = MOVE_DELTA[state.facing]
state.position.x = state.position.x + d.x
state.position.z = state.position.z + d.z
end
return ok, reason
end
turtle.back = function()
local ok, reason = _rawBack()
if ok and state.position then
local d = MOVE_DELTA[state.facing]
state.position.x = state.position.x - d.x
state.position.z = state.position.z - d.z
end
return ok, reason
end
turtle.up = function()
local ok, reason = _rawUp()
if ok and state.position then
state.position.y = state.position.y + 1
end
return ok, reason
end
turtle.down = function()
local ok, reason = _rawDown()
if ok and state.position then
state.position.y = state.position.y - 1
end
return ok, reason
end
turtle.turnLeft = function()
local ok = _rawTurnLeft()
if ok then
state.facing = (state.facing + 3) % 4
_G._turtleFacing = state.facing
end
return ok
end
turtle.turnRight = function()
local ok = _rawTurnRight()
if ok then
state.facing = (state.facing + 1) % 4
_G._turtleFacing = state.facing
end
return ok
end
-- Detect heading via GPS triangulation
local function detectHeading()
local x1, _, z1 = gps.locate(2)
if not x1 then return false end
if _rawForward() then
local x2, _, z2 = gps.locate(2)
_rawBack()
if x2 then
local dx = math.floor(x2) - math.floor(x1)
local dz = math.floor(z2) - math.floor(z1)
if dz > 0 then state.facing = 0 -- south
elseif dz < 0 then state.facing = 2 -- north
elseif dx > 0 then state.facing = 3 -- east
elseif dx < 0 then state.facing = 1 -- west
end
_G._turtleFacing = state.facing
return true
end
end
return false
end
if state.position then
if detectHeading() then
print("Heading: " .. ({"south","west","north","east"})[state.facing + 1])
else
print("Heading: unknown (blocked)")
end
end
-- ========== Pathfinding Module ==========
local pathfind = {}
--- Face a target heading.
function pathfind.face(targetH)
targetH = targetH % 4
while state.facing ~= targetH do
local diff = (targetH - state.facing) % 4
if diff == 1 then
turtle.turnRight()
elseif diff == 3 then
turtle.turnLeft()
else
turtle.turnRight()
turtle.turnRight()
end
end
end
--- Navigate to target coordinates with simple obstacle avoidance.
-- @param tx, ty, tz target position
-- @param options { dig = false, maxAttempts = 256 }
-- @return true or false, error
function pathfind.goto(tx, ty, tz, options)
options = options or {}
local maxAttempts = options.maxAttempts or 256
local dig = options.dig or false
local attempts = 0
while attempts < maxAttempts do
local pos = state.position
if not pos then return false, "No GPS position" end
-- Arrived?
if pos.x == tx and pos.y == ty and pos.z == tz then
return true
end
attempts = attempts + 1
local moved = false
-- Priority: Y first (get to correct height), then X, then Z
if not moved and pos.y ~= ty then
if pos.y < ty then
moved = turtle.up()
if not moved and dig then turtle.digUp(); moved = turtle.up() end
else
moved = turtle.down()
if not moved and dig then turtle.digDown(); moved = turtle.down() end
end
end
if not moved and pos.x ~= tx then
pathfind.face(tx > pos.x and 3 or 1)
moved = turtle.forward()
if not moved and dig then turtle.dig(); moved = turtle.forward() end
end
if not moved and pos.z ~= tz then
pathfind.face(tz > pos.z and 0 or 2)
moved = turtle.forward()
if not moved and dig then turtle.dig(); moved = turtle.forward() end
end
-- Obstacle avoidance: try going around
if not moved then
if turtle.up() then
moved = true
elseif turtle.down() then
moved = true
else
turtle.turnRight()
if turtle.forward() then
moved = true
else
turtle.turnLeft()
turtle.turnLeft()
if turtle.forward() then
moved = true
else
turtle.turnRight() -- restore heading
return false, "Stuck at " .. pos.x .. "," .. pos.y .. "," .. pos.z
end
end
end
end
end
return false, "Max attempts exceeded"
end
--- Go home (to saved home position).
function pathfind.goHome(options)
if not state.homePosition then return false, "No home position set" end
return pathfind.goto(state.homePosition.x, state.homePosition.y, state.homePosition.z, options)
end
--- Get current heading name.
function pathfind.headingName()
return ({"south","west","north","east"})[state.facing + 1]
end
-- Expose globally for eval access
_G._pathfind = pathfind
print("Ready! Turtle " .. os.getComputerID() .. " online (v5 server-driven + pathfinding)")
broadcastStatus() broadcastStatus()
-- ========== Main Loop ========== -- ========== Main Loop ==========
parallel.waitForAny( parallel.waitForAny(
function() function()
-- GPS retry -- GPS retry loop
while true do while true do
if not state.position then if not state.position then
sleep(10) sleep(10)
@@ -626,41 +435,43 @@ parallel.waitForAny(
end end
end end
end, end,
function() function()
-- Status broadcast -- Status broadcast loop
while true do while true do
sleep(config.statusUpdateInterval) sleep(config.statusUpdateInterval)
broadcastStatus() broadcastStatus()
end end
end, end,
function() function()
-- Command processing -- Command processing (eval protocol)
-- Uses Channels.match() for dual-mode safety: accepts messages on
-- both legacy (100) and target (4210) 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 == CHANNEL_RECEIVE then if Channels.match('remoteturtle.command', channel) then
processMessage(channel, message) processMessage(channel, message)
end end
end end
end, end,
function() function()
-- Inventory change events (real-time) -- Inventory change events (real-time)
while true do while true do
os.pullEvent("turtle_inventory") os.pullEvent("turtle_inventory")
local inventory = {} local inventory = {}
local fns = {}
for i = 1, 16 do for i = 1, 16 do
fns[i] = function() local item = turtle.getItemDetail(i)
local item = turtle.getItemDetail(i, true) if item then
if item then table.insert(inventory, {
inventory[tostring(i)] = item slot = i,
end name = item.name,
count = item.count
})
end end
end end
parallel.waitForAll(table.unpack(fns))
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, { modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "inventory_update", type = "inventory_update",
turtleID = os.getComputerID(), turtleID = os.getComputerID(),
@@ -669,7 +480,7 @@ parallel.waitForAny(
}) })
end end
end, end,
function() function()
-- Peripheral attached events (real-time) -- Peripheral attached events (real-time)
while true do while true do
@@ -680,7 +491,7 @@ parallel.waitForAny(
for _, name in ipairs(names) do for _, name in ipairs(names) do
peripherals[name] = {types = {peripheral.getType(name)}} peripherals[name] = {types = {peripheral.getType(name)}}
end end
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, { modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
type = "peripheral_attached", type = "peripheral_attached",
turtleID = os.getComputerID(), turtleID = os.getComputerID(),
@@ -689,7 +500,7 @@ parallel.waitForAny(
}) })
end end
end, end,
function() function()
-- Peripheral detached events (real-time) -- Peripheral detached events (real-time)
while true do while true do
@@ -703,43 +514,5 @@ parallel.waitForAny(
}) })
end end
end end
end,
function()
-- Local autonomous fallback
while true do
if state.mode == "exploring" or state.mode == "mining" then
updateFuel()
if state.position and state.homePosition then
local dist = getDistance(state.position, state.homePosition)
if dist > 200 then
state.mode = "returning"
broadcastStatus()
elseif state.fuel ~= "unlimited" and state.fuel < 500 then
if not tryRefuel() then
state.mode = "returning"
broadcastStatus()
end
elseif inventoryFull() then
state.mode = "returning"
broadcastStatus()
else
localExploreStep()
sleep(0.2)
end
else
localExploreStep()
sleep(0.2)
end
elseif state.mode == "returning" then
local done = localReturnHomeStep()
if not done then sleep(0.1) end
else
sleep(0.5)
end
end
end end
) )

File diff suppressed because it is too large Load Diff