Compare commits
217 Commits
b1d68565cd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a66cad13a | ||
|
|
f008a9e665 | ||
|
|
ed612f3e38 | ||
|
|
dcd9e22b6f | ||
|
|
f1c8f08272 | ||
|
|
ffb6d679c0 | ||
|
|
ea90a860e9 | ||
|
|
bdf7a51675 | ||
|
|
c4b9509b5c | ||
|
|
92ea13a680 | ||
|
|
05cf7e98d9 | ||
|
|
9291b063d0 | ||
|
|
6f462c97e0 | ||
|
|
9ff2ce7ff2 | ||
|
|
fc1b23470e | ||
|
|
cb44dd8d0f | ||
|
|
1f41e1fa51 | ||
|
|
fa085339b8 | ||
|
|
5ad01dfd1d | ||
|
|
b72826bc46 | ||
|
|
459664825c | ||
|
|
b13905dade | ||
|
|
5a4af6c986 | ||
|
|
633d162d81 | ||
|
|
aa3b166453 | ||
|
|
56fc79f5f2 | ||
|
|
b6ab6f94f6 | ||
|
|
4d5d2162e6 | ||
|
|
24570d0fc0 | ||
|
|
6312e45bf1 | ||
|
|
34725d7d71 | ||
|
|
811e2a6e18 | ||
|
|
ad0754113d | ||
|
|
3e55d77592 | ||
|
|
9984dc0760 | ||
|
|
88163be0dd | ||
|
|
679a249f8b | ||
|
|
69041244a2 | ||
|
|
9a56e6b736 | ||
|
|
79b50071ee | ||
|
|
9b09a59eba | ||
|
|
6d8ec7b013 | ||
|
|
90ec195497 | ||
|
|
23515728e0 | ||
|
|
a809bddd46 | ||
|
|
5ff1f3e7f0 | ||
|
|
00d31698a1 | ||
|
|
af2c978185 | ||
|
|
8f23aa5caa | ||
|
|
720c6c20fb | ||
|
|
f61e7ca185 | ||
|
|
ddc1b03506 | ||
|
|
460352ec26 | ||
|
|
3ce0e4c530 | ||
|
|
38ff06eb04 | ||
|
|
cfd127dfab | ||
|
|
d2718b3287 | ||
|
|
8f4eeabee9 | ||
|
|
3b2e00b2b4 | ||
|
|
cb666a6a45 | ||
|
|
c424662c18 | ||
|
|
05519dc17e | ||
|
|
681b4e1fa9 | ||
|
|
465a8bacf4 | ||
|
|
bfae87287a | ||
|
|
12fc109a30 | ||
|
|
973e4be6a3 | ||
|
|
fb84b5a554 | ||
|
|
2e3d5b4b6b | ||
|
|
9a34f72178 | ||
|
|
88fdd1c46d | ||
|
|
e3abdb612c | ||
|
|
e84ca4cfb9 | ||
|
|
b8cd239597 | ||
|
|
5aec3df3b3 | ||
|
|
989b6f9118 | ||
|
|
ec5f048d49 | ||
|
|
60c5b3aaba | ||
|
|
2c806bf994 | ||
|
|
b34cc8cec0 | ||
|
|
cef3cdf03d | ||
|
|
b8a1b7c0b3 | ||
|
|
8fcd3f44c7 | ||
|
|
7385c258d5 | ||
|
|
2686ca4697 | ||
|
|
53ae92a184 | ||
|
|
7da9c1d0d8 | ||
|
|
2549adc49d | ||
|
|
bad3b5bf13 | ||
|
|
de58ec6b08 | ||
|
|
f6b39808aa | ||
|
|
c02bb7db68 | ||
|
|
885ebf698d | ||
|
|
1522523f22 | ||
|
|
bca3cb4508 | ||
|
|
ebe4f10df5 | ||
|
|
586b161da9 | ||
|
|
2316e14b9c | ||
|
|
45a4b4e7ae | ||
|
|
9735fd8776 | ||
|
|
c6ff9094ed | ||
|
|
cb2353785f | ||
|
|
46c0817270 | ||
|
|
f0281ddaa5 | ||
|
|
fc5cbae73e | ||
|
|
9cb2939224 | ||
|
|
1a2de77ae2 | ||
|
|
420456c04b | ||
|
|
b7221327c2 | ||
|
|
997201b139 | ||
|
|
daaf969662 | ||
|
|
0b61d8b2dd | ||
|
|
586d231720 | ||
|
|
3c6ee280ba | ||
|
|
5966d4de2b | ||
|
|
1d99ba534a | ||
|
|
b06f878ca0 | ||
|
|
943cc73163 | ||
|
|
2263fbb1de | ||
|
|
481be70940 | ||
|
|
60ef3b81f7 | ||
|
|
997c64c40b | ||
|
|
a68ddd843f | ||
|
|
fe44978f6e | ||
|
|
c862b2816c | ||
|
|
73f8a21a81 | ||
|
|
617310eade | ||
|
|
7dae800eed | ||
|
|
86a6db04ac | ||
|
|
8e1d1f67fc | ||
|
|
fa8b45b74a | ||
|
|
32677ecac5 | ||
|
|
5f6dbce277 | ||
|
|
75ca027ab4 | ||
|
|
cfc891d164 | ||
|
|
5b89e0432e | ||
|
|
37bd17f26a | ||
|
|
c0865d5196 | ||
|
|
a0eaeb6712 | ||
|
|
d8f3d5d13c | ||
|
|
2571865917 | ||
|
|
0e6d4acfdd | ||
|
|
67b2d7eb2e | ||
|
|
7b63c92434 | ||
|
|
2a0a90892c | ||
|
|
11c289a3ed | ||
|
|
ac6dd5da95 | ||
|
|
58cc909de8 | ||
|
|
b9023758a6 | ||
|
|
6e05efa2f0 | ||
|
|
b632e14932 | ||
|
|
5208c38738 | ||
|
|
a64e9a1a51 | ||
|
|
be0064aae5 | ||
|
|
dd2a877192 | ||
|
|
1ef975cbae | ||
|
|
e62e83cf33 | ||
|
|
eff33dfe09 | ||
|
|
f38a219ad0 | ||
|
|
0bf5590343 | ||
|
|
bf7db42384 | ||
|
|
8731da04f9 | ||
|
|
ff58778f3f | ||
|
|
668d4a3685 | ||
|
|
e401c39bb3 | ||
|
|
d4e6b469df | ||
|
|
150cc4b41a | ||
|
|
220f2a90d4 | ||
|
|
ba11d2c9d2 | ||
|
|
dca14643c6 | ||
|
|
67498b93bf | ||
|
|
df00a6be70 | ||
|
|
f23ebcb05a | ||
|
|
08281d88fe | ||
|
|
882dc75762 | ||
|
|
4c774ac306 | ||
|
|
e38551dfc2 | ||
|
|
87c103ea9e | ||
|
|
836e7b61c7 | ||
|
|
f8baadce3a | ||
|
|
a5aec5800e | ||
|
|
0e41ee12c5 | ||
|
|
bdc4dade9f | ||
|
|
e8ef1d669b | ||
|
|
3dfc4237c6 | ||
|
|
1492f59de7 | ||
|
|
d2185ac493 | ||
|
|
fa98e86055 | ||
|
|
1b08777f88 | ||
|
|
68d4ee52c9 | ||
|
|
6f73fdd0ed | ||
|
|
0f22c8e49b | ||
|
|
9796f73e64 | ||
|
|
6da90fc560 | ||
|
|
afc4c1f97a | ||
|
|
9a294f0c98 | ||
|
|
1f803fbde6 | ||
|
|
207f6901d6 | ||
|
|
d2e0deb945 | ||
|
|
9fd9180087 | ||
|
|
558fb92c41 | ||
|
|
bd68a79cd5 | ||
|
|
f0afbca74b | ||
|
|
35c76cbdca | ||
|
|
7a3b30bbbf | ||
|
|
d7433b8bcc | ||
|
|
3c0f72acf1 | ||
|
|
75b2088b4f | ||
|
|
0eac9497de | ||
|
|
855874576d | ||
|
|
0c925036d9 | ||
|
|
68e21d9c82 | ||
|
|
1cccfb5baa | ||
|
|
4bd999d394 | ||
|
|
8d66ef637e | ||
|
|
5a4fd000fe | ||
|
|
86250deba3 |
34
.package
Normal file
34
.package
Normal 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
|
||||
]],
|
||||
}
|
||||
@@ -64,6 +64,36 @@ You should see:
|
||||
**GPS not working?**
|
||||
- Set up 4 GPS host computers at high altitude
|
||||
- Run `gps host X Y Z` on each (with their coordinates)
|
||||
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||
|
||||
## Pathfinding
|
||||
|
||||
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||
You can trigger it from the web UI via eval commands:
|
||||
|
||||
```lua
|
||||
-- Navigate to coordinates (avoids obstacles)
|
||||
_pathfind.goto(100, 65, -200)
|
||||
|
||||
-- Navigate with block digging enabled
|
||||
_pathfind.goto(100, 65, -200, { dig = true })
|
||||
|
||||
-- Go home
|
||||
_pathfind.goHome()
|
||||
|
||||
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||
_pathfind.face(3)
|
||||
|
||||
-- Get current heading name
|
||||
_pathfind.headingName() -- "east"
|
||||
```
|
||||
|
||||
The pathfinder:
|
||||
- Uses GPS for initial position, then tracks movement locally
|
||||
- Auto-detects heading on startup via GPS triangulation
|
||||
- Handles obstacles by trying to go over/around them
|
||||
- Supports optional block digging for clearing paths
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
130
README.md
130
README.md
@@ -52,54 +52,118 @@ A comprehensive full-stack web application for monitoring and controlling Comput
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ installed on your computer
|
||||
- Minecraft with ComputerCraft mod installed
|
||||
- At least one turtle with a wireless modem
|
||||
- A computer in Minecraft to run the bridge script
|
||||
- **Docker and Docker Compose** installed on your computer
|
||||
- **Minecraft** with ComputerCraft mod installed
|
||||
- At least one **turtle with a wireless modem**
|
||||
- A **computer in Minecraft** to run the bridge script
|
||||
|
||||
### Installation
|
||||
### Installation (Docker - Recommended)
|
||||
|
||||
1. **Clone or download this project**
|
||||
|
||||
2. **Install Server Dependencies**
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
git clone <repository-url>
|
||||
cd remoteturtle
|
||||
```
|
||||
|
||||
3. **Install Client Dependencies**
|
||||
2. **Start with Docker Compose**
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will automatically:
|
||||
- Build and start the backend server on port 3001
|
||||
- Build and start the frontend on port 3000
|
||||
- Create a persistent SQLite database volume
|
||||
- Set up networking between containers
|
||||
|
||||
4. **Start the Backend Server**
|
||||
```bash
|
||||
cd server
|
||||
npm start
|
||||
```
|
||||
Server will run on:
|
||||
- HTTP: http://localhost:3001
|
||||
3. **Access the Web Interface**
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:3001
|
||||
- WebSocket: ws://localhost:3002
|
||||
|
||||
5. **Start the React Frontend**
|
||||
```bash
|
||||
cd client
|
||||
npm run dev
|
||||
4. **Set up Minecraft Bridge Computer**
|
||||
|
||||
In Minecraft, place a computer with a wireless modem and run:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
|
||||
reboot
|
||||
```
|
||||
Frontend will open at: http://localhost:3000
|
||||
|
||||
6. **Set up Minecraft Bridge**
|
||||
- In Minecraft, place a computer with a wireless modem
|
||||
|
||||
This installs an auto-update system that:
|
||||
- Downloads the latest `webbridge.lua` on boot
|
||||
- Falls back to cached version if download fails
|
||||
- Automatically connects to your server
|
||||
|
||||
Or manually:
|
||||
- Copy `webbridge.lua` to the computer
|
||||
- Edit the `SERVER_URL` in webbridge.lua to point to your server
|
||||
(If running locally, use `http://localhost:3001`)
|
||||
- Edit the `SERVER_URL` to point to your Docker host
|
||||
- If Minecraft is on the same machine: `http://host.docker.internal:3001`
|
||||
- If on different machine: `http://<your-ip>:3001`
|
||||
- Run: `webbridge`
|
||||
|
||||
7. **Deploy Turtles**
|
||||
- Copy `turtle.lua` to your turtles
|
||||
- Equip wireless modems on turtles
|
||||
- Run: `turtle`
|
||||
5. **Deploy Auto-Updating Turtles**
|
||||
|
||||
On each turtle with a wireless modem, run:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
|
||||
reboot
|
||||
```
|
||||
|
||||
This installs an auto-update system that:
|
||||
- Downloads the latest `turtle.lua` on boot
|
||||
- Falls back to cached version if download fails
|
||||
- Keeps turtles updated automatically
|
||||
|
||||
6. **Optional: Deploy Pocket Computer Control**
|
||||
|
||||
For wireless pocket computer control:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
|
||||
reboot
|
||||
```
|
||||
|
||||
This provides a wireless control interface for managing turtles from anywhere in-game
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose up -d --build
|
||||
|
||||
# Stop and remove volumes (resets database)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Alternative: Manual Installation (Without Docker)
|
||||
|
||||
If you prefer not to use Docker:
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
```
|
||||
|
||||
2. **Start Backend**
|
||||
```bash
|
||||
cd server && npm start
|
||||
```
|
||||
|
||||
3. **Start Frontend**
|
||||
```bash
|
||||
cd client && npm run dev
|
||||
```
|
||||
|
||||
4. Follow steps 4-6 from the Docker installation above
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
|
||||
@@ -4,37 +4,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.view-controls button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-controls button:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.view-controls button.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
@@ -57,21 +27,13 @@
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.app-content.map .panel-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-content.panel .map-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
background: #0a0e1a;
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
background: #0f172a;
|
||||
background: #2c2c2c;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -81,34 +43,62 @@
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 2px solid #334155;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 3px solid #1a1a1a;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: #5a5a5a;
|
||||
color: #b0b0b0;
|
||||
border-radius: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
|
||||
}
|
||||
|
||||
.panel-tabs button:hover {
|
||||
background: #475569;
|
||||
background: #6b6b6b;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-tabs button.active {
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* === Cross-link Button === */
|
||||
.cross-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: #2e4a8b;
|
||||
color: #55ffff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cross-link-btn:hover {
|
||||
background: #3e5a9b;
|
||||
color: white;
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.panel-content-wrapper {
|
||||
@@ -125,16 +115,16 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
background: #6b6b6b;
|
||||
}
|
||||
|
||||
/* Mobile-Responsive Design */
|
||||
@@ -149,28 +139,9 @@
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile: Force single view mode with tabs */
|
||||
.view-controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.view-controls button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.app-content.split {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -191,17 +162,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Small mobile: Optimize spacing */
|
||||
.view-controls {
|
||||
padding: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.view-controls button {
|
||||
padding: 0.625rem 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.app-content.split .map-container {
|
||||
height: 40vh;
|
||||
}
|
||||
@@ -270,10 +230,6 @@
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.view-controls button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.turtle-card {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Map3D from './components/Map3D';
|
||||
import ControlPanel from './components/ControlPanel';
|
||||
import VoiceControl from './components/VoiceControl';
|
||||
@@ -12,18 +12,19 @@ import './App.css';
|
||||
|
||||
function App() {
|
||||
const connect = useTurtleStore((state) => state.connect);
|
||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
|
||||
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const renderPanelContent = () => {
|
||||
const apiUrl = 'http://localhost:3001';
|
||||
const wsUrl = 'ws://localhost:3002';
|
||||
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
|
||||
switch (panelTab) {
|
||||
case 'control':
|
||||
@@ -47,36 +48,21 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="view-controls">
|
||||
<button
|
||||
className={view === 'split' ? 'active' : ''}
|
||||
onClick={() => setView('split')}
|
||||
>
|
||||
📊 Split View
|
||||
</button>
|
||||
<button
|
||||
className={view === 'map' ? 'active' : ''}
|
||||
onClick={() => setView('map')}
|
||||
>
|
||||
🗺️ Map Only
|
||||
</button>
|
||||
<button
|
||||
className={view === 'panel' ? 'active' : ''}
|
||||
onClick={() => setView('panel')}
|
||||
>
|
||||
🎮 Control Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`app-content ${view}`}>
|
||||
{(view === 'split' || view === 'map') && (
|
||||
<div className="map-container">
|
||||
<Map3D />
|
||||
</div>
|
||||
)}
|
||||
{(view === 'split' || view === 'panel') && (
|
||||
<div className="panel-container">
|
||||
<div className="app-content split">
|
||||
<div className="map-container">
|
||||
<Map3D />
|
||||
</div>
|
||||
<div className="panel-container">
|
||||
<div className="panel-tabs">
|
||||
<a
|
||||
href={inventoryDashboardUrl}
|
||||
className="cross-link-btn"
|
||||
title="Open Inventory Manager Dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
📦 Inventory
|
||||
</a>
|
||||
<button
|
||||
className={panelTab === 'control' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('control')}
|
||||
@@ -131,7 +117,6 @@ function App() {
|
||||
{renderPanelContent()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,44 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTurtleStore } from '../store/turtleStore';
|
||||
import './ControlPanel.css';
|
||||
|
||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
const mode = turtle.mode || 'unknown';
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||
const inventoryCount = turtle.inventory?.length || 0;
|
||||
const inventoryCount = Array.isArray(turtle.inventory)
|
||||
? turtle.inventory.length
|
||||
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||
|
||||
const modeColors = {
|
||||
mining: '#4ade80',
|
||||
exploring: '#60a5fa',
|
||||
returning: '#f59e0b',
|
||||
idle: '#9ca3af',
|
||||
manual: '#a78bfa',
|
||||
unknown: '#6b7280'
|
||||
mining: '#55ff55',
|
||||
exploring: '#55ffff',
|
||||
returning: '#ffaa00',
|
||||
goHome: '#ffaa00',
|
||||
idle: '#aaaaaa',
|
||||
manual: '#ff55ff',
|
||||
refueling: '#ff5555',
|
||||
farming: '#55ff55',
|
||||
dumpInventory: '#aa00aa',
|
||||
dumping: '#aa00aa',
|
||||
moving: '#55ffff',
|
||||
scan: '#5555ff',
|
||||
extraction: '#ffaa00',
|
||||
building: '#00aaaa',
|
||||
autocraft: '#ff55ff',
|
||||
unknown: '#555555'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onSelect}
|
||||
style={{ borderColor: modeColors[mode] }}
|
||||
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
||||
>
|
||||
<div className="turtle-header">
|
||||
<h3>Turtle {turtle.turtleID}</h3>
|
||||
<span className="mode-badge" style={{ background: modeColors[mode] }}>
|
||||
{mode}
|
||||
<h3>{displayName}</h3>
|
||||
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||
{activeState}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
}
|
||||
|
||||
function TurtleDetails({ turtle }) {
|
||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
|
||||
const equipLeft = useTurtleStore((state) => state.equipLeft);
|
||||
const equipRight = useTurtleStore((state) => state.equipRight);
|
||||
const sortInventory = useTurtleStore((state) => state.sortInventory);
|
||||
const selectSlot = useTurtleStore((state) => state.selectSlot);
|
||||
const dropItems = useTurtleStore((state) => state.dropItems);
|
||||
const suckItems = useTurtleStore((state) => state.suckItems);
|
||||
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
|
||||
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
|
||||
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
|
||||
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
|
||||
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
|
||||
|
||||
if (!turtle) {
|
||||
return (
|
||||
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleCommand = (command, param = null) => {
|
||||
sendCommand(turtle.turtleID, command, param);
|
||||
const handleStateChange = (stateName, data = {}) => {
|
||||
setTurtleState(turtle.turtleID, stateName, data);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (renameValue.trim()) {
|
||||
await renameTurtle(turtle.turtleID, renameValue.trim());
|
||||
setRenameValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotClick = async (slotIndex) => {
|
||||
await selectSlot(turtle.turtleID, slotIndex + 1);
|
||||
};
|
||||
|
||||
const handleConfigSave = async () => {
|
||||
await updateTurtleConfig(turtle.turtleID, configValues);
|
||||
setShowConfig(false);
|
||||
};
|
||||
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||
|
||||
return (
|
||||
<div className="turtle-details">
|
||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
||||
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
|
||||
|
||||
{/* Rename + Config bar */}
|
||||
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
placeholder="Rename turtle..."
|
||||
className="rename-input"
|
||||
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
|
||||
/>
|
||||
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
|
||||
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}>⚙️ Config</button>
|
||||
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
|
||||
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
|
||||
</div>
|
||||
|
||||
{/* Config Modal */}
|
||||
{showConfig && (
|
||||
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
|
||||
<h3>⚙️ Configuration</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Max Distance:</span>
|
||||
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Auto Refuel:</span>
|
||||
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
|
||||
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Status</h3>
|
||||
<div className="status-grid">
|
||||
<div className="status-item">
|
||||
<span className="label">Mode:</span>
|
||||
<span className="value">{turtle.mode || 'unknown'}</span>
|
||||
<span className="label">State:</span>
|
||||
<span className="value">{activeState}</span>
|
||||
</div>
|
||||
{turtle.stateDescription && (
|
||||
<div className="status-item">
|
||||
<span className="label">Activity:</span>
|
||||
<span className="value">{turtle.stateDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Fuel:</span>
|
||||
<span className="value">
|
||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||
</span>
|
||||
</div>
|
||||
{turtle.totalSteps > 0 && (
|
||||
<div className="status-item">
|
||||
<span className="label">Steps:</span>
|
||||
<span className="value">
|
||||
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Position:</span>
|
||||
<span className="value">
|
||||
@@ -115,67 +224,168 @@ function TurtleDetails({ turtle }) {
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{turtle.error && (
|
||||
<div className="status-item" style={{ color: '#ff5555' }}>
|
||||
<span className="label">Error:</span>
|
||||
<span className="value">{turtle.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{turtle.warning && (
|
||||
<div className="status-item" style={{ color: '#ffaa00' }}>
|
||||
<span className="label">Warning:</span>
|
||||
<span className="value">{turtle.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peripherals section */}
|
||||
{turtle.peripherals && Object.keys(turtle.peripherals).length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h3>Peripherals</h3>
|
||||
<div className="status-grid">
|
||||
{Object.entries(turtle.peripherals).map(([side, info]) => (
|
||||
<div key={side} className="status-item">
|
||||
<span className="label" style={{ textTransform: 'capitalize' }}>{side}:</span>
|
||||
<span className="value" style={{ color: '#ff55ff' }}>
|
||||
{typeof info === 'string' ? info : (info?.types?.join(', ') || 'unknown')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Commands</h3>
|
||||
<h3>State Machine</h3>
|
||||
<div className="command-grid">
|
||||
<button
|
||||
className="command-btn explore"
|
||||
onClick={() => handleCommand('explore')}
|
||||
title="Start autonomous exploration and mining"
|
||||
<button
|
||||
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('idle')}
|
||||
title="Stop and go idle"
|
||||
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⏸️ Idle
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('exploring')}
|
||||
title="Autonomous exploration"
|
||||
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🔍 Explore
|
||||
</button>
|
||||
<button
|
||||
className="command-btn mine"
|
||||
onClick={() => handleCommand('mine')}
|
||||
title="Start mining mode"
|
||||
<button
|
||||
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('mining')}
|
||||
title="Mining with ore priority"
|
||||
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛏️ Mine
|
||||
</button>
|
||||
<button
|
||||
className="command-btn return"
|
||||
onClick={() => handleCommand('returnHome')}
|
||||
title="Navigate back to home position"
|
||||
<button
|
||||
className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('farming')}
|
||||
title="Automated farming"
|
||||
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🏠 Return Home
|
||||
🌾 Farm
|
||||
</button>
|
||||
<button
|
||||
className="command-btn stop"
|
||||
onClick={() => handleCommand('stop')}
|
||||
title="Stop all autonomous actions"
|
||||
<button
|
||||
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('goHome')}
|
||||
title="Navigate home"
|
||||
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⏹️ Stop
|
||||
🏠 Go Home
|
||||
</button>
|
||||
<button
|
||||
className="command-btn manual"
|
||||
onClick={() => handleCommand('manual')}
|
||||
title="Switch to manual control mode"
|
||||
>
|
||||
🎮 Manual Mode
|
||||
</button>
|
||||
<button
|
||||
className="command-btn setHome"
|
||||
onClick={() => handleCommand('setHome')}
|
||||
title="Set current position as home"
|
||||
>
|
||||
📍 Set Home
|
||||
</button>
|
||||
<button
|
||||
className="command-btn refuel"
|
||||
onClick={() => handleCommand('refuel')}
|
||||
title="Consume fuel from inventory"
|
||||
<button
|
||||
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('refueling')}
|
||||
title="Auto-refuel from inventory"
|
||||
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛽ Refuel
|
||||
</button>
|
||||
<button
|
||||
className="command-btn status"
|
||||
onClick={() => handleCommand('status')}
|
||||
title="Request status update"
|
||||
<button
|
||||
className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('dumpInventory')}
|
||||
title="Dump inventory into nearby container"
|
||||
style={activeState === 'dumpInventory' || activeState === 'dumping' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
📊 Status
|
||||
📦 Dump
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'moving' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('moving')}
|
||||
title="Navigate to target"
|
||||
style={activeState === 'moving' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🧭 Move To
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'scan' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('scan')}
|
||||
title="Scan surroundings with peripheral scanner"
|
||||
style={activeState === 'scan' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
📡 Scan
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'extraction' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const area = prompt('Enter extraction area (startX,startY,startZ,endX,endY,endZ):');
|
||||
if (area) {
|
||||
const [sx,sy,sz,ex,ey,ez] = area.split(',').map(Number);
|
||||
if (!isNaN(sx) && !isNaN(sy) && !isNaN(sz) && !isNaN(ex) && !isNaN(ey) && !isNaN(ez)) {
|
||||
const points = [];
|
||||
for (let x = sx; x <= ex; x++) for (let y = sy; y <= ey; y++) for (let z = sz; z <= ez; z++) {
|
||||
points.push({x, y, z});
|
||||
}
|
||||
handleStateChange('extraction', { area: points });
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Smart ore extraction in an area"
|
||||
style={activeState === 'extraction' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
💎 Extract
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'building' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const input = prompt('Enter blueprint JSON (array of {x,y,z,name} blocks):');
|
||||
if (input) {
|
||||
try {
|
||||
const blocks = JSON.parse(input);
|
||||
handleStateChange('building', { blocks });
|
||||
} catch (e) {
|
||||
alert('Invalid JSON blueprint');
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Build from blueprint"
|
||||
style={activeState === 'building' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🏗️ Build
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'autocraft' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const input = prompt('Enter recipe JSON (array of 16 slot items, null for empty):');
|
||||
if (input) {
|
||||
try {
|
||||
const recipe = JSON.parse(input);
|
||||
handleStateChange('autocraft', { recipe });
|
||||
} catch (e) {
|
||||
alert('Invalid JSON recipe');
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Automated crafting with workbench"
|
||||
style={activeState === 'autocraft' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🔨 Autocraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,16 +394,16 @@ function TurtleDetails({ turtle }) {
|
||||
<h3>Movement</h3>
|
||||
<div className="movement-controls">
|
||||
<div className="movement-row">
|
||||
<button onClick={() => handleCommand('forward')} title="Move forward">↑</button>
|
||||
<button onClick={() => moveForward(turtle.turtleID)} title="Move forward">↑</button>
|
||||
</div>
|
||||
<div className="movement-row">
|
||||
<button onClick={() => handleCommand('turnLeft')} title="Turn left">←</button>
|
||||
<button onClick={() => handleCommand('back')} title="Move backward">↓</button>
|
||||
<button onClick={() => handleCommand('turnRight')} title="Turn right">→</button>
|
||||
<button onClick={() => turnLeft(turtle.turtleID)} title="Turn left">←</button>
|
||||
<button onClick={() => moveBack(turtle.turtleID)} title="Move backward">↓</button>
|
||||
<button onClick={() => turnRight(turtle.turtleID)} title="Turn right">→</button>
|
||||
</div>
|
||||
<div className="movement-row vertical">
|
||||
<button onClick={() => handleCommand('up')} title="Move up">⬆ Up</button>
|
||||
<button onClick={() => handleCommand('down')} title="Move down">⬇ Down</button>
|
||||
<button onClick={() => moveUp(turtle.turtleID)} title="Move up">⬆ Up</button>
|
||||
<button onClick={() => moveDown(turtle.turtleID)} title="Move down">⬇ Down</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,28 +413,28 @@ function TurtleDetails({ turtle }) {
|
||||
<div className="action-grid">
|
||||
<button
|
||||
className="action-btn dig"
|
||||
onClick={() => handleCommand('dig')}
|
||||
onClick={() => digBlock(turtle.turtleID)}
|
||||
title="Dig block in front"
|
||||
>
|
||||
⛏️ Dig
|
||||
</button>
|
||||
<button
|
||||
className="action-btn digup"
|
||||
onClick={() => handleCommand('digUp')}
|
||||
onClick={() => digBlockUp(turtle.turtleID)}
|
||||
title="Dig block above"
|
||||
>
|
||||
⬆️ Dig Up
|
||||
</button>
|
||||
<button
|
||||
className="action-btn digdown"
|
||||
onClick={() => handleCommand('digDown')}
|
||||
onClick={() => digBlockDown(turtle.turtleID)}
|
||||
title="Dig block below"
|
||||
>
|
||||
⬇️ Dig Down
|
||||
</button>
|
||||
<button
|
||||
className="action-btn place"
|
||||
onClick={() => handleCommand('place')}
|
||||
onClick={() => placeBlock(turtle.turtleID)}
|
||||
title="Place block from inventory"
|
||||
>
|
||||
🧱 Place
|
||||
@@ -232,17 +442,86 @@ function TurtleDetails({ turtle }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Equipment & Inventory Actions */}
|
||||
<div className="detail-section">
|
||||
<h3>Equipment & Inventory</h3>
|
||||
<div className="action-grid">
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => equipLeft(turtle.turtleID)}
|
||||
title="Equip selected item on left side"
|
||||
>
|
||||
🛡️ Equip L
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => equipRight(turtle.turtleID)}
|
||||
title="Equip selected item on right side"
|
||||
>
|
||||
🗡️ Equip R
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => sortInventory(turtle.turtleID)}
|
||||
title="Sort and compact inventory"
|
||||
>
|
||||
📋 Sort
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'front')}
|
||||
title="Drop items forward"
|
||||
>
|
||||
📤 Drop
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'up')}
|
||||
title="Drop items upward"
|
||||
>
|
||||
⬆️ Drop Up
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'down')}
|
||||
title="Drop items downward"
|
||||
>
|
||||
⬇️ Drop Down
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => suckItems(turtle.turtleID, 'front')}
|
||||
title="Suck items from front"
|
||||
>
|
||||
📥 Suck
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => {
|
||||
const side = prompt('Enter peripheral side (front/top/bottom/left/right):');
|
||||
if (side) connectToInventory(turtle.turtleID, side);
|
||||
}}
|
||||
title="Read adjacent inventory peripheral contents"
|
||||
>
|
||||
📦 Read Inv
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turtle.inventory && turtle.inventory.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16)</h3>
|
||||
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) — Slot: {turtle.selectedSlot || 1}</h3>
|
||||
<div className="inventory-grid">
|
||||
{Array.from({ length: 16 }, (_, slotIndex) => {
|
||||
const item = turtle.inventory[slotIndex];
|
||||
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
|
||||
return (
|
||||
<div
|
||||
key={slotIndex}
|
||||
className={`inventory-slot ${item ? 'filled' : 'empty'}`}
|
||||
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count})` : 'Empty'}
|
||||
className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
|
||||
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count}) — Click to select` : `Slot ${slotIndex + 1} — Click to select`}
|
||||
onClick={() => handleSlotClick(slotIndex)}
|
||||
style={isSelected ? { outline: '2px solid #55ffff', outlineOffset: '-2px' } : {}}
|
||||
>
|
||||
{item ? (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Groups Panel
|
||||
============================================ */
|
||||
|
||||
.groups-panel {
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.groups-header {
|
||||
@@ -16,19 +20,20 @@
|
||||
.groups-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -36,15 +41,15 @@
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #10b98133;
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #ef444433;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -60,17 +65,19 @@
|
||||
|
||||
/* Create Group Section */
|
||||
.create-group-section {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.create-group-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-group-form {
|
||||
@@ -81,16 +88,16 @@
|
||||
|
||||
.create-group-form input {
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.create-group-form input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.create-group-form input:disabled {
|
||||
@@ -107,11 +114,11 @@
|
||||
.color-option {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid transparent;
|
||||
border: 3px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
position: relative;
|
||||
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.3), inset 0 2px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
@@ -119,25 +126,26 @@
|
||||
}
|
||||
|
||||
.color-option.active {
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 0 0 2px #0f172a, 0 0 0 4px currentColor;
|
||||
border-color: #ffff55;
|
||||
box-shadow: 0 0 0 2px #1a1a1a, 0 0 0 4px #ffff55;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"] {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"]:disabled {
|
||||
@@ -153,15 +161,15 @@
|
||||
}
|
||||
|
||||
.group-card {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.group-card:hover {
|
||||
background: #334155;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
@@ -182,24 +190,23 @@
|
||||
.group-color-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #0f172a;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.group-title h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.25rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.delete-group-btn {
|
||||
@@ -209,7 +216,7 @@
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.delete-group-btn:hover {
|
||||
@@ -225,7 +232,7 @@
|
||||
.group-members h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -243,13 +250,14 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
background: #1e293b;
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
@@ -266,7 +274,7 @@
|
||||
.member-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.member-status {
|
||||
@@ -278,11 +286,11 @@
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ef4444;
|
||||
color: #ff5555;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.remove-member-btn:hover {
|
||||
@@ -293,7 +301,7 @@
|
||||
.no-members {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -305,34 +313,34 @@
|
||||
.add-member-section select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.add-member-section select:hover {
|
||||
border-color: #3b82f6;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.add-member-section select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
/* Group Commands */
|
||||
.group-commands {
|
||||
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||
border-top: 1px solid #334155;
|
||||
border-top: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.group-commands h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -346,21 +354,21 @@
|
||||
|
||||
.command-buttons button {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.command-buttons button:hover {
|
||||
background: #1e293b;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -378,13 +386,13 @@
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@@ -10,14 +10,14 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const colorPresets = [
|
||||
{ name: 'Blue', value: '#3b82f6' },
|
||||
{ name: 'Green', value: '#10b981' },
|
||||
{ name: 'Red', value: '#ef4444' },
|
||||
{ name: 'Yellow', value: '#f59e0b' },
|
||||
{ name: 'Purple', value: '#8b5cf6' },
|
||||
{ name: 'Pink', value: '#ec4899' },
|
||||
{ name: 'Cyan', value: '#06b6d4' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Blue', value: '#345ec3' },
|
||||
{ name: 'Green', value: '#4a8c2a' },
|
||||
{ name: 'Red', value: '#aa0000' },
|
||||
{ name: 'Yellow', value: '#ffaa00' },
|
||||
{ name: 'Purple', value: '#7b2fbe' },
|
||||
{ name: 'Pink', value: '#d4658a' },
|
||||
{ name: 'Cyan', value: '#55ffff' },
|
||||
{ name: 'Orange', value: '#c97a2a' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +33,7 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
||||
const response = await fetch(`${apiUrl}/api/groups`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGroups(data);
|
||||
setGroups(Array.isArray(data) ? data : (data.groups || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Mining Areas Panel
|
||||
============================================ */
|
||||
|
||||
.mining-areas-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
background: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -12,79 +17,86 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 2px solid #334155;
|
||||
background: #6b4e28;
|
||||
border-bottom: 3px solid #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #f8fafc;
|
||||
color: #ffff55;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Filter buttons */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #334155;
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #6b6b6b;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #475569;
|
||||
color: #e2e8f0;
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* Create form */
|
||||
.create-form {
|
||||
padding: 1.5rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.create-form h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #f8fafc;
|
||||
color: #ffaa00;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -94,28 +106,27 @@
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.coordinates-section {
|
||||
@@ -130,31 +141,34 @@
|
||||
}
|
||||
|
||||
.coordinates-header label {
|
||||
color: #cbd5e1;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-use-position {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: #10b981;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-use-position:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
background: #5a9c3a;
|
||||
}
|
||||
|
||||
.btn-use-position:disabled {
|
||||
background: #334155;
|
||||
color: #64748b;
|
||||
background: #4b4b4b;
|
||||
color: #7b7b7b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.coordinate-inputs {
|
||||
@@ -170,21 +184,22 @@
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
margin-top: 1rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Areas list */
|
||||
@@ -197,7 +212,7 @@
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #64748b;
|
||||
color: #7b7b7b;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -206,22 +221,21 @@
|
||||
|
||||
/* Area card */
|
||||
.area-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.area-card:hover {
|
||||
border-color: #475569;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.area-card.has-conflict {
|
||||
border-color: #ef4444;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
|
||||
border-color: #ff5555;
|
||||
background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
|
||||
}
|
||||
|
||||
.area-header {
|
||||
@@ -230,23 +244,24 @@
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.area-header h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: white;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* Area info */
|
||||
@@ -259,7 +274,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
@@ -267,32 +282,32 @@
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #e2e8f0;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.coordinate-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
color: #55ffff;
|
||||
}
|
||||
|
||||
/* Conflict warning */
|
||||
.conflict-warning {
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 6px;
|
||||
background: #6b1a1a44;
|
||||
border: 2px solid #ff5555;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fca5a5;
|
||||
color: #ff5555;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -316,46 +331,45 @@
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: #10b981;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-start:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
background: #3b82f6;
|
||||
background: #345ec3;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #4a6ed3;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ef4444;
|
||||
background: #aa0000;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -366,18 +380,18 @@
|
||||
|
||||
.areas-list::-webkit-scrollbar-track,
|
||||
.create-form::-webkit-scrollbar-track {
|
||||
background: #0f172a;
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.areas-list::-webkit-scrollbar-thumb,
|
||||
.create-form::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 4px;
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.areas-list::-webkit-scrollbar-thumb:hover,
|
||||
.create-form::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
background: #6b6b6b;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@@ -432,3 +446,62 @@
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Color picker row */
|
||||
.color-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.color-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-input::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.color-swatch.active {
|
||||
border-color: #ffff55;
|
||||
box-shadow: 0 0 4px #ffff55;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Color dot in area card */
|
||||
.area-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.area-color-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newArea, setNewArea] = useState({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
@@ -28,7 +29,8 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
const response = await fetch(`${apiUrl}/api/mining-areas`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAreas(data);
|
||||
// Server returns a flat array of formatted areas
|
||||
setAreas(Array.isArray(data) ? data : (data.areas || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load mining areas:', error);
|
||||
@@ -65,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
endX: Number(newArea.endX),
|
||||
endY: Number(newArea.endY),
|
||||
endZ: Number(newArea.endZ),
|
||||
color: newArea.color,
|
||||
status: 'planned'
|
||||
})
|
||||
});
|
||||
@@ -73,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
setShowCreateForm(false);
|
||||
setNewArea({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
@@ -191,12 +195,12 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
// Status badge component
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
planned: '#6366f1',
|
||||
mining: '#f59e0b',
|
||||
completed: '#10b981'
|
||||
planned: '#345ec3',
|
||||
mining: '#ffaa00',
|
||||
completed: '#4a8c2a'
|
||||
};
|
||||
return (
|
||||
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}>
|
||||
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
@@ -258,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Area Color:</label>
|
||||
<div className="color-picker-row">
|
||||
<input
|
||||
type="color"
|
||||
value={newArea.color}
|
||||
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
|
||||
className="color-input"
|
||||
/>
|
||||
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewArea({ ...newArea, color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Assign Turtle:</label>
|
||||
<select
|
||||
@@ -363,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
return (
|
||||
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||
<div className="area-header">
|
||||
<h3>{area.areaName}</h3>
|
||||
<div className="area-title-row">
|
||||
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
|
||||
<h3>{area.areaName}</h3>
|
||||
</div>
|
||||
<StatusBadge status={area.status} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Path Recorder
|
||||
============================================ */
|
||||
|
||||
.path-recorder {
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
@@ -18,23 +22,23 @@
|
||||
.recorder-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.selected-turtle-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
color: #10b981;
|
||||
background: #2d6b1a33;
|
||||
border: 2px solid #55ff55;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -42,21 +46,21 @@
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #10b98133;
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #ef444433;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background: #3b82f633;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
background: #1a4a6b33;
|
||||
color: #55ffff;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -72,17 +76,19 @@
|
||||
|
||||
/* Recording Section */
|
||||
.recording-section {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.recording-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.record-form {
|
||||
@@ -94,36 +100,36 @@
|
||||
.record-form input,
|
||||
.record-form textarea {
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.record-form input:focus,
|
||||
.record-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #ef4444;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: #aa0000;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.record-btn:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
background: #cc0000;
|
||||
box-shadow: inset 0 2px 0 #ff4444, inset 0 -2px 0 #880000;
|
||||
}
|
||||
|
||||
.record-btn:disabled {
|
||||
@@ -141,7 +147,8 @@
|
||||
.waypoint-counter {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
color: #ff5555;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@@ -157,51 +164,53 @@
|
||||
.recording-info {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0.25rem 0;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recording-info strong {
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
padding: 0.875rem 2rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.stop-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Paths Section */
|
||||
.paths-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #55ffff;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -212,15 +221,15 @@
|
||||
}
|
||||
|
||||
.path-card {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.path-card:hover {
|
||||
background: #334155;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.path-card-header {
|
||||
@@ -230,13 +239,13 @@
|
||||
.path-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.path-description {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin: 0.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -246,7 +255,7 @@
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
color: #7b7b7b;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -263,44 +272,45 @@
|
||||
|
||||
.path-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: #3b82f6;
|
||||
background: #345ec3;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
background: #4a6ed3;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #10b981;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
background: #5a9c3a;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
background: #aa0000;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
transform: scale(1.1);
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -318,13 +328,13 @@
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Path Details Modal */
|
||||
@@ -334,7 +344,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -353,13 +363,14 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
border-radius: 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border: 3px solid #1a1a1a;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
@@ -378,30 +389,34 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
background: #6b4e28;
|
||||
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.5rem;
|
||||
background: #aa0000;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #e5e7eb;
|
||||
transform: scale(1.1);
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -409,7 +424,7 @@
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -418,8 +433,9 @@
|
||||
.path-visualization h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.waypoints-grid {
|
||||
@@ -434,8 +450,8 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -445,21 +461,21 @@
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.waypoint-coords {
|
||||
color: #e5e7eb;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.waypoint-action {
|
||||
margin-left: auto;
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -471,25 +487,26 @@
|
||||
|
||||
.stat {
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
.stat .stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
.stat .stat-value {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #55ffff;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTurtleStore } from '../store/turtleStore';
|
||||
import './PathRecorder.css';
|
||||
|
||||
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
@@ -10,6 +11,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
const [selectedPath, setSelectedPath] = useState(null);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playingBack, setPlayingBack] = useState(false);
|
||||
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
|
||||
useEffect(() => {
|
||||
loadPaths();
|
||||
@@ -21,7 +25,7 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
const response = await fetch(`${apiUrl}/api/paths`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPaths(data);
|
||||
setPaths(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load paths:', error);
|
||||
@@ -59,31 +63,18 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the path
|
||||
const pathResponse = await fetch(`${apiUrl}/api/paths`, {
|
||||
const response = await fetch(`${apiUrl}/api/paths`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: currentPath.name,
|
||||
turtleId: currentPath.turtleId,
|
||||
description: currentPath.description
|
||||
pathName: currentPath.name,
|
||||
pathData: currentPath.waypoints
|
||||
})
|
||||
});
|
||||
|
||||
if (!pathResponse.ok) {
|
||||
throw new Error('Failed to create path');
|
||||
}
|
||||
|
||||
const pathData = await pathResponse.json();
|
||||
const pathId = pathData.pathId;
|
||||
|
||||
// Add all waypoints
|
||||
for (const waypoint of currentPath.waypoints) {
|
||||
await fetch(`${apiUrl}/api/paths/${pathId}/waypoints`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(waypoint)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save path');
|
||||
}
|
||||
|
||||
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
|
||||
@@ -132,9 +123,50 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const playbackPath = (path) => {
|
||||
showMessage(`Playback not yet implemented for path: ${path.name}`, 'info');
|
||||
// TODO: Implement playback by sending commands to turtle
|
||||
const playbackPath = async (path) => {
|
||||
if (!selectedTurtle) {
|
||||
showMessage('Please select a turtle to play back this path', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load full path data if we don't have waypoints
|
||||
let waypoints = path.pathData || path.waypoints;
|
||||
if (!waypoints || waypoints.length === 0) {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
waypoints = data.waypoints;
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Failed to load path data', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
showMessage('Path has insufficient waypoints for playback', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayingBack(true);
|
||||
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
|
||||
|
||||
const turtleId = selectedTurtle.turtleID;
|
||||
|
||||
// Use server-side pathfinding to navigate to each waypoint sequentially
|
||||
for (let i = 0; i < waypoints.length; i++) {
|
||||
const wp = waypoints[i];
|
||||
|
||||
// Navigate to each waypoint using the server's moving state
|
||||
await setTurtleState(turtleId, 'moving', { target: { x: wp.x, y: wp.y, z: wp.z } });
|
||||
|
||||
// Wait for turtle to arrive (poll position or just add delay)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
setPlayingBack(false);
|
||||
showMessage('Playback complete! Turtle navigating via server.', 'success');
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
@@ -276,8 +308,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
onClick={() => playbackPath(path)}
|
||||
className="play-btn"
|
||||
title="Playback path"
|
||||
disabled={playingBack}
|
||||
>
|
||||
▶️ Play
|
||||
{playingBack ? '⏳ Playing...' : '▶️ Play'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePath(path.pathId)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Stats Panel
|
||||
============================================ */
|
||||
|
||||
.stats-panel {
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
@@ -18,43 +22,47 @@
|
||||
.stats-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.time-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: #1e293b;
|
||||
gap: 0.25rem;
|
||||
background: #3b3b3b;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.time-filter button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.time-filter button:hover {
|
||||
color: #e5e7eb;
|
||||
background: #334155;
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.time-filter button.active {
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -66,33 +74,36 @@
|
||||
.turtle-stats h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #55ff55;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.stat-section {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.total-mined {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
color: #55ffff;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin-top: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -101,7 +112,7 @@
|
||||
.blocks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.block-stat {
|
||||
@@ -109,14 +120,15 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.block-stat:hover {
|
||||
background: #1e293b;
|
||||
transform: translateY(-2px);
|
||||
background: #4b4b4b;
|
||||
box-shadow: inset 0 -1px 0 #333, inset 0 1px 0 #666;
|
||||
}
|
||||
|
||||
.block-emoji {
|
||||
@@ -131,13 +143,13 @@
|
||||
.block-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.block-count {
|
||||
font-size: 0.75rem;
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -149,14 +161,16 @@
|
||||
.leaderboard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #ffaa00;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
@@ -165,9 +179,10 @@
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.leaderboard-item:last-child {
|
||||
@@ -175,20 +190,19 @@
|
||||
}
|
||||
|
||||
.leaderboard-item:hover {
|
||||
background: #1e293b;
|
||||
transform: translateX(4px);
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-1 {
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-left: 4px solid #ffaa00;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-2 {
|
||||
border-left: 3px solid #9ca3af;
|
||||
border-left: 4px solid #a0a0a0;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-3 {
|
||||
border-left: 3px solid #cd7f32;
|
||||
border-left: 4px solid #cd7f32;
|
||||
}
|
||||
|
||||
.rank {
|
||||
@@ -196,6 +210,8 @@
|
||||
font-weight: 700;
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
color: #ffff55;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.miner-info {
|
||||
@@ -205,27 +221,29 @@
|
||||
.miner-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.miner-stats {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.miner-score {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
color: #55ffff;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
/* All Turtles Overview */
|
||||
.all-turtles-stats h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #55ffff;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.turtles-summary-grid {
|
||||
@@ -235,16 +253,16 @@
|
||||
}
|
||||
|
||||
.turtle-summary-card {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.turtle-summary-card:hover {
|
||||
background: #334155;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #4b4b4b;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.turtle-summary-header {
|
||||
@@ -253,19 +271,20 @@
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.turtle-id {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.turtle-total {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
color: #55ffff;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.turtle-top-blocks {
|
||||
@@ -278,8 +297,8 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.25rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -290,7 +309,7 @@
|
||||
.mini-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@@ -308,19 +327,19 @@
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Response is either a single object (per turtle) or an array (all turtles)
|
||||
setMiningStats(Array.isArray(data) ? data : [data]);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -44,7 +45,8 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
|
||||
const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTopMiners(data);
|
||||
// Response is now a flat array of {turtleId, totalBlocks, uniqueTypes}
|
||||
setTopMiners(Array.isArray(data) ? data : (data.topMiners || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load top miners:', error);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Task Panel
|
||||
============================================ */
|
||||
|
||||
.task-panel {
|
||||
padding: 1.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
@@ -16,31 +20,33 @@
|
||||
.task-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-task-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.create-task-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -48,15 +54,15 @@
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #10b98133;
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #ef444433;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
@@ -72,18 +78,20 @@
|
||||
|
||||
/* Create Task Form */
|
||||
.create-task-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: slideIn 0.3s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.create-task-form h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-task-form form {
|
||||
@@ -104,28 +112,29 @@
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.create-task-form select,
|
||||
.create-task-form input[type="text"],
|
||||
.create-task-form input[type="number"] {
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
color: #e5e7eb;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.create-task-form select:focus,
|
||||
.create-task-form input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.create-task-form input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #4a8c2a;
|
||||
}
|
||||
|
||||
.priority-value {
|
||||
@@ -137,7 +146,7 @@
|
||||
.coordinates-section h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
@@ -156,64 +165,68 @@
|
||||
.coord-group span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.coord-group input {
|
||||
padding: 0.5rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.25rem;
|
||||
color: #e5e7eb;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #10b981;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Task Filters */
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-filters button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1e293b;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.task-filters button:hover {
|
||||
color: #e5e7eb;
|
||||
background: #334155;
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.task-filters button.active {
|
||||
background: #3b82f6;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* Tasks List */
|
||||
@@ -226,20 +239,20 @@
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
background: #334155;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
@@ -264,7 +277,7 @@
|
||||
.task-type {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.task-badges {
|
||||
@@ -275,41 +288,42 @@
|
||||
.priority-badge,
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.task-assignment {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.25rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.task-parameters {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.task-result {
|
||||
font-size: 0.875rem;
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #10b98120;
|
||||
border-radius: 0.25rem;
|
||||
border-left: 3px solid #10b981;
|
||||
background: #2d6b1a20;
|
||||
border: 2px solid #55ff55;
|
||||
border-left: 4px solid #55ff55;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
@@ -320,25 +334,26 @@
|
||||
|
||||
.task-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.25rem;
|
||||
color: #e5e7eb;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.task-actions button:hover {
|
||||
background: #1e293b;
|
||||
border-color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.task-timestamp {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
color: #7b7b7b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -357,13 +372,13 @@
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@@ -44,7 +44,8 @@ const TaskPanel = ({ turtles, apiUrl }) => {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data);
|
||||
// Server returns flat array of formatted tasks
|
||||
setTasks(Array.isArray(data) ? data : (data.tasks || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
@@ -156,19 +157,19 @@ const TaskPanel = ({ turtles, apiUrl }) => {
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return '#94a3b8';
|
||||
case 'in_progress': return '#3b82f6';
|
||||
case 'completed': return '#10b981';
|
||||
case 'failed': return '#ef4444';
|
||||
default: return '#94a3b8';
|
||||
case 'pending': return '#a0a0a0';
|
||||
case 'in_progress': return '#345ec3';
|
||||
case 'completed': return '#4a8c2a';
|
||||
case 'failed': return '#aa0000';
|
||||
default: return '#a0a0a0';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
if (priority >= 8) return { label: 'Critical', color: '#ef4444' };
|
||||
if (priority >= 6) return { label: 'High', color: '#f59e0b' };
|
||||
if (priority >= 4) return { label: 'Medium', color: '#3b82f6' };
|
||||
return { label: 'Low', color: '#94a3b8' };
|
||||
if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
|
||||
if (priority >= 6) return { label: 'High', color: '#ffaa00' };
|
||||
if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
|
||||
return { label: 'Low', color: '#a0a0a0' };
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Voice Control
|
||||
============================================ */
|
||||
|
||||
.voice-control {
|
||||
padding: 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #334155;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.voice-control.unsupported {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ef4444;
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.voice-control.unsupported small {
|
||||
color: #9ca3af;
|
||||
color: #a0a0a0;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -27,62 +32,67 @@
|
||||
.voice-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f9fafb;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.selected-turtle {
|
||||
font-size: 0.875rem;
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
background: #345ec3;
|
||||
border: 3px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.1s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -3px 0 #223399;
|
||||
}
|
||||
|
||||
.voice-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
||||
background: #4a6ed3;
|
||||
box-shadow: inset 0 2px 0 #6688ee, inset 0 -3px 0 #3344aa;
|
||||
}
|
||||
|
||||
.voice-button:disabled {
|
||||
background: #374151;
|
||||
background: #4b4b4b;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.voice-button.listening {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
background: #aa0000;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 20px rgba(255, 85, 85, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 40px rgba(255, 85, 85, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +105,6 @@
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
@@ -113,29 +122,28 @@
|
||||
.voice-feedback {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #1e293b;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.transcript strong {
|
||||
color: #60a5fa;
|
||||
color: #55ffff;
|
||||
}
|
||||
|
||||
.last-command {
|
||||
color: #10b981;
|
||||
color: #55ff55;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.last-command strong {
|
||||
color: #34d399;
|
||||
color: #55ff55;
|
||||
}
|
||||
|
||||
.voice-commands-help {
|
||||
@@ -143,23 +151,22 @@
|
||||
}
|
||||
|
||||
.voice-commands-help details {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #1e293b;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.voice-commands-help summary {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
color: #a0a0a0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.voice-commands-help summary:hover {
|
||||
color: #e5e7eb;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.commands-grid {
|
||||
@@ -171,11 +178,12 @@
|
||||
|
||||
.command-category h4 {
|
||||
font-size: 0.75rem;
|
||||
color: #60a5fa;
|
||||
color: #ffaa00;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.command-category ul {
|
||||
@@ -186,14 +194,14 @@
|
||||
|
||||
.command-category li {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
color: #a0a0a0;
|
||||
padding: 0.25rem 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.command-category li::before {
|
||||
content: "▸ ";
|
||||
color: #3b82f6;
|
||||
color: #55ff55;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,18 @@ export default function VoiceControl() {
|
||||
const [recognition, setRecognition] = useState(null);
|
||||
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||
const refuelTurtle = useTurtleStore((state) => state.refuelTurtle);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if speech recognition is supported
|
||||
@@ -49,52 +60,68 @@ export default function VoiceControl() {
|
||||
}
|
||||
|
||||
const turtleId = selectedTurtle.turtleID;
|
||||
let action = null;
|
||||
let param = null;
|
||||
let actionName = null;
|
||||
|
||||
// Parse voice commands
|
||||
// Movement commands (server-side)
|
||||
if (command.includes('forward') || command.includes('go ahead')) {
|
||||
action = 'forward';
|
||||
moveForward(turtleId);
|
||||
actionName = 'forward';
|
||||
} else if (command.includes('back') || command.includes('backward')) {
|
||||
action = 'back';
|
||||
moveBack(turtleId);
|
||||
actionName = 'back';
|
||||
} else if (command.includes('turn left') || command.includes('left')) {
|
||||
action = 'turnLeft';
|
||||
turnLeft(turtleId);
|
||||
actionName = 'turn left';
|
||||
} else if (command.includes('turn right') || command.includes('right')) {
|
||||
action = 'turnRight';
|
||||
turnRight(turtleId);
|
||||
actionName = 'turn right';
|
||||
} else if (command.includes('go up') || command.includes('move up')) {
|
||||
action = 'up';
|
||||
moveUp(turtleId);
|
||||
actionName = 'up';
|
||||
} else if (command.includes('go down') || command.includes('move down')) {
|
||||
action = 'down';
|
||||
moveDown(turtleId);
|
||||
actionName = 'down';
|
||||
} else if (command.includes('dig')) {
|
||||
if (command.includes('up')) {
|
||||
action = 'digUp';
|
||||
digBlockUp(turtleId);
|
||||
actionName = 'dig up';
|
||||
} else if (command.includes('down')) {
|
||||
action = 'digDown';
|
||||
digBlockDown(turtleId);
|
||||
actionName = 'dig down';
|
||||
} else {
|
||||
action = 'dig';
|
||||
digBlock(turtleId);
|
||||
actionName = 'dig';
|
||||
}
|
||||
} else if (command.includes('place') || command.includes('build')) {
|
||||
action = 'place';
|
||||
placeBlock(turtleId);
|
||||
actionName = 'place';
|
||||
// State machine commands (server-side)
|
||||
} else if (command.includes('explore') || command.includes('start exploring')) {
|
||||
action = 'explore';
|
||||
setTurtleState(turtleId, 'exploring');
|
||||
actionName = 'explore';
|
||||
} else if (command.includes('mine') || command.includes('start mining')) {
|
||||
action = 'mine';
|
||||
setTurtleState(turtleId, 'mining');
|
||||
actionName = 'mine';
|
||||
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
||||
action = 'returnHome';
|
||||
setTurtleState(turtleId, 'goHome');
|
||||
actionName = 'go home';
|
||||
} else if (command.includes('stop')) {
|
||||
action = 'stop';
|
||||
} else if (command.includes('set home') || command.includes('mark home')) {
|
||||
action = 'setHome';
|
||||
setTurtleState(turtleId, 'idle');
|
||||
actionName = 'idle';
|
||||
} else if (command.includes('refuel')) {
|
||||
action = 'refuel';
|
||||
} else if (command.includes('status') || command.includes('report')) {
|
||||
action = 'status';
|
||||
setTurtleState(turtleId, 'refueling');
|
||||
actionName = 'refuel';
|
||||
} else if (command.includes('farm')) {
|
||||
setTurtleState(turtleId, 'farming');
|
||||
actionName = 'farm';
|
||||
} else if (command.includes('dump')) {
|
||||
setTurtleState(turtleId, 'dumpInventory');
|
||||
actionName = 'dump inventory';
|
||||
}
|
||||
|
||||
if (action) {
|
||||
sendCommand(turtleId, action, param);
|
||||
setLastCommand(`${action}${param ? ` ${param}` : ''}`);
|
||||
speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`);
|
||||
if (actionName) {
|
||||
setLastCommand(actionName);
|
||||
speak(`Sending ${actionName} command`);
|
||||
} else {
|
||||
speak('Command not recognized');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -5,13 +7,12 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0a0e1a;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: unset;
|
||||
image-rendering: pixelated;
|
||||
background: #2c2c2c;
|
||||
color: #d4d4d4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -21,5 +22,5 @@ body {
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const useTurtleStore = create((set, get) => ({
|
||||
turtles: {},
|
||||
players: {},
|
||||
worldBlocks: [],
|
||||
chunkAnalyses: {},
|
||||
selectedTurtleId: null,
|
||||
connected: false,
|
||||
ws: null,
|
||||
@@ -34,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
|
||||
data.turtles.forEach(turtle => {
|
||||
turtlesMap[turtle.turtleID] = turtle;
|
||||
});
|
||||
const playersMap = {};
|
||||
if (data.players && Array.isArray(data.players)) {
|
||||
data.players.forEach(player => {
|
||||
playersMap[player.playerID] = player;
|
||||
});
|
||||
}
|
||||
set({
|
||||
turtles: turtlesMap,
|
||||
players: playersMap,
|
||||
worldBlocks: data.blocks || []
|
||||
});
|
||||
} else if (data.type === 'turtle_update') {
|
||||
@@ -73,10 +81,77 @@ export const useTurtleStore = create((set, get) => ({
|
||||
[data.playerID]: {
|
||||
playerID: data.playerID,
|
||||
position: data.position,
|
||||
timestamp: data.timestamp
|
||||
label: data.label || null,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else if (data.type === 'block_discovered') {
|
||||
if (data.block) {
|
||||
set(state => {
|
||||
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
const key = `${data.block.x},${data.block.y},${data.block.z}`;
|
||||
blockMap.set(key, data.block);
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'blocks_discovered') {
|
||||
if (data.blocks && Array.isArray(data.blocks)) {
|
||||
set(state => {
|
||||
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
data.blocks.forEach(block => {
|
||||
const key = `${block.x},${block.y},${block.z}`;
|
||||
blockMap.set(key, block);
|
||||
});
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'turtle_event') {
|
||||
// Handle live turtle events (inventory, peripherals)
|
||||
const turtleId = data.turtleID;
|
||||
if (turtleId && data.eventType === 'inventory_update' && data.inventory) {
|
||||
set(state => {
|
||||
const turtle = state.turtles[turtleId];
|
||||
if (!turtle) return state;
|
||||
return {
|
||||
turtles: {
|
||||
...state.turtles,
|
||||
[turtleId]: { ...turtle, inventory: data.inventory }
|
||||
}
|
||||
};
|
||||
});
|
||||
} else if (turtleId && (data.eventType === 'peripheral_attached' || data.eventType === 'peripheral_detached')) {
|
||||
set(state => {
|
||||
const turtle = state.turtles[turtleId];
|
||||
if (!turtle) return state;
|
||||
return {
|
||||
turtles: {
|
||||
...state.turtles,
|
||||
[turtleId]: { ...turtle, peripherals: data.peripherals || turtle.peripherals }
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'chunk_analysis') {
|
||||
// Store chunk analysis results
|
||||
if (data.chunk) {
|
||||
set(state => ({
|
||||
chunkAnalyses: {
|
||||
...state.chunkAnalyses,
|
||||
[`${data.chunk.x},${data.chunk.z}`]: data.chunk
|
||||
}
|
||||
}));
|
||||
}
|
||||
} else if (data.type === 'block_deleted') {
|
||||
// Remove a block from the world map (turtle moved into it)
|
||||
if (data.x !== undefined && data.y !== undefined && data.z !== undefined) {
|
||||
set(state => {
|
||||
const key = `${data.x},${data.y},${data.z}`;
|
||||
return {
|
||||
worldBlocks: state.worldBlocks.filter(b => `${b.x},${b.y},${b.z}` !== key)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
@@ -149,35 +224,288 @@ export const useTurtleStore = create((set, get) => ({
|
||||
set({ worldBlocks: Array.from(blockMap.values()) });
|
||||
},
|
||||
|
||||
sendCommand: async (turtleId, command, param = null) => {
|
||||
const { ws } = get();
|
||||
|
||||
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${param})` : '');
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
turtleID: turtleId,
|
||||
command,
|
||||
param
|
||||
}));
|
||||
console.log(' ✅ Sent via WebSocket');
|
||||
} else {
|
||||
// Fallback to REST API
|
||||
console.log(' ⚠️ WebSocket not connected, using REST API fallback');
|
||||
try {
|
||||
await fetch(`${API_URL}/turtle/${turtleId}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command, param })
|
||||
});
|
||||
console.log(' ✅ Sent via REST API');
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error sending command:', error);
|
||||
// Set turtle state machine state via REST API
|
||||
setTurtleState: async (turtleId, stateName, stateData = {}) => {
|
||||
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: stateName, data: stateData })
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log(' ✅ State set:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error setting state:', error);
|
||||
// Fallback: send via WebSocket as a command
|
||||
const { ws } = get();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
turtleID: turtleId,
|
||||
command: 'set_state',
|
||||
param: { state: stateName, data: stateData }
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Execute arbitrary Lua code on a turtle
|
||||
execOnTurtle: async (turtleId, code) => {
|
||||
console.log(`💻 Exec on turtle ${turtleId}:`, code);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error executing:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Server-Side Movement & Actions ==========
|
||||
// All movement/actions are routed through the server's Turtle.js exec() pipeline
|
||||
|
||||
_turtleAction: async (turtleId, action, body = {}) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error ${action}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
moveForward: async (turtleId) => get()._turtleAction(turtleId, 'forward'),
|
||||
moveBack: async (turtleId) => get()._turtleAction(turtleId, 'back'),
|
||||
moveUp: async (turtleId) => get()._turtleAction(turtleId, 'up'),
|
||||
moveDown: async (turtleId) => get()._turtleAction(turtleId, 'down'),
|
||||
turnLeft: async (turtleId) => get()._turtleAction(turtleId, 'turnLeft'),
|
||||
turnRight: async (turtleId) => get()._turtleAction(turtleId, 'turnRight'),
|
||||
digBlock: async (turtleId) => get()._turtleAction(turtleId, 'dig'),
|
||||
digBlockUp: async (turtleId) => get()._turtleAction(turtleId, 'digUp'),
|
||||
digBlockDown: async (turtleId) => get()._turtleAction(turtleId, 'digDown'),
|
||||
placeBlock: async (turtleId, text) => get()._turtleAction(turtleId, 'place', { text }),
|
||||
placeBlockUp: async (turtleId, text) => get()._turtleAction(turtleId, 'placeUp', { text }),
|
||||
placeBlockDown: async (turtleId, text) => get()._turtleAction(turtleId, 'placeDown', { text }),
|
||||
refuelTurtle: async (turtleId, count) => get()._turtleAction(turtleId, 'refuel-action', { count }),
|
||||
|
||||
// Fetch chunk analyses from server
|
||||
fetchChunkAnalyses: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/chunks`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const analyses = {};
|
||||
data.forEach(c => { analyses[`${c.x},${c.z}`] = c; });
|
||||
set({ chunkAnalyses: analyses });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error fetching chunks:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Request chunk analysis for a specific chunk
|
||||
analyzeChunk: async (chunkX, chunkZ) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/chunks/${chunkX}/${chunkZ}/analyze`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set(state => ({
|
||||
chunkAnalyses: {
|
||||
...state.chunkAnalyses,
|
||||
[`${chunkX},${chunkZ}`]: data
|
||||
}
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error analyzing chunk:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Search blocks by name pattern
|
||||
searchBlocks: async (namePattern) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/world/blocks/search?name=${encodeURIComponent(namePattern)}`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error searching blocks:', error);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
// Rename a turtle
|
||||
renameTurtle: async (turtleId, name) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error renaming turtle:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Equip left
|
||||
equipLeft: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-left`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Equip right
|
||||
equipRight: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-right`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Select inventory slot
|
||||
selectSlot: async (turtleId, slot) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/select`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Transfer items between slots
|
||||
transferItems: async (turtleId, fromSlot, toSlot, count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/transfer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fromSlot, toSlot, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Sort/compact inventory
|
||||
sortInventory: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/sort`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Connect to adjacent inventory
|
||||
connectToInventory: async (turtleId, side) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/connect-inventory`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ side })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Drop items
|
||||
dropItems: async (turtleId, direction = 'front', count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/drop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Suck items
|
||||
suckItems: async (turtleId, direction = 'front', count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/suck`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Update turtle config
|
||||
updateTurtleConfig: async (turtleId, config) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Get turtle config
|
||||
getTurtleConfig: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Explore (batched 3-direction inspect)
|
||||
exploreTurtle: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/explore`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// GPS locate
|
||||
gpsLocateTurtle: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/gps`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Helper getters
|
||||
getTurtleArray: () => {
|
||||
return Object.values(get().turtles);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend server
|
||||
server:
|
||||
@@ -8,12 +6,12 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: turtle-server
|
||||
ports:
|
||||
- "4200:3001" # HTTP API
|
||||
- "3002:3002" # WebSocket
|
||||
- "4200:3001" # HTTP API + WebSocket (unified)
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- WS_PORT=3002
|
||||
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
|
||||
- API_KEY=${API_KEY:-}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- turtle-network
|
||||
|
||||
36
etc/apps.db
Normal file
36
etc/apps.db
Normal 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",
|
||||
},
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||
|
||||
-- Find modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -19,9 +21,12 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
modem.open(POCKET_CHANNEL)
|
||||
local WebBridge = require('platform.webbridge')
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
@@ -98,10 +103,10 @@ local function updateMyPosition()
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "player_position",
|
||||
playerID = os.getComputerID(),
|
||||
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
|
||||
position = myPosition,
|
||||
timestamp = os.epoch("utc")
|
||||
})
|
||||
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
|
||||
return true
|
||||
else
|
||||
addLog("GPS: Failed to locate", colors.red)
|
||||
@@ -292,8 +297,25 @@ local function drawControl()
|
||||
sendCommand(turtle.turtleID, "up")
|
||||
end, colors.green)
|
||||
|
||||
-- Action buttons (bottom row)
|
||||
local btnY = h - 3
|
||||
-- Action buttons (bottom rows)
|
||||
local btnY = h - 5
|
||||
addButton(2, btnY, 6, 1, "EXPLR", function()
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(9, btnY, 6, 1, "MINE", function()
|
||||
sendCommand(turtle.turtleID, "mine")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(16, btnY, 6, 1, "HOME", function()
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
addButton(23, btnY, 6, 1, "STOP", function()
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.red)
|
||||
|
||||
btnY = h - 3
|
||||
addButton(2, btnY, 6, 1, "DOWN", function()
|
||||
sendCommand(turtle.turtleID, "down")
|
||||
end, colors.green)
|
||||
@@ -302,14 +324,6 @@ local function drawControl()
|
||||
sendCommand(turtle.turtleID, "dig")
|
||||
end, colors.red)
|
||||
|
||||
addButton(16, btnY, 6, 1, "MINE", function()
|
||||
sendCommand(turtle.turtleID, "mineStart")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(23, btnY, 6, 1, "HOME", function()
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
for i = #buttons - 8, #buttons do
|
||||
if buttons[i] then
|
||||
drawButton(buttons[i], false)
|
||||
@@ -547,7 +561,7 @@ parallel.waitForAny(
|
||||
function()
|
||||
-- GPS update loop
|
||||
while true do
|
||||
sleep(5)
|
||||
sleep(2)
|
||||
updateMyPosition()
|
||||
end
|
||||
end,
|
||||
@@ -565,7 +579,9 @@ parallel.waitForAny(
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
if message.type == "status" then
|
||||
-- Update turtle list
|
||||
local found = false
|
||||
@@ -581,7 +597,7 @@ parallel.waitForAny(
|
||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||
end
|
||||
end
|
||||
elseif channel == POCKET_CHANNEL and type(message) == "table" then
|
||||
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||
-- Handle responses from webbridge
|
||||
if message.type == "webbridge_status" then
|
||||
webbridgeStatus = message.data
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
-- Live GPS Tracker for Pocket Computer
|
||||
-- Shows your current location in real-time
|
||||
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- Setup modem
|
||||
local modem = peripheral.find("modem")
|
||||
@@ -15,7 +18,7 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||
|
||||
local w, h = term.getSize()
|
||||
local myID = os.getComputerID()
|
||||
@@ -228,7 +231,9 @@ local function main()
|
||||
local replyChannel = param3
|
||||
local message = param4
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
handleStatus(message)
|
||||
end
|
||||
|
||||
|
||||
236
pocketremote.lua
236
pocketremote.lua
@@ -1,9 +1,12 @@
|
||||
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||
-- Monitor and control autonomous mining turtles
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
@@ -15,15 +18,17 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
-- Tracked turtles
|
||||
local turtles = {}
|
||||
local selectedTurtle = nil
|
||||
local viewMode = "overview" -- overview, detail, manual
|
||||
local viewMode = "overview" -- overview, detail, manual, modes
|
||||
|
||||
-- Button system
|
||||
local buttons = {}
|
||||
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
|
||||
})
|
||||
end
|
||||
|
||||
-- Send state change command to turtle (new protocol)
|
||||
local function sendStateCommand(turtleID, stateName, stateData)
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "state_change",
|
||||
state = stateName,
|
||||
data = stateData or {},
|
||||
target = turtleID
|
||||
})
|
||||
end
|
||||
|
||||
-- Helper function to format fuel display
|
||||
local function formatFuel(fuel)
|
||||
if not fuel then
|
||||
@@ -127,7 +142,7 @@ local function drawOverview()
|
||||
-- Turtle info box
|
||||
term.setCursorPos(1, y)
|
||||
term.setTextColor(selected and colors.lime or colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
if turtle.position then
|
||||
print(string.format(" %d,%d,%d",
|
||||
@@ -197,7 +212,7 @@ local function drawDetail()
|
||||
term.setTextColor(colors.white)
|
||||
|
||||
print("")
|
||||
print("Mode: " .. (turtle.mode or "unknown"))
|
||||
print("State: " .. (turtle.state or turtle.mode or "idle"))
|
||||
print("Fuel: " .. formatFuel(turtle.fuel))
|
||||
|
||||
if turtle.position then
|
||||
@@ -234,8 +249,8 @@ local function drawDetail()
|
||||
print(" Empty")
|
||||
end
|
||||
|
||||
-- Action buttons
|
||||
local btnY = h - 7
|
||||
-- Action buttons (row 1: explore/home/stop)
|
||||
local btnY = h - 10
|
||||
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.green)
|
||||
@@ -248,16 +263,60 @@ local function drawDetail()
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.red)
|
||||
|
||||
btnY = h - 4
|
||||
addButton(1, btnY, 12, 2, "MANUAL", function()
|
||||
-- Row 2: manual/modes/setHome
|
||||
btnY = h - 7
|
||||
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||
viewMode = "manual"
|
||||
sendCommand(turtle.turtleID, "manual")
|
||||
end, colors.purple)
|
||||
|
||||
addButton(14, btnY, 12, 2, "SET HOME", function()
|
||||
addButton(10, btnY, 8, 2, "MODES", function()
|
||||
viewMode = "modes"
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(19, btnY, 7, 2, "HOME*", function()
|
||||
sendCommand(turtle.turtleID, "setHome")
|
||||
end, colors.blue)
|
||||
|
||||
-- Row 3: equip/rename
|
||||
btnY = h - 4
|
||||
addButton(1, btnY, 8, 2, "EQUIP L", function()
|
||||
-- Send eval to equip left
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = "return turtle.equipLeft()",
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.purple)
|
||||
|
||||
addButton(10, btnY, 8, 2, "EQUIP R", function()
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = "return turtle.equipRight()",
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.purple)
|
||||
|
||||
addButton(19, btnY, 7, 2, "RENAME", function()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setTextColor(colors.yellow)
|
||||
print("Enter new name:")
|
||||
term.setTextColor(colors.white)
|
||||
local name = read()
|
||||
if name and #name > 0 then
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "rename",
|
||||
name = name,
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end
|
||||
draw()
|
||||
end, colors.lightBlue)
|
||||
|
||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||
viewMode = "overview"
|
||||
end, colors.gray)
|
||||
@@ -349,9 +408,37 @@ local function drawManual()
|
||||
sendCommand(turtle.turtleID, "refuel")
|
||||
end, colors.lime)
|
||||
|
||||
addButton(19, h - 3, 7, 2, "INFO", function()
|
||||
sendCommand(turtle.turtleID, "status")
|
||||
end, colors.lightBlue)
|
||||
addButton(19, h - 3, 7, 2, "SORT", function()
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = [[
|
||||
local moved = 0
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
for target = 1, slot - 1 do
|
||||
local ti = turtle.getItemDetail(target)
|
||||
if not ti then
|
||||
turtle.select(slot)
|
||||
turtle.transferTo(target)
|
||||
moved = moved + 1
|
||||
break
|
||||
elseif ti.name == item.name and ti.count < 64 then
|
||||
turtle.select(slot)
|
||||
turtle.transferTo(target)
|
||||
moved = moved + 1
|
||||
if turtle.getItemCount(slot) == 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
return moved
|
||||
]],
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(1, h, 12, 1, "< BACK", function()
|
||||
viewMode = "detail"
|
||||
@@ -369,6 +456,98 @@ local function drawManual()
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function drawModes()
|
||||
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||
viewMode = "overview"
|
||||
return
|
||||
end
|
||||
|
||||
clearButtons()
|
||||
local turtle = turtles[selectedTurtle]
|
||||
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setTextColor(colors.cyan)
|
||||
print("=STATE MACHINE=")
|
||||
term.setTextColor(colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
-- State buttons
|
||||
local btnY = 4
|
||||
local btnW = 12
|
||||
|
||||
addButton(1, btnY, btnW, 2, "IDLE", function()
|
||||
sendStateCommand(turtle.turtleID, "idle")
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.gray)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "EXPLORE", function()
|
||||
sendStateCommand(turtle.turtleID, "exploring")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.green)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "MINE", function()
|
||||
sendStateCommand(turtle.turtleID, "mining")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "FARM", function()
|
||||
sendStateCommand(turtle.turtleID, "farming")
|
||||
end, colors.lime)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "GO HOME", function()
|
||||
sendStateCommand(turtle.turtleID, "goHome")
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "REFUEL", function()
|
||||
sendStateCommand(turtle.turtleID, "refueling")
|
||||
sendCommand(turtle.turtleID, "refuel")
|
||||
end, colors.red)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "DUMP INV", function()
|
||||
sendStateCommand(turtle.turtleID, "dumpInventory")
|
||||
end, colors.brown)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "MOVE TO", function()
|
||||
-- Could prompt for coordinates, for now just sends moving state
|
||||
sendStateCommand(turtle.turtleID, "moving")
|
||||
end, colors.lightBlue)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "SCAN", function()
|
||||
sendStateCommand(turtle.turtleID, "scan")
|
||||
end, colors.purple)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "EXTRACT", function()
|
||||
sendStateCommand(turtle.turtleID, "extraction")
|
||||
end, colors.magenta)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "BUILD", function()
|
||||
sendStateCommand(turtle.turtleID, "building")
|
||||
end, colors.lightBlue)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "AUTOCRAFT", function()
|
||||
sendStateCommand(turtle.turtleID, "autocraft")
|
||||
end, colors.pink)
|
||||
|
||||
-- Back button
|
||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||
viewMode = "detail"
|
||||
end, colors.gray)
|
||||
|
||||
for _, btn in ipairs(buttons) do
|
||||
drawButton(btn)
|
||||
end
|
||||
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function draw()
|
||||
if viewMode == "overview" then
|
||||
drawOverview()
|
||||
@@ -376,6 +555,8 @@ local function draw()
|
||||
drawDetail()
|
||||
elseif viewMode == "manual" then
|
||||
drawManual()
|
||||
elseif viewMode == "modes" then
|
||||
drawModes()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -450,14 +631,20 @@ parallel.waitForAny(
|
||||
end,
|
||||
function()
|
||||
-- Status receiver
|
||||
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||
-- Update or add turtle
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
-- Preserve state if not in message
|
||||
if not message.state then
|
||||
message.state = t.state or message.mode or "idle"
|
||||
end
|
||||
turtles[i] = message
|
||||
found = true
|
||||
break
|
||||
@@ -465,6 +652,9 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
if not found then
|
||||
if not message.state then
|
||||
message.state = message.mode or "idle"
|
||||
end
|
||||
table.insert(turtles, message)
|
||||
if not selectedTurtle then
|
||||
selectedTurtle = 1
|
||||
@@ -472,8 +662,16 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
draw()
|
||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
|
||||
-- Response from turtle
|
||||
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||
-- State change confirmation or other response
|
||||
if message.type == "state_changed" and message.turtleID then
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
turtles[i].state = message.state
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
draw()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
# Node.js backend
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy server code
|
||||
COPY server.js ./
|
||||
COPY database.js ./
|
||||
# Copy all server code
|
||||
COPY . .
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3001 3002
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
# Development Dockerfile with hot reload
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Development with hot reload
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Install nodemon for hot reload
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm install
|
||||
|
||||
|
||||
387
server/TaskDispatcher.js
Normal file
387
server/TaskDispatcher.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* TaskDispatcher — Automatic task queue dispatcher for RemoteTurtle
|
||||
*
|
||||
* Periodically polls the task_queue for pending tasks, matches them to
|
||||
* available (idle + connected) turtles, maps task_type to state machine
|
||||
* states, and drives the turtle state machine. Handles turtle disconnection
|
||||
* mid-task (re-queues) and completion callbacks.
|
||||
*
|
||||
* Task type → State machine mapping:
|
||||
* mine_area → mining (requires bounds in task_data)
|
||||
* explore → exploring (requires target/area in task_data)
|
||||
* gather → extracting (requires blockName in task_data)
|
||||
* build → building (requires plan in task_data)
|
||||
* transport → moving (requires target position)
|
||||
* clear_area → mining (same as mine_area)
|
||||
* scan → scanning (requires area/range)
|
||||
* farm → farming (requires area)
|
||||
* autocraft → autocrafting (requires recipe)
|
||||
*/
|
||||
|
||||
// Map task_type values from the TaskPanel UI to turtle state machine state names + data mappers
|
||||
const TASK_TYPE_MAP = {
|
||||
mine_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||
explore: { state: 'exploring', mapData: d => ({ target: d.target || d, ...d }) },
|
||||
gather: { state: 'extracting', mapData: d => ({ blockName: d.blockName, count: d.count, ...d }) },
|
||||
build: { state: 'building', mapData: d => ({ plan: d.plan, origin: d.origin, ...d }) },
|
||||
transport: { state: 'moving', mapData: d => ({ target: d.target || d.destination || d, ...d }) },
|
||||
clear_area: { state: 'mining', mapData: d => ({ bounds: d.bounds || d, ...d }) },
|
||||
scan: { state: 'scanning', mapData: d => ({ area: d.area, ...d }) },
|
||||
farm: { state: 'farming', mapData: d => ({ area: d.area, ...d }) },
|
||||
autocraft: { state: 'autocrafting', mapData: d => ({ recipe: d.recipe, count: d.count, ...d }) },
|
||||
};
|
||||
|
||||
export class TaskDispatcher {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {Map<number, import('./Turtle.js').Turtle>} opts.turtles - Live turtle map
|
||||
* @param {Object} opts.db - Database module (server/database.js)
|
||||
* @param {Function} opts.broadcastToClients - WebSocket broadcaster
|
||||
* @param {number} [opts.pollInterval=5000] - ms between dispatch cycles
|
||||
*/
|
||||
constructor({ turtles, db, broadcastToClients, pollInterval = 5000 }) {
|
||||
this._turtles = turtles;
|
||||
this._db = db;
|
||||
this._broadcast = broadcastToClients;
|
||||
this._pollInterval = pollInterval;
|
||||
this._timer = null;
|
||||
this._enabled = true;
|
||||
|
||||
// Track which tasks are actively being executed by which turtles
|
||||
// turtleId -> { taskId, taskType }
|
||||
this._activeTasks = new Map();
|
||||
|
||||
// Reverse lookup: taskId -> turtleId
|
||||
this._taskToTurtle = new Map();
|
||||
}
|
||||
|
||||
/** Start the dispatch loop */
|
||||
start() {
|
||||
if (this._timer) return;
|
||||
console.log(`🚀 TaskDispatcher started (poll every ${this._pollInterval}ms)`);
|
||||
this._timer = setInterval(() => this._tick(), this._pollInterval);
|
||||
// Run immediately on start
|
||||
this._tick();
|
||||
}
|
||||
|
||||
/** Stop the dispatch loop */
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
console.log('🛑 TaskDispatcher stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable/disable automatic dispatching (tasks still tracked when disabled) */
|
||||
set enabled(val) {
|
||||
this._enabled = !!val;
|
||||
console.log(`TaskDispatcher: auto-dispatch ${this._enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/** Get dispatcher status for the API */
|
||||
status() {
|
||||
return {
|
||||
enabled: this._enabled,
|
||||
activeTasks: Array.from(this._activeTasks.entries()).map(([turtleId, info]) => ({
|
||||
turtleId,
|
||||
...info,
|
||||
})),
|
||||
pollInterval: this._pollInterval,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Internal ==========
|
||||
|
||||
/**
|
||||
* One dispatch cycle:
|
||||
* 1. Reconcile active tasks (detect turtle disconnects, state completions)
|
||||
* 2. If enabled, find pending tasks and assign to idle turtles
|
||||
*/
|
||||
_tick() {
|
||||
try {
|
||||
this._reconcile();
|
||||
if (this._enabled) {
|
||||
this._dispatch();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TaskDispatcher] tick error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile: check if turtles executing tasks have finished or disconnected.
|
||||
*/
|
||||
_reconcile() {
|
||||
for (const [turtleId, info] of this._activeTasks) {
|
||||
const turtle = this._turtles.get(turtleId);
|
||||
|
||||
// Turtle removed from server
|
||||
if (!turtle) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, 'Turtle no longer exists');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle disconnected mid-task
|
||||
if (!turtle.connected) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, 'Turtle disconnected');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle transitioned to idle → task completed
|
||||
if (turtle.stateName === 'idle') {
|
||||
this._handleTaskCompletion(info.taskId, turtleId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turtle errored out
|
||||
if (turtle._error) {
|
||||
this._handleTaskFailure(info.taskId, turtleId, turtle._error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch: find pending (unassigned or assigned-but-not-started) tasks,
|
||||
* match to available turtles, and start them.
|
||||
*/
|
||||
_dispatch() {
|
||||
// Get all pending tasks (sorted by priority DESC, created_at ASC via DB)
|
||||
let pendingTasks;
|
||||
try {
|
||||
pendingTasks = this._db.getAllTasks('pending');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingTasks || pendingTasks.length === 0) return;
|
||||
|
||||
// Find idle, connected turtles not already executing a task
|
||||
const availableTurtles = this._getAvailableTurtles();
|
||||
if (availableTurtles.length === 0) return;
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
if (availableTurtles.length === 0) break;
|
||||
|
||||
const mapping = TASK_TYPE_MAP[task.task_type];
|
||||
if (!mapping) {
|
||||
console.warn(`[TaskDispatcher] Unknown task type: ${task.task_type} (task #${task.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If task has a specific turtle assignment, respect it
|
||||
let turtle = null;
|
||||
if (task.assigned_turtle_id) {
|
||||
turtle = this._turtles.get(task.assigned_turtle_id);
|
||||
if (!turtle || !turtle.connected || turtle.stateName !== 'idle') {
|
||||
// Assigned turtle not available — skip this task for now
|
||||
continue;
|
||||
}
|
||||
// Remove from available pool
|
||||
const idx = availableTurtles.indexOf(turtle);
|
||||
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||
} else {
|
||||
// Pick the best available turtle (closest to target if we have coords, else first)
|
||||
turtle = this._pickBestTurtle(availableTurtles, task.task_data);
|
||||
const idx = availableTurtles.indexOf(turtle);
|
||||
if (idx !== -1) availableTurtles.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (!turtle) continue;
|
||||
|
||||
// Dispatch!
|
||||
this._startTask(task, turtle, mapping);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a task on a turtle.
|
||||
*/
|
||||
_startTask(task, turtle, mapping) {
|
||||
const taskId = task.id;
|
||||
const turtleId = turtle.id;
|
||||
|
||||
console.log(`[TaskDispatcher] Assigning task #${taskId} (${task.task_type}) → Turtle #${turtleId}`);
|
||||
|
||||
// Map task_data to state data
|
||||
const stateData = mapping.mapData(task.task_data || {});
|
||||
|
||||
// Update DB: assign + set in_progress
|
||||
try {
|
||||
this._db.assignTask(taskId, turtleId);
|
||||
this._db.updateTaskStatus(taskId, 'in_progress');
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error assigning task #${taskId}:`, e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track
|
||||
this._activeTasks.set(turtleId, { taskId, taskType: task.task_type });
|
||||
this._taskToTurtle.set(taskId, turtleId);
|
||||
|
||||
// Broadcast updates
|
||||
this._broadcast({ type: 'task_assigned', taskId, turtleId });
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'in_progress' });
|
||||
|
||||
// Set the turtle's state machine
|
||||
turtle.setState(mapping.state, stateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion (turtle went back to idle after executing).
|
||||
*/
|
||||
_handleTaskCompletion(taskId, turtleId) {
|
||||
console.log(`[TaskDispatcher] Task #${taskId} completed by Turtle #${turtleId}`);
|
||||
|
||||
try {
|
||||
this._db.completeTask(taskId);
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error completing task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
|
||||
this._broadcast({ type: 'task_completed', taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task failure (turtle disconnected, errored, etc.).
|
||||
* Re-queues the task as pending so another turtle can pick it up.
|
||||
*/
|
||||
_handleTaskFailure(taskId, turtleId, reason) {
|
||||
console.warn(`[TaskDispatcher] Task #${taskId} failed on Turtle #${turtleId}: ${reason}`);
|
||||
|
||||
try {
|
||||
// Re-queue: set back to pending, clear assignment
|
||||
this._db.updateTaskStatus(taskId, 'pending', reason);
|
||||
this._db.assignTask(taskId, null);
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error re-queuing task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'pending' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually cancel a running task (called via API).
|
||||
*/
|
||||
cancelTask(taskId) {
|
||||
const turtleId = this._taskToTurtle.get(taskId);
|
||||
if (turtleId !== undefined) {
|
||||
const turtle = this._turtles.get(turtleId);
|
||||
if (turtle) {
|
||||
turtle.setState('idle');
|
||||
}
|
||||
this._activeTasks.delete(turtleId);
|
||||
this._taskToTurtle.delete(taskId);
|
||||
}
|
||||
|
||||
try {
|
||||
this._db.updateTaskStatus(taskId, 'cancelled');
|
||||
} catch (e) {
|
||||
console.error(`[TaskDispatcher] DB error cancelling task #${taskId}:`, e.message);
|
||||
}
|
||||
|
||||
this._broadcast({ type: 'task_updated', taskId, status: 'cancelled' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific task is currently being executed.
|
||||
*/
|
||||
isTaskActive(taskId) {
|
||||
return this._taskToTurtle.has(taskId);
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Get connected, idle turtles not currently executing a dispatched task.
|
||||
*/
|
||||
_getAvailableTurtles() {
|
||||
const available = [];
|
||||
for (const [id, turtle] of this._turtles) {
|
||||
if (
|
||||
turtle.connected &&
|
||||
turtle.stateName === 'idle' &&
|
||||
!this._activeTasks.has(id)
|
||||
) {
|
||||
available.push(turtle);
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best turtle for a task.
|
||||
* If task_data has coordinates, pick the closest turtle with a known position.
|
||||
* Otherwise, pick the turtle with the most fuel.
|
||||
*/
|
||||
_pickBestTurtle(candidates, taskData) {
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Try to extract a target position from task data
|
||||
const target = this._extractTarget(taskData);
|
||||
|
||||
if (target) {
|
||||
// Sort by Manhattan distance to target
|
||||
let bestTurtle = candidates[0];
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const t of candidates) {
|
||||
if (t.position) {
|
||||
const dist = Math.abs(t.position.x - target.x)
|
||||
+ Math.abs(t.position.y - target.y)
|
||||
+ Math.abs(t.position.z - target.z);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestTurtle = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestTurtle;
|
||||
}
|
||||
|
||||
// No target — pick turtle with highest fuel
|
||||
return candidates.reduce((best, t) => (t._fuel > best._fuel ? t : best), candidates[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a target {x,y,z} from task_data.
|
||||
*/
|
||||
_extractTarget(data) {
|
||||
if (!data) return null;
|
||||
|
||||
// Direct target
|
||||
if (data.target && typeof data.target.x === 'number') return data.target;
|
||||
if (data.destination && typeof data.destination.x === 'number') return data.destination;
|
||||
|
||||
// Bounds — use center
|
||||
if (data.bounds) {
|
||||
const b = data.bounds;
|
||||
if (typeof b.minX === 'number' && typeof b.maxX === 'number') {
|
||||
return {
|
||||
x: Math.floor((b.minX + b.maxX) / 2),
|
||||
y: Math.floor((b.minY + b.maxY) / 2),
|
||||
z: Math.floor((b.minZ + b.maxZ) / 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinate pair (from TaskPanel)
|
||||
if (typeof data.startX === 'number' && typeof data.startZ === 'number') {
|
||||
return { x: data.startX, y: data.startY || 64, z: data.startZ };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1251
server/Turtle.js
Normal file
1251
server/Turtle.js
Normal file
File diff suppressed because it is too large
Load Diff
226
server/WorldBlockCache.js
Normal file
226
server/WorldBlockCache.js
Normal 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);
|
||||
}
|
||||
}
|
||||
222
server/__tests__/TaskDispatcher.test.js
Normal file
222
server/__tests__/TaskDispatcher.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
127
server/__tests__/WorldBlockCache.test.js
Normal file
127
server/__tests__/WorldBlockCache.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const db = new Database(path.join(__dirname, 'turtle_control.db'));
|
||||
|
||||
// Enable WAL journal mode for better concurrent read/write performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize database schema
|
||||
export function initializeDatabase() {
|
||||
// Turtle homes table
|
||||
@@ -82,12 +85,28 @@ export function initializeDatabase() {
|
||||
max_x INTEGER NOT NULL,
|
||||
max_y INTEGER NOT NULL,
|
||||
max_z INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
color TEXT DEFAULT '#4a8c2a',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate existing mining_areas table to add name/color columns if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('name')) {
|
||||
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
|
||||
}
|
||||
if (!columns.includes('color')) {
|
||||
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Mining statistics table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mining_stats (
|
||||
@@ -96,7 +115,8 @@ export function initializeDatabase() {
|
||||
block_type TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
session_start INTEGER NOT NULL,
|
||||
last_mined INTEGER NOT NULL
|
||||
last_mined INTEGER NOT NULL,
|
||||
UNIQUE(turtle_id, block_type, session_start)
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -140,10 +160,41 @@ export function initializeDatabase() {
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate player_positions table to add label column if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('label')) {
|
||||
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Chunk analysis table (ore density per chunk)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
x INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
analysis TEXT DEFAULT '{}',
|
||||
scanned_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (x, z)
|
||||
)
|
||||
`);
|
||||
|
||||
// Add block_state and block_tags columns to world_blocks if not present
|
||||
try {
|
||||
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_state TEXT DEFAULT '{}'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
try {
|
||||
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_tags TEXT DEFAULT '{}'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Create indexes for better performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
|
||||
@@ -155,6 +206,26 @@ export function initializeDatabase() {
|
||||
ON task_queue(status, priority DESC);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_world_blocks_name
|
||||
ON world_blocks(block_name);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_coords
|
||||
ON chunks(x, z);
|
||||
`);
|
||||
|
||||
// Turtle state persistence table (for reconnect recovery)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_state (
|
||||
turtle_id INTEGER PRIMARY KEY,
|
||||
state_name TEXT NOT NULL,
|
||||
state_data TEXT DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('✅ Database initialized');
|
||||
}
|
||||
|
||||
@@ -202,12 +273,14 @@ export function getTurtleConfig(turtleId) {
|
||||
}
|
||||
|
||||
// World Blocks
|
||||
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy) {
|
||||
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy, blockState = null, blockTags = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at, block_state, block_tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now());
|
||||
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now(),
|
||||
blockState ? JSON.stringify(blockState) : '{}',
|
||||
blockTags ? JSON.stringify(blockTags) : '{}');
|
||||
}
|
||||
|
||||
export function getWorldBlocks(limit = 10000) {
|
||||
@@ -215,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
|
||||
return stmt.all(limit);
|
||||
}
|
||||
|
||||
export function getWorldBlockCount() {
|
||||
const row = db.prepare('SELECT COUNT(*) as cnt FROM world_blocks').get();
|
||||
return row ? row.cnt : 0;
|
||||
}
|
||||
|
||||
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM world_blocks
|
||||
@@ -239,15 +317,33 @@ export function savePath(turtleId, pathName, pathData) {
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
|
||||
const result = stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getPaths(turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
|
||||
return stmt.all(turtleId).map(row => ({
|
||||
...row,
|
||||
path_data: JSON.parse(row.path_data)
|
||||
}));
|
||||
export function getPaths(turtleId = null) {
|
||||
if (turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
|
||||
return stmt.all(turtleId).map(row => ({
|
||||
...row,
|
||||
path_data: JSON.parse(row.path_data)
|
||||
}));
|
||||
} else {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths ORDER BY created_at DESC');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
path_data: JSON.parse(row.path_data)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(pathId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE id = ?');
|
||||
const row = stmt.get(pathId);
|
||||
if (row) {
|
||||
return { ...row, path_data: JSON.parse(row.path_data) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deletePath(pathId) {
|
||||
@@ -256,13 +352,13 @@ export function deletePath(pathId) {
|
||||
}
|
||||
|
||||
// Task Queue
|
||||
export function createTask(taskType, taskData, priority = 0) {
|
||||
export function createTask(taskType, taskData, priority = 0, assignedTurtleId = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO task_queue (task_type, task_data, priority, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||
INSERT INTO task_queue (task_type, task_data, assigned_turtle_id, priority, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
const result = stmt.run(taskType, JSON.stringify(taskData), priority, now, now);
|
||||
const result = stmt.run(taskType, JSON.stringify(taskData), assignedTurtleId, priority, now, now);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
@@ -284,12 +380,31 @@ export function getNextTask() {
|
||||
}
|
||||
|
||||
export function assignTask(taskId, turtleId) {
|
||||
if (turtleId === null || turtleId === undefined) {
|
||||
// Un-assign: clear turtle and revert to pending
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(Date.now(), taskId);
|
||||
} else {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(turtleId, Date.now(), taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTaskStatus(taskId, status, result = null) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||
SET status = ?, task_data = CASE WHEN ? IS NOT NULL THEN json_set(task_data, '$.result', ?) ELSE task_data END, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(turtleId, Date.now(), taskId);
|
||||
stmt.run(status, result, result, Date.now(), taskId);
|
||||
}
|
||||
|
||||
export function completeTask(taskId) {
|
||||
@@ -301,7 +416,19 @@ export function completeTask(taskId) {
|
||||
stmt.run(Date.now(), taskId);
|
||||
}
|
||||
|
||||
export function getAllTasks() {
|
||||
export function deleteTask(taskId) {
|
||||
const stmt = db.prepare('DELETE FROM task_queue WHERE id = ?');
|
||||
return stmt.run(taskId);
|
||||
}
|
||||
|
||||
export function getAllTasks(status = null) {
|
||||
if (status) {
|
||||
const stmt = db.prepare('SELECT * FROM task_queue WHERE status = ? ORDER BY priority DESC, created_at DESC');
|
||||
return stmt.all(status).map(row => ({
|
||||
...row,
|
||||
task_data: JSON.parse(row.task_data)
|
||||
}));
|
||||
}
|
||||
const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
@@ -310,25 +437,59 @@ export function getAllTasks() {
|
||||
}
|
||||
|
||||
// Mining Areas
|
||||
export function saveMiningArea(turtleId, bounds) {
|
||||
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
stmt.run(
|
||||
const result = stmt.run(
|
||||
turtleId,
|
||||
bounds.minX, bounds.minY, bounds.minZ,
|
||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||
now, now
|
||||
areaName, color,
|
||||
status, now, now
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getMiningAreas() {
|
||||
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = \'active\'');
|
||||
export function getMiningAreas(statusFilter = null) {
|
||||
if (statusFilter) {
|
||||
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = ? ORDER BY created_at DESC');
|
||||
return stmt.all(statusFilter);
|
||||
}
|
||||
const stmt = db.prepare('SELECT * FROM mining_areas ORDER BY created_at DESC');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
export function updateMiningAreaStatus(areaId, status) {
|
||||
const stmt = db.prepare('UPDATE mining_areas SET status = ?, updated_at = ? WHERE id = ?');
|
||||
stmt.run(status, Date.now(), areaId);
|
||||
}
|
||||
|
||||
export function updateMiningArea(areaId, updates) {
|
||||
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
setClauses.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) return;
|
||||
setClauses.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(areaId);
|
||||
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...values);
|
||||
}
|
||||
|
||||
export function deleteMiningArea(areaId) {
|
||||
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
||||
return stmt.run(areaId);
|
||||
}
|
||||
|
||||
export function closeMiningArea(areaId) {
|
||||
const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?');
|
||||
stmt.run(Date.now(), areaId);
|
||||
@@ -373,7 +534,7 @@ export function getMiningStats(turtleId = null, days = 7) {
|
||||
|
||||
export function getTopMiners(limit = 10) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT turtle_id, SUM(count) as total_blocks
|
||||
SELECT turtle_id, SUM(count) as total_blocks, COUNT(DISTINCT block_type) as unique_types
|
||||
FROM mining_stats
|
||||
GROUP BY turtle_id
|
||||
ORDER BY total_blocks DESC
|
||||
@@ -467,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
|
||||
}
|
||||
|
||||
// Player Positions
|
||||
export function savePlayerPosition(playerId, position) {
|
||||
export function savePlayerPosition(playerId, position, label = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(playerId, position.x, position.y, position.z, Date.now());
|
||||
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
|
||||
}
|
||||
|
||||
export function getPlayerPosition(playerId) {
|
||||
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?');
|
||||
return stmt.get(playerId);
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
|
||||
const row = stmt.get(playerId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllPlayerPositions() {
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions');
|
||||
return stmt.all();
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
|
||||
return stmt.all().map(row => ({
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
@@ -490,5 +663,117 @@ export function closeDatabase() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ========== CHUNK ANALYSIS ==========
|
||||
|
||||
export function saveChunkAnalysis(x, z, analysis) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO chunks (x, z, analysis, scanned_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(x, z, JSON.stringify(analysis), Date.now());
|
||||
}
|
||||
|
||||
export function getChunkAnalysis(x, z) {
|
||||
const stmt = db.prepare('SELECT * FROM chunks WHERE x = ? AND z = ?');
|
||||
const row = stmt.get(x, z);
|
||||
if (row) {
|
||||
return { ...row, analysis: JSON.parse(row.analysis) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAllChunkAnalyses() {
|
||||
const stmt = db.prepare('SELECT * FROM chunks');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
analysis: JSON.parse(row.analysis)
|
||||
}));
|
||||
}
|
||||
|
||||
// ========== BLOCK SEARCH ==========
|
||||
|
||||
export function getBlocksWithNameLike(fromX, fromY, fromZ, toX, toY, toZ, namePattern) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT x, y, z, block_name as name, metadata, block_state, block_tags
|
||||
FROM world_blocks
|
||||
WHERE x BETWEEN ? AND ?
|
||||
AND y BETWEEN ? AND ?
|
||||
AND z BETWEEN ? AND ?
|
||||
AND block_name LIKE ?
|
||||
`);
|
||||
return stmt.all(
|
||||
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||
Math.min(fromZ, toZ), Math.max(fromZ, toZ),
|
||||
namePattern
|
||||
).map(row => ({
|
||||
...row,
|
||||
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBlock(x, y, z) {
|
||||
const stmt = db.prepare('SELECT block_name as name, metadata, block_state, block_tags FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||
const row = stmt.get(x, y, z);
|
||||
if (row) {
|
||||
return {
|
||||
...row,
|
||||
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteBlocksInArea(fromX, fromY, fromZ, toX, toY, toZ) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM world_blocks
|
||||
WHERE x BETWEEN ? AND ?
|
||||
AND y BETWEEN ? AND ?
|
||||
AND z BETWEEN ? AND ?
|
||||
`);
|
||||
return stmt.run(
|
||||
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||
Math.min(fromZ, toZ), Math.max(fromZ, toZ)
|
||||
);
|
||||
}
|
||||
|
||||
// ========== BLOCK DELETION ==========
|
||||
|
||||
export function deleteBlock(x, y, z) {
|
||||
const stmt = db.prepare('DELETE FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||
return stmt.run(x, y, z);
|
||||
}
|
||||
|
||||
// ========== TURTLE STATE PERSISTENCE ==========
|
||||
|
||||
export function saveTurtleState(turtleId, stateName, stateData = {}) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO turtle_state (turtle_id, state_name, state_data, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(turtleId, stateName, JSON.stringify(stateData), Date.now());
|
||||
}
|
||||
|
||||
export function getTurtleState(turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_state WHERE turtle_id = ?');
|
||||
const row = stmt.get(turtleId);
|
||||
if (row) {
|
||||
return {
|
||||
stateName: row.state_name,
|
||||
stateData: JSON.parse(row.state_data),
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteTurtleState(turtleId) {
|
||||
const stmt = db.prepare('DELETE FROM turtle_state WHERE turtle_id = ?');
|
||||
return stmt.run(turtleId);
|
||||
}
|
||||
|
||||
// Export database instance for custom queries if needed
|
||||
export { db };
|
||||
|
||||
109
server/helpers/levenshtein.js
Normal file
109
server/helpers/levenshtein.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Levenshtein distance utility
|
||||
* Used for fuzzy matching block/item names for inventory operations.
|
||||
* Inspired by runi95/turtle-control-panel utility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the Levenshtein edit distance between two strings.
|
||||
* @param {string} a - First string
|
||||
* @param {string} b - Second string
|
||||
* @returns {number} The edit distance
|
||||
*/
|
||||
export function levenshtein(a, b) {
|
||||
if (a === b) return 0;
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
// Use a single-row approach for memory efficiency
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
const row = new Array(bLen + 1);
|
||||
|
||||
// Initialize the first row (distance from empty string to b[0..j])
|
||||
for (let j = 0; j <= bLen; j++) {
|
||||
row[j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
let prev = i; // row[0] for this iteration = i
|
||||
for (let j = 1; j <= bLen; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
const val = Math.min(
|
||||
row[j] + 1, // deletion
|
||||
prev + 1, // insertion
|
||||
row[j - 1] + cost // substitution
|
||||
);
|
||||
row[j - 1] = prev;
|
||||
prev = val;
|
||||
}
|
||||
row[bLen] = prev;
|
||||
}
|
||||
|
||||
return row[bLen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best match for a query string among candidates using Levenshtein distance.
|
||||
* @param {string} query - The search term
|
||||
* @param {string[]} candidates - Array of candidate strings
|
||||
* @param {number} maxDistance - Maximum acceptable distance (default: Infinity)
|
||||
* @returns {{ match: string|null, distance: number }} Best match and its distance
|
||||
*/
|
||||
export function findBestMatch(query, candidates, maxDistance = Infinity) {
|
||||
let bestMatch = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
|
||||
// Exact substring match is distance 0
|
||||
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||
const dist = Math.abs(lowerCandidate.length - lowerQuery.length);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||
if (dist < bestDistance && dist <= maxDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return { match: bestMatch, distance: bestDistance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all matches within a given distance.
|
||||
* @param {string} query - The search term
|
||||
* @param {string[]} candidates - Array of candidate strings
|
||||
* @param {number} maxDistance - Maximum acceptable distance
|
||||
* @returns {Array<{match: string, distance: number}>} Matches sorted by distance
|
||||
*/
|
||||
export function findAllMatches(query, candidates, maxDistance = 3) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
|
||||
// Exact substring match
|
||||
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||
matches.push({ match: candidate, distance: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||
if (dist <= maxDistance) {
|
||||
matches.push({ match: candidate, distance: dist });
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
@@ -17,12 +19,14 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^9.2.2"
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
"nodemon": "^3.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
361
server/pathfinding/DStarLite.js
Normal file
361
server/pathfinding/DStarLite.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* D* Lite Pathfinding Algorithm
|
||||
*
|
||||
* An incremental heuristic search algorithm that efficiently replans
|
||||
* when the environment changes (new blocks discovered, blocks mined, etc.)
|
||||
*
|
||||
* Based on Koenig & Likhachev (2002) and adapted from
|
||||
* runi95/turtle-control-panel implementation.
|
||||
*/
|
||||
import { Point } from './Point.js';
|
||||
import { Node } from './Node.js';
|
||||
import { PriorityQueue } from './PriorityQueue.js';
|
||||
|
||||
// Unbreakable blocks that cannot be mined
|
||||
const UNBREAKABLE_BLOCKS = new Set([
|
||||
'minecraft:bedrock',
|
||||
'minecraft:barrier',
|
||||
'minecraft:command_block',
|
||||
'minecraft:chain_command_block',
|
||||
'minecraft:repeating_command_block',
|
||||
'minecraft:structure_block',
|
||||
'minecraft:jigsaw',
|
||||
'minecraft:end_portal_frame',
|
||||
'minecraft:end_portal',
|
||||
'minecraft:nether_portal',
|
||||
'minecraft:spawner',
|
||||
'minecraft:reinforced_deepslate',
|
||||
]);
|
||||
|
||||
// Blocks that are liquid/hazardous - avoid unless necessary
|
||||
const HAZARDOUS_BLOCKS = new Set([
|
||||
'minecraft:lava',
|
||||
'minecraft:water',
|
||||
'minecraft:flowing_lava',
|
||||
'minecraft:flowing_water',
|
||||
]);
|
||||
|
||||
export class DStarLite {
|
||||
/**
|
||||
* @param {Point} start - Starting position
|
||||
* @param {Point} goal - Goal position
|
||||
* @param {Map} worldBlocks - Map of "x,y,z" -> blockData from server
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(start, goal, worldBlocks, options = {}) {
|
||||
this.start = start;
|
||||
this.goal = goal;
|
||||
this.worldBlocks = worldBlocks;
|
||||
this.km = 0;
|
||||
this.nodes = new Map(); // key -> Node
|
||||
this.queue = new PriorityQueue();
|
||||
|
||||
// Options
|
||||
this.maxSteps = options.maxSteps || 50000;
|
||||
this.canMine = options.canMine !== false; // Default: true - can mine through blocks
|
||||
this.boundaryMin = options.boundaryMin || null; // Point - min boundary
|
||||
this.boundaryMax = options.boundaryMax || null; // Point - max boundary
|
||||
this.avoidHazards = options.avoidHazards !== false; // Default: true
|
||||
|
||||
// Initialize
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a node for a point
|
||||
*/
|
||||
getNode(point) {
|
||||
const key = point.toKey();
|
||||
if (!this.nodes.has(key)) {
|
||||
const node = new Node(point);
|
||||
|
||||
// Check world blocks for this position
|
||||
const blockData = this.worldBlocks.get(key);
|
||||
if (blockData) {
|
||||
node.blockData = blockData;
|
||||
|
||||
if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
} else if (blockData.name && blockData.name !== 'minecraft:air') {
|
||||
// There's a block here - it's mineable
|
||||
node.mineable = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check boundary constraints
|
||||
if (this.boundaryMin && this.boundaryMax) {
|
||||
if (point.x < this.boundaryMin.x || point.x > this.boundaryMax.x ||
|
||||
point.y < this.boundaryMin.y || point.y > this.boundaryMax.y ||
|
||||
point.z < this.boundaryMin.z || point.z > this.boundaryMax.z) {
|
||||
node.blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't go below bedrock
|
||||
if (point.y < -64) node.blocked = true;
|
||||
if (point.y > 320) node.blocked = true;
|
||||
|
||||
this.nodes.set(key, node);
|
||||
}
|
||||
return this.nodes.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the D* Lite algorithm
|
||||
*/
|
||||
_initialize() {
|
||||
const goalNode = this.getNode(this.goal);
|
||||
goalNode.rhs = 0;
|
||||
|
||||
const key = goalNode.calculateKey(this.start, this.km);
|
||||
this.queue.insertOrUpdate(goalNode, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the shortest path
|
||||
* Returns true if a path exists, false otherwise
|
||||
*/
|
||||
computeShortestPath() {
|
||||
const startNode = this.getNode(this.start);
|
||||
let steps = 0;
|
||||
|
||||
while (
|
||||
!this.queue.isEmpty() &&
|
||||
(PriorityQueue.compareKeys(this.queue.topKey(), startNode.calculateKey(this.start, this.km)) < 0 ||
|
||||
startNode.rhs !== startNode.g)
|
||||
) {
|
||||
steps++;
|
||||
if (steps > this.maxSteps) {
|
||||
console.warn(`D* Lite: Max steps (${this.maxSteps}) exceeded`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldKey = this.queue.topKey();
|
||||
const u = this.queue.pop();
|
||||
if (!u) break;
|
||||
|
||||
const newKey = u.calculateKey(this.start, this.km);
|
||||
|
||||
if (PriorityQueue.compareKeys(oldKey, newKey) < 0) {
|
||||
// Key has changed, reinsert with new key
|
||||
this.queue.insertOrUpdate(u, newKey);
|
||||
} else if (u.g > u.rhs) {
|
||||
// Overconsistent - decrease g
|
||||
u.g = u.rhs;
|
||||
|
||||
// Update predecessors
|
||||
const neighbors = u.point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
} else {
|
||||
// Underconsistent - increase g
|
||||
u.g = Infinity;
|
||||
|
||||
// Update u and its predecessors
|
||||
this._updateNode(u);
|
||||
const neighbors = u.point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return startNode.g !== Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's rhs value and queue status
|
||||
*/
|
||||
_updateNode(node) {
|
||||
if (!node.point.equals(this.goal)) {
|
||||
// rhs = min over all successors of (cost(node, succ) + succ.g)
|
||||
let minRhs = Infinity;
|
||||
const neighbors = node.point.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(node, neighborNode);
|
||||
const val = cost + neighborNode.g;
|
||||
if (val < minRhs) {
|
||||
minRhs = val;
|
||||
}
|
||||
}
|
||||
|
||||
node.rhs = minRhs;
|
||||
}
|
||||
|
||||
// Remove from queue if present
|
||||
if (this.queue.contains(node)) {
|
||||
this.queue.remove(node);
|
||||
}
|
||||
|
||||
// Re-insert if inconsistent
|
||||
if (node.g !== node.rhs) {
|
||||
const key = node.calculateKey(this.start, this.km);
|
||||
this.queue.insertOrUpdate(node, key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cost to traverse from one node to an adjacent node
|
||||
*/
|
||||
_cost(from, to) {
|
||||
return to.getTraversalCost();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the computed path from start to goal
|
||||
* Returns array of Points, or null if no path exists
|
||||
*/
|
||||
getPath() {
|
||||
if (!this.computeShortestPath()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = [this.start];
|
||||
let current = this.start;
|
||||
let maxPathLength = 10000;
|
||||
|
||||
while (!current.equals(this.goal) && maxPathLength > 0) {
|
||||
maxPathLength--;
|
||||
|
||||
const currentNode = this.getNode(current);
|
||||
if (currentNode.g === Infinity) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
// Find the best neighbor to move to
|
||||
let bestNeighbor = null;
|
||||
let bestCost = Infinity;
|
||||
const neighbors = current.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(currentNode, neighborNode) + neighborNode.g;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestNeighbor = neighborPoint;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestNeighbor || bestCost === Infinity) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
path.push(bestNeighbor);
|
||||
current = bestNeighbor;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the algorithm when the turtle moves to a new position
|
||||
* This is the key D* Lite optimization - it adjusts km to avoid full recomputation
|
||||
*/
|
||||
updateStart(newStart) {
|
||||
this.km += this.start.euclideanDistanceTo(newStart);
|
||||
this.start = newStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the algorithm that a block has changed at a position
|
||||
* This triggers efficient replanning
|
||||
* @param {Point} point - The position that changed
|
||||
* @param {Object|null} blockData - New block data, or null if block was removed
|
||||
*/
|
||||
updateBlock(point, blockData) {
|
||||
const node = this.getNode(point);
|
||||
const oldBlocked = node.blocked;
|
||||
const oldMineable = node.mineable;
|
||||
|
||||
// Update node state
|
||||
node.blockData = blockData;
|
||||
|
||||
if (!blockData || blockData.name === 'minecraft:air') {
|
||||
node.blocked = false;
|
||||
node.mineable = false;
|
||||
} else if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
node.mineable = false;
|
||||
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
node.mineable = false;
|
||||
} else {
|
||||
node.blocked = false;
|
||||
node.mineable = true;
|
||||
}
|
||||
|
||||
// Only replan if the traversability changed
|
||||
if (node.blocked !== oldBlocked || node.mineable !== oldMineable) {
|
||||
// Update this node and all its neighbors
|
||||
this._updateNode(node);
|
||||
const neighbors = point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
if (this.nodes.has(neighborPoint.toKey())) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next step the turtle should take from current position
|
||||
* Returns the next Point to move to, or null if at goal or no path
|
||||
*/
|
||||
getNextStep() {
|
||||
if (this.start.equals(this.goal)) {
|
||||
return null; // Already at goal
|
||||
}
|
||||
|
||||
if (!this.computeShortestPath()) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
const startNode = this.getNode(this.start);
|
||||
if (startNode.g === Infinity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestNeighbor = null;
|
||||
let bestCost = Infinity;
|
||||
const neighbors = this.start.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(startNode, neighborNode) + neighborNode.g;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestNeighbor = neighborPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return bestNeighbor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replan the path (called after block updates)
|
||||
*/
|
||||
replan() {
|
||||
return this.computeShortestPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the pathfinding
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
nodesExplored: this.nodes.size,
|
||||
queueSize: this.queue.size,
|
||||
startG: this.getNode(this.start).g,
|
||||
goalRhs: this.getNode(this.goal).rhs,
|
||||
km: this.km,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
server/pathfinding/Node.js
Normal file
61
server/pathfinding/Node.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Node - Represents a pathfinding node in the D* Lite algorithm
|
||||
* Each node wraps a Point and stores pathfinding metadata
|
||||
*/
|
||||
import { Point } from './Point.js';
|
||||
|
||||
export class Node {
|
||||
constructor(point) {
|
||||
this.point = point;
|
||||
this.key = point.toKey();
|
||||
|
||||
// D* Lite values
|
||||
this.g = Infinity; // Cost from start to this node
|
||||
this.rhs = Infinity; // One-step lookahead cost
|
||||
|
||||
// Whether this node is blocked (wall, unbreakable block, etc.)
|
||||
this.blocked = false;
|
||||
|
||||
// Whether this node contains a mineable block (costs more to traverse but possible)
|
||||
this.mineable = false;
|
||||
|
||||
// The block data at this position (if known)
|
||||
this.blockData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the D* Lite key pair for priority queue ordering
|
||||
* @param {Point} start - The current start position
|
||||
* @param {number} km - The key modifier (updated on robot movement)
|
||||
*/
|
||||
calculateKey(start, km = 0) {
|
||||
const minVal = Math.min(this.g, this.rhs);
|
||||
return [
|
||||
minVal + start.euclideanDistanceTo(this.point) + km,
|
||||
minVal
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this node is consistent (g === rhs)
|
||||
*/
|
||||
isConsistent() {
|
||||
return this.g === this.rhs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the traversal cost to move to this node
|
||||
* - Blocked nodes: Infinity
|
||||
* - Mineable blocks: 2 (higher cost to prefer open paths)
|
||||
* - Open space: 1
|
||||
*/
|
||||
getTraversalCost() {
|
||||
if (this.blocked) return Infinity;
|
||||
if (this.mineable) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Node(${this.point}, g=${this.g}, rhs=${this.rhs}, blocked=${this.blocked})`;
|
||||
}
|
||||
}
|
||||
100
server/pathfinding/Point.js
Normal file
100
server/pathfinding/Point.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Point - Represents a 3D coordinate in the Minecraft world
|
||||
* Inspired by runi95/turtle-control-panel Point class
|
||||
*/
|
||||
export class Point {
|
||||
constructor(x, y, z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Manhattan distance to another point
|
||||
*/
|
||||
distanceTo(other) {
|
||||
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y) + Math.abs(this.z - other.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Euclidean distance to another point (used for D* Lite heuristic)
|
||||
*/
|
||||
euclideanDistanceTo(other) {
|
||||
const dx = this.x - other.x;
|
||||
const dy = this.y - other.y;
|
||||
const dz = this.z - other.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another point
|
||||
*/
|
||||
equals(other) {
|
||||
if (!other) return false;
|
||||
return this.x === other.x && this.y === other.y && this.z === other.z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 6 neighboring points (up, down, north, south, east, west)
|
||||
*/
|
||||
getNeighbors() {
|
||||
return [
|
||||
new Point(this.x + 1, this.y, this.z), // East
|
||||
new Point(this.x - 1, this.y, this.z), // West
|
||||
new Point(this.x, this.y + 1, this.z), // Up
|
||||
new Point(this.x, this.y - 1, this.z), // Down
|
||||
new Point(this.x, this.y, this.z + 1), // South
|
||||
new Point(this.x, this.y, this.z - 1), // North
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cardinal direction needed to move from this point to another adjacent point
|
||||
* Returns: 'forward', 'back', 'up', 'down', or facing adjustment needed
|
||||
*/
|
||||
directionTo(other) {
|
||||
const dx = other.x - this.x;
|
||||
const dy = other.y - this.y;
|
||||
const dz = other.z - this.z;
|
||||
|
||||
if (dy === 1) return 'up';
|
||||
if (dy === -1) return 'down';
|
||||
if (dx === 1) return { facing: 1 }; // East
|
||||
if (dx === -1) return { facing: 3 }; // West
|
||||
if (dz === 1) return { facing: 2 }; // South
|
||||
if (dz === -1) return { facing: 0 }; // North
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique string key for maps
|
||||
*/
|
||||
toKey() {
|
||||
return `${this.x},${this.y},${this.z}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Point from key string
|
||||
*/
|
||||
static fromKey(key) {
|
||||
const [x, y, z] = key.split(',').map(Number);
|
||||
return new Point(x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Point from object with x,y,z properties
|
||||
*/
|
||||
static from(obj) {
|
||||
if (!obj) return null;
|
||||
return new Point(obj.x, obj.y, obj.z);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `(${this.x}, ${this.y}, ${this.z})`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { x: this.x, y: this.y, z: this.z };
|
||||
}
|
||||
}
|
||||
175
server/pathfinding/PriorityQueue.js
Normal file
175
server/pathfinding/PriorityQueue.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* PriorityQueue - Min-heap priority queue for D* Lite
|
||||
* Uses two-element key pairs [k1, k2] for ordering
|
||||
*/
|
||||
export class PriorityQueue {
|
||||
constructor() {
|
||||
this.heap = [];
|
||||
this.keyMap = new Map(); // key string -> index in heap
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two key pairs
|
||||
* Returns negative if a < b, positive if a > b, 0 if equal
|
||||
*/
|
||||
static compareKeys(a, b) {
|
||||
if (a[0] !== b[0]) return a[0] - b[0];
|
||||
return a[1] - b[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum key without removing
|
||||
*/
|
||||
topKey() {
|
||||
if (this.heap.length === 0) return [Infinity, Infinity];
|
||||
return this.heap[0].priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a node with a priority
|
||||
*/
|
||||
insertOrUpdate(node, priority) {
|
||||
const key = node.key;
|
||||
|
||||
if (this.keyMap.has(key)) {
|
||||
// Update existing entry
|
||||
const index = this.keyMap.get(key);
|
||||
const oldPriority = this.heap[index].priority;
|
||||
this.heap[index].priority = priority;
|
||||
this.heap[index].node = node;
|
||||
|
||||
// Determine whether to bubble up or sift down
|
||||
if (PriorityQueue.compareKeys(priority, oldPriority) < 0) {
|
||||
this._bubbleUp(index);
|
||||
} else {
|
||||
this._siftDown(index);
|
||||
}
|
||||
} else {
|
||||
// Insert new entry
|
||||
const entry = { node, priority };
|
||||
this.heap.push(entry);
|
||||
const index = this.heap.length - 1;
|
||||
this.keyMap.set(key, index);
|
||||
this._bubbleUp(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the minimum node
|
||||
*/
|
||||
pop() {
|
||||
if (this.heap.length === 0) return null;
|
||||
|
||||
const min = this.heap[0];
|
||||
this.keyMap.delete(min.node.key);
|
||||
|
||||
const last = this.heap.pop();
|
||||
if (this.heap.length > 0) {
|
||||
this.heap[0] = last;
|
||||
this.keyMap.set(last.node.key, 0);
|
||||
this._siftDown(0);
|
||||
}
|
||||
|
||||
return min.node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific node by key
|
||||
*/
|
||||
remove(node) {
|
||||
const key = node.key;
|
||||
if (!this.keyMap.has(key)) return;
|
||||
|
||||
const index = this.keyMap.get(key);
|
||||
this.keyMap.delete(key);
|
||||
|
||||
const last = this.heap.pop();
|
||||
if (index < this.heap.length) {
|
||||
this.heap[index] = last;
|
||||
this.keyMap.set(last.node.key, index);
|
||||
this._bubbleUp(index);
|
||||
this._siftDown(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is in the queue
|
||||
*/
|
||||
contains(node) {
|
||||
return this.keyMap.has(node.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the queue
|
||||
*/
|
||||
clear() {
|
||||
this.heap = [];
|
||||
this.keyMap.clear();
|
||||
}
|
||||
|
||||
// --- Internal heap operations ---
|
||||
|
||||
_parent(i) {
|
||||
return Math.floor((i - 1) / 2);
|
||||
}
|
||||
|
||||
_leftChild(i) {
|
||||
return 2 * i + 1;
|
||||
}
|
||||
|
||||
_rightChild(i) {
|
||||
return 2 * i + 2;
|
||||
}
|
||||
|
||||
_swap(i, j) {
|
||||
const temp = this.heap[i];
|
||||
this.heap[i] = this.heap[j];
|
||||
this.heap[j] = temp;
|
||||
|
||||
this.keyMap.set(this.heap[i].node.key, i);
|
||||
this.keyMap.set(this.heap[j].node.key, j);
|
||||
}
|
||||
|
||||
_bubbleUp(i) {
|
||||
while (i > 0) {
|
||||
const parent = this._parent(i);
|
||||
if (PriorityQueue.compareKeys(this.heap[i].priority, this.heap[parent].priority) < 0) {
|
||||
this._swap(i, parent);
|
||||
i = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_siftDown(i) {
|
||||
const n = this.heap.length;
|
||||
while (true) {
|
||||
let smallest = i;
|
||||
const left = this._leftChild(i);
|
||||
const right = this._rightChild(i);
|
||||
|
||||
if (left < n && PriorityQueue.compareKeys(this.heap[left].priority, this.heap[smallest].priority) < 0) {
|
||||
smallest = left;
|
||||
}
|
||||
if (right < n && PriorityQueue.compareKeys(this.heap[right].priority, this.heap[smallest].priority) < 0) {
|
||||
smallest = right;
|
||||
}
|
||||
|
||||
if (smallest !== i) {
|
||||
this._swap(i, smallest);
|
||||
i = smallest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
server/pathfinding/index.js
Normal file
4
server/pathfinding/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Point } from './Point.js';
|
||||
export { Node } from './Node.js';
|
||||
export { PriorityQueue } from './PriorityQueue.js';
|
||||
export { DStarLite } from './DStarLite.js';
|
||||
1427
server/server.js
1427
server/server.js
File diff suppressed because it is too large
Load Diff
222
server/states/AutocraftState.js
Normal file
222
server/states/AutocraftState.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* AutocraftState - Automated crafting using a workbench peripheral
|
||||
*
|
||||
* The turtle must have a crafting table equipped as a peripheral.
|
||||
* Given a recipe (16-slot inventory layout), it will:
|
||||
* 1. Verify workbench peripheral is present
|
||||
* 2. Arrange items in the correct slots
|
||||
* 3. Pull materials from adjacent inventories
|
||||
* 4. Craft continuously
|
||||
* 5. Push crafted items into adjacent inventories
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class AutocraftState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
// recipe: { 1: {name, count}, 2: null, 3: {name, count}, ... } (1-16 slots)
|
||||
this.recipe = data.recipe || {};
|
||||
this.craftCount = 0;
|
||||
this.totalCrafted = 0;
|
||||
this.maxCrafts = data.maxCrafts || Infinity;
|
||||
this.warning = null;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'autocrafting';
|
||||
}
|
||||
|
||||
get description() {
|
||||
if (this.warning) return `Crafting - ${this.warning}`;
|
||||
return `Crafting - ${this.totalCrafted} items crafted`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting autocraft`);
|
||||
|
||||
// Verify workbench peripheral
|
||||
const hasWorkbench = await this.exec(`
|
||||
local names = peripheral.getNames()
|
||||
for _, name in ipairs(names) do
|
||||
local types = {peripheral.getType(name)}
|
||||
for _, t in ipairs(types) do
|
||||
if t == "workbench" then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
`);
|
||||
|
||||
if (!hasWorkbench) {
|
||||
console.log(`[${this.turtle.id}] No workbench peripheral found`);
|
||||
this.warning = 'No crafting table equipped';
|
||||
await this._sleep(3000);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
yield;
|
||||
|
||||
// Initial craft to verify recipe works
|
||||
const initialCraft = await this.exec(`
|
||||
local workbench = peripheral.find("workbench")
|
||||
if workbench then
|
||||
local ok, msg = workbench.craft(0)
|
||||
return ok
|
||||
end
|
||||
return false
|
||||
`);
|
||||
|
||||
if (!initialCraft) {
|
||||
console.log(`[${this.turtle.id}] Recipe validation failed`);
|
||||
this.warning = 'Invalid recipe';
|
||||
await this._sleep(3000);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
yield;
|
||||
|
||||
// Main crafting loop
|
||||
while (!this.cancelled && this.totalCrafted < this.maxCrafts) {
|
||||
this.warning = null;
|
||||
let recipeReady = true;
|
||||
|
||||
// Arrange items for recipe
|
||||
for (let slot = 1; slot <= 16; slot++) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
const recipeItem = this.recipe[slot];
|
||||
|
||||
// Get current item in slot
|
||||
const currentItem = await this.exec(`
|
||||
local item = turtle.getItemDetail(${slot})
|
||||
if item then return {name=item.name, count=item.count} end
|
||||
return nil
|
||||
`);
|
||||
yield;
|
||||
|
||||
if (!recipeItem) {
|
||||
// Slot should be empty - move item out if present
|
||||
if (currentItem) {
|
||||
await this._transferSlotOut(slot);
|
||||
yield;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Slot needs a specific item
|
||||
if (currentItem) {
|
||||
if (currentItem.name === recipeItem.name) {
|
||||
continue; // Already has correct item
|
||||
}
|
||||
// Wrong item - move it out
|
||||
await this._transferSlotOut(slot);
|
||||
yield;
|
||||
}
|
||||
|
||||
// Pull the required item into this slot
|
||||
const pulled = await this._pullItemToSlot(recipeItem.name, slot);
|
||||
yield;
|
||||
|
||||
if (!pulled) {
|
||||
this.warning = `Missing: ${recipeItem.name.replace('minecraft:', '')}`;
|
||||
recipeReady = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipeReady) {
|
||||
yield;
|
||||
await this._sleep(5000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Craft!
|
||||
const craftResult = await this.exec(`
|
||||
local workbench = peripheral.find("workbench")
|
||||
if workbench then
|
||||
local ok, msg = workbench.craft()
|
||||
if ok then return true
|
||||
else return false
|
||||
end
|
||||
end
|
||||
return false
|
||||
`);
|
||||
|
||||
if (craftResult) {
|
||||
this.totalCrafted++;
|
||||
this.craftCount++;
|
||||
console.log(`[${this.turtle.id}] Crafted item #${this.totalCrafted}`);
|
||||
|
||||
// Push crafted items out to adjacent inventory
|
||||
await this._pushCraftedItems();
|
||||
yield;
|
||||
} else {
|
||||
this.warning = 'Craft failed';
|
||||
await this._sleep(2000);
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(500);
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Autocraft finished: ${this.totalCrafted} total`);
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
async _transferSlotOut(slot) {
|
||||
// Try to drop the item in the given slot into an adjacent inventory
|
||||
await this.exec(`
|
||||
turtle.select(${slot})
|
||||
if not turtle.dropDown() then
|
||||
if not turtle.dropUp() then
|
||||
turtle.drop()
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
`);
|
||||
}
|
||||
|
||||
async _pullItemToSlot(itemName, targetSlot) {
|
||||
// Try to pull item from adjacent inventories
|
||||
// First try suckDown, then suckUp, then suck
|
||||
const result = await this.exec(`
|
||||
turtle.select(${targetSlot})
|
||||
|
||||
-- Try each direction
|
||||
for _, suckFn in ipairs({turtle.suckDown, turtle.suckUp, turtle.suck}) do
|
||||
suckFn(1)
|
||||
local item = turtle.getItemDetail(${targetSlot})
|
||||
if item and item.name == "${itemName}" then
|
||||
return true
|
||||
elseif item then
|
||||
-- Wrong item, put it back
|
||||
for _, dropFn in ipairs({turtle.dropDown, turtle.dropUp, turtle.drop}) do
|
||||
if dropFn() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
turtle.select(1)
|
||||
return false
|
||||
`);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
async _pushCraftedItems() {
|
||||
// Push all items out of inventory
|
||||
await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
turtle.select(slot)
|
||||
if not turtle.dropDown() then
|
||||
if not turtle.dropUp() then
|
||||
turtle.drop()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
`);
|
||||
}
|
||||
}
|
||||
356
server/states/BaseState.js
Normal file
356
server/states/BaseState.js
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* BaseState - Abstract base class for turtle state machine
|
||||
*
|
||||
* Uses async generator pattern inspired by runi95/turtle-control-panel.
|
||||
* Each state implements *act() which yields control back periodically,
|
||||
* allowing the state machine to be cooperative and interruptible.
|
||||
*/
|
||||
export class BaseState {
|
||||
/**
|
||||
* @param {import('../Turtle.js').Turtle} turtle - The turtle instance
|
||||
* @param {Object} data - State-specific data for initialization/recovery
|
||||
*/
|
||||
constructor(turtle, data = {}) {
|
||||
this.turtle = turtle;
|
||||
this.data = data;
|
||||
this.cancelled = false;
|
||||
this.startedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of this state (used for logging and UI)
|
||||
*/
|
||||
get name() {
|
||||
return 'base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of what the state is doing
|
||||
*/
|
||||
get description() {
|
||||
return 'Idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* The async generator that drives the state's behavior.
|
||||
* Subclasses MUST override this method.
|
||||
*
|
||||
* Yield to give up control temporarily (cooperative multitasking).
|
||||
* Return to signal that the state is complete.
|
||||
* Throw to signal an error.
|
||||
*
|
||||
* @yields {void}
|
||||
*/
|
||||
async *act() {
|
||||
// Base state does nothing - just idles
|
||||
while (!this.cancelled) {
|
||||
yield;
|
||||
await this._sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel this state. The act() generator should check this.cancelled
|
||||
* and clean up gracefully.
|
||||
*/
|
||||
cancel() {
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery data for persisting state across reconnections
|
||||
*/
|
||||
getRecoveryData() {
|
||||
return {
|
||||
stateName: this.name,
|
||||
data: this.data,
|
||||
startedAt: this.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Helper Methods for Subclasses ==========
|
||||
|
||||
/**
|
||||
* Execute a Lua command on the turtle and wait for the result
|
||||
*/
|
||||
async exec(luaCode) {
|
||||
return this.turtle.exec(luaCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle forward, digging if necessary
|
||||
*/
|
||||
async moveForward(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.dig(); return turtle.forward()');
|
||||
if (result) this.turtle.updatePositionForward();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.forward()');
|
||||
if (result) this.turtle.updatePositionForward();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle up, digging if necessary
|
||||
*/
|
||||
async moveUp(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.digUp(); return turtle.up()');
|
||||
if (result) this.turtle.updatePositionUp();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.up()');
|
||||
if (result) this.turtle.updatePositionUp();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the turtle down, digging if necessary
|
||||
*/
|
||||
async moveDown(dig = true) {
|
||||
if (dig) {
|
||||
const result = await this.exec('turtle.digDown(); return turtle.down()');
|
||||
if (result) this.turtle.updatePositionDown();
|
||||
return result;
|
||||
}
|
||||
const result = await this.exec('return turtle.down()');
|
||||
if (result) this.turtle.updatePositionDown();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the turtle to face a specific direction
|
||||
* @param {number} targetFacing - 0=North, 1=East, 2=South, 3=West
|
||||
*/
|
||||
async turnToFace(targetFacing) {
|
||||
const currentFacing = this.turtle.facing;
|
||||
if (currentFacing === targetFacing) return true;
|
||||
|
||||
const diff = (targetFacing - currentFacing + 4) % 4;
|
||||
|
||||
if (diff === 1) {
|
||||
await this.exec(`turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
|
||||
this.turtle.facing = targetFacing;
|
||||
} else if (diff === 2) {
|
||||
await this.exec(`turtle.turnRight(); turtle.turnRight(); _G._turtleFacing = ${targetFacing}`);
|
||||
this.turtle.facing = targetFacing;
|
||||
} else if (diff === 3) {
|
||||
await this.exec(`turtle.turnLeft(); _G._turtleFacing = ${targetFacing}`);
|
||||
this.turtle.facing = targetFacing;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to an adjacent point (one of the 6 neighbors)
|
||||
* Handles turning and digging automatically
|
||||
*/
|
||||
async moveToAdjacent(targetPoint) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return false;
|
||||
|
||||
const dx = targetPoint.x - pos.x;
|
||||
const dy = targetPoint.y - pos.y;
|
||||
const dz = targetPoint.z - pos.z;
|
||||
|
||||
if (dy === 1) return this.moveUp();
|
||||
if (dy === -1) return this.moveDown();
|
||||
|
||||
// Horizontal movement - determine facing
|
||||
let targetFacing;
|
||||
if (dx === 1) targetFacing = 1; // East
|
||||
else if (dx === -1) targetFacing = 3; // West
|
||||
else if (dz === 1) targetFacing = 2; // South
|
||||
else if (dz === -1) targetFacing = 0; // North
|
||||
else return false; // Not adjacent
|
||||
|
||||
await this.turnToFace(targetFacing);
|
||||
return this.moveForward();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a target point using the D* Lite pathfinder
|
||||
* This is a generator that yields after each step
|
||||
*/
|
||||
async *navigateTo(targetPoint, options = {}) {
|
||||
const { Point, DStarLite } = await import('../pathfinding/index.js');
|
||||
|
||||
const start = Point.from(this.turtle.position);
|
||||
const goal = Point.from(targetPoint);
|
||||
|
||||
if (!start || !goal) {
|
||||
console.error('Cannot navigate: missing position');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start.equals(goal)) return true;
|
||||
|
||||
const pathfinder = new DStarLite(start, goal, this.turtle.server.worldBlocks, {
|
||||
canMine: options.canMine !== false,
|
||||
maxSteps: options.maxSteps || 50000,
|
||||
});
|
||||
|
||||
let currentPos = start;
|
||||
let attempts = 0;
|
||||
const maxAttempts = options.maxAttempts || 5000;
|
||||
|
||||
while (!currentPos.equals(goal) && attempts < maxAttempts && !this.cancelled) {
|
||||
attempts++;
|
||||
|
||||
const nextStep = pathfinder.getNextStep();
|
||||
if (!nextStep) {
|
||||
console.log(`[${this.turtle.id}] No path to ${goal} from ${currentPos}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan surroundings before moving
|
||||
const scanResult = await this.exec(`
|
||||
local results = {}
|
||||
local hasBlock, data
|
||||
hasBlock, data = turtle.inspect()
|
||||
if hasBlock then results.forward = data end
|
||||
hasBlock, data = turtle.inspectUp()
|
||||
if hasBlock then results.up = data end
|
||||
hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then results.down = data end
|
||||
return results
|
||||
`);
|
||||
|
||||
// Update pathfinder with discovered blocks
|
||||
if (scanResult && typeof scanResult === 'object') {
|
||||
this.turtle.processScanResults(scanResult);
|
||||
// Update pathfinder with new information
|
||||
for (const [dir, blockData] of Object.entries(scanResult)) {
|
||||
const blockPos = this.turtle.getBlockPositionInDirection(dir);
|
||||
if (blockPos) {
|
||||
pathfinder.updateBlock(Point.from(blockPos), blockData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to move to the next step
|
||||
const success = await this.moveToAdjacent(nextStep);
|
||||
|
||||
if (success) {
|
||||
currentPos = Point.from(this.turtle.position);
|
||||
pathfinder.updateStart(currentPos);
|
||||
} else {
|
||||
// Movement failed - update the blocked node and replan
|
||||
pathfinder.updateBlock(nextStep, { name: 'minecraft:bedrock' }); // Treat as blocked
|
||||
if (!pathfinder.replan()) {
|
||||
console.log(`[${this.turtle.id}] Replanning failed - no path exists`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
yield; // Cooperative yield
|
||||
}
|
||||
|
||||
return currentPos.equals(goal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all 6 directions around the turtle
|
||||
*/
|
||||
async scanSurroundings() {
|
||||
const result = await this.exec(`
|
||||
local results = {}
|
||||
local facing = _G._turtleFacing or 0
|
||||
|
||||
-- Scan up/down
|
||||
local hasBlock, data
|
||||
hasBlock, data = turtle.inspectUp()
|
||||
if hasBlock then results.up = {name=data.name, metadata=data.metadata or 0} end
|
||||
hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then results.down = {name=data.name, metadata=data.metadata or 0} end
|
||||
|
||||
-- Scan all 4 horizontal directions
|
||||
for i = 0, 3 do
|
||||
hasBlock, data = turtle.inspect()
|
||||
if hasBlock then
|
||||
local dir = (facing + i) % 4
|
||||
results["h" .. dir] = {name=data.name, metadata=data.metadata or 0}
|
||||
end
|
||||
if i < 3 then turtle.turnRight(); facing = (facing + 1) % 4 end
|
||||
end
|
||||
-- Turn back to original facing
|
||||
turtle.turnRight()
|
||||
facing = (facing + 1) % 4
|
||||
_G._turtleFacing = facing
|
||||
|
||||
return results
|
||||
`);
|
||||
|
||||
if (result) {
|
||||
this.turtle.processScanResults(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check fuel level
|
||||
*/
|
||||
async checkFuel() {
|
||||
const fuel = await this.exec('return turtle.getFuelLevel()');
|
||||
if (fuel !== null) this.turtle.fuel = fuel;
|
||||
return fuel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory contents
|
||||
*/
|
||||
async getInventory() {
|
||||
const inv = await this.exec(`
|
||||
local items = {}
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
items[slot] = {name=item.name, count=item.count, damage=item.damage}
|
||||
end
|
||||
end
|
||||
return items
|
||||
`);
|
||||
if (inv) this.turtle.inventory = inv;
|
||||
return inv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if inventory is full
|
||||
*/
|
||||
async isInventoryFull() {
|
||||
const result = await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) == 0 then return false end
|
||||
end
|
||||
return true
|
||||
`);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to refuel from inventory
|
||||
*/
|
||||
async tryRefuel() {
|
||||
return this.exec(`
|
||||
for slot = 1, 16 do
|
||||
turtle.select(slot)
|
||||
if turtle.refuel(0) then
|
||||
turtle.refuel(1)
|
||||
turtle.select(1)
|
||||
return true
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
return false
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a duration (non-blocking)
|
||||
*/
|
||||
_sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
311
server/states/BuildingState.js
Normal file
311
server/states/BuildingState.js
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* BuildingState - Blueprint-based autonomous block placement
|
||||
*
|
||||
* Given a list of blocks (x, y, z, name, state), the turtle will:
|
||||
* 1. Calculate required materials
|
||||
* 2. Go home to collect materials from nearby inventories
|
||||
* 3. Navigate to each block position and place it
|
||||
* 4. Handle facing-aware placement (stairs, logs, etc.)
|
||||
* 5. Track failed placements and retry later
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class BuildingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
// blocks: [{x, y, z, name, state?}]
|
||||
this.blocks = data.blocks || [];
|
||||
this.placedCount = 0;
|
||||
this.failedPlacements = new Map(); // "x,y,z" -> retry count
|
||||
this.maxRetries = 3;
|
||||
|
||||
// Group blocks by column (x,z) for efficient placement
|
||||
this.columns = new Map(); // "x,z" -> [{x,y,z,name,state}] sorted by y ascending
|
||||
this._organizeBlocks();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'building';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Building - ${this.placedCount}/${this.blocks.length} placed`;
|
||||
}
|
||||
|
||||
_organizeBlocks() {
|
||||
// Filter out blocks that might already be placed (we can't check DB here,
|
||||
// but we organize for efficient traversal)
|
||||
for (const block of this.blocks) {
|
||||
const key = `${block.x},${block.z}`;
|
||||
if (!this.columns.has(key)) {
|
||||
this.columns.set(key, []);
|
||||
}
|
||||
this.columns.get(key).push(block);
|
||||
}
|
||||
|
||||
// Sort each column by Y ascending (place from bottom up)
|
||||
for (const [key, column] of this.columns) {
|
||||
column.sort((a, b) => a.y - b.y);
|
||||
}
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting build: ${this.blocks.length} blocks to place`);
|
||||
|
||||
while (!this.cancelled) {
|
||||
// Check if we have any blocks left to place
|
||||
const remainingBlocks = this._getRemainingBlocks();
|
||||
if (remainingBlocks.length === 0) {
|
||||
console.log(`[${this.turtle.id}] Build complete: ${this.placedCount} blocks placed`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety checks
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < 500) {
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
console.log(`[${this.turtle.id}] Low fuel during build, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'building', returnData: this.data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
yield;
|
||||
|
||||
// Calculate required materials
|
||||
const requiredMaterials = this._calculateMaterials(remainingBlocks);
|
||||
|
||||
// Check if we have the materials
|
||||
const inventory = await this.getInventory();
|
||||
const hasMaterials = this._hasRequiredMaterials(requiredMaterials, inventory);
|
||||
yield;
|
||||
|
||||
if (!hasMaterials) {
|
||||
// Go home to collect materials
|
||||
const home = this.turtle.homePosition;
|
||||
if (!home) {
|
||||
console.log(`[${this.turtle.id}] No home set, cannot collect materials`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Going home to collect building materials`);
|
||||
|
||||
// Navigate home
|
||||
const reachedHome = yield* this.navigateTo(home);
|
||||
if (!reachedHome) {
|
||||
console.log(`[${this.turtle.id}] Cannot reach home`);
|
||||
await this._sleep(5000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
yield;
|
||||
|
||||
// Dump current inventory into nearby chests
|
||||
await this._dumpInventory();
|
||||
yield;
|
||||
|
||||
// Try to pull required materials
|
||||
const gotMaterials = await this._pullMaterials(requiredMaterials);
|
||||
yield;
|
||||
|
||||
if (!gotMaterials) {
|
||||
console.log(`[${this.turtle.id}] Cannot get required materials, waiting...`);
|
||||
await this._sleep(10000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the next block to place (closest to turtle)
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) {
|
||||
await this._sleep(1000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextBlock = this._findClosestBlock(remainingBlocks, pos);
|
||||
if (!nextBlock) {
|
||||
await this._sleep(1000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Navigate to one block above the target (to place down)
|
||||
const placePos = { x: nextBlock.x, y: nextBlock.y + 1, z: nextBlock.z };
|
||||
const reached = yield* this.navigateTo(placePos);
|
||||
yield;
|
||||
|
||||
if (!reached) {
|
||||
// Mark as failed
|
||||
const key = `${nextBlock.x},${nextBlock.y},${nextBlock.z}`;
|
||||
const retries = (this.failedPlacements.get(key) || 0) + 1;
|
||||
this.failedPlacements.set(key, retries);
|
||||
|
||||
if (retries >= this.maxRetries) {
|
||||
console.log(`[${this.turtle.id}] Cannot reach ${key}, skipping permanently`);
|
||||
}
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle facing - turn turtle to face correct direction before placing
|
||||
if (nextBlock.state && nextBlock.state.facing) {
|
||||
await this._turnForPlacement(nextBlock.state.facing);
|
||||
yield;
|
||||
}
|
||||
|
||||
// Select the correct item
|
||||
const selected = await this._selectItem(nextBlock.name);
|
||||
if (!selected) {
|
||||
console.log(`[${this.turtle.id}] Don't have ${nextBlock.name} to place`);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Place the block
|
||||
const placed = await this.exec('return turtle.placeDown()');
|
||||
yield;
|
||||
|
||||
if (placed) {
|
||||
this.placedCount++;
|
||||
// Remove from our list
|
||||
this._markBlockPlaced(nextBlock);
|
||||
console.log(`[${this.turtle.id}] Placed ${nextBlock.name} at ${nextBlock.x},${nextBlock.y},${nextBlock.z} (${this.placedCount}/${this.blocks.length})`);
|
||||
} else {
|
||||
const key = `${nextBlock.x},${nextBlock.y},${nextBlock.z}`;
|
||||
const retries = (this.failedPlacements.get(key) || 0) + 1;
|
||||
this.failedPlacements.set(key, retries);
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
_getRemainingBlocks() {
|
||||
const remaining = [];
|
||||
for (const [key, column] of this.columns) {
|
||||
for (const block of column) {
|
||||
const blockKey = `${block.x},${block.y},${block.z}`;
|
||||
const retries = this.failedPlacements.get(blockKey) || 0;
|
||||
if (retries < this.maxRetries) {
|
||||
remaining.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
_calculateMaterials(blocks) {
|
||||
const materials = new Map(); // name -> count
|
||||
for (const block of blocks) {
|
||||
materials.set(block.name, (materials.get(block.name) || 0) + 1);
|
||||
}
|
||||
return materials;
|
||||
}
|
||||
|
||||
_hasRequiredMaterials(required, inventory) {
|
||||
if (!inventory) return false;
|
||||
const available = new Map();
|
||||
for (const [slot, item] of Object.entries(inventory)) {
|
||||
if (item && item.name) {
|
||||
available.set(item.name, (available.get(item.name) || 0) + item.count);
|
||||
}
|
||||
}
|
||||
for (const [name, count] of required) {
|
||||
if ((available.get(name) || 0) < Math.min(count, 1)) {
|
||||
return false; // Need at least 1 of each material
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_findClosestBlock(blocks, from) {
|
||||
let closest = null;
|
||||
let minDist = Infinity;
|
||||
for (const block of blocks) {
|
||||
const dist = Math.abs(block.x - from.x) + Math.abs(block.y - from.y) + Math.abs(block.z - from.z);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = block;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
_markBlockPlaced(block) {
|
||||
const key = `${block.x},${block.z}`;
|
||||
const column = this.columns.get(key);
|
||||
if (column) {
|
||||
const idx = column.findIndex(b => b.x === block.x && b.y === block.y && b.z === block.z);
|
||||
if (idx >= 0) column.splice(idx, 1);
|
||||
if (column.length === 0) this.columns.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async _turnForPlacement(facing) {
|
||||
// When placing blocks with facing, the turtle should face the opposite direction
|
||||
// because placed blocks face toward the placer
|
||||
const facingMap = { north: 2, south: 0, east: 3, west: 1 };
|
||||
const targetFacing = facingMap[facing];
|
||||
if (targetFacing !== undefined) {
|
||||
await this.turnToFace(targetFacing);
|
||||
}
|
||||
}
|
||||
|
||||
async _selectItem(blockName) {
|
||||
const result = await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == "${blockName}" then
|
||||
turtle.select(slot)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
`);
|
||||
return result === true;
|
||||
}
|
||||
|
||||
async _dumpInventory() {
|
||||
await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
turtle.select(slot)
|
||||
turtle.dropDown()
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
`);
|
||||
}
|
||||
|
||||
async _pullMaterials(required) {
|
||||
// Try to suck items from chests below (at home position)
|
||||
let gotAny = false;
|
||||
for (const [name, count] of required) {
|
||||
const result = await this.exec(`
|
||||
local needed = "${name}"
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
turtle.select(slot)
|
||||
turtle.suckDown(64)
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == needed then
|
||||
return true
|
||||
elseif item then
|
||||
turtle.dropDown()
|
||||
end
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
return false
|
||||
`);
|
||||
if (result) gotAny = true;
|
||||
}
|
||||
return gotAny;
|
||||
}
|
||||
}
|
||||
209
server/states/DumpInventoryState.js
Normal file
209
server/states/DumpInventoryState.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* DumpInventoryState - Dump inventory into nearby containers
|
||||
* Keeps fuel items, dumps everything else.
|
||||
*
|
||||
* Improvements over v1:
|
||||
* - Uses Levenshtein distance for fuzzy container name matching
|
||||
* - Detects containers via peripheral inspection on all sides
|
||||
* - Tracks dump success/failure per direction
|
||||
* - Reports detailed dump stats
|
||||
* - Optionally navigates home before dumping
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
import { findBestMatch } from '../helpers/levenshtein.js';
|
||||
|
||||
// Items to keep (fuel sources and tools)
|
||||
const KEEP_ITEMS = new Set([
|
||||
'minecraft:coal', 'minecraft:charcoal', 'minecraft:coal_block',
|
||||
'minecraft:torch', 'minecraft:lava_bucket',
|
||||
]);
|
||||
|
||||
// Known container block names (used for fuzzy matching)
|
||||
const CONTAINER_NAMES = [
|
||||
'chest', 'barrel', 'shulker_box', 'hopper', 'dropper', 'dispenser',
|
||||
'trapped_chest', 'ender_chest', 'furnace', 'blast_furnace', 'smoker',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a block name is likely a container using fuzzy matching
|
||||
*/
|
||||
function isContainerBlock(blockName) {
|
||||
if (!blockName) return false;
|
||||
const shortName = blockName.replace(/^[^:]+:/, '').toLowerCase();
|
||||
|
||||
// Exact substring match first
|
||||
for (const container of CONTAINER_NAMES) {
|
||||
if (shortName.includes(container)) return true;
|
||||
}
|
||||
|
||||
// Fuzzy match as fallback
|
||||
const { match, distance } = findBestMatch(shortName, CONTAINER_NAMES, 3);
|
||||
return match !== null && distance <= 2;
|
||||
}
|
||||
|
||||
export class DumpInventoryState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.returnState = data.returnState || null;
|
||||
this.returnData = data.returnData || {};
|
||||
this.navigateHome = data.navigateHome || false;
|
||||
this.keepItems = data.keepItems
|
||||
? new Set([...KEEP_ITEMS, ...data.keepItems])
|
||||
: KEEP_ITEMS;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'dumping';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.navigateHome ? 'Returning home to dump inventory' : 'Dumping inventory';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting inventory dump`);
|
||||
|
||||
// Optionally navigate home first
|
||||
if (this.navigateHome && this.turtle.homePosition) {
|
||||
console.log(`[${this.turtle.id}] Navigating home before dumping`);
|
||||
yield* this.navigateTo(this.turtle.homePosition);
|
||||
yield;
|
||||
}
|
||||
|
||||
// First, detect containers by inspecting all directions
|
||||
const containerDirections = await this._detectContainers();
|
||||
|
||||
if (containerDirections.length === 0) {
|
||||
console.log(`[${this.turtle.id}] No containers found nearby, trying blind dump`);
|
||||
containerDirections.push('front', 'up', 'down');
|
||||
} else {
|
||||
console.log(`[${this.turtle.id}] Found containers: ${containerDirections.join(', ')}`);
|
||||
}
|
||||
|
||||
// Build keep items set for Lua
|
||||
const keepItemsList = [...this.keepItems].map(n => `"${n}"`).join(', ');
|
||||
|
||||
// Map direction names to Lua drop functions
|
||||
const dropFnMap = {
|
||||
front: 'turtle.drop',
|
||||
up: 'turtle.dropUp',
|
||||
down: 'turtle.dropDown',
|
||||
};
|
||||
|
||||
// Build the drop functions array for only detected container directions
|
||||
const dropFnEntries = containerDirections
|
||||
.filter(d => dropFnMap[d])
|
||||
.map(d => dropFnMap[d]);
|
||||
|
||||
if (dropFnEntries.length === 0) {
|
||||
console.log(`[${this.turtle.id}] No valid drop directions`);
|
||||
this._transitionOut();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.exec(`
|
||||
local keepItems = {${keepItemsList}}
|
||||
local keepSet = {}
|
||||
for _, name in ipairs(keepItems) do keepSet[name] = true end
|
||||
|
||||
local dropFns = {${dropFnEntries.join(', ')}}
|
||||
|
||||
local dumpedCount = 0
|
||||
local failedCount = 0
|
||||
|
||||
for _, dropFn in ipairs(dropFns) do
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and not keepSet[item.name] then
|
||||
turtle.select(slot)
|
||||
local prevCount = item.count
|
||||
if dropFn() then
|
||||
local remaining = turtle.getItemCount(slot)
|
||||
dumpedCount = dumpedCount + (prevCount - remaining)
|
||||
else
|
||||
failedCount = failedCount + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
turtle.select(1)
|
||||
return {dumped = dumpedCount, failed = failedCount}
|
||||
`);
|
||||
|
||||
if (result) {
|
||||
console.log(`[${this.turtle.id}] Dumped ${result.dumped} items (${result.failed} drops failed)`);
|
||||
if (result.failed > 0) {
|
||||
this.turtle.warning = `Dump: ${result.failed} drops failed (container full?)`;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Update inventory after dumping
|
||||
await this.getInventory();
|
||||
|
||||
// Refresh any adjacent inventory peripherals
|
||||
for (const dir of containerDirections) {
|
||||
if (this.turtle._peripherals?.[dir]?.types?.includes('inventory')) {
|
||||
try {
|
||||
await this.turtle.connectToInventory(dir);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
this._transitionOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which directions have containers by inspecting blocks
|
||||
*/
|
||||
async _detectContainers() {
|
||||
const result = await this.exec(`
|
||||
local dirs = {}
|
||||
local h, d
|
||||
h, d = turtle.inspect()
|
||||
if h then dirs.front = d.name end
|
||||
h, d = turtle.inspectUp()
|
||||
if h then dirs.up = d.name end
|
||||
h, d = turtle.inspectDown()
|
||||
if h then dirs.down = d.name end
|
||||
return dirs
|
||||
`);
|
||||
|
||||
if (!result || typeof result !== 'object') return [];
|
||||
|
||||
const containerDirs = [];
|
||||
for (const [dir, blockName] of Object.entries(result)) {
|
||||
if (isContainerBlock(blockName)) {
|
||||
containerDirs.push(dir);
|
||||
}
|
||||
}
|
||||
return containerDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to the next state (return state or idle)
|
||||
*/
|
||||
_transitionOut() {
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
navigateHome: this.navigateHome,
|
||||
keepItems: this.keepItems !== KEEP_ITEMS
|
||||
? [...this.keepItems].filter(i => !KEEP_ITEMS.has(i))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
380
server/states/ExploringState.js
Normal file
380
server/states/ExploringState.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* ExploringState - Chunk-based spiral exploration to discover the world map
|
||||
*
|
||||
* Instead of random walking, this systematically explores in a spiral pattern
|
||||
* outward from the starting position, chunk by chunk (16x16 areas).
|
||||
* Within each chunk, it does a strip-mine pattern to scan all blocks.
|
||||
*
|
||||
* Inspired by runi95/turtle-control-panel's exploration pattern.
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class ExploringState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.maxDistance = data.maxDistance || 200;
|
||||
this.minFuel = data.minFuel || 500;
|
||||
this.yLevel = data.yLevel || null; // null = stay at current Y
|
||||
this.blocksDiscovered = 0;
|
||||
this.chunksExplored = 0;
|
||||
|
||||
// Spiral state
|
||||
this.spiralRing = data.spiralRing || 0;
|
||||
this.spiralSide = data.spiralSide || 0;
|
||||
this.spiralStep = data.spiralStep || 0;
|
||||
this.spiralChunkX = data.spiralChunkX ?? null;
|
||||
this.spiralChunkZ = data.spiralChunkZ ?? null;
|
||||
this.startChunkX = data.startChunkX ?? null;
|
||||
this.startChunkZ = data.startChunkZ ?? null;
|
||||
this.exploredChunks = new Set(data.exploredChunks || []);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'exploring';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Exploring - ${this.blocksDiscovered} blocks, ${this.chunksExplored} chunks (ring ${this.spiralRing})`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) {
|
||||
console.log(`[${this.turtle.id}] No position, trying GPS...`);
|
||||
await this.turtle.gpsLocate();
|
||||
if (!this.turtle.position) {
|
||||
console.error(`[${this.turtle.id}] Cannot explore without position`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize start chunk from current position
|
||||
if (this.startChunkX === null) {
|
||||
this.startChunkX = Math.floor(this.turtle.position.x / 16);
|
||||
this.startChunkZ = Math.floor(this.turtle.position.z / 16);
|
||||
this.spiralChunkX = this.startChunkX;
|
||||
this.spiralChunkZ = this.startChunkZ;
|
||||
}
|
||||
|
||||
// Set Y level for exploration
|
||||
if (!this.yLevel) {
|
||||
this.yLevel = this.turtle.position.y;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Starting chunk-based spiral exploration from chunk (${this.startChunkX}, ${this.startChunkZ}), Y=${this.yLevel}`);
|
||||
|
||||
while (!this.cancelled) {
|
||||
try {
|
||||
// Safety: check fuel
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
console.log(`[${this.turtle.id}] Low fuel, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'exploring', returnData: this.getRecoveryData().data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety: check distance
|
||||
if (this._isTooFar()) {
|
||||
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current target chunk
|
||||
const chunkKey = `${this.spiralChunkX},${this.spiralChunkZ}`;
|
||||
|
||||
if (!this.exploredChunks.has(chunkKey)) {
|
||||
// Explore this chunk
|
||||
console.log(`[${this.turtle.id}] Exploring chunk (${this.spiralChunkX}, ${this.spiralChunkZ}) [ring ${this.spiralRing}]`);
|
||||
yield* this._exploreChunk(this.spiralChunkX, this.spiralChunkZ);
|
||||
this.exploredChunks.add(chunkKey);
|
||||
this.chunksExplored++;
|
||||
}
|
||||
|
||||
// Advance spiral to next chunk
|
||||
this._advanceSpiral();
|
||||
|
||||
yield;
|
||||
} catch (error) {
|
||||
const isTimeout = error.message?.includes('timed out');
|
||||
if (isTimeout) {
|
||||
console.warn(`[${this.turtle.id}] Exploration timeout, retrying...`);
|
||||
await this._sleep(3000);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore a single chunk by navigating to it and doing a strip-mine scan pattern.
|
||||
* Scans every 3rd row at the exploration Y level for good coverage.
|
||||
*/
|
||||
async *_exploreChunk(chunkX, chunkZ) {
|
||||
const startX = chunkX * 16;
|
||||
const startZ = chunkZ * 16;
|
||||
|
||||
// Navigate to the start corner of the chunk
|
||||
const chunkStart = { x: startX, y: this.yLevel, z: startZ };
|
||||
yield* this._navigateToSafe(chunkStart);
|
||||
|
||||
// Strip-mine scan pattern across the chunk
|
||||
// Every 3 blocks gives good coverage with turtle.inspect() range
|
||||
let forward = true;
|
||||
for (let row = 0; row < 16; row += 3) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
const rowZ = startZ + row;
|
||||
const rowStartX = forward ? startX : startX + 15;
|
||||
|
||||
// Navigate to row start
|
||||
yield* this._navigateToSafe({ x: rowStartX, y: this.yLevel, z: rowZ });
|
||||
|
||||
// Walk along the row, scanning as we go
|
||||
const direction = forward ? 1 : 3; // East or West
|
||||
await this.turnToFace(direction);
|
||||
|
||||
for (let step = 0; step < 15; step++) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
// 3-direction scan
|
||||
const scanResult = await this.exec(`
|
||||
local results = {}
|
||||
local h, d
|
||||
h, d = turtle.inspect()
|
||||
if h then results.forward = d end
|
||||
h, d = turtle.inspectUp()
|
||||
if h then results.up = d end
|
||||
h, d = turtle.inspectDown()
|
||||
if h then results.down = d end
|
||||
return results
|
||||
`);
|
||||
|
||||
if (scanResult && typeof scanResult === 'object') {
|
||||
this.turtle.processScanResults(scanResult);
|
||||
this.blocksDiscovered += Object.keys(scanResult).length;
|
||||
}
|
||||
|
||||
// Mine any valuable ores we find
|
||||
yield* this._checkAndMineOres();
|
||||
|
||||
// Move forward (digging through obstacles)
|
||||
const moved = await this.moveForward(true);
|
||||
if (!moved) {
|
||||
// Stuck - try to go over
|
||||
await this.moveUp(true);
|
||||
const movedOver = await this.moveForward(true);
|
||||
if (movedOver) {
|
||||
await this.moveDown(true);
|
||||
} else {
|
||||
// Really stuck, skip rest of row
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(100);
|
||||
}
|
||||
|
||||
// Alternate direction for serpentine pattern
|
||||
forward = !forward;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a target position safely using pathfinding with simple fallback
|
||||
*/
|
||||
async *_navigateToSafe(target) {
|
||||
try {
|
||||
const success = yield* this.navigateTo(target, { canMine: true, maxAttempts: 500 });
|
||||
if (success) return;
|
||||
} catch (error) {
|
||||
// Pathfinding failed, use simple navigation
|
||||
}
|
||||
|
||||
// Fallback: simple direct navigation
|
||||
yield* this._simpleNavigate(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple direct navigation (X -> Z -> Y)
|
||||
*/
|
||||
async *_simpleNavigate(target) {
|
||||
const maxAttempts = 300;
|
||||
let attempts = 0;
|
||||
let stuckCount = 0;
|
||||
|
||||
while (attempts < maxAttempts && !this.cancelled) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const dx = target.x - pos.x;
|
||||
const dy = target.y - pos.y;
|
||||
const dz = target.z - pos.z;
|
||||
|
||||
// Close enough (within 2 blocks)
|
||||
if (Math.abs(dx) <= 1 && Math.abs(dy) <= 1 && Math.abs(dz) <= 1) return;
|
||||
|
||||
attempts++;
|
||||
let moved = false;
|
||||
|
||||
// Move in X direction
|
||||
if (Math.abs(dx) > 1) {
|
||||
const facing = dx > 0 ? 1 : 3;
|
||||
await this.turnToFace(facing);
|
||||
moved = await this.moveForward(true);
|
||||
}
|
||||
// Then Z direction
|
||||
else if (Math.abs(dz) > 1) {
|
||||
const facing = dz > 0 ? 2 : 0;
|
||||
await this.turnToFace(facing);
|
||||
moved = await this.moveForward(true);
|
||||
}
|
||||
// Then Y direction
|
||||
else if (dy > 0) {
|
||||
moved = await this.moveUp(true);
|
||||
} else if (dy < 0) {
|
||||
moved = await this.moveDown(true);
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
stuckCount++;
|
||||
if (stuckCount > 5) {
|
||||
// Try going up and over
|
||||
await this.moveUp(true);
|
||||
await this.moveUp(true);
|
||||
stuckCount = 0;
|
||||
}
|
||||
} else {
|
||||
stuckCount = 0;
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
}
|
||||
|
||||
async *_checkAndMineOres() {
|
||||
const valuableOres = new Set([
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore',
|
||||
'minecraft:deepslate_diamond_ore', 'minecraft:deepslate_emerald_ore',
|
||||
'minecraft:gold_ore', 'minecraft:deepslate_gold_ore',
|
||||
'minecraft:iron_ore', 'minecraft:deepslate_iron_ore',
|
||||
'minecraft:lapis_ore', 'minecraft:deepslate_lapis_ore',
|
||||
'minecraft:redstone_ore', 'minecraft:deepslate_redstone_ore',
|
||||
'minecraft:copper_ore', 'minecraft:deepslate_copper_ore',
|
||||
'minecraft:coal_ore', 'minecraft:deepslate_coal_ore',
|
||||
'minecraft:ancient_debris',
|
||||
]);
|
||||
|
||||
const directions = [
|
||||
{ check: 'turtle.inspect()', dig: 'turtle.dig()' },
|
||||
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()' },
|
||||
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()' },
|
||||
];
|
||||
|
||||
for (const { check, dig } of directions) {
|
||||
if (this.cancelled) return;
|
||||
const result = await this.exec(`
|
||||
local hasBlock, data = ${check}
|
||||
if hasBlock then return {name = data.name} end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && valuableOres.has(result.name)) {
|
||||
await this.exec(dig);
|
||||
this.turtle.emit('blockMined', { blockType: result.name });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the spiral to the next chunk position.
|
||||
* Standard clockwise spiral: start at center, go E, then S, W, N with increasing lengths.
|
||||
* Ring 0: just center
|
||||
* Ring 1: side lengths = 1,2,2,1 (total 6 chunks)
|
||||
* Ring R: 8*R chunks around the ring
|
||||
*/
|
||||
_advanceSpiral() {
|
||||
if (this.spiralRing === 0) {
|
||||
// Center done, start ring 1 going East
|
||||
this.spiralRing = 1;
|
||||
this.spiralSide = 0;
|
||||
this.spiralStep = 0;
|
||||
this.spiralChunkX = this.startChunkX + 1;
|
||||
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Side directions: S, W, N, E
|
||||
const sideDirs = [
|
||||
{ dx: 0, dz: 1 }, // South
|
||||
{ dx: -1, dz: 0 }, // West
|
||||
{ dx: 0, dz: -1 }, // North
|
||||
{ dx: 1, dz: 0 }, // East
|
||||
];
|
||||
|
||||
this.spiralStep++;
|
||||
const sideLength = this.spiralRing * 2;
|
||||
|
||||
if (this.spiralStep >= sideLength) {
|
||||
this.spiralStep = 0;
|
||||
this.spiralSide++;
|
||||
|
||||
if (this.spiralSide >= 4) {
|
||||
// Done with this ring, move to next
|
||||
this.spiralRing++;
|
||||
this.spiralSide = 0;
|
||||
// Jump to start of new ring (one East, one North from current)
|
||||
this.spiralChunkX = this.startChunkX + this.spiralRing;
|
||||
this.spiralChunkZ = this.startChunkZ - (this.spiralRing - 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move in current side direction
|
||||
const dir = sideDirs[this.spiralSide];
|
||||
this.spiralChunkX += dir.dx;
|
||||
this.spiralChunkZ += dir.dz;
|
||||
}
|
||||
|
||||
_isTooFar() {
|
||||
const pos = this.turtle.position;
|
||||
const home = this.turtle.homePosition;
|
||||
if (!pos || !home) return false;
|
||||
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
|
||||
return dist > this.maxDistance;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
maxDistance: this.maxDistance,
|
||||
minFuel: this.minFuel,
|
||||
yLevel: this.yLevel,
|
||||
spiralRing: this.spiralRing,
|
||||
spiralSide: this.spiralSide,
|
||||
spiralStep: this.spiralStep,
|
||||
spiralChunkX: this.spiralChunkX,
|
||||
spiralChunkZ: this.spiralChunkZ,
|
||||
startChunkX: this.startChunkX,
|
||||
startChunkZ: this.startChunkZ,
|
||||
exploredChunks: [...this.exploredChunks],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
321
server/states/ExtractionState.js
Normal file
321
server/states/ExtractionState.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* ExtractionState - Smart ore extraction in a defined area
|
||||
*
|
||||
* Uses scanner peripherals to find ores, navigates to them,
|
||||
* mines them, and returns home when full or low on fuel.
|
||||
* Much more targeted than basic MiningState.
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const ORE_BLOCKS = new Set([
|
||||
'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:gold_ore',
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:redstone_ore',
|
||||
'minecraft:lapis_ore', 'minecraft:copper_ore',
|
||||
'minecraft:deepslate_coal_ore', 'minecraft:deepslate_iron_ore',
|
||||
'minecraft:deepslate_gold_ore', 'minecraft:deepslate_diamond_ore',
|
||||
'minecraft:deepslate_emerald_ore', 'minecraft:deepslate_redstone_ore',
|
||||
'minecraft:deepslate_lapis_ore', 'minecraft:deepslate_copper_ore',
|
||||
'minecraft:nether_gold_ore', 'minecraft:nether_quartz_ore',
|
||||
'minecraft:ancient_debris',
|
||||
]);
|
||||
|
||||
export class ExtractionState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
// Area bounds for extraction
|
||||
this.area = data.area || []; // [{x,y,z}, ...] coordinates to scan/mine
|
||||
this.minY = data.minY ?? -64;
|
||||
this.maxY = data.maxY ?? 320;
|
||||
this.oreTargets = []; // Known ore positions to mine
|
||||
this.scanPositions = []; // Positions still needing scanning
|
||||
this.remainingPositions = []; // Ore positions still to mine
|
||||
this.blocksMined = 0;
|
||||
this.oresExtracted = 0;
|
||||
this.scannerType = null;
|
||||
this.hasInitialized = false;
|
||||
|
||||
// If area not specified, generate from bounds
|
||||
if (this.area.length === 0 && data.bounds) {
|
||||
this._generateAreaFromBounds(data.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'extracting';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Extracting - ${this.oresExtracted} ores, ${this.remainingPositions.length} remaining`;
|
||||
}
|
||||
|
||||
_generateAreaFromBounds(bounds) {
|
||||
const { minX, minY, minZ, maxX, maxY, maxZ } = bounds;
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let z = minZ; z <= maxZ; z++) {
|
||||
this.area.push({ x, y, z });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting extraction (${this.area.length} area blocks)`);
|
||||
|
||||
// Detect scanner
|
||||
this.scannerType = await this._detectScanner();
|
||||
yield;
|
||||
|
||||
// Initialize: query DB for known ores in area, compute scan positions
|
||||
if (!this.hasInitialized) {
|
||||
await this._initializeScanPlan();
|
||||
this.hasInitialized = true;
|
||||
}
|
||||
yield;
|
||||
|
||||
while (!this.cancelled) {
|
||||
// Done if no more scan positions and no remaining ore positions
|
||||
if (this.scanPositions.length === 0 && this.remainingPositions.length === 0) {
|
||||
console.log(`[${this.turtle.id}] Extraction complete: ${this.oresExtracted} ores extracted`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety: check fuel
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < 500) {
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
console.log(`[${this.turtle.id}] Low fuel, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel', returnState: 'extracting', returnData: this.data });
|
||||
return;
|
||||
}
|
||||
}
|
||||
yield;
|
||||
|
||||
// Check inventory fullness
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'extracting', returnData: this.data });
|
||||
return;
|
||||
}
|
||||
yield;
|
||||
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) {
|
||||
await this._sleep(1000);
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 1: Navigate to scan positions and scan
|
||||
if (this.scanPositions.length > 0) {
|
||||
const scanTarget = this._findClosest(this.scanPositions, pos);
|
||||
if (scanTarget) {
|
||||
// Navigate to scan position
|
||||
const success = yield* this.navigateTo(scanTarget);
|
||||
if (success) {
|
||||
// Perform scan
|
||||
const scannedBlocks = await this._performScan();
|
||||
yield;
|
||||
|
||||
// Process results - find ores
|
||||
if (scannedBlocks) {
|
||||
for (const block of scannedBlocks) {
|
||||
if (this._isOre(block.name)) {
|
||||
this.remainingPositions.push({ x: block.x, y: block.y, z: block.z });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this scan position
|
||||
const idx = this.scanPositions.findIndex(p => p.x === scanTarget.x && p.y === scanTarget.y && p.z === scanTarget.z);
|
||||
if (idx >= 0) this.scanPositions.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
yield;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 2: Navigate to and mine ore positions
|
||||
if (this.remainingPositions.length > 0) {
|
||||
const oreTarget = this._findClosest(this.remainingPositions, pos);
|
||||
if (oreTarget) {
|
||||
// Navigate adjacent to ore
|
||||
const success = yield* this.navigateTo(oreTarget);
|
||||
yield;
|
||||
|
||||
if (success) {
|
||||
// Mine the ore (we should be at or adjacent to it)
|
||||
await this._mineAt(oreTarget);
|
||||
this.oresExtracted++;
|
||||
}
|
||||
|
||||
// Remove from remaining
|
||||
const idx = this.remainingPositions.findIndex(p => p.x === oreTarget.x && p.y === oreTarget.y && p.z === oreTarget.z);
|
||||
if (idx >= 0) this.remainingPositions.splice(idx, 1);
|
||||
}
|
||||
yield;
|
||||
}
|
||||
|
||||
await this._sleep(200);
|
||||
yield;
|
||||
}
|
||||
}
|
||||
|
||||
async _detectScanner() {
|
||||
const result = await this.exec(`
|
||||
local names = peripheral.getNames()
|
||||
for _, name in ipairs(names) do
|
||||
local types = {peripheral.getType(name)}
|
||||
for _, t in ipairs(types) do
|
||||
if t == "geoScanner" then return "geoScanner"
|
||||
elseif t == "universal_scanner" then return "universal_scanner"
|
||||
elseif t == "plethora:scanner" then return "plethora:scanner"
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
return result || 'native';
|
||||
}
|
||||
|
||||
async _initializeScanPlan() {
|
||||
// Query the DB for known ores in the area
|
||||
if (this.area.length > 0) {
|
||||
const bounds = this._getBoundingBox();
|
||||
try {
|
||||
const knownOres = this.turtle.server.db.getBlocksWithNameLike(
|
||||
bounds.minX, bounds.minY, bounds.minZ,
|
||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||
'%_ore'
|
||||
);
|
||||
for (const ore of knownOres) {
|
||||
this.remainingPositions.push({ x: ore.x, y: ore.y, z: ore.z });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[${this.turtle.id}] DB ore query failed:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scan positions (evenly spaced grid across area)
|
||||
if (this.scannerType !== 'native' && this.area.length > 0) {
|
||||
const bounds = this._getBoundingBox();
|
||||
const scanSpacing = this.scannerType === 'plethora:scanner' ? 16 : 32;
|
||||
|
||||
for (let x = bounds.minX; x <= bounds.maxX; x += scanSpacing) {
|
||||
for (let z = bounds.minZ; z <= bounds.maxZ; z += scanSpacing) {
|
||||
const y = Math.floor((bounds.minY + bounds.maxY) / 2);
|
||||
this.scanPositions.push({ x, y, z });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getBoundingBox() {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
for (const p of this.area) {
|
||||
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
|
||||
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
|
||||
minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
|
||||
}
|
||||
return { minX, minY, minZ, maxX, maxY, maxZ };
|
||||
}
|
||||
|
||||
_findClosest(positions, from) {
|
||||
let closest = null;
|
||||
let minDist = Infinity;
|
||||
for (const pos of positions) {
|
||||
const dist = Math.abs(pos.x - from.x) + Math.abs(pos.y - from.y) + Math.abs(pos.z - from.z);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = pos;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
_isOre(name) {
|
||||
return ORE_BLOCKS.has(name) || (name && name.endsWith('_ore'));
|
||||
}
|
||||
|
||||
async _performScan() {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return null;
|
||||
|
||||
let rawBlocks = null;
|
||||
|
||||
if (this.scannerType === 'geoScanner') {
|
||||
rawBlocks = await this.exec(`
|
||||
local scanner = peripheral.find("geoScanner")
|
||||
if scanner then
|
||||
local ok, blocks = pcall(scanner.scan, 16)
|
||||
if ok then return blocks end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
} else if (this.scannerType === 'universal_scanner') {
|
||||
rawBlocks = await this.exec(`
|
||||
local scanner = peripheral.find("universal_scanner")
|
||||
if scanner then
|
||||
local ok, blocks = pcall(scanner.scan, "block", 16)
|
||||
if ok then return blocks end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
} else if (this.scannerType === 'plethora:scanner') {
|
||||
rawBlocks = await this.exec(`
|
||||
local scanner = peripheral.find("plethora:scanner")
|
||||
if scanner then
|
||||
local ok, blocks = pcall(scanner.scan)
|
||||
if ok then return blocks end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
}
|
||||
|
||||
if (!rawBlocks || !Array.isArray(rawBlocks)) return null;
|
||||
|
||||
// Convert relative positions to absolute
|
||||
return rawBlocks
|
||||
.filter(b => b && b.name && b.name !== 'minecraft:air')
|
||||
.map(b => ({
|
||||
x: pos.x + (b.x || 0),
|
||||
y: pos.y + (b.y || 0),
|
||||
z: pos.z + (b.z || 0),
|
||||
name: b.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async _mineAt(target) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const dx = target.x - pos.x;
|
||||
const dy = target.y - pos.y;
|
||||
const dz = target.z - pos.z;
|
||||
|
||||
// We should be at or adjacent - mine in appropriate direction
|
||||
if (dy === 1 || (dy === 0 && dx === 0 && dz === 0)) {
|
||||
await this.exec('turtle.digUp()');
|
||||
} else if (dy === -1) {
|
||||
await this.exec('turtle.digDown()');
|
||||
} else {
|
||||
// Face the block and dig
|
||||
let targetFacing;
|
||||
if (dx === 1) targetFacing = 1;
|
||||
else if (dx === -1) targetFacing = 3;
|
||||
else if (dz === 1) targetFacing = 2;
|
||||
else if (dz === -1) targetFacing = 0;
|
||||
else targetFacing = this.turtle.facing;
|
||||
|
||||
await this.turnToFace(targetFacing);
|
||||
await this.exec('turtle.dig()');
|
||||
}
|
||||
|
||||
this.blocksMined++;
|
||||
this.turtle.emit('blockMined', { blockType: 'ore' });
|
||||
}
|
||||
}
|
||||
124
server/states/FarmingState.js
Normal file
124
server/states/FarmingState.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* FarmingState - Automated farming (plant, harvest, replant)
|
||||
* Works with wheat, carrots, potatoes, beetroot, etc.
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const CROP_SEEDS = {
|
||||
'minecraft:wheat': 'minecraft:wheat_seeds',
|
||||
'minecraft:carrots': 'minecraft:carrot',
|
||||
'minecraft:potatoes': 'minecraft:potato',
|
||||
'minecraft:beetroots': 'minecraft:beetroot_seeds',
|
||||
};
|
||||
|
||||
export class FarmingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.area = data.area || null; // {minX, minZ, maxX, maxZ, y}
|
||||
this.harvestCount = 0;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'farming';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Farming - ${this.harvestCount} harvested`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting farming operation`);
|
||||
|
||||
if (!this.area) {
|
||||
console.log(`[${this.turtle.id}] No farming area defined`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Farm in a zigzag pattern over the area
|
||||
const { minX, minZ, maxX, maxZ, y } = this.area;
|
||||
let direction = 1; // 1 = positive Z, -1 = negative Z
|
||||
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const zStart = direction === 1 ? minZ : maxZ;
|
||||
const zEnd = direction === 1 ? maxZ : minZ;
|
||||
const zStep = direction;
|
||||
|
||||
for (let z = zStart; direction === 1 ? z <= zEnd : z >= zEnd; z += zStep) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
// Navigate to position above the crop
|
||||
const target = { x, y: y + 1, z };
|
||||
const navGen = this.navigateTo(target);
|
||||
for await (const _ of navGen) {
|
||||
if (this.cancelled) return;
|
||||
yield;
|
||||
}
|
||||
|
||||
// Check the block below
|
||||
const blockBelow = await this.exec(`
|
||||
local hasBlock, data = turtle.inspectDown()
|
||||
if hasBlock then
|
||||
return {name = data.name, state = data.state}
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (blockBelow) {
|
||||
// Check if crop is mature (age = 7 for most crops)
|
||||
const isMature = blockBelow.state && blockBelow.state.age === 7;
|
||||
|
||||
if (isMature) {
|
||||
// Harvest
|
||||
await this.exec('turtle.digDown()');
|
||||
this.harvestCount++;
|
||||
|
||||
// Replant
|
||||
const seedName = CROP_SEEDS[blockBelow.name];
|
||||
if (seedName) {
|
||||
await this.exec(`
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == "${seedName}" then
|
||||
turtle.select(slot)
|
||||
turtle.placeDown()
|
||||
turtle.select(1)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
direction *= -1; // Zigzag
|
||||
}
|
||||
|
||||
// Done farming one pass - check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
this.turtle.setState('goHome', {
|
||||
reason: 'inventory_full',
|
||||
returnState: 'farming',
|
||||
returnData: this.data
|
||||
});
|
||||
} else {
|
||||
// Start another pass
|
||||
console.log(`[${this.turtle.id}] Farm pass complete, ${this.harvestCount} harvested`);
|
||||
yield* this.act(); // Loop
|
||||
}
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
area: this.area,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
89
server/states/GoHomeState.js
Normal file
89
server/states/GoHomeState.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* GoHomeState - Navigate the turtle back to its home position
|
||||
* After arrival, optionally transitions to dump inventory or another state
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class GoHomeState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.reason = data.reason || 'manual'; // 'low_fuel', 'inventory_full', 'too_far', 'manual'
|
||||
this.returnState = data.returnState || null; // State to return to after going home
|
||||
this.returnData = data.returnData || {};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'returning';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Returning home (${this.reason})`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
const home = this.turtle.homePosition;
|
||||
if (!home) {
|
||||
console.log(`[${this.turtle.id}] No home position set, going idle`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Going home: reason=${this.reason}`);
|
||||
|
||||
// Navigate to home using D* Lite
|
||||
const navGen = this.navigateTo(home, { canMine: true });
|
||||
for await (const _ of navGen) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
// Check fuel during return
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < 100) {
|
||||
// Emergency refuel
|
||||
await this.tryRefuel();
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
// Arrived home
|
||||
console.log(`[${this.turtle.id}] Arrived home`);
|
||||
|
||||
// If we came home to dump inventory, do so
|
||||
if (this.reason === 'inventory_full') {
|
||||
this.turtle.setState('dumpInventory', {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If we came home to refuel, try refueling
|
||||
if (this.reason === 'low_fuel') {
|
||||
this.turtle.setState('refueling', {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's a return state, go to it
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: go idle
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
reason: this.reason,
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
32
server/states/IdleState.js
Normal file
32
server/states/IdleState.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* IdleState - Turtle is idle, waiting for commands
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class IdleState extends BaseState {
|
||||
get name() {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return 'Idle - waiting for commands';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
// Periodic fuel check while idle (no scanning — avoids rotating turtle)
|
||||
// Errors are caught here so they don't bubble up and trigger
|
||||
// the _runStateLoop retry mechanism (which would create an
|
||||
// infinite idle→timeout→idle loop).
|
||||
while (!this.cancelled) {
|
||||
try {
|
||||
await this.checkFuel();
|
||||
} catch (e) {
|
||||
// Fuel check failed (likely command timeout) — not critical in idle
|
||||
// Just wait longer before trying again
|
||||
await this._sleep(30000);
|
||||
}
|
||||
yield;
|
||||
await this._sleep(10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
232
server/states/MiningState.js
Normal file
232
server/states/MiningState.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* MiningState - Autonomous mining with intelligent exploration
|
||||
* Mines ores, explores caves, manages inventory
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const VALUABLE_BLOCKS = new Set([
|
||||
'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:gold_ore',
|
||||
'minecraft:diamond_ore', 'minecraft:emerald_ore', 'minecraft:redstone_ore',
|
||||
'minecraft:lapis_ore', 'minecraft:copper_ore',
|
||||
'minecraft:deepslate_coal_ore', 'minecraft:deepslate_iron_ore',
|
||||
'minecraft:deepslate_gold_ore', 'minecraft:deepslate_diamond_ore',
|
||||
'minecraft:deepslate_emerald_ore', 'minecraft:deepslate_redstone_ore',
|
||||
'minecraft:deepslate_lapis_ore', 'minecraft:deepslate_copper_ore',
|
||||
'minecraft:nether_gold_ore', 'minecraft:nether_quartz_ore',
|
||||
'minecraft:ancient_debris',
|
||||
]);
|
||||
|
||||
export class MiningState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.maxDistance = data.maxDistance || 200;
|
||||
this.minFuel = data.minFuel || 500;
|
||||
this.blocksMined = 0;
|
||||
this.oresFound = 0;
|
||||
this.stuckCounter = 0;
|
||||
this.visitedPositions = new Set();
|
||||
this.miningArea = data.miningArea || null; // Optional bounded area
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'mining';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Mining - ${this.blocksMined} mined, ${this.oresFound} ores found`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting mining operation`);
|
||||
|
||||
while (!this.cancelled) {
|
||||
try {
|
||||
// Safety checks
|
||||
const fuel = await this.checkFuel();
|
||||
if (fuel !== 'unlimited' && fuel < this.minFuel) {
|
||||
console.log(`[${this.turtle.id}] Low fuel (${fuel}), attempting refuel`);
|
||||
const refueled = await this.tryRefuel();
|
||||
if (!refueled) {
|
||||
console.log(`[${this.turtle.id}] Cannot refuel, going home`);
|
||||
this.turtle.setState('goHome', { reason: 'low_fuel' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check inventory
|
||||
const isFull = await this.isInventoryFull();
|
||||
if (isFull) {
|
||||
console.log(`[${this.turtle.id}] Inventory full, going home to dump`);
|
||||
this.turtle.setState('goHome', { reason: 'inventory_full', returnState: 'mining', returnData: this.data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check distance from home
|
||||
if (this._isTooFar()) {
|
||||
console.log(`[${this.turtle.id}] Too far from home, returning`);
|
||||
this.turtle.setState('goHome', { reason: 'too_far' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark current position as visited
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
this.visitedPositions.add(`${pos.x},${pos.y},${pos.z}`);
|
||||
}
|
||||
|
||||
// Scan surroundings for ores
|
||||
const scanResult = await this.scanSurroundings();
|
||||
|
||||
// Mine any ores found in surroundings
|
||||
yield* this._mineAdjacentOres();
|
||||
|
||||
// Explore step - try to find new areas
|
||||
yield* this._exploreStep();
|
||||
|
||||
} catch (error) {
|
||||
const isTimeout = error.message?.includes('timed out');
|
||||
if (isTimeout) {
|
||||
console.warn(`[${this.turtle.id}] Mining exec timeout, will retry next iteration`);
|
||||
await this._sleep(3000);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
await this._sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mine ores in adjacent positions
|
||||
*/
|
||||
async *_mineAdjacentOres() {
|
||||
// Check all 6 directions for ores
|
||||
const directions = [
|
||||
{ check: 'turtle.inspect()', dig: 'turtle.dig()', dir: 'forward' },
|
||||
{ check: 'turtle.inspectUp()', dig: 'turtle.digUp()', dir: 'up' },
|
||||
{ check: 'turtle.inspectDown()', dig: 'turtle.digDown()', dir: 'down' },
|
||||
];
|
||||
|
||||
for (const { check, dig, dir } of directions) {
|
||||
if (this.cancelled) return;
|
||||
|
||||
const result = await this.exec(`
|
||||
local hasBlock, data = ${check}
|
||||
if hasBlock then
|
||||
return {name = data.name, metadata = data.metadata or 0}
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && VALUABLE_BLOCKS.has(result.name)) {
|
||||
console.log(`[${this.turtle.id}] Found ore: ${result.name} (${dir})`);
|
||||
await this.exec(dig);
|
||||
this.blocksMined++;
|
||||
this.oresFound++;
|
||||
|
||||
// Report mined block to server
|
||||
this.turtle.emit('blockMined', { blockType: result.name, direction: dir });
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore step - move to unvisited positions, favor horizontal movement
|
||||
*/
|
||||
async *_exploreStep() {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const r = Math.random() * 100;
|
||||
|
||||
// 75%: horizontal exploration
|
||||
if (r < 75) {
|
||||
// Try to find an unvisited forward direction
|
||||
let moved = false;
|
||||
|
||||
// Try current facing first
|
||||
const forwardPos = this.turtle.getBlockPositionInDirection('forward');
|
||||
if (forwardPos && !this.visitedPositions.has(`${forwardPos.x},${forwardPos.y},${forwardPos.z}`)) {
|
||||
const success = await this.moveForward(true);
|
||||
if (success) {
|
||||
this.stuckCounter = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If forward is visited or blocked, try other directions
|
||||
if (!moved) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await this.exec('turtle.turnRight()');
|
||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
||||
|
||||
const newForwardPos = this.turtle.getBlockPositionInDirection('forward');
|
||||
if (newForwardPos && !this.visitedPositions.has(`${newForwardPos.x},${newForwardPos.y},${newForwardPos.z}`)) {
|
||||
const success = await this.moveForward(true);
|
||||
if (success) {
|
||||
this.stuckCounter = 0;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
// All directions visited, just move forward
|
||||
const success = await this.moveForward(true);
|
||||
if (!success) {
|
||||
this.stuckCounter++;
|
||||
await this.exec('turtle.turnRight()');
|
||||
this.turtle.facing = (this.turtle.facing + 1) % 4;
|
||||
}
|
||||
}
|
||||
|
||||
// 15%: go down (if not too deep)
|
||||
} else if (r < 90 && pos.y > -50) {
|
||||
const downPos = { x: pos.x, y: pos.y - 1, z: pos.z };
|
||||
if (!this.visitedPositions.has(`${downPos.x},${downPos.y},${downPos.z}`)) {
|
||||
await this.moveDown(true);
|
||||
}
|
||||
|
||||
// 10%: go up
|
||||
} else {
|
||||
const upPos = { x: pos.x, y: pos.y + 1, z: pos.z };
|
||||
if (!this.visitedPositions.has(`${upPos.x},${upPos.y},${upPos.z}`)) {
|
||||
await this.moveUp(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle being stuck
|
||||
if (this.stuckCounter > 5) {
|
||||
console.log(`[${this.turtle.id}] Stuck! Trying to escape`);
|
||||
await this.moveUp(true);
|
||||
this.stuckCounter = 0;
|
||||
}
|
||||
|
||||
yield;
|
||||
}
|
||||
|
||||
_isTooFar() {
|
||||
const pos = this.turtle.position;
|
||||
const home = this.turtle.homePosition;
|
||||
if (!pos || !home) return false;
|
||||
|
||||
const dist = Math.abs(pos.x - home.x) + Math.abs(pos.y - home.y) + Math.abs(pos.z - home.z);
|
||||
return dist > this.maxDistance;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
maxDistance: this.maxDistance,
|
||||
minFuel: this.minFuel,
|
||||
miningArea: this.miningArea,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
78
server/states/MovingState.js
Normal file
78
server/states/MovingState.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* MovingState - Navigate the turtle to a target position using D* Lite
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class MovingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.target = data.target; // {x, y, z}
|
||||
this.onArrival = data.onArrival || null; // Callback/next state when arrived
|
||||
this.canMine = data.canMine !== false;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'moving';
|
||||
}
|
||||
|
||||
get description() {
|
||||
const pos = this.target;
|
||||
if (pos) {
|
||||
return `Moving to (${pos.x}, ${pos.y}, ${pos.z})`;
|
||||
}
|
||||
return 'Moving to target';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
if (!this.target) {
|
||||
console.log(`[${this.turtle.id}] MovingState: No target set`);
|
||||
this.turtle.setState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Moving to ${this.target.x},${this.target.y},${this.target.z}`);
|
||||
|
||||
// Use the navigateTo generator from BaseState
|
||||
const nav = this.navigateTo(this.target, { canMine: this.canMine });
|
||||
|
||||
for await (const _ of nav) {
|
||||
if (this.cancelled) return;
|
||||
yield;
|
||||
}
|
||||
|
||||
// Check if we actually arrived
|
||||
const pos = this.turtle.position;
|
||||
if (pos) {
|
||||
const { Point } = await import('../pathfinding/index.js');
|
||||
const current = Point.from(pos);
|
||||
const goal = Point.from(this.target);
|
||||
|
||||
if (current.distanceTo(goal) <= 1) {
|
||||
console.log(`[${this.turtle.id}] Arrived at destination`);
|
||||
|
||||
if (this.onArrival === 'idle') {
|
||||
this.turtle.setState('idle');
|
||||
} else if (this.onArrival === 'mine') {
|
||||
this.turtle.setState('mining', this.data);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Failed to reach destination`);
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
target: this.target,
|
||||
onArrival: this.onArrival,
|
||||
canMine: this.canMine,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
126
server/states/RefuelingState.js
Normal file
126
server/states/RefuelingState.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* RefuelingState - Refuel the turtle from inventory or nearby containers
|
||||
* Prioritizes fuel sources: lava buckets > coal blocks > coal > logs > sticks
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
const FUEL_PRIORITY = [
|
||||
'minecraft:lava_bucket',
|
||||
'minecraft:coal_block',
|
||||
'minecraft:charcoal',
|
||||
'minecraft:coal',
|
||||
'minecraft:oak_log', 'minecraft:birch_log', 'minecraft:spruce_log',
|
||||
'minecraft:dark_oak_log', 'minecraft:jungle_log', 'minecraft:acacia_log',
|
||||
'minecraft:mangrove_log', 'minecraft:cherry_log',
|
||||
'minecraft:oak_planks', 'minecraft:birch_planks', 'minecraft:spruce_planks',
|
||||
'minecraft:stick',
|
||||
];
|
||||
|
||||
export class RefuelingState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.returnState = data.returnState || null;
|
||||
this.returnData = data.returnData || {};
|
||||
this.targetFuel = data.targetFuel || 5000;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'refueling';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return 'Refueling turtle';
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting refueling, target: ${this.targetFuel}`);
|
||||
|
||||
// First try to refuel from own inventory using priority order
|
||||
const refueled = await this.exec(`
|
||||
local fuelPriority = ${this._luaFuelTable()}
|
||||
local targetFuel = ${this.targetFuel}
|
||||
local refueled = false
|
||||
|
||||
for _, fuelName in ipairs(fuelPriority) do
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item and item.name == fuelName then
|
||||
turtle.select(slot)
|
||||
turtle.refuel()
|
||||
refueled = true
|
||||
if turtle.getFuelLevel() >= targetFuel then
|
||||
turtle.select(1)
|
||||
return {success = true, fuel = turtle.getFuelLevel()}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
turtle.select(1)
|
||||
return {success = refueled, fuel = turtle.getFuelLevel()}
|
||||
`);
|
||||
|
||||
if (refueled) {
|
||||
this.turtle.fuel = refueled.fuel;
|
||||
console.log(`[${this.turtle.id}] Refueled to ${refueled.fuel}`);
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Try to refuel from nearby containers (chests, barrels)
|
||||
if (!refueled || !refueled.success || refueled.fuel < this.targetFuel) {
|
||||
console.log(`[${this.turtle.id}] Trying nearby containers for fuel`);
|
||||
|
||||
const containerResult = await this.exec(`
|
||||
local directions = {"front", "top", "bottom"}
|
||||
local suckFns = {turtle.suck, turtle.suckUp, turtle.suckDown}
|
||||
|
||||
for i, dir in ipairs(directions) do
|
||||
-- Try to suck items from container
|
||||
for attempt = 1, 16 do
|
||||
if suckFns[i]() then
|
||||
-- Check if it's fuel
|
||||
local slot = turtle.getSelectedSlot()
|
||||
if turtle.refuel(0) then
|
||||
turtle.refuel()
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {fuel = turtle.getFuelLevel()}
|
||||
`);
|
||||
|
||||
if (containerResult) {
|
||||
this.turtle.fuel = containerResult.fuel;
|
||||
}
|
||||
}
|
||||
|
||||
yield;
|
||||
|
||||
// Transition to next state
|
||||
if (this.returnState) {
|
||||
this.turtle.setState(this.returnState, this.returnData);
|
||||
} else {
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
_luaFuelTable() {
|
||||
const items = FUEL_PRIORITY.map(name => `"${name}"`).join(', ');
|
||||
return `{${items}}`;
|
||||
}
|
||||
|
||||
getRecoveryData() {
|
||||
return {
|
||||
...super.getRecoveryData(),
|
||||
data: {
|
||||
returnState: this.returnState,
|
||||
returnData: this.returnData,
|
||||
targetFuel: this.targetFuel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
184
server/states/ScanState.js
Normal file
184
server/states/ScanState.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* ScanState - Systematic scanning of surroundings using peripheral scanners
|
||||
*
|
||||
* Supports multiple scanner types:
|
||||
* - geoScanner (Advanced Peripherals)
|
||||
* - universal_scanner
|
||||
* - plethora:scanner (Plethora mod)
|
||||
* - Native turtle.inspect() fallback (6-directional scan)
|
||||
*/
|
||||
import { BaseState } from './BaseState.js';
|
||||
|
||||
export class ScanState extends BaseState {
|
||||
constructor(turtle, data = {}) {
|
||||
super(turtle, data);
|
||||
this.scanRadius = data.scanRadius || 16;
|
||||
this.blocksScanned = 0;
|
||||
this.scannerType = null;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'scanning';
|
||||
}
|
||||
|
||||
get description() {
|
||||
return `Scanning (${this.scannerType || 'detecting'}) - ${this.blocksScanned} blocks`;
|
||||
}
|
||||
|
||||
async *act() {
|
||||
console.log(`[${this.turtle.id}] Starting scan`);
|
||||
|
||||
// Detect available scanner peripheral
|
||||
this.scannerType = await this._detectScanner();
|
||||
yield;
|
||||
|
||||
if (this.scannerType === 'geoScanner') {
|
||||
yield* this._geoScan();
|
||||
} else if (this.scannerType === 'universal_scanner') {
|
||||
yield* this._universalScan();
|
||||
} else if (this.scannerType === 'plethora:scanner') {
|
||||
yield* this._plethuraScan();
|
||||
} else {
|
||||
// Fallback to native inspect-based scanning
|
||||
yield* this._nativeScan();
|
||||
}
|
||||
|
||||
console.log(`[${this.turtle.id}] Scan complete: ${this.blocksScanned} blocks`);
|
||||
this.turtle.setState('idle');
|
||||
}
|
||||
|
||||
async _detectScanner() {
|
||||
// Check for geoScanner
|
||||
const geoResult = await this.exec(`
|
||||
local names = peripheral.getNames()
|
||||
for _, name in ipairs(names) do
|
||||
local types = {peripheral.getType(name)}
|
||||
for _, t in ipairs(types) do
|
||||
if t == "geoScanner" then return "geoScanner" end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
if (geoResult) return geoResult;
|
||||
|
||||
// Check for universal_scanner
|
||||
const uniResult = await this.exec(`
|
||||
local names = peripheral.getNames()
|
||||
for _, name in ipairs(names) do
|
||||
local types = {peripheral.getType(name)}
|
||||
for _, t in ipairs(types) do
|
||||
if t == "universal_scanner" then return "universal_scanner" end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
if (uniResult) return uniResult;
|
||||
|
||||
// Check for plethora scanner
|
||||
const plResult = await this.exec(`
|
||||
local names = peripheral.getNames()
|
||||
for _, name in ipairs(names) do
|
||||
local types = {peripheral.getType(name)}
|
||||
for _, t in ipairs(types) do
|
||||
if t == "plethora:scanner" then return "plethora:scanner" end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
`);
|
||||
if (plResult) return plResult;
|
||||
|
||||
return 'native';
|
||||
}
|
||||
|
||||
async *_geoScan() {
|
||||
// Wait for cooldown
|
||||
yield;
|
||||
await this._sleep(500);
|
||||
|
||||
const result = await this.exec(`
|
||||
local scanner = peripheral.find("geoScanner")
|
||||
if not scanner then return nil end
|
||||
local ok, blocks = pcall(scanner.scan, ${this.scanRadius})
|
||||
if ok then return blocks end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && Array.isArray(result)) {
|
||||
this._processScannedBlocks(result);
|
||||
}
|
||||
yield;
|
||||
}
|
||||
|
||||
async *_universalScan() {
|
||||
yield;
|
||||
await this._sleep(500);
|
||||
|
||||
const result = await this.exec(`
|
||||
local scanner = peripheral.find("universal_scanner")
|
||||
if not scanner then return nil end
|
||||
local ok, blocks = pcall(scanner.scan, "block", ${this.scanRadius})
|
||||
if ok then return blocks end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && Array.isArray(result)) {
|
||||
this._processScannedBlocks(result);
|
||||
}
|
||||
yield;
|
||||
}
|
||||
|
||||
async *_plethuraScan() {
|
||||
yield;
|
||||
|
||||
const result = await this.exec(`
|
||||
local scanner = peripheral.find("plethora:scanner")
|
||||
if not scanner then return nil end
|
||||
local ok, blocks = pcall(scanner.scan)
|
||||
if ok then return blocks end
|
||||
return nil
|
||||
`);
|
||||
|
||||
if (result && Array.isArray(result)) {
|
||||
this._processScannedBlocks(result);
|
||||
}
|
||||
yield;
|
||||
}
|
||||
|
||||
async *_nativeScan() {
|
||||
// Fallback: scan all 6 directions using turtle.inspect
|
||||
const scanResult = await this.scanSurroundings();
|
||||
if (scanResult) {
|
||||
this.blocksScanned += Object.keys(scanResult).length;
|
||||
}
|
||||
yield;
|
||||
}
|
||||
|
||||
_processScannedBlocks(blocks) {
|
||||
const pos = this.turtle.position;
|
||||
if (!pos) return;
|
||||
|
||||
const discoveredBlocks = [];
|
||||
for (const block of blocks) {
|
||||
if (!block || !block.name) continue;
|
||||
if (block.name === 'minecraft:air') continue;
|
||||
if (block.x === 0 && block.y === 0 && block.z === 0) continue;
|
||||
|
||||
discoveredBlocks.push({
|
||||
x: pos.x + (block.x || 0),
|
||||
y: pos.y + (block.y || 0),
|
||||
z: pos.z + (block.z || 0),
|
||||
name: block.name,
|
||||
metadata: block.metadata || 0,
|
||||
state: block.state || {},
|
||||
tags: block.tags || {},
|
||||
discoveredBy: this.turtle.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.blocksScanned = discoveredBlocks.length;
|
||||
|
||||
if (discoveredBlocks.length > 0) {
|
||||
this.turtle.emit('blocksDiscovered', discoveredBlocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
server/states/index.js
Normal file
13
server/states/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export { BaseState } from './BaseState.js';
|
||||
export { IdleState } from './IdleState.js';
|
||||
export { MovingState } from './MovingState.js';
|
||||
export { MiningState } from './MiningState.js';
|
||||
export { ExploringState } from './ExploringState.js';
|
||||
export { GoHomeState } from './GoHomeState.js';
|
||||
export { RefuelingState } from './RefuelingState.js';
|
||||
export { DumpInventoryState } from './DumpInventoryState.js';
|
||||
export { FarmingState } from './FarmingState.js';
|
||||
export { ScanState } from './ScanState.js';
|
||||
export { ExtractionState } from './ExtractionState.js';
|
||||
export { BuildingState } from './BuildingState.js';
|
||||
export { AutocraftState } from './AutocraftState.js';
|
||||
1382
turtle.lua
1382
turtle.lua
File diff suppressed because it is too large
Load Diff
891
webbridge.lua
891
webbridge.lua
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user