Compare commits
215 Commits
5a4fd000fe
...
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 |
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?**
|
**GPS not working?**
|
||||||
- Set up 4 GPS host computers at high altitude
|
- Set up 4 GPS host computers at high altitude
|
||||||
- Run `gps host X Y Z` on each (with their coordinates)
|
- Run `gps host X Y Z` on each (with their coordinates)
|
||||||
|
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||||
|
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||||
|
|
||||||
|
## Pathfinding
|
||||||
|
|
||||||
|
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||||
|
You can trigger it from the web UI via eval commands:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Navigate to coordinates (avoids obstacles)
|
||||||
|
_pathfind.goto(100, 65, -200)
|
||||||
|
|
||||||
|
-- Navigate with block digging enabled
|
||||||
|
_pathfind.goto(100, 65, -200, { dig = true })
|
||||||
|
|
||||||
|
-- Go home
|
||||||
|
_pathfind.goHome()
|
||||||
|
|
||||||
|
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||||
|
_pathfind.face(3)
|
||||||
|
|
||||||
|
-- Get current heading name
|
||||||
|
_pathfind.headingName() -- "east"
|
||||||
|
```
|
||||||
|
|
||||||
|
The pathfinder:
|
||||||
|
- Uses GPS for initial position, then tracks movement locally
|
||||||
|
- Auto-detects heading on startup via GPS triangulation
|
||||||
|
- Handles obstacles by trying to go over/around them
|
||||||
|
- Supports optional block digging for clearing paths
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|||||||
130
README.md
130
README.md
@@ -52,54 +52,118 @@ A comprehensive full-stack web application for monitoring and controlling Comput
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ installed on your computer
|
- **Docker and Docker Compose** installed on your computer
|
||||||
- Minecraft with ComputerCraft mod installed
|
- **Minecraft** with ComputerCraft mod installed
|
||||||
- At least one turtle with a wireless modem
|
- At least one **turtle with a wireless modem**
|
||||||
- A computer in Minecraft to run the bridge script
|
- A **computer in Minecraft** to run the bridge script
|
||||||
|
|
||||||
### Installation
|
### Installation (Docker - Recommended)
|
||||||
|
|
||||||
1. **Clone or download this project**
|
1. **Clone or download this project**
|
||||||
|
|
||||||
2. **Install Server Dependencies**
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
git clone <repository-url>
|
||||||
npm install
|
cd remoteturtle
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install Client Dependencies**
|
2. **Start with Docker Compose**
|
||||||
```bash
|
```bash
|
||||||
cd client
|
docker-compose up -d
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Start the Backend Server**
|
This will automatically:
|
||||||
```bash
|
- Build and start the backend server on port 3001
|
||||||
cd server
|
- Build and start the frontend on port 3000
|
||||||
npm start
|
- Create a persistent SQLite database volume
|
||||||
```
|
- Set up networking between containers
|
||||||
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
|
- WebSocket: ws://localhost:3002
|
||||||
|
|
||||||
5. **Start the React Frontend**
|
4. **Set up Minecraft Bridge Computer**
|
||||||
```bash
|
|
||||||
cd client
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
Frontend will open at: http://localhost:3000
|
|
||||||
|
|
||||||
6. **Set up Minecraft Bridge**
|
In Minecraft, place a computer with a wireless modem and run:
|
||||||
- In Minecraft, place a computer with a wireless modem
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
- Copy `webbridge.lua` to the computer
|
||||||
- Edit the `SERVER_URL` in webbridge.lua to point to your server
|
- Edit the `SERVER_URL` to point to your Docker host
|
||||||
(If running locally, use `http://localhost:3001`)
|
- If Minecraft is on the same machine: `http://host.docker.internal:3001`
|
||||||
|
- If on different machine: `http://<your-ip>:3001`
|
||||||
- Run: `webbridge`
|
- Run: `webbridge`
|
||||||
|
|
||||||
7. **Deploy Turtles**
|
5. **Deploy Auto-Updating Turtles**
|
||||||
- Copy `turtle.lua` to your turtles
|
|
||||||
- Equip wireless modems on turtles
|
On each turtle with a wireless modem, run:
|
||||||
- Run: `turtle`
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs an auto-update system that:
|
||||||
|
- Downloads the latest `turtle.lua` on boot
|
||||||
|
- Falls back to cached version if download fails
|
||||||
|
- Keeps turtles updated automatically
|
||||||
|
|
||||||
|
6. **Optional: Deploy Pocket Computer Control**
|
||||||
|
|
||||||
|
For wireless pocket computer control:
|
||||||
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a wireless control interface for managing turtles from anywhere in-game
|
||||||
|
|
||||||
|
### Docker Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Stop and remove volumes (resets database)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: Manual Installation (Without Docker)
|
||||||
|
|
||||||
|
If you prefer not to use Docker:
|
||||||
|
|
||||||
|
1. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
cd server && npm install
|
||||||
|
cd ../client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Backend**
|
||||||
|
```bash
|
||||||
|
cd server && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start Frontend**
|
||||||
|
```bash
|
||||||
|
cd client && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Follow steps 4-6 from the Docker installation above
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -4,37 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
background: #2c2c2c;
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-bottom: 2px solid #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button:hover {
|
|
||||||
background: #475569;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
@@ -57,21 +27,13 @@
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content.map .panel-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.panel .map-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0a0e1a;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -81,34 +43,62 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 2px solid #334155;
|
border-bottom: 3px solid #1a1a1a;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button {
|
.panel-tabs button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
background: #334155;
|
background: #5a5a5a;
|
||||||
color: #94a3b8;
|
color: #b0b0b0;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button:hover {
|
.panel-tabs button:hover {
|
||||||
background: #475569;
|
background: #6b6b6b;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs button.active {
|
.panel-tabs button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Cross-link Button === */
|
||||||
|
.cross-link-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
background: #2e4a8b;
|
||||||
|
color: #55ffff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 2px 0 #4466bb, inset 0 -2px 0 #1a2a66;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cross-link-btn:hover {
|
||||||
|
background: #3e5a9b;
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-content-wrapper {
|
.panel-content-wrapper {
|
||||||
@@ -125,16 +115,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1e293b;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #475569;
|
background: #5a5a5a;
|
||||||
border-radius: 4px;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #64748b;
|
background: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-Responsive Design */
|
/* Mobile-Responsive Design */
|
||||||
@@ -149,28 +139,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Mobile: Force single view mode with tabs */
|
|
||||||
.view-controls {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.split {
|
.app-content.split {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -191,17 +162,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* Small mobile: Optimize spacing */
|
|
||||||
.view-controls {
|
|
||||||
padding: 0.5rem;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 0.625rem 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.split .map-container {
|
.app-content.split .map-container {
|
||||||
height: 40vh;
|
height: 40vh;
|
||||||
}
|
}
|
||||||
@@ -270,10 +230,6 @@
|
|||||||
|
|
||||||
/* High contrast mode */
|
/* High contrast mode */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
.view-controls button {
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.turtle-card {
|
.turtle-card {
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,19 @@ import './App.css';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const connect = useTurtleStore((state) => state.connect);
|
const connect = useTurtleStore((state) => state.connect);
|
||||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
|
||||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
|
|
||||||
|
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
const renderPanelContent = () => {
|
||||||
const apiUrl = 'http://localhost:3001';
|
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
|
||||||
const wsUrl = 'ws://localhost:3002';
|
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||||
|
|
||||||
switch (panelTab) {
|
switch (panelTab) {
|
||||||
case 'control':
|
case 'control':
|
||||||
@@ -47,36 +48,21 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="view-controls">
|
<div className="app-content split">
|
||||||
<button
|
|
||||||
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">
|
<div className="map-container">
|
||||||
<Map3D />
|
<Map3D />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{(view === 'split' || view === 'panel') && (
|
|
||||||
<div className="panel-container">
|
<div className="panel-container">
|
||||||
<div className="panel-tabs">
|
<div className="panel-tabs">
|
||||||
|
<a
|
||||||
|
href={inventoryDashboardUrl}
|
||||||
|
className="cross-link-btn"
|
||||||
|
title="Open Inventory Manager Dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
📦 Inventory
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
className={panelTab === 'control' ? 'active' : ''}
|
className={panelTab === 'control' ? 'active' : ''}
|
||||||
onClick={() => setPanelTab('control')}
|
onClick={() => setPanelTab('control')}
|
||||||
@@ -131,7 +117,6 @@ function App() {
|
|||||||
{renderPanelContent()}
|
{renderPanelContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,44 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useTurtleStore } from '../store/turtleStore';
|
import { useTurtleStore } from '../store/turtleStore';
|
||||||
import './ControlPanel.css';
|
import './ControlPanel.css';
|
||||||
|
|
||||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||||
const mode = turtle.mode || 'unknown';
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||||
const inventoryCount = turtle.inventory?.length || 0;
|
const inventoryCount = Array.isArray(turtle.inventory)
|
||||||
|
? turtle.inventory.length
|
||||||
|
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
const modeColors = {
|
const modeColors = {
|
||||||
mining: '#4ade80',
|
mining: '#55ff55',
|
||||||
exploring: '#60a5fa',
|
exploring: '#55ffff',
|
||||||
returning: '#f59e0b',
|
returning: '#ffaa00',
|
||||||
idle: '#9ca3af',
|
goHome: '#ffaa00',
|
||||||
manual: '#a78bfa',
|
idle: '#aaaaaa',
|
||||||
unknown: '#6b7280'
|
manual: '#ff55ff',
|
||||||
|
refueling: '#ff5555',
|
||||||
|
farming: '#55ff55',
|
||||||
|
dumpInventory: '#aa00aa',
|
||||||
|
dumping: '#aa00aa',
|
||||||
|
moving: '#55ffff',
|
||||||
|
scan: '#5555ff',
|
||||||
|
extraction: '#ffaa00',
|
||||||
|
building: '#00aaaa',
|
||||||
|
autocraft: '#ff55ff',
|
||||||
|
unknown: '#555555'
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
style={{ borderColor: modeColors[mode] }}
|
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
||||||
>
|
>
|
||||||
<div className="turtle-header">
|
<div className="turtle-header">
|
||||||
<h3>Turtle {turtle.turtleID}</h3>
|
<h3>{displayName}</h3>
|
||||||
<span className="mode-badge" style={{ background: modeColors[mode] }}>
|
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||||
{mode}
|
{activeState}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TurtleDetails({ turtle }) {
|
function TurtleDetails({ turtle }) {
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
|
||||||
|
const equipLeft = useTurtleStore((state) => state.equipLeft);
|
||||||
|
const equipRight = useTurtleStore((state) => state.equipRight);
|
||||||
|
const sortInventory = useTurtleStore((state) => state.sortInventory);
|
||||||
|
const selectSlot = useTurtleStore((state) => state.selectSlot);
|
||||||
|
const dropItems = useTurtleStore((state) => state.dropItems);
|
||||||
|
const suckItems = useTurtleStore((state) => state.suckItems);
|
||||||
|
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
|
||||||
|
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
|
||||||
|
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
|
||||||
|
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
|
||||||
|
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||||
|
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||||
|
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||||
|
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||||
|
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||||
|
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||||
|
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||||
|
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||||
|
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||||
|
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||||
|
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
|
||||||
|
|
||||||
if (!turtle) {
|
if (!turtle) {
|
||||||
return (
|
return (
|
||||||
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCommand = (command, param = null) => {
|
const handleStateChange = (stateName, data = {}) => {
|
||||||
sendCommand(turtle.turtleID, command, param);
|
setTurtleState(turtle.turtleID, stateName, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (renameValue.trim()) {
|
||||||
|
await renameTurtle(turtle.turtleID, renameValue.trim());
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = async (slotIndex) => {
|
||||||
|
await selectSlot(turtle.turtleID, slotIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigSave = async () => {
|
||||||
|
await updateTurtleConfig(turtle.turtleID, configValues);
|
||||||
|
setShowConfig(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turtle-details">
|
<div className="turtle-details">
|
||||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
|
||||||
|
|
||||||
|
{/* Rename + Config bar */}
|
||||||
|
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
|
placeholder="Rename turtle..."
|
||||||
|
className="rename-input"
|
||||||
|
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
|
||||||
|
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}>⚙️ Config</button>
|
||||||
|
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
|
||||||
|
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Modal */}
|
||||||
|
{showConfig && (
|
||||||
|
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
|
||||||
|
<h3>⚙️ Configuration</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Max Distance:</span>
|
||||||
|
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Auto Refuel:</span>
|
||||||
|
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
|
||||||
|
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Status</h3>
|
<h3>Status</h3>
|
||||||
<div className="status-grid">
|
<div className="status-grid">
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Mode:</span>
|
<span className="label">State:</span>
|
||||||
<span className="value">{turtle.mode || 'unknown'}</span>
|
<span className="value">{activeState}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.stateDescription && (
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">Activity:</span>
|
||||||
|
<span className="value">{turtle.stateDescription}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Fuel:</span>
|
<span className="label">Fuel:</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.totalSteps > 0 && (
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">Steps:</span>
|
||||||
|
<span className="value">
|
||||||
|
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Position:</span>
|
<span className="label">Position:</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
@@ -115,67 +224,168 @@ function TurtleDetails({ turtle }) {
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.error && (
|
||||||
|
<div className="status-item" style={{ color: '#ff5555' }}>
|
||||||
|
<span className="label">Error:</span>
|
||||||
|
<span className="value">{turtle.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{turtle.warning && (
|
||||||
|
<div className="status-item" style={{ color: '#ffaa00' }}>
|
||||||
|
<span className="label">Warning:</span>
|
||||||
|
<span className="value">{turtle.warning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Peripherals section */}
|
||||||
|
{turtle.peripherals && Object.keys(turtle.peripherals).length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Commands</h3>
|
<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>State Machine</h3>
|
||||||
<div className="command-grid">
|
<div className="command-grid">
|
||||||
<button
|
<button
|
||||||
className="command-btn explore"
|
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('explore')}
|
onClick={() => handleStateChange('idle')}
|
||||||
title="Start autonomous exploration and mining"
|
title="Stop and go idle"
|
||||||
|
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
⏸️ Idle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleStateChange('exploring')}
|
||||||
|
title="Autonomous exploration"
|
||||||
|
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
🔍 Explore
|
🔍 Explore
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn mine"
|
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('mine')}
|
onClick={() => handleStateChange('mining')}
|
||||||
title="Start mining mode"
|
title="Mining with ore priority"
|
||||||
|
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
⛏️ Mine
|
⛏️ Mine
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn return"
|
className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('returnHome')}
|
onClick={() => handleStateChange('farming')}
|
||||||
title="Navigate back to home position"
|
title="Automated farming"
|
||||||
|
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
🏠 Return Home
|
🌾 Farm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn stop"
|
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('stop')}
|
onClick={() => handleStateChange('goHome')}
|
||||||
title="Stop all autonomous actions"
|
title="Navigate home"
|
||||||
|
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
⏹️ Stop
|
🏠 Go Home
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn manual"
|
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('manual')}
|
onClick={() => handleStateChange('refueling')}
|
||||||
title="Switch to manual control mode"
|
title="Auto-refuel from inventory"
|
||||||
>
|
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
||||||
🎮 Manual Mode
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn setHome"
|
|
||||||
onClick={() => handleCommand('setHome')}
|
|
||||||
title="Set current position as home"
|
|
||||||
>
|
|
||||||
📍 Set Home
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="command-btn refuel"
|
|
||||||
onClick={() => handleCommand('refuel')}
|
|
||||||
title="Consume fuel from inventory"
|
|
||||||
>
|
>
|
||||||
⛽ Refuel
|
⛽ Refuel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn status"
|
className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
|
||||||
onClick={() => handleCommand('status')}
|
onClick={() => handleStateChange('dumpInventory')}
|
||||||
title="Request status update"
|
title="Dump inventory into nearby container"
|
||||||
|
style={activeState === 'dumpInventory' || activeState === 'dumping' ? { outline: '2px solid #fff' } : {}}
|
||||||
>
|
>
|
||||||
📊 Status
|
📦 Dump
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn ${activeState === 'moving' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleStateChange('moving')}
|
||||||
|
title="Navigate to target"
|
||||||
|
style={activeState === 'moving' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
🧭 Move To
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn ${activeState === 'scan' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleStateChange('scan')}
|
||||||
|
title="Scan surroundings with peripheral scanner"
|
||||||
|
style={activeState === 'scan' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
📡 Scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn ${activeState === 'extraction' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const area = prompt('Enter extraction area (startX,startY,startZ,endX,endY,endZ):');
|
||||||
|
if (area) {
|
||||||
|
const [sx,sy,sz,ex,ey,ez] = area.split(',').map(Number);
|
||||||
|
if (!isNaN(sx) && !isNaN(sy) && !isNaN(sz) && !isNaN(ex) && !isNaN(ey) && !isNaN(ez)) {
|
||||||
|
const points = [];
|
||||||
|
for (let x = sx; x <= ex; x++) for (let y = sy; y <= ey; y++) for (let z = sz; z <= ez; z++) {
|
||||||
|
points.push({x, y, z});
|
||||||
|
}
|
||||||
|
handleStateChange('extraction', { area: points });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Smart ore extraction in an area"
|
||||||
|
style={activeState === 'extraction' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
💎 Extract
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn ${activeState === 'building' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const input = prompt('Enter blueprint JSON (array of {x,y,z,name} blocks):');
|
||||||
|
if (input) {
|
||||||
|
try {
|
||||||
|
const blocks = JSON.parse(input);
|
||||||
|
handleStateChange('building', { blocks });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Invalid JSON blueprint');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Build from blueprint"
|
||||||
|
style={activeState === 'building' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
🏗️ Build
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`command-btn ${activeState === 'autocraft' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const input = prompt('Enter recipe JSON (array of 16 slot items, null for empty):');
|
||||||
|
if (input) {
|
||||||
|
try {
|
||||||
|
const recipe = JSON.parse(input);
|
||||||
|
handleStateChange('autocraft', { recipe });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Invalid JSON recipe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Automated crafting with workbench"
|
||||||
|
style={activeState === 'autocraft' ? { outline: '2px solid #fff' } : {}}
|
||||||
|
>
|
||||||
|
🔨 Autocraft
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,16 +394,16 @@ function TurtleDetails({ turtle }) {
|
|||||||
<h3>Movement</h3>
|
<h3>Movement</h3>
|
||||||
<div className="movement-controls">
|
<div className="movement-controls">
|
||||||
<div className="movement-row">
|
<div className="movement-row">
|
||||||
<button onClick={() => handleCommand('forward')} title="Move forward">↑</button>
|
<button onClick={() => moveForward(turtle.turtleID)} title="Move forward">↑</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="movement-row">
|
<div className="movement-row">
|
||||||
<button onClick={() => handleCommand('turnLeft')} title="Turn left">←</button>
|
<button onClick={() => turnLeft(turtle.turtleID)} title="Turn left">←</button>
|
||||||
<button onClick={() => handleCommand('back')} title="Move backward">↓</button>
|
<button onClick={() => moveBack(turtle.turtleID)} title="Move backward">↓</button>
|
||||||
<button onClick={() => handleCommand('turnRight')} title="Turn right">→</button>
|
<button onClick={() => turnRight(turtle.turtleID)} title="Turn right">→</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="movement-row vertical">
|
<div className="movement-row vertical">
|
||||||
<button onClick={() => handleCommand('up')} title="Move up">⬆ Up</button>
|
<button onClick={() => moveUp(turtle.turtleID)} title="Move up">⬆ Up</button>
|
||||||
<button onClick={() => handleCommand('down')} title="Move down">⬇ Down</button>
|
<button onClick={() => moveDown(turtle.turtleID)} title="Move down">⬇ Down</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,28 +413,28 @@ function TurtleDetails({ turtle }) {
|
|||||||
<div className="action-grid">
|
<div className="action-grid">
|
||||||
<button
|
<button
|
||||||
className="action-btn dig"
|
className="action-btn dig"
|
||||||
onClick={() => handleCommand('dig')}
|
onClick={() => digBlock(turtle.turtleID)}
|
||||||
title="Dig block in front"
|
title="Dig block in front"
|
||||||
>
|
>
|
||||||
⛏️ Dig
|
⛏️ Dig
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn digup"
|
className="action-btn digup"
|
||||||
onClick={() => handleCommand('digUp')}
|
onClick={() => digBlockUp(turtle.turtleID)}
|
||||||
title="Dig block above"
|
title="Dig block above"
|
||||||
>
|
>
|
||||||
⬆️ Dig Up
|
⬆️ Dig Up
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn digdown"
|
className="action-btn digdown"
|
||||||
onClick={() => handleCommand('digDown')}
|
onClick={() => digBlockDown(turtle.turtleID)}
|
||||||
title="Dig block below"
|
title="Dig block below"
|
||||||
>
|
>
|
||||||
⬇️ Dig Down
|
⬇️ Dig Down
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="action-btn place"
|
className="action-btn place"
|
||||||
onClick={() => handleCommand('place')}
|
onClick={() => placeBlock(turtle.turtleID)}
|
||||||
title="Place block from inventory"
|
title="Place block from inventory"
|
||||||
>
|
>
|
||||||
🧱 Place
|
🧱 Place
|
||||||
@@ -232,17 +442,86 @@ function TurtleDetails({ turtle }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Equipment & Inventory Actions */}
|
||||||
|
<div className="detail-section">
|
||||||
|
<h3>Equipment & Inventory</h3>
|
||||||
|
<div className="action-grid">
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => equipLeft(turtle.turtleID)}
|
||||||
|
title="Equip selected item on left side"
|
||||||
|
>
|
||||||
|
🛡️ Equip L
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => equipRight(turtle.turtleID)}
|
||||||
|
title="Equip selected item on right side"
|
||||||
|
>
|
||||||
|
🗡️ Equip R
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => sortInventory(turtle.turtleID)}
|
||||||
|
title="Sort and compact inventory"
|
||||||
|
>
|
||||||
|
📋 Sort
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'front')}
|
||||||
|
title="Drop items forward"
|
||||||
|
>
|
||||||
|
📤 Drop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'up')}
|
||||||
|
title="Drop items upward"
|
||||||
|
>
|
||||||
|
⬆️ Drop Up
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => dropItems(turtle.turtleID, 'down')}
|
||||||
|
title="Drop items downward"
|
||||||
|
>
|
||||||
|
⬇️ Drop Down
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => suckItems(turtle.turtleID, 'front')}
|
||||||
|
title="Suck items from front"
|
||||||
|
>
|
||||||
|
📥 Suck
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const side = prompt('Enter peripheral side (front/top/bottom/left/right):');
|
||||||
|
if (side) connectToInventory(turtle.turtleID, side);
|
||||||
|
}}
|
||||||
|
title="Read adjacent inventory peripheral contents"
|
||||||
|
>
|
||||||
|
📦 Read Inv
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{turtle.inventory && turtle.inventory.length > 0 && (
|
{turtle.inventory && turtle.inventory.length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16)</h3>
|
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) — Slot: {turtle.selectedSlot || 1}</h3>
|
||||||
<div className="inventory-grid">
|
<div className="inventory-grid">
|
||||||
{Array.from({ length: 16 }, (_, slotIndex) => {
|
{Array.from({ length: 16 }, (_, slotIndex) => {
|
||||||
const item = turtle.inventory[slotIndex];
|
const item = turtle.inventory[slotIndex];
|
||||||
|
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slotIndex}
|
key={slotIndex}
|
||||||
className={`inventory-slot ${item ? 'filled' : 'empty'}`}
|
className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
|
||||||
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count})` : 'Empty'}
|
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count}) — Click to select` : `Slot ${slotIndex + 1} — Click to select`}
|
||||||
|
onClick={() => handleSlotClick(slotIndex)}
|
||||||
|
style={isSelected ? { outline: '2px solid #55ffff', outlineOffset: '-2px' } : {}}
|
||||||
>
|
>
|
||||||
{item ? (
|
{item ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Groups Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.groups-panel {
|
.groups-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-header {
|
.groups-header {
|
||||||
@@ -16,19 +20,20 @@
|
|||||||
.groups-header h2 {
|
.groups-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-count {
|
.group-count {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -36,15 +41,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -60,17 +65,19 @@
|
|||||||
|
|
||||||
/* Create Group Section */
|
/* Create Group Section */
|
||||||
.create-group-section {
|
.create-group-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-section h3 {
|
.create-group-section h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form {
|
.create-group-form {
|
||||||
@@ -81,16 +88,16 @@
|
|||||||
|
|
||||||
.create-group-form input {
|
.create-group-form input {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form input:focus {
|
.create-group-form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form input:disabled {
|
.create-group-form input:disabled {
|
||||||
@@ -107,11 +114,11 @@
|
|||||||
.color-option {
|
.color-option {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 0.375rem;
|
border: 3px solid #1a1a1a;
|
||||||
border: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.3), inset 0 2px 0 rgba(255,255,255,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-option:hover {
|
.color-option:hover {
|
||||||
@@ -119,25 +126,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-option.active {
|
.color-option.active {
|
||||||
border-color: #e5e7eb;
|
border-color: #ffff55;
|
||||||
box-shadow: 0 0 0 2px #0f172a, 0 0 0 4px currentColor;
|
box-shadow: 0 0 0 2px #1a1a1a, 0 0 0 4px #ffff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"] {
|
.create-group-form button[type="submit"] {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-group-form button[type="submit"]:disabled {
|
.create-group-form button[type="submit"]:disabled {
|
||||||
@@ -153,15 +161,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-card {
|
.group-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-card:hover {
|
.group-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
@@ -182,24 +190,23 @@
|
|||||||
.group-color-indicator {
|
.group-color-indicator {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
border-radius: 50%;
|
border: 2px solid #1a1a1a;
|
||||||
border: 2px solid #0f172a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-title h3 {
|
.group-title h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #e0e0e0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-count {
|
.member-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-group-btn {
|
.delete-group-btn {
|
||||||
@@ -209,7 +216,7 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-group-btn:hover {
|
.delete-group-btn:hover {
|
||||||
@@ -225,7 +232,7 @@
|
|||||||
.group-members h4 {
|
.group-members h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -243,13 +250,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item:hover {
|
.member-item:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-info {
|
.member-info {
|
||||||
@@ -266,7 +274,7 @@
|
|||||||
.member-name {
|
.member-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-status {
|
.member-status {
|
||||||
@@ -278,11 +286,11 @@
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-member-btn:hover {
|
.remove-member-btn:hover {
|
||||||
@@ -293,7 +301,7 @@
|
|||||||
.no-members {
|
.no-members {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -305,34 +313,34 @@
|
|||||||
.add-member-section select {
|
.add-member-section select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-member-section select:hover {
|
.add-member-section select:hover {
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-member-section select:focus {
|
.add-member-section select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Group Commands */
|
/* Group Commands */
|
||||||
.group-commands {
|
.group-commands {
|
||||||
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||||
border-top: 1px solid #334155;
|
border-top: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-commands h4 {
|
.group-commands h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -346,21 +354,21 @@
|
|||||||
|
|
||||||
.command-buttons button {
|
.command-buttons button {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: #0f172a;
|
background: #6b6b6b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-buttons button:hover {
|
.command-buttons button:hover {
|
||||||
background: #1e293b;
|
background: #7b7b7b;
|
||||||
border-color: #3b82f6;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -378,13 +386,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
|||||||
const [message, setMessage] = useState(null);
|
const [message, setMessage] = useState(null);
|
||||||
|
|
||||||
const colorPresets = [
|
const colorPresets = [
|
||||||
{ name: 'Blue', value: '#3b82f6' },
|
{ name: 'Blue', value: '#345ec3' },
|
||||||
{ name: 'Green', value: '#10b981' },
|
{ name: 'Green', value: '#4a8c2a' },
|
||||||
{ name: 'Red', value: '#ef4444' },
|
{ name: 'Red', value: '#aa0000' },
|
||||||
{ name: 'Yellow', value: '#f59e0b' },
|
{ name: 'Yellow', value: '#ffaa00' },
|
||||||
{ name: 'Purple', value: '#8b5cf6' },
|
{ name: 'Purple', value: '#7b2fbe' },
|
||||||
{ name: 'Pink', value: '#ec4899' },
|
{ name: 'Pink', value: '#d4658a' },
|
||||||
{ name: 'Cyan', value: '#06b6d4' },
|
{ name: 'Cyan', value: '#55ffff' },
|
||||||
{ name: 'Orange', value: '#f97316' },
|
{ name: 'Orange', value: '#c97a2a' },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,7 +33,7 @@ const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
|||||||
const response = await fetch(`${apiUrl}/api/groups`);
|
const response = await fetch(`${apiUrl}/api/groups`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setGroups(data);
|
setGroups(Array.isArray(data) ? data : (data.groups || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load groups:', error);
|
console.error('Failed to load groups:', error);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Mining Areas Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.mining-areas-panel {
|
.mining-areas-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
color: #e2e8f0;
|
color: #e0e0e0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -12,79 +17,86 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: #1e293b;
|
background: #6b4e28;
|
||||||
border-bottom: 2px solid #334155;
|
border-bottom: 3px solid #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h2 {
|
.panel-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #f8fafc;
|
color: #ffff55;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-create {
|
.btn-create {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-create:hover {
|
.btn-create:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter buttons */
|
/* Filter buttons */
|
||||||
.filter-buttons {
|
.filter-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #1a1a1a;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #334155;
|
background: #6b6b6b;
|
||||||
color: #94a3b8;
|
color: #e0e0e0;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
background: #475569;
|
background: #7b7b7b;
|
||||||
color: #e2e8f0;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create form */
|
/* Create form */
|
||||||
.create-form {
|
.create-form {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #1a1a1a;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form h3 {
|
.create-form h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #f8fafc;
|
color: #ffaa00;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
@@ -94,28 +106,27 @@
|
|||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #cbd5e1;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.form-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 6px;
|
color: #e0e0e0;
|
||||||
color: #e2e8f0;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group select:focus {
|
.form-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-section {
|
.coordinates-section {
|
||||||
@@ -130,31 +141,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.coordinates-header label {
|
.coordinates-header label {
|
||||||
color: #cbd5e1;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position {
|
.btn-use-position {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position:hover:not(:disabled) {
|
.btn-use-position:hover:not(:disabled) {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-use-position:disabled {
|
.btn-use-position:disabled {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-inputs {
|
.coordinate-inputs {
|
||||||
@@ -170,21 +184,22 @@
|
|||||||
.btn-submit {
|
.btn-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit:hover {
|
.btn-submit:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Areas list */
|
/* Areas list */
|
||||||
@@ -197,7 +212,7 @@
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -206,22 +221,21 @@
|
|||||||
|
|
||||||
/* Area card */
|
/* Area card */
|
||||||
.area-card {
|
.area-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-card:hover {
|
.area-card:hover {
|
||||||
border-color: #475569;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-card.has-conflict {
|
.area-card.has-conflict {
|
||||||
border-color: #ef4444;
|
border-color: #ff5555;
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
|
background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-header {
|
.area-header {
|
||||||
@@ -230,23 +244,24 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.area-header h3 {
|
.area-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #f8fafc;
|
color: #e0e0e0;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 12px;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Area info */
|
/* Area info */
|
||||||
@@ -259,7 +274,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row:last-child {
|
.info-row:last-child {
|
||||||
@@ -267,32 +282,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
color: #e2e8f0;
|
color: #e0e0e0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coordinate-value {
|
.coordinate-value {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Conflict warning */
|
/* Conflict warning */
|
||||||
.conflict-warning {
|
.conflict-warning {
|
||||||
background: #7f1d1d;
|
background: #6b1a1a44;
|
||||||
border: 1px solid #ef4444;
|
border: 2px solid #ff5555;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #fca5a5;
|
color: #ff5555;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,46 +331,45 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start {
|
.btn-start {
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-start:hover {
|
.btn-start:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-1px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-complete {
|
.btn-complete {
|
||||||
background: #3b82f6;
|
background: #345ec3;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-complete:hover {
|
.btn-complete:hover {
|
||||||
background: #2563eb;
|
background: #4a6ed3;
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
@@ -366,18 +380,18 @@
|
|||||||
|
|
||||||
.areas-list::-webkit-scrollbar-track,
|
.areas-list::-webkit-scrollbar-track,
|
||||||
.create-form::-webkit-scrollbar-track {
|
.create-form::-webkit-scrollbar-track {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areas-list::-webkit-scrollbar-thumb,
|
.areas-list::-webkit-scrollbar-thumb,
|
||||||
.create-form::-webkit-scrollbar-thumb {
|
.create-form::-webkit-scrollbar-thumb {
|
||||||
background: #334155;
|
background: #5a5a5a;
|
||||||
border-radius: 4px;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.areas-list::-webkit-scrollbar-thumb:hover,
|
.areas-list::-webkit-scrollbar-thumb:hover,
|
||||||
.create-form::-webkit-scrollbar-thumb:hover {
|
.create-form::-webkit-scrollbar-thumb:hover {
|
||||||
background: #475569;
|
background: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@@ -432,3 +446,62 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker row */
|
||||||
|
.color-picker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch.active {
|
||||||
|
border-color: #ffff55;
|
||||||
|
box-shadow: 0 0 4px #ffff55;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color dot in area card */
|
||||||
|
.area-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-color-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [newArea, setNewArea] = useState({
|
const [newArea, setNewArea] = useState({
|
||||||
areaName: '',
|
areaName: '',
|
||||||
|
color: '#4a8c2a',
|
||||||
startX: '',
|
startX: '',
|
||||||
startY: '',
|
startY: '',
|
||||||
startZ: '',
|
startZ: '',
|
||||||
@@ -28,7 +29,8 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
const response = await fetch(`${apiUrl}/api/mining-areas`);
|
const response = await fetch(`${apiUrl}/api/mining-areas`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAreas(data);
|
// Server returns a flat array of formatted areas
|
||||||
|
setAreas(Array.isArray(data) ? data : (data.areas || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load mining areas:', error);
|
console.error('Failed to load mining areas:', error);
|
||||||
@@ -65,6 +67,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
endX: Number(newArea.endX),
|
endX: Number(newArea.endX),
|
||||||
endY: Number(newArea.endY),
|
endY: Number(newArea.endY),
|
||||||
endZ: Number(newArea.endZ),
|
endZ: Number(newArea.endZ),
|
||||||
|
color: newArea.color,
|
||||||
status: 'planned'
|
status: 'planned'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -73,6 +76,7 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewArea({
|
setNewArea({
|
||||||
areaName: '',
|
areaName: '',
|
||||||
|
color: '#4a8c2a',
|
||||||
startX: '',
|
startX: '',
|
||||||
startY: '',
|
startY: '',
|
||||||
startZ: '',
|
startZ: '',
|
||||||
@@ -191,12 +195,12 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
// Status badge component
|
// Status badge component
|
||||||
const StatusBadge = ({ status }) => {
|
const StatusBadge = ({ status }) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
planned: '#6366f1',
|
planned: '#345ec3',
|
||||||
mining: '#f59e0b',
|
mining: '#ffaa00',
|
||||||
completed: '#10b981'
|
completed: '#4a8c2a'
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}>
|
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -258,6 +262,27 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Area Color:</label>
|
||||||
|
<div className="color-picker-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newArea.color}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
|
||||||
|
className="color-input"
|
||||||
|
/>
|
||||||
|
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => setNewArea({ ...newArea, color: c })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Assign Turtle:</label>
|
<label>Assign Turtle:</label>
|
||||||
<select
|
<select
|
||||||
@@ -363,7 +388,10 @@ export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
|||||||
return (
|
return (
|
||||||
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||||
<div className="area-header">
|
<div className="area-header">
|
||||||
|
<div className="area-title-row">
|
||||||
|
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
|
||||||
<h3>{area.areaName}</h3>
|
<h3>{area.areaName}</h3>
|
||||||
|
</div>
|
||||||
<StatusBadge status={area.status} />
|
<StatusBadge status={area.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Path Recorder
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.path-recorder {
|
.path-recorder {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recorder-header {
|
.recorder-header {
|
||||||
@@ -18,23 +22,23 @@
|
|||||||
.recorder-header h2 {
|
.recorder-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-turtle-badge {
|
.selected-turtle-badge {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #1e293b;
|
background: #2d6b1a33;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #55ff55;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border: 1px solid #10b981;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -42,21 +46,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.info {
|
.message.info {
|
||||||
background: #3b82f633;
|
background: #1a4a6b33;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
border: 1px solid #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -72,17 +76,19 @@
|
|||||||
|
|
||||||
/* Recording Section */
|
/* Recording Section */
|
||||||
.recording-section {
|
.recording-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-section h3 {
|
.recording-section h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #e0e0e0;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-form {
|
.record-form {
|
||||||
@@ -94,36 +100,36 @@
|
|||||||
.record-form input,
|
.record-form input,
|
||||||
.record-form textarea {
|
.record-form textarea {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-form input:focus,
|
.record-form input:focus,
|
||||||
.record-form textarea:focus {
|
.record-form textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn {
|
.record-btn {
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.875rem 1.5rem;
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn:hover:not(:disabled) {
|
.record-btn:hover:not(:disabled) {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #ff4444, inset 0 -2px 0 #880000;
|
||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-btn:disabled {
|
.record-btn:disabled {
|
||||||
@@ -141,7 +147,8 @@
|
|||||||
.waypoint-counter {
|
.waypoint-counter {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,51 +164,53 @@
|
|||||||
.recording-info {
|
.recording-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-info p {
|
.recording-info p {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recording-info strong {
|
.recording-info strong {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-btn {
|
.stop-btn {
|
||||||
padding: 0.875rem 2rem;
|
padding: 0.875rem 2rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-btn:hover {
|
.stop-btn:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paths Section */
|
/* Paths Section */
|
||||||
.paths-section h3 {
|
.paths-section h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ffff;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +221,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.path-card {
|
.path-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-card:hover {
|
.path-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-card-header {
|
.path-card-header {
|
||||||
@@ -230,13 +239,13 @@
|
|||||||
.path-info h4 {
|
.path-info h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path-description {
|
.path-description {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -246,7 +255,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,44 +272,45 @@
|
|||||||
|
|
||||||
.path-actions button {
|
.path-actions button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
background: #3b82f6;
|
background: #345ec3;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn:hover {
|
.view-btn:hover {
|
||||||
background: #2563eb;
|
background: #4a6ed3;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.play-btn {
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn:hover {
|
.play-btn:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background: #ef4444;
|
background: #aa0000;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background: #dc2626;
|
background: #cc0000;
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -318,13 +328,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Path Details Modal */
|
/* Path Details Modal */
|
||||||
@@ -334,7 +344,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -353,13 +363,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.75rem;
|
border: 3px solid #1a1a1a;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
animation: slideUp 0.3s;
|
animation: slideUp 0.3s;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
@@ -378,30 +389,34 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
|
background: #6b4e28;
|
||||||
|
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: transparent;
|
background: #aa0000;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
color: #94a3b8;
|
color: white;
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #e5e7eb;
|
background: #cc0000;
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -409,7 +424,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-description {
|
.modal-description {
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -418,8 +433,9 @@
|
|||||||
.path-visualization h4 {
|
.path-visualization h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoints-grid {
|
.waypoints-grid {
|
||||||
@@ -434,8 +450,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,21 +461,21 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 50%;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-coords {
|
.waypoint-coords {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waypoint-action {
|
.waypoint-action {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,25 +487,26 @@
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat .stat-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat .stat-value {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTurtleStore } from '../store/turtleStore';
|
||||||
import './PathRecorder.css';
|
import './PathRecorder.css';
|
||||||
|
|
||||||
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||||
@@ -10,6 +11,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
const [selectedPath, setSelectedPath] = useState(null);
|
const [selectedPath, setSelectedPath] = useState(null);
|
||||||
const [message, setMessage] = useState(null);
|
const [message, setMessage] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [playingBack, setPlayingBack] = useState(false);
|
||||||
|
|
||||||
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPaths();
|
loadPaths();
|
||||||
@@ -21,7 +25,7 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
const response = await fetch(`${apiUrl}/api/paths`);
|
const response = await fetch(`${apiUrl}/api/paths`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setPaths(data);
|
setPaths(Array.isArray(data) ? data : []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load paths:', error);
|
console.error('Failed to load paths:', error);
|
||||||
@@ -59,31 +63,18 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the path
|
const response = await fetch(`${apiUrl}/api/paths`, {
|
||||||
const pathResponse = await fetch(`${apiUrl}/api/paths`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: currentPath.name,
|
|
||||||
turtleId: currentPath.turtleId,
|
turtleId: currentPath.turtleId,
|
||||||
description: currentPath.description
|
pathName: currentPath.name,
|
||||||
|
pathData: currentPath.waypoints
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!pathResponse.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to create path');
|
throw new Error('Failed to save path');
|
||||||
}
|
|
||||||
|
|
||||||
const pathData = await pathResponse.json();
|
|
||||||
const pathId = pathData.pathId;
|
|
||||||
|
|
||||||
// Add all waypoints
|
|
||||||
for (const waypoint of currentPath.waypoints) {
|
|
||||||
await fetch(`${apiUrl}/api/paths/${pathId}/waypoints`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(waypoint)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
|
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
|
||||||
@@ -132,9 +123,50 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const playbackPath = (path) => {
|
const playbackPath = async (path) => {
|
||||||
showMessage(`Playback not yet implemented for path: ${path.name}`, 'info');
|
if (!selectedTurtle) {
|
||||||
// TODO: Implement playback by sending commands to turtle
|
showMessage('Please select a turtle to play back this path', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load full path data if we don't have waypoints
|
||||||
|
let waypoints = path.pathData || path.waypoints;
|
||||||
|
if (!waypoints || waypoints.length === 0) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
waypoints = data.waypoints;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage('Failed to load path data', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
showMessage('Path has insufficient waypoints for playback', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayingBack(true);
|
||||||
|
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
|
||||||
|
|
||||||
|
const turtleId = selectedTurtle.turtleID;
|
||||||
|
|
||||||
|
// Use server-side pathfinding to navigate to each waypoint sequentially
|
||||||
|
for (let i = 0; i < waypoints.length; i++) {
|
||||||
|
const wp = waypoints[i];
|
||||||
|
|
||||||
|
// Navigate to each waypoint using the server's moving state
|
||||||
|
await setTurtleState(turtleId, 'moving', { target: { x: wp.x, y: wp.y, z: wp.z } });
|
||||||
|
|
||||||
|
// Wait for turtle to arrive (poll position or just add delay)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayingBack(false);
|
||||||
|
showMessage('Playback complete! Turtle navigating via server.', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const showMessage = (text, type) => {
|
const showMessage = (text, type) => {
|
||||||
@@ -276,8 +308,9 @@ const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
|||||||
onClick={() => playbackPath(path)}
|
onClick={() => playbackPath(path)}
|
||||||
className="play-btn"
|
className="play-btn"
|
||||||
title="Playback path"
|
title="Playback path"
|
||||||
|
disabled={playingBack}
|
||||||
>
|
>
|
||||||
▶️ Play
|
{playingBack ? '⏳ Playing...' : '▶️ Play'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deletePath(path.pathId)}
|
onClick={() => deletePath(path.pathId)}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Stats Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.stats-panel {
|
.stats-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-header {
|
.stats-header {
|
||||||
@@ -18,43 +22,47 @@
|
|||||||
.stats-header h2 {
|
.stats-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter {
|
.time-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button {
|
.time-filter button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: transparent;
|
background: #6b6b6b;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #94a3b8;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button:hover {
|
.time-filter button:hover {
|
||||||
color: #e5e7eb;
|
background: #7b7b7b;
|
||||||
background: #334155;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-filter button.active {
|
.time-filter button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,33 +74,36 @@
|
|||||||
.turtle-stats h3 {
|
.turtle-stats h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ff55;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-section {
|
.stat-section {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-mined {
|
.total-mined {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@@ -101,7 +112,7 @@
|
|||||||
.blocks-grid {
|
.blocks-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-stat {
|
.block-stat {
|
||||||
@@ -109,14 +120,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-stat:hover {
|
.block-stat:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 -1px 0 #333, inset 0 1px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-emoji {
|
.block-emoji {
|
||||||
@@ -131,13 +143,13 @@
|
|||||||
.block-name {
|
.block-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-count {
|
.block-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +161,16 @@
|
|||||||
.leaderboard h3 {
|
.leaderboard h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-list {
|
.leaderboard-list {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item {
|
.leaderboard-item {
|
||||||
@@ -165,9 +179,10 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.375rem;
|
border: 2px solid #1a1a1a;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item:last-child {
|
.leaderboard-item:last-child {
|
||||||
@@ -175,20 +190,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item:hover {
|
.leaderboard-item:hover {
|
||||||
background: #1e293b;
|
background: #4b4b4b;
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-1 {
|
.leaderboard-item.rank-1 {
|
||||||
border-left: 3px solid #fbbf24;
|
border-left: 4px solid #ffaa00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-2 {
|
.leaderboard-item.rank-2 {
|
||||||
border-left: 3px solid #9ca3af;
|
border-left: 4px solid #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-item.rank-3 {
|
.leaderboard-item.rank-3 {
|
||||||
border-left: 3px solid #cd7f32;
|
border-left: 4px solid #cd7f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank {
|
.rank {
|
||||||
@@ -196,6 +210,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #ffff55;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-info {
|
.miner-info {
|
||||||
@@ -205,27 +221,29 @@
|
|||||||
.miner-name {
|
.miner-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-stats {
|
.miner-stats {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.miner-score {
|
.miner-score {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All Turtles Overview */
|
/* All Turtles Overview */
|
||||||
.all-turtles-stats h3 {
|
.all-turtles-stats h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #55ffff;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtles-summary-grid {
|
.turtles-summary-grid {
|
||||||
@@ -235,16 +253,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-card {
|
.turtle-summary-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-card:hover {
|
.turtle-summary-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-summary-header {
|
.turtle-summary-header {
|
||||||
@@ -253,19 +271,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 2px solid #4b4b4b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-id {
|
.turtle-id {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-total {
|
.turtle-total {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3b82f6;
|
color: #55ffff;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turtle-top-blocks {
|
.turtle-top-blocks {
|
||||||
@@ -278,8 +297,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +309,7 @@
|
|||||||
.mini-count {
|
.mini-count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@@ -308,19 +327,19 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data {
|
.no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
// Response is either a single object (per turtle) or an array (all turtles)
|
||||||
setMiningStats(Array.isArray(data) ? data : [data]);
|
setMiningStats(Array.isArray(data) ? data : [data]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -44,7 +45,8 @@ const StatsPanel = ({ selectedTurtle, apiUrl }) => {
|
|||||||
const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`);
|
const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTopMiners(data);
|
// Response is now a flat array of {turtleId, totalBlocks, uniqueTypes}
|
||||||
|
setTopMiners(Array.isArray(data) ? data : (data.topMiners || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load top miners:', error);
|
console.error('Failed to load top miners:', error);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Task Panel
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.task-panel {
|
.task-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-header {
|
.task-header {
|
||||||
@@ -16,31 +20,33 @@
|
|||||||
.task-header h2 {
|
.task-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 2px 2px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-btn {
|
.create-task-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-btn:hover {
|
.create-task-btn:hover {
|
||||||
background: #2563eb;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -48,15 +54,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
.message.success {
|
||||||
background: #10b98133;
|
background: #2d6b1a33;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
border: 1px solid #10b981;
|
border-color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.error {
|
.message.error {
|
||||||
background: #ef444433;
|
background: #6b1a1a33;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
border: 1px solid #ef4444;
|
border-color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -72,18 +78,20 @@
|
|||||||
|
|
||||||
/* Create Task Form */
|
/* Create Task Form */
|
||||||
.create-task-form {
|
.create-task-form {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
animation: slideIn 0.3s;
|
animation: slideIn 0.3s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form h3 {
|
.create-task-form h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffaa00;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form form {
|
.create-task-form form {
|
||||||
@@ -104,28 +112,29 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form select,
|
.create-task-form select,
|
||||||
.create-task-form input[type="text"],
|
.create-task-form input[type="text"],
|
||||||
.create-task-form input[type="number"] {
|
.create-task-form input[type="number"] {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form select:focus,
|
.create-task-form select:focus,
|
||||||
.create-task-form input:focus {
|
.create-task-form input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-task-form input[type="range"] {
|
.create-task-form input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
accent-color: #4a8c2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priority-value {
|
.priority-value {
|
||||||
@@ -137,7 +146,7 @@
|
|||||||
.coordinates-section h4 {
|
.coordinates-section h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,64 +165,68 @@
|
|||||||
.coord-group span {
|
.coord-group span {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coord-group input {
|
.coord-group input {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #4b4b4b;
|
||||||
border-radius: 0.25rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: #10b981;
|
background: #4a8c2a;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:hover {
|
.submit-btn:hover {
|
||||||
background: #059669;
|
background: #5a9c3a;
|
||||||
transform: translateY(-2px);
|
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task Filters */
|
/* Task Filters */
|
||||||
.task-filters {
|
.task-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button {
|
.task-filters button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #1e293b;
|
background: #6b6b6b;
|
||||||
border: none;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.375rem;
|
color: #e0e0e0;
|
||||||
color: #94a3b8;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button:hover {
|
.task-filters button:hover {
|
||||||
color: #e5e7eb;
|
background: #7b7b7b;
|
||||||
background: #334155;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button.active {
|
.task-filters button.active {
|
||||||
background: #3b82f6;
|
background: #4a8c2a;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tasks List */
|
/* Tasks List */
|
||||||
@@ -226,20 +239,20 @@
|
|||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card {
|
.task-card {
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
background: #334155;
|
background: #4b4b4b;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card-header {
|
.task-card-header {
|
||||||
@@ -264,7 +277,7 @@
|
|||||||
.task-type {
|
.task-type {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-badges {
|
.task-badges {
|
||||||
@@ -275,41 +288,42 @@
|
|||||||
.priority-badge,
|
.priority-badge,
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-assignment {
|
.task-assignment {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-parameters {
|
.task-parameters {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #1a1a1a;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-result {
|
.task-result {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: #10b98120;
|
background: #2d6b1a20;
|
||||||
border-radius: 0.25rem;
|
border: 2px solid #55ff55;
|
||||||
border-left: 3px solid #10b981;
|
border-left: 4px solid #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-actions {
|
.task-actions {
|
||||||
@@ -320,25 +334,26 @@
|
|||||||
|
|
||||||
.task-actions button {
|
.task-actions button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #0f172a;
|
background: #6b6b6b;
|
||||||
border: 1px solid #334155;
|
border: 2px solid #1a1a1a;
|
||||||
border-radius: 0.25rem;
|
color: #e0e0e0;
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-actions button:hover {
|
.task-actions button:hover {
|
||||||
background: #1e293b;
|
background: #7b7b7b;
|
||||||
border-color: #3b82f6;
|
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-timestamp {
|
.task-timestamp {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: #7b7b7b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,13 +372,13 @@
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const TaskPanel = ({ turtles, apiUrl }) => {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setTasks(data);
|
// Server returns flat array of formatted tasks
|
||||||
|
setTasks(Array.isArray(data) ? data : (data.tasks || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tasks:', error);
|
console.error('Failed to load tasks:', error);
|
||||||
@@ -156,19 +157,19 @@ const TaskPanel = ({ turtles, apiUrl }) => {
|
|||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return '#94a3b8';
|
case 'pending': return '#a0a0a0';
|
||||||
case 'in_progress': return '#3b82f6';
|
case 'in_progress': return '#345ec3';
|
||||||
case 'completed': return '#10b981';
|
case 'completed': return '#4a8c2a';
|
||||||
case 'failed': return '#ef4444';
|
case 'failed': return '#aa0000';
|
||||||
default: return '#94a3b8';
|
default: return '#a0a0a0';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPriorityLabel = (priority) => {
|
const getPriorityLabel = (priority) => {
|
||||||
if (priority >= 8) return { label: 'Critical', color: '#ef4444' };
|
if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
|
||||||
if (priority >= 6) return { label: 'High', color: '#f59e0b' };
|
if (priority >= 6) return { label: 'High', color: '#ffaa00' };
|
||||||
if (priority >= 4) return { label: 'Medium', color: '#3b82f6' };
|
if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
|
||||||
return { label: 'Low', color: '#94a3b8' };
|
return { label: 'Low', color: '#a0a0a0' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
|
/* ============================================
|
||||||
|
Minecraft-Themed Voice Control
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.voice-control {
|
.voice-control {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #1e293b;
|
background: #3b3b3b;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #334155;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-control.unsupported {
|
.voice-control.unsupported {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #ef4444;
|
color: #ff5555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-control.unsupported small {
|
.voice-control.unsupported small {
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -27,62 +32,67 @@
|
|||||||
.voice-header h3 {
|
.voice-header h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f9fafb;
|
color: #ffff55;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-turtle {
|
.selected-turtle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-selection {
|
.no-selection {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button {
|
.voice-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
background: #345ec3;
|
||||||
border: none;
|
border: 3px solid #1a1a1a;
|
||||||
border-radius: 0.75rem;
|
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition: all 0.3s;
|
transition: all 0.1s;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
|
box-shadow: inset 0 2px 0 #5577dd, inset 0 -3px 0 #223399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button:hover:not(:disabled) {
|
.voice-button:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
background: #4a6ed3;
|
||||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
box-shadow: inset 0 2px 0 #6688ee, inset 0 -3px 0 #3344aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button:disabled {
|
.voice-button:disabled {
|
||||||
background: #374151;
|
background: #4b4b4b;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-button.listening {
|
.voice-button.listening {
|
||||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
background: #aa0000;
|
||||||
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000;
|
||||||
animation: pulse-glow 2s infinite;
|
animation: pulse-glow 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 20px rgba(255, 85, 85, 0.5);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.8);
|
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 40px rgba(255, 85, 85, 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +105,6 @@
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.6);
|
border: 3px solid rgba(255, 255, 255, 0.6);
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse-ring 1.5s infinite;
|
animation: pulse-ring 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,29 +122,28 @@
|
|||||||
.voice-feedback {
|
.voice-feedback {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript {
|
.transcript {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript strong {
|
.transcript strong {
|
||||||
color: #60a5fa;
|
color: #55ffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-command {
|
.last-command {
|
||||||
color: #10b981;
|
color: #55ff55;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-command strong {
|
.last-command strong {
|
||||||
color: #34d399;
|
color: #55ff55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help {
|
.voice-commands-help {
|
||||||
@@ -143,23 +151,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help details {
|
.voice-commands-help details {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
border-radius: 0.5rem;
|
border: 2px solid #1a1a1a;
|
||||||
border: 1px solid #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help summary {
|
.voice-commands-help summary {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #a0a0a0;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-commands-help summary:hover {
|
.voice-commands-help summary:hover {
|
||||||
color: #e5e7eb;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commands-grid {
|
.commands-grid {
|
||||||
@@ -171,11 +178,12 @@
|
|||||||
|
|
||||||
.command-category h4 {
|
.command-category h4 {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #60a5fa;
|
color: #ffaa00;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 1px 1px 0 #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-category ul {
|
.command-category ul {
|
||||||
@@ -186,14 +194,14 @@
|
|||||||
|
|
||||||
.command-category li {
|
.command-category li {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9ca3af;
|
color: #a0a0a0;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-category li::before {
|
.command-category li::before {
|
||||||
content: "▸ ";
|
content: "▸ ";
|
||||||
color: #3b82f6;
|
color: #55ff55;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ export default function VoiceControl() {
|
|||||||
const [recognition, setRecognition] = useState(null);
|
const [recognition, setRecognition] = useState(null);
|
||||||
|
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||||
|
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||||
|
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||||
|
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||||
|
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||||
|
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||||
|
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||||
|
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||||
|
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||||
|
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||||
|
const refuelTurtle = useTurtleStore((state) => state.refuelTurtle);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if speech recognition is supported
|
// Check if speech recognition is supported
|
||||||
@@ -49,52 +60,68 @@ export default function VoiceControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const turtleId = selectedTurtle.turtleID;
|
const turtleId = selectedTurtle.turtleID;
|
||||||
let action = null;
|
let actionName = null;
|
||||||
let param = null;
|
|
||||||
|
|
||||||
// Parse voice commands
|
// Movement commands (server-side)
|
||||||
if (command.includes('forward') || command.includes('go ahead')) {
|
if (command.includes('forward') || command.includes('go ahead')) {
|
||||||
action = 'forward';
|
moveForward(turtleId);
|
||||||
|
actionName = 'forward';
|
||||||
} else if (command.includes('back') || command.includes('backward')) {
|
} else if (command.includes('back') || command.includes('backward')) {
|
||||||
action = 'back';
|
moveBack(turtleId);
|
||||||
|
actionName = 'back';
|
||||||
} else if (command.includes('turn left') || command.includes('left')) {
|
} else if (command.includes('turn left') || command.includes('left')) {
|
||||||
action = 'turnLeft';
|
turnLeft(turtleId);
|
||||||
|
actionName = 'turn left';
|
||||||
} else if (command.includes('turn right') || command.includes('right')) {
|
} else if (command.includes('turn right') || command.includes('right')) {
|
||||||
action = 'turnRight';
|
turnRight(turtleId);
|
||||||
|
actionName = 'turn right';
|
||||||
} else if (command.includes('go up') || command.includes('move up')) {
|
} else if (command.includes('go up') || command.includes('move up')) {
|
||||||
action = 'up';
|
moveUp(turtleId);
|
||||||
|
actionName = 'up';
|
||||||
} else if (command.includes('go down') || command.includes('move down')) {
|
} else if (command.includes('go down') || command.includes('move down')) {
|
||||||
action = 'down';
|
moveDown(turtleId);
|
||||||
|
actionName = 'down';
|
||||||
} else if (command.includes('dig')) {
|
} else if (command.includes('dig')) {
|
||||||
if (command.includes('up')) {
|
if (command.includes('up')) {
|
||||||
action = 'digUp';
|
digBlockUp(turtleId);
|
||||||
|
actionName = 'dig up';
|
||||||
} else if (command.includes('down')) {
|
} else if (command.includes('down')) {
|
||||||
action = 'digDown';
|
digBlockDown(turtleId);
|
||||||
|
actionName = 'dig down';
|
||||||
} else {
|
} else {
|
||||||
action = 'dig';
|
digBlock(turtleId);
|
||||||
|
actionName = 'dig';
|
||||||
}
|
}
|
||||||
} else if (command.includes('place') || command.includes('build')) {
|
} else if (command.includes('place') || command.includes('build')) {
|
||||||
action = 'place';
|
placeBlock(turtleId);
|
||||||
|
actionName = 'place';
|
||||||
|
// State machine commands (server-side)
|
||||||
} else if (command.includes('explore') || command.includes('start exploring')) {
|
} else if (command.includes('explore') || command.includes('start exploring')) {
|
||||||
action = 'explore';
|
setTurtleState(turtleId, 'exploring');
|
||||||
|
actionName = 'explore';
|
||||||
} else if (command.includes('mine') || command.includes('start mining')) {
|
} else if (command.includes('mine') || command.includes('start mining')) {
|
||||||
action = 'mine';
|
setTurtleState(turtleId, 'mining');
|
||||||
|
actionName = 'mine';
|
||||||
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
||||||
action = 'returnHome';
|
setTurtleState(turtleId, 'goHome');
|
||||||
|
actionName = 'go home';
|
||||||
} else if (command.includes('stop')) {
|
} else if (command.includes('stop')) {
|
||||||
action = 'stop';
|
setTurtleState(turtleId, 'idle');
|
||||||
} else if (command.includes('set home') || command.includes('mark home')) {
|
actionName = 'idle';
|
||||||
action = 'setHome';
|
|
||||||
} else if (command.includes('refuel')) {
|
} else if (command.includes('refuel')) {
|
||||||
action = 'refuel';
|
setTurtleState(turtleId, 'refueling');
|
||||||
} else if (command.includes('status') || command.includes('report')) {
|
actionName = 'refuel';
|
||||||
action = 'status';
|
} else if (command.includes('farm')) {
|
||||||
|
setTurtleState(turtleId, 'farming');
|
||||||
|
actionName = 'farm';
|
||||||
|
} else if (command.includes('dump')) {
|
||||||
|
setTurtleState(turtleId, 'dumpInventory');
|
||||||
|
actionName = 'dump inventory';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action) {
|
if (actionName) {
|
||||||
sendCommand(turtleId, action, param);
|
setLastCommand(actionName);
|
||||||
setLastCommand(`${action}${param ? ` ${param}` : ''}`);
|
speak(`Sending ${actionName} command`);
|
||||||
speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`);
|
|
||||||
} else {
|
} else {
|
||||||
speak('Command not recognized');
|
speak('Command not recognized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -5,13 +7,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
-webkit-font-smoothing: none;
|
||||||
sans-serif;
|
-moz-osx-font-smoothing: unset;
|
||||||
-webkit-font-smoothing: antialiased;
|
image-rendering: pixelated;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
background: #2c2c2c;
|
||||||
background: #0a0e1a;
|
color: #d4d4d4;
|
||||||
color: #e0e0e0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,5 +22,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
turtles: {},
|
turtles: {},
|
||||||
players: {},
|
players: {},
|
||||||
worldBlocks: [],
|
worldBlocks: [],
|
||||||
|
chunkAnalyses: {},
|
||||||
selectedTurtleId: null,
|
selectedTurtleId: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
ws: null,
|
ws: null,
|
||||||
@@ -34,8 +35,15 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
data.turtles.forEach(turtle => {
|
data.turtles.forEach(turtle => {
|
||||||
turtlesMap[turtle.turtleID] = turtle;
|
turtlesMap[turtle.turtleID] = turtle;
|
||||||
});
|
});
|
||||||
|
const playersMap = {};
|
||||||
|
if (data.players && Array.isArray(data.players)) {
|
||||||
|
data.players.forEach(player => {
|
||||||
|
playersMap[player.playerID] = player;
|
||||||
|
});
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
turtles: turtlesMap,
|
turtles: turtlesMap,
|
||||||
|
players: playersMap,
|
||||||
worldBlocks: data.blocks || []
|
worldBlocks: data.blocks || []
|
||||||
});
|
});
|
||||||
} else if (data.type === 'turtle_update') {
|
} else if (data.type === 'turtle_update') {
|
||||||
@@ -73,10 +81,77 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
[data.playerID]: {
|
[data.playerID]: {
|
||||||
playerID: data.playerID,
|
playerID: data.playerID,
|
||||||
position: data.position,
|
position: data.position,
|
||||||
timestamp: data.timestamp
|
label: data.label || null,
|
||||||
|
timestamp: data.timestamp || Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
} else if (data.type === 'block_discovered') {
|
||||||
|
if (data.block) {
|
||||||
|
set(state => {
|
||||||
|
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||||
|
const key = `${data.block.x},${data.block.y},${data.block.z}`;
|
||||||
|
blockMap.set(key, data.block);
|
||||||
|
return { worldBlocks: Array.from(blockMap.values()) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.type === 'blocks_discovered') {
|
||||||
|
if (data.blocks && Array.isArray(data.blocks)) {
|
||||||
|
set(state => {
|
||||||
|
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||||
|
data.blocks.forEach(block => {
|
||||||
|
const key = `${block.x},${block.y},${block.z}`;
|
||||||
|
blockMap.set(key, block);
|
||||||
|
});
|
||||||
|
return { worldBlocks: Array.from(blockMap.values()) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.type === 'turtle_event') {
|
||||||
|
// Handle live turtle events (inventory, peripherals)
|
||||||
|
const turtleId = data.turtleID;
|
||||||
|
if (turtleId && data.eventType === 'inventory_update' && data.inventory) {
|
||||||
|
set(state => {
|
||||||
|
const turtle = state.turtles[turtleId];
|
||||||
|
if (!turtle) return state;
|
||||||
|
return {
|
||||||
|
turtles: {
|
||||||
|
...state.turtles,
|
||||||
|
[turtleId]: { ...turtle, inventory: data.inventory }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (turtleId && (data.eventType === 'peripheral_attached' || data.eventType === 'peripheral_detached')) {
|
||||||
|
set(state => {
|
||||||
|
const turtle = state.turtles[turtleId];
|
||||||
|
if (!turtle) return state;
|
||||||
|
return {
|
||||||
|
turtles: {
|
||||||
|
...state.turtles,
|
||||||
|
[turtleId]: { ...turtle, peripherals: data.peripherals || turtle.peripherals }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.type === 'chunk_analysis') {
|
||||||
|
// Store chunk analysis results
|
||||||
|
if (data.chunk) {
|
||||||
|
set(state => ({
|
||||||
|
chunkAnalyses: {
|
||||||
|
...state.chunkAnalyses,
|
||||||
|
[`${data.chunk.x},${data.chunk.z}`]: data.chunk
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (data.type === 'block_deleted') {
|
||||||
|
// Remove a block from the world map (turtle moved into it)
|
||||||
|
if (data.x !== undefined && data.y !== undefined && data.z !== undefined) {
|
||||||
|
set(state => {
|
||||||
|
const key = `${data.x},${data.y},${data.z}`;
|
||||||
|
return {
|
||||||
|
worldBlocks: state.worldBlocks.filter(b => `${b.x},${b.y},${b.z}` !== key)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing message:', error);
|
console.error('Error processing message:', error);
|
||||||
@@ -149,32 +224,285 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
set({ worldBlocks: Array.from(blockMap.values()) });
|
set({ worldBlocks: Array.from(blockMap.values()) });
|
||||||
},
|
},
|
||||||
|
|
||||||
sendCommand: async (turtleId, command, param = null) => {
|
// Set turtle state machine state via REST API
|
||||||
|
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();
|
const { ws } = get();
|
||||||
|
|
||||||
console.log(`🎮 Sending command to turtle ${turtleId}: ${command}`, param ? `(param: ${param})` : '');
|
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'command',
|
type: 'command',
|
||||||
turtleID: turtleId,
|
turtleID: turtleId,
|
||||||
command,
|
command: 'set_state',
|
||||||
param
|
param: { state: stateName, data: stateData }
|
||||||
}));
|
}));
|
||||||
console.log(' ✅ Sent via WebSocket');
|
}
|
||||||
} else {
|
}
|
||||||
// Fallback to REST API
|
},
|
||||||
console.log(' ⚠️ WebSocket not connected, using REST API fallback');
|
|
||||||
|
// Execute arbitrary Lua code on a turtle
|
||||||
|
execOnTurtle: async (turtleId, code) => {
|
||||||
|
console.log(`💻 Exec on turtle ${turtleId}:`, code);
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_URL}/turtle/${turtleId}/command`, {
|
const response = await fetch(`${API_URL}/turtle/${turtleId}/exec`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command, param })
|
body: JSON.stringify({ code })
|
||||||
});
|
});
|
||||||
console.log(' ✅ Sent via REST API');
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(' ❌ Error sending command:', 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Backend server
|
# Backend server
|
||||||
server:
|
server:
|
||||||
@@ -8,12 +6,12 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: turtle-server
|
container_name: turtle-server
|
||||||
ports:
|
ports:
|
||||||
- "4200:3001" # HTTP API
|
- "4200:3001" # HTTP API + WebSocket (unified)
|
||||||
- "3002:3002" # WebSocket
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- WS_PORT=3002
|
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- turtle-network
|
- turtle-network
|
||||||
|
|||||||
36
etc/apps.db
Normal file
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
|
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||||
|
|
||||||
local CHANNEL_SEND = 100
|
local Channels = require('platform.channels')
|
||||||
local CHANNEL_RECEIVE = 101
|
|
||||||
local STATUS_CHANNEL = 102
|
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||||
local POCKET_CHANNEL = 103 -- Pocket <-> Webbridge communication
|
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||||
|
|
||||||
-- Find modem
|
-- Find modem
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
@@ -19,9 +21,12 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
local WebBridge = require('platform.webbridge')
|
||||||
modem.open(STATUS_CHANNEL)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(POCKET_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
'remoteturtle.pocket',
|
||||||
|
})
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
|
|
||||||
@@ -98,10 +103,10 @@ local function updateMyPosition()
|
|||||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
type = "player_position",
|
type = "player_position",
|
||||||
playerID = os.getComputerID(),
|
playerID = os.getComputerID(),
|
||||||
|
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
|
||||||
position = myPosition,
|
position = myPosition,
|
||||||
timestamp = os.epoch("utc")
|
timestamp = os.epoch("utc")
|
||||||
})
|
})
|
||||||
addLog("GPS: " .. x .. "," .. y .. "," .. z, colors.lime)
|
|
||||||
return true
|
return true
|
||||||
else
|
else
|
||||||
addLog("GPS: Failed to locate", colors.red)
|
addLog("GPS: Failed to locate", colors.red)
|
||||||
@@ -292,8 +297,25 @@ local function drawControl()
|
|||||||
sendCommand(turtle.turtleID, "up")
|
sendCommand(turtle.turtleID, "up")
|
||||||
end, colors.green)
|
end, colors.green)
|
||||||
|
|
||||||
-- Action buttons (bottom row)
|
-- Action buttons (bottom rows)
|
||||||
local btnY = h - 3
|
local btnY = h - 5
|
||||||
|
addButton(2, btnY, 6, 1, "EXPLR", function()
|
||||||
|
sendCommand(turtle.turtleID, "explore")
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
|
addButton(9, btnY, 6, 1, "MINE", function()
|
||||||
|
sendCommand(turtle.turtleID, "mine")
|
||||||
|
end, colors.orange)
|
||||||
|
|
||||||
|
addButton(16, btnY, 6, 1, "HOME", function()
|
||||||
|
sendCommand(turtle.turtleID, "returnHome")
|
||||||
|
end, colors.yellow)
|
||||||
|
|
||||||
|
addButton(23, btnY, 6, 1, "STOP", function()
|
||||||
|
sendCommand(turtle.turtleID, "stop")
|
||||||
|
end, colors.red)
|
||||||
|
|
||||||
|
btnY = h - 3
|
||||||
addButton(2, btnY, 6, 1, "DOWN", function()
|
addButton(2, btnY, 6, 1, "DOWN", function()
|
||||||
sendCommand(turtle.turtleID, "down")
|
sendCommand(turtle.turtleID, "down")
|
||||||
end, colors.green)
|
end, colors.green)
|
||||||
@@ -302,14 +324,6 @@ local function drawControl()
|
|||||||
sendCommand(turtle.turtleID, "dig")
|
sendCommand(turtle.turtleID, "dig")
|
||||||
end, colors.red)
|
end, colors.red)
|
||||||
|
|
||||||
addButton(16, btnY, 6, 1, "MINE", function()
|
|
||||||
sendCommand(turtle.turtleID, "mineStart")
|
|
||||||
end, colors.orange)
|
|
||||||
|
|
||||||
addButton(23, btnY, 6, 1, "HOME", function()
|
|
||||||
sendCommand(turtle.turtleID, "returnHome")
|
|
||||||
end, colors.yellow)
|
|
||||||
|
|
||||||
for i = #buttons - 8, #buttons do
|
for i = #buttons - 8, #buttons do
|
||||||
if buttons[i] then
|
if buttons[i] then
|
||||||
drawButton(buttons[i], false)
|
drawButton(buttons[i], false)
|
||||||
@@ -547,7 +561,7 @@ parallel.waitForAny(
|
|||||||
function()
|
function()
|
||||||
-- GPS update loop
|
-- GPS update loop
|
||||||
while true do
|
while true do
|
||||||
sleep(5)
|
sleep(2)
|
||||||
updateMyPosition()
|
updateMyPosition()
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
@@ -565,7 +579,9 @@ parallel.waitForAny(
|
|||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||||
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||||
if message.type == "status" then
|
if message.type == "status" then
|
||||||
-- Update turtle list
|
-- Update turtle list
|
||||||
local found = false
|
local found = false
|
||||||
@@ -581,7 +597,7 @@ parallel.waitForAny(
|
|||||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif channel == POCKET_CHANNEL and type(message) == "table" then
|
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||||
-- Handle responses from webbridge
|
-- Handle responses from webbridge
|
||||||
if message.type == "webbridge_status" then
|
if message.type == "webbridge_status" then
|
||||||
webbridgeStatus = message.data
|
webbridgeStatus = message.data
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
-- Live GPS Tracker for Pocket Computer
|
-- Live GPS Tracker for Pocket Computer
|
||||||
-- Shows your current location in real-time
|
-- Shows your current location in real-time
|
||||||
|
|
||||||
local STATUS_CHANNEL = 102
|
local Channels = require('platform.channels')
|
||||||
|
local WebBridge = require('platform.webbridge')
|
||||||
|
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
|
||||||
-- Setup modem
|
-- Setup modem
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
@@ -15,7 +18,7 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(STATUS_CHANNEL)
|
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
local myID = os.getComputerID()
|
local myID = os.getComputerID()
|
||||||
@@ -228,7 +231,9 @@ local function main()
|
|||||||
local replyChannel = param3
|
local replyChannel = param3
|
||||||
local message = param4
|
local message = param4
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (102) and target (4212) channels during migration.
|
||||||
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||||
handleStatus(message)
|
handleStatus(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
236
pocketremote.lua
236
pocketremote.lua
@@ -1,9 +1,12 @@
|
|||||||
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||||
-- Monitor and control autonomous mining turtles
|
-- Monitor and control autonomous mining turtles
|
||||||
|
|
||||||
local CHANNEL_SEND = 100
|
local Channels = require('platform.channels')
|
||||||
local CHANNEL_RECEIVE = 101
|
local WebBridge = require('platform.webbridge')
|
||||||
local STATUS_CHANNEL = 102
|
|
||||||
|
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||||
|
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
if not modem then
|
if not modem then
|
||||||
@@ -15,15 +18,17 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(STATUS_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
})
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
|
|
||||||
-- Tracked turtles
|
-- Tracked turtles
|
||||||
local turtles = {}
|
local turtles = {}
|
||||||
local selectedTurtle = nil
|
local selectedTurtle = nil
|
||||||
local viewMode = "overview" -- overview, detail, manual
|
local viewMode = "overview" -- overview, detail, manual, modes
|
||||||
|
|
||||||
-- Button system
|
-- Button system
|
||||||
local buttons = {}
|
local buttons = {}
|
||||||
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Send state change command to turtle (new protocol)
|
||||||
|
local function sendStateCommand(turtleID, stateName, stateData)
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "state_change",
|
||||||
|
state = stateName,
|
||||||
|
data = stateData or {},
|
||||||
|
target = turtleID
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- Helper function to format fuel display
|
-- Helper function to format fuel display
|
||||||
local function formatFuel(fuel)
|
local function formatFuel(fuel)
|
||||||
if not fuel then
|
if not fuel then
|
||||||
@@ -127,7 +142,7 @@ local function drawOverview()
|
|||||||
-- Turtle info box
|
-- Turtle info box
|
||||||
term.setCursorPos(1, y)
|
term.setCursorPos(1, y)
|
||||||
term.setTextColor(selected and colors.lime or colors.white)
|
term.setTextColor(selected and colors.lime or colors.white)
|
||||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
|
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||||
|
|
||||||
if turtle.position then
|
if turtle.position then
|
||||||
print(string.format(" %d,%d,%d",
|
print(string.format(" %d,%d,%d",
|
||||||
@@ -197,7 +212,7 @@ local function drawDetail()
|
|||||||
term.setTextColor(colors.white)
|
term.setTextColor(colors.white)
|
||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("Mode: " .. (turtle.mode or "unknown"))
|
print("State: " .. (turtle.state or turtle.mode or "idle"))
|
||||||
print("Fuel: " .. formatFuel(turtle.fuel))
|
print("Fuel: " .. formatFuel(turtle.fuel))
|
||||||
|
|
||||||
if turtle.position then
|
if turtle.position then
|
||||||
@@ -234,8 +249,8 @@ local function drawDetail()
|
|||||||
print(" Empty")
|
print(" Empty")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Action buttons
|
-- Action buttons (row 1: explore/home/stop)
|
||||||
local btnY = h - 7
|
local btnY = h - 10
|
||||||
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
||||||
sendCommand(turtle.turtleID, "explore")
|
sendCommand(turtle.turtleID, "explore")
|
||||||
end, colors.green)
|
end, colors.green)
|
||||||
@@ -248,16 +263,60 @@ local function drawDetail()
|
|||||||
sendCommand(turtle.turtleID, "stop")
|
sendCommand(turtle.turtleID, "stop")
|
||||||
end, colors.red)
|
end, colors.red)
|
||||||
|
|
||||||
btnY = h - 4
|
-- Row 2: manual/modes/setHome
|
||||||
addButton(1, btnY, 12, 2, "MANUAL", function()
|
btnY = h - 7
|
||||||
|
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||||
viewMode = "manual"
|
viewMode = "manual"
|
||||||
sendCommand(turtle.turtleID, "manual")
|
sendCommand(turtle.turtleID, "manual")
|
||||||
end, colors.purple)
|
end, colors.purple)
|
||||||
|
|
||||||
addButton(14, btnY, 12, 2, "SET HOME", function()
|
addButton(10, btnY, 8, 2, "MODES", function()
|
||||||
|
viewMode = "modes"
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
|
addButton(19, btnY, 7, 2, "HOME*", function()
|
||||||
sendCommand(turtle.turtleID, "setHome")
|
sendCommand(turtle.turtleID, "setHome")
|
||||||
end, colors.blue)
|
end, colors.blue)
|
||||||
|
|
||||||
|
-- Row 3: equip/rename
|
||||||
|
btnY = h - 4
|
||||||
|
addButton(1, btnY, 8, 2, "EQUIP L", function()
|
||||||
|
-- Send eval to equip left
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipLeft()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(10, btnY, 8, 2, "EQUIP R", function()
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipRight()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(19, btnY, 7, 2, "RENAME", function()
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
term.setTextColor(colors.yellow)
|
||||||
|
print("Enter new name:")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
local name = read()
|
||||||
|
if name and #name > 0 then
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "rename",
|
||||||
|
name = name,
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end
|
||||||
|
draw()
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
viewMode = "overview"
|
viewMode = "overview"
|
||||||
end, colors.gray)
|
end, colors.gray)
|
||||||
@@ -349,9 +408,37 @@ local function drawManual()
|
|||||||
sendCommand(turtle.turtleID, "refuel")
|
sendCommand(turtle.turtleID, "refuel")
|
||||||
end, colors.lime)
|
end, colors.lime)
|
||||||
|
|
||||||
addButton(19, h - 3, 7, 2, "INFO", function()
|
addButton(19, h - 3, 7, 2, "SORT", function()
|
||||||
sendCommand(turtle.turtleID, "status")
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
end, colors.lightBlue)
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = [[
|
||||||
|
local moved = 0
|
||||||
|
for slot = 1, 16 do
|
||||||
|
local item = turtle.getItemDetail(slot)
|
||||||
|
if item then
|
||||||
|
for target = 1, slot - 1 do
|
||||||
|
local ti = turtle.getItemDetail(target)
|
||||||
|
if not ti then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
break
|
||||||
|
elseif ti.name == item.name and ti.count < 64 then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
if turtle.getItemCount(slot) == 0 then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
turtle.select(1)
|
||||||
|
return moved
|
||||||
|
]],
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
addButton(1, h, 12, 1, "< BACK", function()
|
addButton(1, h, 12, 1, "< BACK", function()
|
||||||
viewMode = "detail"
|
viewMode = "detail"
|
||||||
@@ -369,6 +456,98 @@ local function drawManual()
|
|||||||
term.setTextColor(colors.white)
|
term.setTextColor(colors.white)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function drawModes()
|
||||||
|
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||||
|
viewMode = "overview"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
clearButtons()
|
||||||
|
local turtle = turtles[selectedTurtle]
|
||||||
|
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
term.setTextColor(colors.cyan)
|
||||||
|
print("=STATE MACHINE=")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||||
|
|
||||||
|
-- State buttons
|
||||||
|
local btnY = 4
|
||||||
|
local btnW = 12
|
||||||
|
|
||||||
|
addButton(1, btnY, btnW, 2, "IDLE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "idle")
|
||||||
|
sendCommand(turtle.turtleID, "stop")
|
||||||
|
end, colors.gray)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "EXPLORE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "exploring")
|
||||||
|
sendCommand(turtle.turtleID, "explore")
|
||||||
|
end, colors.green)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "MINE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "mining")
|
||||||
|
sendCommand(turtle.turtleID, "explore")
|
||||||
|
end, colors.orange)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "FARM", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "farming")
|
||||||
|
end, colors.lime)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "GO HOME", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "goHome")
|
||||||
|
sendCommand(turtle.turtleID, "returnHome")
|
||||||
|
end, colors.yellow)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "REFUEL", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "refueling")
|
||||||
|
sendCommand(turtle.turtleID, "refuel")
|
||||||
|
end, colors.red)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "DUMP INV", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "dumpInventory")
|
||||||
|
end, colors.brown)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "MOVE TO", function()
|
||||||
|
-- Could prompt for coordinates, for now just sends moving state
|
||||||
|
sendStateCommand(turtle.turtleID, "moving")
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "SCAN", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "scan")
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "EXTRACT", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "extraction")
|
||||||
|
end, colors.magenta)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "BUILD", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "building")
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "AUTOCRAFT", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "autocraft")
|
||||||
|
end, colors.pink)
|
||||||
|
|
||||||
|
-- Back button
|
||||||
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
|
viewMode = "detail"
|
||||||
|
end, colors.gray)
|
||||||
|
|
||||||
|
for _, btn in ipairs(buttons) do
|
||||||
|
drawButton(btn)
|
||||||
|
end
|
||||||
|
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
end
|
||||||
|
|
||||||
local function draw()
|
local function draw()
|
||||||
if viewMode == "overview" then
|
if viewMode == "overview" then
|
||||||
drawOverview()
|
drawOverview()
|
||||||
@@ -376,6 +555,8 @@ local function draw()
|
|||||||
drawDetail()
|
drawDetail()
|
||||||
elseif viewMode == "manual" then
|
elseif viewMode == "manual" then
|
||||||
drawManual()
|
drawManual()
|
||||||
|
elseif viewMode == "modes" then
|
||||||
|
drawModes()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -450,14 +631,20 @@ parallel.waitForAny(
|
|||||||
end,
|
end,
|
||||||
function()
|
function()
|
||||||
-- Status receiver
|
-- Status receiver
|
||||||
|
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||||
|
-- both legacy (102) and target (4212) channels during migration.
|
||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||||
-- Update or add turtle
|
-- Update or add turtle
|
||||||
local found = false
|
local found = false
|
||||||
for i, t in ipairs(turtles) do
|
for i, t in ipairs(turtles) do
|
||||||
if t.turtleID == message.turtleID then
|
if t.turtleID == message.turtleID then
|
||||||
|
-- Preserve state if not in message
|
||||||
|
if not message.state then
|
||||||
|
message.state = t.state or message.mode or "idle"
|
||||||
|
end
|
||||||
turtles[i] = message
|
turtles[i] = message
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
@@ -465,6 +652,9 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
|
|
||||||
if not found then
|
if not found then
|
||||||
|
if not message.state then
|
||||||
|
message.state = message.mode or "idle"
|
||||||
|
end
|
||||||
table.insert(turtles, message)
|
table.insert(turtles, message)
|
||||||
if not selectedTurtle then
|
if not selectedTurtle then
|
||||||
selectedTurtle = 1
|
selectedTurtle = 1
|
||||||
@@ -472,8 +662,16 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
|
|
||||||
draw()
|
draw()
|
||||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
|
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||||
-- Response from turtle
|
-- State change confirmation or other response
|
||||||
|
if message.type == "state_changed" and message.turtleID then
|
||||||
|
for i, t in ipairs(turtles) do
|
||||||
|
if t.turtleID == message.turtleID then
|
||||||
|
turtles[i].state = message.state
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
draw()
|
draw()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
# Node.js backend
|
# Stage 1: Fetch platform server package from git
|
||||||
|
FROM alpine:3.20 AS platform
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||||
|
ARG PLATFORM_BRANCH=master
|
||||||
|
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||||
|
&& rm -rf /src/server/node_modules /src/.git
|
||||||
|
|
||||||
|
# Stage 2: Node.js backend
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy platform server package from the git-clone stage
|
||||||
|
COPY --from=platform /src/server /app/platform-server/
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Rewrite file: dependency to use the local copy inside the container
|
||||||
|
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||||
|
&& rm -f package-lock.json
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy server code
|
# Copy all server code
|
||||||
COPY server.js ./
|
COPY . .
|
||||||
COPY database.js ./
|
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
EXPOSE 3001 3002
|
EXPOSE 3001 3002
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
# Development Dockerfile with hot reload
|
# Stage 1: Fetch platform server package from git
|
||||||
|
FROM alpine:3.20 AS platform
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||||
|
ARG PLATFORM_BRANCH=master
|
||||||
|
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||||
|
&& rm -rf /src/server/node_modules /src/.git
|
||||||
|
|
||||||
|
# Stage 2: Development with hot reload
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy platform server package from the git-clone stage
|
||||||
|
COPY --from=platform /src/server /app/platform-server/
|
||||||
|
|
||||||
# Install nodemon for hot reload
|
# Install nodemon for hot reload
|
||||||
RUN npm install -g nodemon
|
RUN npm install -g nodemon
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Rewrite file: dependency to use the local copy inside the container
|
||||||
|
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||||
|
&& rm -f package-lock.json
|
||||||
|
|
||||||
# Install all dependencies (including dev)
|
# Install all dependencies (including dev)
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
387
server/TaskDispatcher.js
Normal file
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'));
|
const db = new Database(path.join(__dirname, 'turtle_control.db'));
|
||||||
|
|
||||||
|
// Enable WAL journal mode for better concurrent read/write performance
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
// Initialize database schema
|
// Initialize database schema
|
||||||
export function initializeDatabase() {
|
export function initializeDatabase() {
|
||||||
// Turtle homes table
|
// Turtle homes table
|
||||||
@@ -82,12 +85,28 @@ export function initializeDatabase() {
|
|||||||
max_x INTEGER NOT NULL,
|
max_x INTEGER NOT NULL,
|
||||||
max_y INTEGER NOT NULL,
|
max_y INTEGER NOT NULL,
|
||||||
max_z INTEGER NOT NULL,
|
max_z INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
color TEXT DEFAULT '#4a8c2a',
|
||||||
status TEXT DEFAULT 'active',
|
status TEXT DEFAULT 'active',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate existing mining_areas table to add name/color columns if missing
|
||||||
|
try {
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
|
||||||
|
const columns = tableInfo.map(c => c.name);
|
||||||
|
if (!columns.includes('name')) {
|
||||||
|
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
|
||||||
|
}
|
||||||
|
if (!columns.includes('color')) {
|
||||||
|
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore migration errors
|
||||||
|
}
|
||||||
|
|
||||||
// Mining statistics table
|
// Mining statistics table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS mining_stats (
|
CREATE TABLE IF NOT EXISTS mining_stats (
|
||||||
@@ -96,7 +115,8 @@ export function initializeDatabase() {
|
|||||||
block_type TEXT NOT NULL,
|
block_type TEXT NOT NULL,
|
||||||
count INTEGER DEFAULT 1,
|
count INTEGER DEFAULT 1,
|
||||||
session_start INTEGER NOT NULL,
|
session_start INTEGER NOT NULL,
|
||||||
last_mined INTEGER NOT NULL
|
last_mined INTEGER NOT NULL,
|
||||||
|
UNIQUE(turtle_id, block_type, session_start)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -140,10 +160,41 @@ export function initializeDatabase() {
|
|||||||
x INTEGER NOT NULL,
|
x INTEGER NOT NULL,
|
||||||
y INTEGER NOT NULL,
|
y INTEGER NOT NULL,
|
||||||
z INTEGER NOT NULL,
|
z INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migrate player_positions table to add label column if missing
|
||||||
|
try {
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
|
||||||
|
const columns = tableInfo.map(c => c.name);
|
||||||
|
if (!columns.includes('label')) {
|
||||||
|
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore migration errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk analysis table (ore density per chunk)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS chunks (
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
z INTEGER NOT NULL,
|
||||||
|
analysis TEXT DEFAULT '{}',
|
||||||
|
scanned_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (x, z)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add block_state and block_tags columns to world_blocks if not present
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_state TEXT DEFAULT '{}'`);
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_tags TEXT DEFAULT '{}'`);
|
||||||
|
} catch (e) { /* column already exists */ }
|
||||||
|
|
||||||
// Create indexes for better performance
|
// Create indexes for better performance
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
|
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
|
||||||
@@ -155,6 +206,26 @@ export function initializeDatabase() {
|
|||||||
ON task_queue(status, priority DESC);
|
ON task_queue(status, priority DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_world_blocks_name
|
||||||
|
ON world_blocks(block_name);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chunks_coords
|
||||||
|
ON chunks(x, z);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Turtle state persistence table (for reconnect recovery)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS turtle_state (
|
||||||
|
turtle_id INTEGER PRIMARY KEY,
|
||||||
|
state_name TEXT NOT NULL,
|
||||||
|
state_data TEXT DEFAULT '{}',
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
console.log('✅ Database initialized');
|
console.log('✅ Database initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,12 +273,14 @@ export function getTurtleConfig(turtleId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// World Blocks
|
// World Blocks
|
||||||
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy) {
|
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy, blockState = null, blockTags = null) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at)
|
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at, block_state, block_tags)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now());
|
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now(),
|
||||||
|
blockState ? JSON.stringify(blockState) : '{}',
|
||||||
|
blockTags ? JSON.stringify(blockTags) : '{}');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorldBlocks(limit = 10000) {
|
export function getWorldBlocks(limit = 10000) {
|
||||||
@@ -215,6 +288,11 @@ export function getWorldBlocks(limit = 10000) {
|
|||||||
return stmt.all(limit);
|
return stmt.all(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWorldBlockCount() {
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as cnt FROM world_blocks').get();
|
||||||
|
return row ? row.cnt : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
export function getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT * FROM world_blocks
|
SELECT * FROM world_blocks
|
||||||
@@ -239,15 +317,33 @@ export function savePath(turtleId, pathName, pathData) {
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
|
const result = stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
|
||||||
|
return result.lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPaths(turtleId) {
|
export function getPaths(turtleId = null) {
|
||||||
|
if (turtleId) {
|
||||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
|
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
|
||||||
return stmt.all(turtleId).map(row => ({
|
return stmt.all(turtleId).map(row => ({
|
||||||
...row,
|
...row,
|
||||||
path_data: JSON.parse(row.path_data)
|
path_data: JSON.parse(row.path_data)
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
const stmt = db.prepare('SELECT * FROM turtle_paths ORDER BY created_at DESC');
|
||||||
|
return stmt.all().map(row => ({
|
||||||
|
...row,
|
||||||
|
path_data: JSON.parse(row.path_data)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPath(pathId) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE id = ?');
|
||||||
|
const row = stmt.get(pathId);
|
||||||
|
if (row) {
|
||||||
|
return { ...row, path_data: JSON.parse(row.path_data) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePath(pathId) {
|
export function deletePath(pathId) {
|
||||||
@@ -256,13 +352,13 @@ export function deletePath(pathId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Task Queue
|
// Task Queue
|
||||||
export function createTask(taskType, taskData, priority = 0) {
|
export function createTask(taskType, taskData, priority = 0, assignedTurtleId = null) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO task_queue (task_type, task_data, priority, status, created_at, updated_at)
|
INSERT INTO task_queue (task_type, task_data, assigned_turtle_id, priority, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, 'pending', ?, ?)
|
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||||
`);
|
`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const result = stmt.run(taskType, JSON.stringify(taskData), priority, now, now);
|
const result = stmt.run(taskType, JSON.stringify(taskData), assignedTurtleId, priority, now, now);
|
||||||
return result.lastInsertRowid;
|
return result.lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +380,15 @@ export function getNextTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assignTask(taskId, turtleId) {
|
export function assignTask(taskId, turtleId) {
|
||||||
|
if (turtleId === null || turtleId === undefined) {
|
||||||
|
// Un-assign: clear turtle and revert to pending
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE task_queue
|
||||||
|
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
stmt.run(Date.now(), taskId);
|
||||||
|
} else {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
UPDATE task_queue
|
UPDATE task_queue
|
||||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||||
@@ -291,6 +396,16 @@ export function assignTask(taskId, turtleId) {
|
|||||||
`);
|
`);
|
||||||
stmt.run(turtleId, Date.now(), taskId);
|
stmt.run(turtleId, Date.now(), taskId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTaskStatus(taskId, status, result = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE task_queue
|
||||||
|
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(status, result, result, Date.now(), taskId);
|
||||||
|
}
|
||||||
|
|
||||||
export function completeTask(taskId) {
|
export function completeTask(taskId) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
@@ -301,7 +416,19 @@ export function completeTask(taskId) {
|
|||||||
stmt.run(Date.now(), taskId);
|
stmt.run(Date.now(), taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllTasks() {
|
export function deleteTask(taskId) {
|
||||||
|
const stmt = db.prepare('DELETE FROM task_queue WHERE id = ?');
|
||||||
|
return stmt.run(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllTasks(status = null) {
|
||||||
|
if (status) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM task_queue WHERE status = ? ORDER BY priority DESC, created_at DESC');
|
||||||
|
return stmt.all(status).map(row => ({
|
||||||
|
...row,
|
||||||
|
task_data: JSON.parse(row.task_data)
|
||||||
|
}));
|
||||||
|
}
|
||||||
const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC');
|
const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC');
|
||||||
return stmt.all().map(row => ({
|
return stmt.all().map(row => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -310,25 +437,59 @@ export function getAllTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mining Areas
|
// Mining Areas
|
||||||
export function saveMiningArea(turtleId, bounds) {
|
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, status, created_at, updated_at)
|
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
stmt.run(
|
const result = stmt.run(
|
||||||
turtleId,
|
turtleId,
|
||||||
bounds.minX, bounds.minY, bounds.minZ,
|
bounds.minX, bounds.minY, bounds.minZ,
|
||||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||||
now, now
|
areaName, color,
|
||||||
|
status, now, now
|
||||||
);
|
);
|
||||||
|
return result.lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMiningAreas() {
|
export function getMiningAreas(statusFilter = null) {
|
||||||
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = \'active\'');
|
if (statusFilter) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = ? ORDER BY created_at DESC');
|
||||||
|
return stmt.all(statusFilter);
|
||||||
|
}
|
||||||
|
const stmt = db.prepare('SELECT * FROM mining_areas ORDER BY created_at DESC');
|
||||||
return stmt.all();
|
return stmt.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateMiningAreaStatus(areaId, status) {
|
||||||
|
const stmt = db.prepare('UPDATE mining_areas SET status = ?, updated_at = ? WHERE id = ?');
|
||||||
|
stmt.run(status, Date.now(), areaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMiningArea(areaId, updates) {
|
||||||
|
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
|
||||||
|
const setClauses = [];
|
||||||
|
const values = [];
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (allowedFields.includes(key)) {
|
||||||
|
setClauses.push(`${key} = ?`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (setClauses.length === 0) return;
|
||||||
|
setClauses.push('updated_at = ?');
|
||||||
|
values.push(Date.now());
|
||||||
|
values.push(areaId);
|
||||||
|
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
|
||||||
|
stmt.run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMiningArea(areaId) {
|
||||||
|
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
||||||
|
return stmt.run(areaId);
|
||||||
|
}
|
||||||
|
|
||||||
export function closeMiningArea(areaId) {
|
export function closeMiningArea(areaId) {
|
||||||
const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?');
|
||||||
stmt.run(Date.now(), areaId);
|
stmt.run(Date.now(), areaId);
|
||||||
@@ -373,7 +534,7 @@ export function getMiningStats(turtleId = null, days = 7) {
|
|||||||
|
|
||||||
export function getTopMiners(limit = 10) {
|
export function getTopMiners(limit = 10) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT turtle_id, SUM(count) as total_blocks
|
SELECT turtle_id, SUM(count) as total_blocks, COUNT(DISTINCT block_type) as unique_types
|
||||||
FROM mining_stats
|
FROM mining_stats
|
||||||
GROUP BY turtle_id
|
GROUP BY turtle_id
|
||||||
ORDER BY total_blocks DESC
|
ORDER BY total_blocks DESC
|
||||||
@@ -467,22 +628,34 @@ export function getSessionStats(turtleId, limit = 10) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Player Positions
|
// Player Positions
|
||||||
export function savePlayerPosition(playerId, position) {
|
export function savePlayerPosition(playerId, position, label = null) {
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, updated_at)
|
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
stmt.run(playerId, position.x, position.y, position.z, Date.now());
|
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlayerPosition(playerId) {
|
export function getPlayerPosition(playerId) {
|
||||||
const stmt = db.prepare('SELECT x, y, z, updated_at FROM player_positions WHERE player_id = ?');
|
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
|
||||||
return stmt.get(playerId);
|
const row = stmt.get(playerId);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
playerID: row.player_id,
|
||||||
|
position: { x: row.x, y: row.y, z: row.z },
|
||||||
|
label: row.label,
|
||||||
|
lastUpdate: row.updated_at
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPlayerPositions() {
|
export function getAllPlayerPositions() {
|
||||||
const stmt = db.prepare('SELECT player_id, x, y, z, updated_at FROM player_positions');
|
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
|
||||||
return stmt.all();
|
return stmt.all().map(row => ({
|
||||||
|
playerID: row.player_id,
|
||||||
|
position: { x: row.x, y: row.y, z: row.z },
|
||||||
|
label: row.label,
|
||||||
|
lastUpdate: row.updated_at
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
@@ -490,5 +663,117 @@ export function closeDatabase() {
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== CHUNK ANALYSIS ==========
|
||||||
|
|
||||||
|
export function saveChunkAnalysis(x, z, analysis) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO chunks (x, z, analysis, scanned_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(x, z, JSON.stringify(analysis), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChunkAnalysis(x, z) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM chunks WHERE x = ? AND z = ?');
|
||||||
|
const row = stmt.get(x, z);
|
||||||
|
if (row) {
|
||||||
|
return { ...row, analysis: JSON.parse(row.analysis) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllChunkAnalyses() {
|
||||||
|
const stmt = db.prepare('SELECT * FROM chunks');
|
||||||
|
return stmt.all().map(row => ({
|
||||||
|
...row,
|
||||||
|
analysis: JSON.parse(row.analysis)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== BLOCK SEARCH ==========
|
||||||
|
|
||||||
|
export function getBlocksWithNameLike(fromX, fromY, fromZ, toX, toY, toZ, namePattern) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT x, y, z, block_name as name, metadata, block_state, block_tags
|
||||||
|
FROM world_blocks
|
||||||
|
WHERE x BETWEEN ? AND ?
|
||||||
|
AND y BETWEEN ? AND ?
|
||||||
|
AND z BETWEEN ? AND ?
|
||||||
|
AND block_name LIKE ?
|
||||||
|
`);
|
||||||
|
return stmt.all(
|
||||||
|
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||||
|
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||||
|
Math.min(fromZ, toZ), Math.max(fromZ, toZ),
|
||||||
|
namePattern
|
||||||
|
).map(row => ({
|
||||||
|
...row,
|
||||||
|
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||||
|
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlock(x, y, z) {
|
||||||
|
const stmt = db.prepare('SELECT block_name as name, metadata, block_state, block_tags FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||||
|
const row = stmt.get(x, y, z);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||||
|
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBlocksInArea(fromX, fromY, fromZ, toX, toY, toZ) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
DELETE FROM world_blocks
|
||||||
|
WHERE x BETWEEN ? AND ?
|
||||||
|
AND y BETWEEN ? AND ?
|
||||||
|
AND z BETWEEN ? AND ?
|
||||||
|
`);
|
||||||
|
return stmt.run(
|
||||||
|
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||||
|
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||||
|
Math.min(fromZ, toZ), Math.max(fromZ, toZ)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== BLOCK DELETION ==========
|
||||||
|
|
||||||
|
export function deleteBlock(x, y, z) {
|
||||||
|
const stmt = db.prepare('DELETE FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||||
|
return stmt.run(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TURTLE STATE PERSISTENCE ==========
|
||||||
|
|
||||||
|
export function saveTurtleState(turtleId, stateName, stateData = {}) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO turtle_state (turtle_id, state_name, state_data, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(turtleId, stateName, JSON.stringify(stateData), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTurtleState(turtleId) {
|
||||||
|
const stmt = db.prepare('SELECT * FROM turtle_state WHERE turtle_id = ?');
|
||||||
|
const row = stmt.get(turtleId);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
stateName: row.state_name,
|
||||||
|
stateData: JSON.parse(row.state_data),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTurtleState(turtleId) {
|
||||||
|
const stmt = db.prepare('DELETE FROM turtle_state WHERE turtle_id = ?');
|
||||||
|
return stmt.run(turtleId);
|
||||||
|
}
|
||||||
|
|
||||||
// Export database instance for custom queries if needed
|
// Export database instance for custom queries if needed
|
||||||
export { db };
|
export { db };
|
||||||
|
|||||||
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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"minecraft",
|
"minecraft",
|
||||||
@@ -17,12 +19,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||||
"ws": "^8.14.2",
|
"better-sqlite3": "^9.2.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"better-sqlite3": "^9.2.2"
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
1421
server/server.js
1421
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';
|
||||||
1352
turtle.lua
1352
turtle.lua
File diff suppressed because it is too large
Load Diff
804
webbridge.lua
804
webbridge.lua
@@ -1,37 +1,61 @@
|
|||||||
-- Web Bridge Dashboard for Turtle System
|
-- Web Bridge v2 (WebSocket Protocol)
|
||||||
-- Beautiful visual interface for 2x3 monitor setup
|
-- Connects to server via WebSocket for instant bidirectional communication.
|
||||||
-- Forwards turtle status updates to the web server
|
-- Forwards turtle modem messages to server and server commands to turtles.
|
||||||
|
-- Falls back to HTTP polling if WebSocket is unavailable.
|
||||||
|
--
|
||||||
|
-- Uses cc-platform-core for shared infrastructure (config, HTTP, modem, channels).
|
||||||
|
|
||||||
local SERVER_URL = "http://10.10.10.6:4200" -- Change to your server address
|
local WebBridge = require('platform.webbridge')
|
||||||
local CHANNEL_RECEIVE = 101
|
local Channels = require('platform.channels')
|
||||||
local STATUS_CHANNEL = 102
|
|
||||||
local COMMAND_CHANNEL = 100
|
|
||||||
local POCKET_CHANNEL = 103 -- Pocket computer communication
|
|
||||||
|
|
||||||
-- Find peripherals
|
-------------------------------------------------
|
||||||
local modem = peripheral.find("modem")
|
-- Configuration (via platform)
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||||
|
|
||||||
|
local config, configSource = WebBridge.loadConfig({
|
||||||
|
serverUrl = "http://localhost:4200",
|
||||||
|
wsUrl = nil, -- derived from serverUrl if not set
|
||||||
|
}, {
|
||||||
|
fs.combine(_baseDir, ".webbridge_config"),
|
||||||
|
})
|
||||||
|
|
||||||
|
local SERVER_URL = config.serverUrl
|
||||||
|
local WS_URL = config.wsUrl or SERVER_URL:gsub("^http", "ws") .. "/ws/bridge"
|
||||||
|
|
||||||
|
if configSource then
|
||||||
|
print("[CONFIG] Loaded from " .. configSource)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Channels from platform registry
|
||||||
|
local COMMAND_CHANNEL = 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')
|
||||||
|
|
||||||
|
-------------------------------------------------
|
||||||
|
-- Peripherals
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
local modem, modemSide = WebBridge.findModem(true) -- prefer wireless
|
||||||
local monitor = peripheral.find("monitor")
|
local monitor = peripheral.find("monitor")
|
||||||
|
|
||||||
if not modem then
|
if not modem then
|
||||||
error("No wireless modem found!")
|
error("No wireless modem found!")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Check if we have a monitor for dashboard
|
|
||||||
local hasMonitor = monitor ~= nil
|
local hasMonitor = monitor ~= nil
|
||||||
|
|
||||||
if hasMonitor then
|
if hasMonitor then
|
||||||
monitor.setTextScale(0.5)
|
monitor.setTextScale(0.5)
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
-- Open channels via platform (respects dual-mode migration)
|
||||||
modem.open(STATUS_CHANNEL)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(POCKET_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
print("Webbridge Channel Configuration:")
|
'remoteturtle.pocket',
|
||||||
print(" CHANNEL_RECEIVE: " .. CHANNEL_RECEIVE)
|
})
|
||||||
print(" STATUS_CHANNEL: " .. STATUS_CHANNEL)
|
|
||||||
print(" COMMAND_CHANNEL: " .. COMMAND_CHANNEL .. " (transmit only)")
|
|
||||||
print(" POCKET_CHANNEL: " .. POCKET_CHANNEL)
|
|
||||||
|
|
||||||
-- Track turtles and stats
|
-- Track turtles and stats
|
||||||
local turtles = {}
|
local turtles = {}
|
||||||
@@ -43,8 +67,11 @@ local stats = {
|
|||||||
startTime = os.epoch("utc")
|
startTime = os.epoch("utc")
|
||||||
}
|
}
|
||||||
local activityLog = {}
|
local activityLog = {}
|
||||||
|
local wsHandle = nil
|
||||||
|
local wsConnected = false
|
||||||
|
|
||||||
|
-- ========== Dashboard Drawing ==========
|
||||||
|
|
||||||
-- Dashboard drawing functions (only if monitor available)
|
|
||||||
local function centerText(y, text, fg, bg)
|
local function centerText(y, text, fg, bg)
|
||||||
if not hasMonitor then return end
|
if not hasMonitor then return end
|
||||||
local w = monitor.getSize()
|
local w = monitor.getSize()
|
||||||
@@ -54,40 +81,21 @@ local function centerText(y, text, fg, bg)
|
|||||||
monitor.write(text)
|
monitor.write(text)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawBox(x, y, width, height, color)
|
local function getStatusColor(turtle)
|
||||||
if not hasMonitor then return end
|
if not turtle.lastSeen then return colors.red end
|
||||||
monitor.setBackgroundColor(color)
|
local timeSince = os.epoch("utc") - turtle.lastSeen
|
||||||
for dy = 0, height - 1 do
|
if timeSince < 10000 then return colors.lime
|
||||||
monitor.setCursorPos(x, y + dy)
|
elseif timeSince < 30000 then return colors.yellow
|
||||||
monitor.write(string.rep(" ", width))
|
else return colors.red end
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function formatTime(ms)
|
local function formatTime(ms)
|
||||||
local seconds = math.floor(ms / 1000)
|
local seconds = math.floor(ms / 1000)
|
||||||
local minutes = math.floor(seconds / 60)
|
local minutes = math.floor(seconds / 60)
|
||||||
local hours = math.floor(minutes / 60)
|
local hours = math.floor(minutes / 60)
|
||||||
|
if hours > 0 then return string.format("%dh %dm", hours, minutes % 60)
|
||||||
if hours > 0 then
|
elseif minutes > 0 then return string.format("%dm %ds", minutes, seconds % 60)
|
||||||
return string.format("%dh %dm", hours, minutes % 60)
|
else return string.format("%ds", seconds) end
|
||||||
elseif minutes > 0 then
|
|
||||||
return string.format("%dm %ds", minutes, seconds % 60)
|
|
||||||
else
|
|
||||||
return string.format("%ds", seconds)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function getStatusColor(turtle)
|
|
||||||
if not turtle.lastSeen then return colors.red end
|
|
||||||
|
|
||||||
local timeSince = os.epoch("utc") - turtle.lastSeen
|
|
||||||
if timeSince < 10000 then
|
|
||||||
return colors.lime
|
|
||||||
elseif timeSince < 30000 then
|
|
||||||
return colors.yellow
|
|
||||||
else
|
|
||||||
return colors.red
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawDashboard()
|
local function drawDashboard()
|
||||||
@@ -99,16 +107,14 @@ local function drawDashboard()
|
|||||||
|
|
||||||
-- Count turtles
|
-- Count turtles
|
||||||
local turtleCount = 0
|
local turtleCount = 0
|
||||||
for _ in pairs(turtles) do
|
for _ in pairs(turtles) do turtleCount = turtleCount + 1 end
|
||||||
turtleCount = turtleCount + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Simple header
|
-- Header
|
||||||
|
local connIcon = wsConnected and "WS" or "HTTP"
|
||||||
monitor.setBackgroundColor(colors.blue)
|
monitor.setBackgroundColor(colors.blue)
|
||||||
monitor.setTextColor(colors.white)
|
|
||||||
monitor.setCursorPos(1, 1)
|
monitor.setCursorPos(1, 1)
|
||||||
monitor.clearLine()
|
monitor.clearLine()
|
||||||
centerText(1, "TURTLE BRIDGE - " .. turtleCount .. " ONLINE", colors.white, colors.blue)
|
centerText(1, "TURTLE BRIDGE [" .. connIcon .. "] - " .. turtleCount .. " ONLINE", colors.white, colors.blue)
|
||||||
|
|
||||||
-- Status line
|
-- Status line
|
||||||
monitor.setBackgroundColor(colors.gray)
|
monitor.setBackgroundColor(colors.gray)
|
||||||
@@ -116,61 +122,48 @@ local function drawDashboard()
|
|||||||
monitor.clearLine()
|
monitor.clearLine()
|
||||||
monitor.setTextColor(colors.white)
|
monitor.setTextColor(colors.white)
|
||||||
monitor.setCursorPos(2, 2)
|
monitor.setCursorPos(2, 2)
|
||||||
monitor.write("MSG: " .. stats.messagesReceived .. " | CMD: " .. stats.commandsSent .. " | ERR: " .. stats.errors)
|
monitor.write("MSG:" .. stats.messagesReceived .. " CMD:" .. stats.commandsSent .. " ERR:" .. stats.errors)
|
||||||
|
|
||||||
-- Turtle list - much larger, starts at line 4
|
-- Turtle list
|
||||||
monitor.setBackgroundColor(colors.black)
|
monitor.setBackgroundColor(colors.black)
|
||||||
local startY = 4
|
local startY = 4
|
||||||
|
|
||||||
if turtleCount == 0 then
|
if turtleCount == 0 then
|
||||||
monitor.setTextColor(colors.gray)
|
monitor.setTextColor(colors.gray)
|
||||||
monitor.setCursorPos(2, startY)
|
monitor.setCursorPos(2, startY)
|
||||||
monitor.write("No turtles connected. Waiting for signals...")
|
monitor.write("No turtles connected. Waiting...")
|
||||||
else
|
else
|
||||||
-- Show all turtles in a compact list
|
|
||||||
local y = startY
|
local y = startY
|
||||||
local count = 0
|
|
||||||
for id, turtle in pairs(turtles) do
|
for id, turtle in pairs(turtles) do
|
||||||
if y >= h - 5 then break end -- Leave room for activity log
|
if y >= h - 5 then break end
|
||||||
|
|
||||||
local statusColor = getStatusColor(turtle)
|
local statusColor = getStatusColor(turtle)
|
||||||
local timeSince = 0
|
local timeSince = 0
|
||||||
if turtle.lastSeen then
|
if turtle.lastSeen then
|
||||||
local currentTime = os.epoch("utc") or 0
|
timeSince = math.floor((os.epoch("utc") - turtle.lastSeen) / 1000)
|
||||||
timeSince = math.floor((currentTime - turtle.lastSeen) / 1000)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
monitor.setCursorPos(2, y)
|
monitor.setCursorPos(2, y)
|
||||||
monitor.setTextColor(statusColor)
|
monitor.setTextColor(statusColor)
|
||||||
monitor.write("\7")
|
monitor.write("\7")
|
||||||
|
|
||||||
monitor.setTextColor(colors.white)
|
monitor.setTextColor(colors.white)
|
||||||
monitor.write(string.format(" Turtle #%-3d", id))
|
monitor.write(string.format(" T#%-3d", id))
|
||||||
|
|
||||||
monitor.setTextColor(colors.lightGray)
|
monitor.setTextColor(colors.lightGray)
|
||||||
local state = (turtle.state or "IDLE"):upper()
|
local tState = (turtle.mode or "IDLE"):upper()
|
||||||
if #state > 12 then state = state:sub(1, 12) end
|
if #tState > 10 then tState = tState:sub(1, 10) end
|
||||||
monitor.write(" " .. state)
|
monitor.write(" " .. tState)
|
||||||
|
|
||||||
-- Position on same line
|
|
||||||
if turtle.position then
|
if turtle.position then
|
||||||
monitor.setTextColor(colors.gray)
|
monitor.setTextColor(colors.gray)
|
||||||
monitor.write(string.format(" [%d,%d,%d]",
|
monitor.write(string.format(" [%d,%d,%d]", turtle.position.x or 0, turtle.position.y or 0, turtle.position.z or 0))
|
||||||
turtle.position.x or 0,
|
|
||||||
turtle.position.y or 0,
|
|
||||||
turtle.position.z or 0))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Last seen
|
|
||||||
monitor.setTextColor(colors.gray)
|
monitor.setTextColor(colors.gray)
|
||||||
monitor.write(string.format(" %ds", timeSince or 0))
|
monitor.write(string.format(" %ds", timeSince))
|
||||||
|
|
||||||
y = y + 1
|
y = y + 1
|
||||||
count = count + 1
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Activity log (last 5 lines of screen)
|
-- Activity log
|
||||||
local logY = h - 4
|
local logY = h - 4
|
||||||
monitor.setBackgroundColor(colors.gray)
|
monitor.setBackgroundColor(colors.gray)
|
||||||
monitor.setCursorPos(1, logY)
|
monitor.setCursorPos(1, logY)
|
||||||
@@ -181,464 +174,363 @@ local function drawDashboard()
|
|||||||
|
|
||||||
logY = logY + 1
|
logY = logY + 1
|
||||||
monitor.setBackgroundColor(colors.black)
|
monitor.setBackgroundColor(colors.black)
|
||||||
|
|
||||||
-- Show last 3 log entries
|
|
||||||
for i = 1, math.min(3, #activityLog) do
|
for i = 1, math.min(3, #activityLog) do
|
||||||
local entry = activityLog[i]
|
local entry = activityLog[i]
|
||||||
monitor.setCursorPos(1, logY)
|
monitor.setCursorPos(1, logY)
|
||||||
monitor.clearLine()
|
monitor.clearLine()
|
||||||
|
|
||||||
monitor.setTextColor(colors.gray)
|
monitor.setTextColor(colors.gray)
|
||||||
local time = os.date("%H:%M:%S", (entry.time or 0) / 1000)
|
monitor.write(os.date("%H:%M:%S", (entry.time or 0) / 1000) .. " ")
|
||||||
monitor.write(time .. " ")
|
|
||||||
|
|
||||||
monitor.setTextColor(entry.color or colors.white)
|
monitor.setTextColor(entry.color or colors.white)
|
||||||
local maxWidth = w - 10
|
local maxWidth = w - 10
|
||||||
local text = entry.text
|
local text = entry.text
|
||||||
if #text > maxWidth then
|
if #text > maxWidth then text = text:sub(1, maxWidth - 3) .. "..." end
|
||||||
text = text:sub(1, maxWidth - 3) .. "..."
|
|
||||||
end
|
|
||||||
monitor.write(text)
|
monitor.write(text)
|
||||||
|
|
||||||
logY = logY + 1
|
logY = logY + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function addLog(text, color)
|
local function addLog(text, color)
|
||||||
table.insert(activityLog, 1, { text = text, color = color or colors.white, time = os.epoch("utc") })
|
table.insert(activityLog, 1, { text = text, color = color or colors.white, time = os.epoch("utc") })
|
||||||
if #activityLog > 35 then
|
if #activityLog > 35 then table.remove(activityLog) end
|
||||||
table.remove(activityLog)
|
if not hasMonitor then print(text) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Also print to console if no monitor
|
-- ========== WebSocket Send Helper ==========
|
||||||
if not hasMonitor then
|
|
||||||
print(text)
|
local function wsSend(data)
|
||||||
|
if wsHandle and wsConnected then
|
||||||
|
local ok = pcall(function()
|
||||||
|
wsHandle.send(textutils.serializeJSON(data))
|
||||||
|
end)
|
||||||
|
return ok
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
-- Function to send data to web server
|
|
||||||
local function sendToServer(data)
|
|
||||||
local success, result = pcall(function()
|
|
||||||
local jsonData = textutils.serializeJSON(data)
|
|
||||||
|
|
||||||
local response = http.post(
|
|
||||||
SERVER_URL .. "/api/turtle/update",
|
|
||||||
jsonData,
|
|
||||||
{["Content-Type"] = "application/json"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response then
|
|
||||||
response.close()
|
|
||||||
return true
|
|
||||||
else
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
end)
|
|
||||||
|
|
||||||
return success and result
|
-- ========== HTTP Helpers (via platform) ==========
|
||||||
|
|
||||||
|
local function httpPost(path, data)
|
||||||
|
local result = WebBridge.httpPost(SERVER_URL .. path, data)
|
||||||
|
if result then
|
||||||
|
local ok, parsed = pcall(textutils.unserialiseJSON, result)
|
||||||
|
if ok then return parsed end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Function to poll for commands from server
|
local function httpGet(path)
|
||||||
local function pollCommands(turtleID)
|
local result = WebBridge.httpGet(SERVER_URL .. path)
|
||||||
local success, result = pcall(function()
|
if result then
|
||||||
local response = http.get(SERVER_URL .. "/api/turtle/" .. turtleID .. "/commands")
|
local ok, parsed = pcall(textutils.unserialiseJSON, result)
|
||||||
|
if ok then return parsed end
|
||||||
if response then
|
|
||||||
local content = response.readAll()
|
|
||||||
response.close()
|
|
||||||
|
|
||||||
local data = textutils.unserializeJSON(content)
|
|
||||||
if data and data.commands then
|
|
||||||
return data.commands
|
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return {}
|
-- ========== Forward Turtle Modem Message to Server ==========
|
||||||
end)
|
|
||||||
|
|
||||||
if success then
|
local function forwardToServer(message)
|
||||||
return result
|
if not message or type(message) ~= "table" then return end
|
||||||
else
|
|
||||||
stats.errors = stats.errors + 1
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Function to acknowledge commands were sent
|
local msgType = message.type
|
||||||
local function acknowledgeCommands(turtleID)
|
|
||||||
local success = pcall(function()
|
|
||||||
local response = http.post(
|
|
||||||
SERVER_URL .. "/api/turtle/" .. turtleID .. "/commands/ack",
|
|
||||||
"{}",
|
|
||||||
{["Content-Type"] = "application/json"}
|
|
||||||
)
|
|
||||||
|
|
||||||
if response then
|
|
||||||
response.close()
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end)
|
|
||||||
|
|
||||||
return success
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Initial setup
|
|
||||||
if hasMonitor then
|
|
||||||
drawDashboard()
|
|
||||||
addLog("System initialized with monitor", colors.lime)
|
|
||||||
else
|
|
||||||
print("Web Bridge Started (No monitor)")
|
|
||||||
print("Listening for turtle updates...")
|
|
||||||
print("Server: " .. SERVER_URL)
|
|
||||||
end
|
|
||||||
|
|
||||||
addLog("Listening on channels " .. STATUS_CHANNEL .. ", " .. CHANNEL_RECEIVE .. ", " .. POCKET_CHANNEL, colors.lightBlue)
|
|
||||||
|
|
||||||
-- Debug: Print what channels are actually open
|
|
||||||
print("Opened channels:")
|
|
||||||
for i = 0, 65535 do
|
|
||||||
if modem.isOpen(i) then
|
|
||||||
print(" Channel " .. i .. " is OPEN")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Start polling timer
|
|
||||||
local POLL_INTERVAL = 2 -- Poll every 2 seconds (reduced frequency for better reliability)
|
|
||||||
os.startTimer(POLL_INTERVAL)
|
|
||||||
|
|
||||||
-- Track which turtles we've sent commands to recently
|
|
||||||
local recentCommandSends = {}
|
|
||||||
|
|
||||||
-- Main loop
|
|
||||||
local lastRefresh = os.epoch("utc")
|
|
||||||
print("🎧 Starting main event loop...")
|
|
||||||
while true do
|
|
||||||
local event, side, channel, replyChannel, message, distance = os.pullEvent()
|
|
||||||
|
|
||||||
-- Log ALL modem messages for debugging
|
|
||||||
if event == "modem_message" then
|
|
||||||
print("🔔 MODEM MESSAGE RECEIVED!")
|
|
||||||
print(" Event: " .. event)
|
|
||||||
print(" Channel: " .. channel)
|
|
||||||
print(" Expected channels: " .. STATUS_CHANNEL .. " (status), " .. CHANNEL_RECEIVE .. " (receive), " .. POCKET_CHANNEL .. " (pocket)")
|
|
||||||
end
|
|
||||||
|
|
||||||
if event == "timer" then
|
|
||||||
-- Poll for commands for all known turtles
|
|
||||||
for turtleID, turtleData in pairs(turtles) do
|
|
||||||
local commands = pollCommands(turtleID)
|
|
||||||
|
|
||||||
-- Only send commands if we got some
|
|
||||||
if #commands > 0 then
|
|
||||||
addLog("Received " .. #commands .. " command(s) for Turtle #" .. turtleID, colors.cyan)
|
|
||||||
|
|
||||||
-- Forward commands back to turtle
|
|
||||||
for _, cmd in ipairs(commands) do
|
|
||||||
stats.commandsSent = stats.commandsSent + 1
|
|
||||||
addLog(" CMD: " .. cmd.command .. " -> Turtle #" .. turtleID, colors.yellow)
|
|
||||||
|
|
||||||
local commandPacket = {
|
|
||||||
command = cmd.command,
|
|
||||||
param = cmd.param,
|
|
||||||
target = turtleID
|
|
||||||
}
|
|
||||||
|
|
||||||
print("📡 Transmitting command to Turtle #" .. turtleID)
|
|
||||||
print(" Channel: " .. COMMAND_CHANNEL)
|
|
||||||
print(" Command: " .. cmd.command)
|
|
||||||
print(" Target: " .. turtleID)
|
|
||||||
print(" Packet: " .. textutils.serialize(commandPacket))
|
|
||||||
|
|
||||||
-- Send command multiple times for reliability
|
|
||||||
for i = 1, 3 do
|
|
||||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, commandPacket)
|
|
||||||
print(" Transmission " .. i .. "/3 sent")
|
|
||||||
os.sleep(0.05) -- Small delay between retransmissions
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Debug: show what we're sending
|
|
||||||
if not hasMonitor then
|
|
||||||
print(" ✅ Sent 3 times on channel " .. COMMAND_CHANNEL)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Acknowledge that we sent the commands
|
|
||||||
-- Wait longer to ensure turtle has received them
|
|
||||||
os.sleep(1.5) -- Give turtle time to receive and process
|
|
||||||
if acknowledgeCommands(turtleID) then
|
|
||||||
addLog(" ACK: Commands acknowledged", colors.lime)
|
|
||||||
else
|
|
||||||
addLog(" WARN: Failed to acknowledge", colors.orange)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Mark that we sent commands to this turtle
|
|
||||||
recentCommandSends[turtleID] = os.epoch("utc")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Clean up old command send timestamps (older than 10 seconds)
|
|
||||||
local now = os.epoch("utc")
|
|
||||||
for turtleID, timestamp in pairs(recentCommandSends) do
|
|
||||||
if now - timestamp > 10000 then
|
|
||||||
recentCommandSends[turtleID] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Restart timer
|
|
||||||
os.startTimer(POLL_INTERVAL)
|
|
||||||
|
|
||||||
-- Refresh display if we have a monitor
|
|
||||||
if hasMonitor then
|
|
||||||
local now = os.epoch("utc")
|
|
||||||
if now - lastRefresh > 2000 then
|
|
||||||
drawDashboard()
|
|
||||||
lastRefresh = now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
elseif event == "modem_message" then
|
|
||||||
-- Only process messages on our channels
|
|
||||||
if channel == STATUS_CHANNEL or channel == CHANNEL_RECEIVE then
|
|
||||||
stats.messagesReceived = stats.messagesReceived + 1
|
|
||||||
|
|
||||||
if type(message) == "table" then
|
|
||||||
-- Debug: log what type of message we got
|
|
||||||
print("📨 Received message on channel " .. channel)
|
|
||||||
if message.type then
|
|
||||||
print(" Type: " .. message.type)
|
|
||||||
end
|
|
||||||
if message.turtleID then
|
|
||||||
print(" From turtle: " .. message.turtleID)
|
|
||||||
end
|
|
||||||
|
|
||||||
if message.type == "status" then
|
|
||||||
local turtleID = message.turtleID
|
local turtleID = message.turtleID
|
||||||
|
|
||||||
print("✅ Processing status update from Turtle #" .. turtleID)
|
if msgType == "status" then
|
||||||
|
|
||||||
-- Update turtle data
|
|
||||||
turtles[turtleID] = message
|
turtles[turtleID] = message
|
||||||
turtles[turtleID].lastSeen = os.epoch("utc")
|
turtles[turtleID].lastSeen = os.epoch("utc")
|
||||||
|
addLog("T#" .. turtleID .. " status", colors.lightBlue)
|
||||||
|
|
||||||
-- Count turtles
|
if wsSend(message) then
|
||||||
local count = 0
|
stats.serverUpdates = stats.serverUpdates + 1
|
||||||
for _ in pairs(turtles) do
|
else
|
||||||
count = count + 1
|
if httpPost("/api/turtle/update", message) then
|
||||||
end
|
|
||||||
print(" Total turtles tracked: " .. count)
|
|
||||||
|
|
||||||
addLog("Turtle #" .. turtleID .. " - " .. (message.state or "status"), colors.lightBlue)
|
|
||||||
|
|
||||||
-- Forward to web server
|
|
||||||
local success = sendToServer(message)
|
|
||||||
if success then
|
|
||||||
stats.serverUpdates = stats.serverUpdates + 1
|
stats.serverUpdates = stats.serverUpdates + 1
|
||||||
addLog(" -> Forwarded to server", colors.lime)
|
|
||||||
else
|
else
|
||||||
stats.errors = stats.errors + 1
|
stats.errors = stats.errors + 1
|
||||||
addLog(" -> Server error", colors.red)
|
addLog(" -> Server error", colors.red)
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif message.type == "request_home" then
|
|
||||||
-- Turtle requesting its home position from server
|
|
||||||
local turtleID = message.turtleID
|
|
||||||
addLog("Turtle #" .. turtleID .. " requesting home", colors.cyan)
|
|
||||||
|
|
||||||
local success, result = pcall(function()
|
|
||||||
local response = http.get(SERVER_URL .. "/api/turtle/" .. turtleID .. "/home")
|
|
||||||
if response then
|
|
||||||
local content = response.readAll()
|
|
||||||
response.close()
|
|
||||||
local data = textutils.unserializeJSON(content)
|
|
||||||
return data
|
|
||||||
end
|
end
|
||||||
return nil
|
|
||||||
end)
|
|
||||||
|
|
||||||
if success and result then
|
elseif msgType == "eval_response" then
|
||||||
modem.transmit(CHANNEL_RECEIVE, CHANNEL_RECEIVE, {
|
addLog("Eval resp T#" .. turtleID .. " " .. (message.uuid or "?"):sub(1, 8), colors.cyan)
|
||||||
|
if not wsSend({ type = "eval_response", turtleID = turtleID, uuid = message.uuid, result = message.result, error = message.error }) then
|
||||||
|
local result = httpPost("/api/turtle/eval-response", { turtleID = turtleID, uuid = message.uuid, result = message.result, error = message.error })
|
||||||
|
if not result then
|
||||||
|
addLog(" -> Eval resp FAILED to send!", colors.red)
|
||||||
|
stats.errors = stats.errors + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif msgType == "request_home" then
|
||||||
|
addLog("T#" .. turtleID .. " requesting home", colors.cyan)
|
||||||
|
if wsConnected then
|
||||||
|
wsSend({ type = "request_home", turtleID = turtleID })
|
||||||
|
else
|
||||||
|
local result = httpGet("/api/turtle/" .. turtleID .. "/home")
|
||||||
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
type = "home_position",
|
type = "home_position",
|
||||||
turtleID = turtleID,
|
turtleID = turtleID,
|
||||||
homePosition = result.homePosition
|
homePosition = result and result.homePosition or nil
|
||||||
})
|
})
|
||||||
addLog(" -> Sent home position", colors.lime)
|
end
|
||||||
|
|
||||||
|
elseif msgType == "set_home" then
|
||||||
|
addLog("T#" .. turtleID .. " setting home", colors.cyan)
|
||||||
|
if wsConnected then
|
||||||
|
wsSend({ type = "set_home", turtleID = turtleID, position = message.position })
|
||||||
else
|
else
|
||||||
modem.transmit(CHANNEL_RECEIVE, CHANNEL_RECEIVE, {
|
local result = httpPost("/api/turtle/" .. turtleID .. "/home", { position = message.position })
|
||||||
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
|
type = "home_set_confirm",
|
||||||
|
turtleID = turtleID,
|
||||||
|
homePosition = (result and result.success) and result.homePosition or message.position
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif msgType == "blocks_discovered" then
|
||||||
|
local blocks = message.blocks or {}
|
||||||
|
if #blocks > 0 then
|
||||||
|
addLog("T#" .. turtleID .. " discovered " .. #blocks .. " blocks", colors.cyan)
|
||||||
|
if not wsSend({ type = "blocks_discovered", turtleID = turtleID, blocks = blocks }) then
|
||||||
|
for _, block in ipairs(blocks) do
|
||||||
|
httpPost("/api/world/blocks", { x = block.x, y = block.y, z = block.z, blockName = block.name, metadata = block.metadata or 0, discoveredBy = turtleID })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif msgType == "inventory_update" then
|
||||||
|
addLog("T#" .. turtleID .. " inventory", colors.cyan)
|
||||||
|
if not wsSend({ type = "event", turtleID = turtleID, eventType = "INVENTORY_UPDATE", message = message.inventory }) then
|
||||||
|
httpPost("/api/turtle/event", { turtleID = turtleID, type = "INVENTORY_UPDATE", message = message.inventory })
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif msgType == "peripheral_attached" then
|
||||||
|
addLog("T#" .. turtleID .. " peripheral attached", colors.cyan)
|
||||||
|
if not wsSend({ type = "event", turtleID = turtleID, eventType = "PERIPHERAL_ATTACHED", message = message.peripherals }) then
|
||||||
|
httpPost("/api/turtle/event", { turtleID = turtleID, type = "PERIPHERAL_ATTACHED", message = message.peripherals })
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif msgType == "peripheral_detached" then
|
||||||
|
addLog("T#" .. turtleID .. " peripheral detached: " .. (message.side or "?"), colors.cyan)
|
||||||
|
if not wsSend({ type = "event", turtleID = turtleID, eventType = "PERIPHERAL_DETACHED", message = message.side }) then
|
||||||
|
httpPost("/api/turtle/event", { turtleID = turtleID, type = "PERIPHERAL_DETACHED", message = message.side })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ========== Handle Server Commands (from WS or HTTP poll) ==========
|
||||||
|
|
||||||
|
local function handleServerCommand(data)
|
||||||
|
if data.type == "command" then
|
||||||
|
local turtleID = data.turtleID
|
||||||
|
local cmd = data.command
|
||||||
|
|
||||||
|
if cmd and cmd.type == "eval" then
|
||||||
|
addLog("EVAL -> T#" .. turtleID .. " " .. (cmd.uuid or "?"):sub(1, 8), colors.yellow)
|
||||||
|
stats.commandsSent = stats.commandsSent + 1
|
||||||
|
|
||||||
|
local commandPacket = {
|
||||||
|
type = "eval",
|
||||||
|
uuid = cmd.uuid,
|
||||||
|
code = cmd.code,
|
||||||
|
target = turtleID,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Send twice for reliability over modem
|
||||||
|
for i = 1, 2 do
|
||||||
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, commandPacket)
|
||||||
|
os.sleep(0.05)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif data.type == "home_position" then
|
||||||
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
type = "home_position",
|
type = "home_position",
|
||||||
turtleID = turtleID,
|
turtleID = data.turtleID,
|
||||||
homePosition = nil
|
homePosition = data.homePosition
|
||||||
})
|
})
|
||||||
addLog(" -> No home on server", colors.yellow)
|
addLog("Home -> T#" .. data.turtleID, colors.lime)
|
||||||
end
|
|
||||||
|
|
||||||
elseif message.type == "set_home" then
|
elseif data.type == "home_set_confirm" then
|
||||||
-- Turtle setting its home position
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
local turtleID = message.turtleID
|
|
||||||
local position = message.position
|
|
||||||
addLog("Turtle #" .. turtleID .. " setting home", colors.cyan)
|
|
||||||
|
|
||||||
local success, result = pcall(function()
|
|
||||||
local response = http.post(
|
|
||||||
SERVER_URL .. "/api/turtle/" .. turtleID .. "/home",
|
|
||||||
textutils.serializeJSON({position = position}),
|
|
||||||
{["Content-Type"] = "application/json"}
|
|
||||||
)
|
|
||||||
if response then
|
|
||||||
local content = response.readAll()
|
|
||||||
response.close()
|
|
||||||
local data = textutils.unserializeJSON(content)
|
|
||||||
return data
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end)
|
|
||||||
|
|
||||||
if success and result and result.success then
|
|
||||||
modem.transmit(CHANNEL_RECEIVE, CHANNEL_RECEIVE, {
|
|
||||||
type = "home_set_confirm",
|
type = "home_set_confirm",
|
||||||
turtleID = turtleID,
|
turtleID = data.turtleID,
|
||||||
homePosition = result.homePosition
|
homePosition = data.homePosition
|
||||||
})
|
})
|
||||||
addLog(" -> Home saved to server", colors.lime)
|
addLog("Home confirmed T#" .. data.turtleID, colors.lime)
|
||||||
else
|
|
||||||
modem.transmit(CHANNEL_RECEIVE, CHANNEL_RECEIVE, {
|
|
||||||
type = "home_set_confirm",
|
|
||||||
turtleID = turtleID,
|
|
||||||
homePosition = position
|
|
||||||
})
|
|
||||||
addLog(" -> Server error, local only", colors.red)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif channel == POCKET_CHANNEL then
|
|
||||||
-- Handle pocket computer requests
|
|
||||||
stats.messagesReceived = stats.messagesReceived + 1
|
|
||||||
|
|
||||||
if type(message) == "table" and message.from then
|
elseif data.type == "init" then
|
||||||
|
local ids = data.turtleIDs or {}
|
||||||
|
addLog("Server knows " .. #ids .. " turtles", colors.lime)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ========== Handle Pocket Computer Messages ==========
|
||||||
|
|
||||||
|
local function handlePocketMessage(message)
|
||||||
|
if type(message) ~= "table" or not message.from then return end
|
||||||
local pocketID = message.from
|
local pocketID = message.from
|
||||||
|
|
||||||
if message.type == "turtle_command" then
|
if message.type == "turtle_command" then
|
||||||
-- Forward command to turtle
|
addLog("Pocket #" .. pocketID .. " -> T#" .. message.turtleID .. ": " .. message.command, colors.magenta)
|
||||||
local turtleID = message.turtleID
|
|
||||||
addLog("Pocket #" .. pocketID .. " -> Turtle #" .. turtleID .. ": " .. message.command, colors.magenta)
|
|
||||||
|
|
||||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||||
command = message.command,
|
command = message.command,
|
||||||
param = message.param,
|
param = message.param,
|
||||||
target = turtleID
|
target = message.turtleID
|
||||||
})
|
|
||||||
|
|
||||||
-- Send acknowledgment
|
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
|
||||||
type = "command_ack",
|
|
||||||
to = pocketID
|
|
||||||
})
|
})
|
||||||
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "command_ack", to = pocketID })
|
||||||
|
|
||||||
elseif message.type == "player_position" then
|
elseif message.type == "player_position" then
|
||||||
-- Forward player position to server
|
addLog("Pocket #" .. pocketID .. " GPS", colors.cyan)
|
||||||
addLog("Pocket #" .. pocketID .. " GPS update", colors.cyan)
|
if not wsSend({ type = "player_update", playerID = message.playerID, position = message.position, label = message.label }) then
|
||||||
|
httpPost("/api/player/update", { playerID = message.playerID, position = message.position, label = message.label, timestamp = message.timestamp })
|
||||||
local success = pcall(function()
|
|
||||||
http.post(
|
|
||||||
SERVER_URL .. "/api/player/update",
|
|
||||||
textutils.serializeJSON({
|
|
||||||
playerID = message.playerID,
|
|
||||||
position = message.position,
|
|
||||||
timestamp = message.timestamp
|
|
||||||
}),
|
|
||||||
{["Content-Type"] = "application/json"}
|
|
||||||
)
|
|
||||||
end)
|
|
||||||
|
|
||||||
if not success then
|
|
||||||
addLog(" -> Failed to send player position", colors.red)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif message.type == "server_stats_request" then
|
elseif message.type == "server_stats_request" then
|
||||||
-- Fetch server stats and send to pocket
|
addLog("Pocket #" .. pocketID .. " stats", colors.yellow)
|
||||||
addLog("Pocket #" .. pocketID .. " requesting stats", colors.yellow)
|
local result = httpGet("/api/stats")
|
||||||
|
|
||||||
local success, result = pcall(function()
|
|
||||||
local response = http.get(SERVER_URL .. "/api/stats")
|
|
||||||
if response then
|
|
||||||
local content = response.readAll()
|
|
||||||
response.close()
|
|
||||||
return textutils.unserializeJSON(content)
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end)
|
|
||||||
|
|
||||||
if success and result then
|
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
||||||
type = "server_stats",
|
type = result and "server_stats" or "error",
|
||||||
to = pocketID,
|
to = pocketID,
|
||||||
data = result
|
data = result,
|
||||||
|
error = not result and "Failed to fetch" or nil
|
||||||
})
|
})
|
||||||
else
|
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
|
||||||
type = "error",
|
|
||||||
to = pocketID,
|
|
||||||
error = "Failed to fetch server stats"
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
elseif message.type == "webbridge_control" then
|
elseif message.type == "webbridge_control" then
|
||||||
-- Handle webbridge control commands
|
addLog("Pocket #" .. pocketID .. " " .. message.command, colors.orange)
|
||||||
addLog("Pocket #" .. pocketID .. " control: " .. message.command, colors.orange)
|
|
||||||
|
|
||||||
if message.command == "ping" then
|
if message.command == "ping" then
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Pong! (" .. (wsConnected and "WS" or "HTTP") .. ")" })
|
||||||
type = "webbridge_log",
|
|
||||||
to = pocketID,
|
|
||||||
text = "Pong! Webbridge online"
|
|
||||||
})
|
|
||||||
elseif message.command == "status" then
|
elseif message.command == "status" then
|
||||||
local turtleCount = 0
|
local tc = 0
|
||||||
for _ in pairs(turtles) do
|
for _ in pairs(turtles) do tc = tc + 1 end
|
||||||
turtleCount = turtleCount + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
||||||
type = "webbridge_status",
|
type = "webbridge_status", to = pocketID,
|
||||||
to = pocketID,
|
data = { messages = stats.messagesReceived, commands = stats.commandsSent, turtles = tc, uptime = os.epoch("utc") - stats.startTime, wsConnected = wsConnected }
|
||||||
data = {
|
|
||||||
messages = stats.messagesReceived,
|
|
||||||
commands = stats.commandsSent,
|
|
||||||
turtles = turtleCount,
|
|
||||||
uptime = os.epoch("utc") - stats.startTime
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
elseif message.command == "restart" then
|
elseif message.command == "restart" then
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Restarting..." })
|
||||||
type = "webbridge_log",
|
|
||||||
to = pocketID,
|
|
||||||
text = "Restarting webbridge..."
|
|
||||||
})
|
|
||||||
sleep(1)
|
sleep(1)
|
||||||
os.reboot()
|
os.reboot()
|
||||||
elseif message.command == "logs" then
|
elseif message.command == "logs" then
|
||||||
-- Send recent log entries
|
|
||||||
for i = math.max(1, #activityLog - 5), #activityLog do
|
for i = math.max(1, #activityLog - 5), #activityLog do
|
||||||
if activityLog[i] then
|
if activityLog[i] then
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = activityLog[i].text })
|
||||||
type = "webbridge_log",
|
end
|
||||||
to = pocketID,
|
end
|
||||||
text = activityLog[i].text
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif message.type == "web_interface_request" then
|
-- ========== Initialization ==========
|
||||||
-- Send web interface info
|
|
||||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
print("Web Bridge v2 (WebSocket Protocol)")
|
||||||
type = "webbridge_log",
|
print("Server: " .. SERVER_URL)
|
||||||
to = pocketID,
|
print("WS: " .. WS_URL)
|
||||||
text = "Web: " .. SERVER_URL
|
print("Channels: RX=" .. CHANNEL_RECEIVE .. " STATUS=" .. STATUS_CHANNEL .. " CMD=" .. COMMAND_CHANNEL)
|
||||||
})
|
|
||||||
|
if hasMonitor then
|
||||||
|
drawDashboard()
|
||||||
|
addLog("System initialized", colors.lime)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ========== Main Loop ==========
|
||||||
|
|
||||||
|
parallel.waitForAny(
|
||||||
|
-- WebSocket connection manager (reconnects automatically)
|
||||||
|
function()
|
||||||
|
while true do
|
||||||
|
addLog("Connecting WebSocket...", colors.yellow)
|
||||||
|
local ok, handle = pcall(http.websocket, WS_URL)
|
||||||
|
|
||||||
|
if ok and handle then
|
||||||
|
wsHandle = handle
|
||||||
|
wsConnected = true
|
||||||
|
addLog("WebSocket connected!", colors.lime)
|
||||||
|
|
||||||
|
-- Read messages from WebSocket
|
||||||
|
while true do
|
||||||
|
local readOk, msg = pcall(function() return wsHandle.receive(30) end)
|
||||||
|
|
||||||
|
if not readOk then
|
||||||
|
addLog("WS read error, reconnecting...", colors.red)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
if msg == nil then
|
||||||
|
-- Timeout, send keepalive
|
||||||
|
local pingOk = pcall(function() wsHandle.send('{"type":"ping"}') end)
|
||||||
|
if not pingOk then
|
||||||
|
addLog("WS ping failed, reconnecting...", colors.red)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local parseOk, data = pcall(textutils.unserializeJSON, msg)
|
||||||
|
if parseOk and data then
|
||||||
|
handleServerCommand(data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Clean up
|
||||||
|
pcall(function() wsHandle.close() end)
|
||||||
|
wsHandle = nil
|
||||||
|
wsConnected = false
|
||||||
|
addLog("WebSocket disconnected", colors.orange)
|
||||||
|
else
|
||||||
|
addLog("WS connect failed, retry in 5s", colors.red)
|
||||||
|
stats.errors = stats.errors + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
sleep(5)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- Modem message handler (turtles -> bridge -> server)
|
||||||
|
function()
|
||||||
|
while true do
|
||||||
|
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||||
|
stats.messagesReceived = stats.messagesReceived + 1
|
||||||
|
|
||||||
|
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||||
|
-- both legacy (101/102/103) and target (4211/4212/4213) during migration.
|
||||||
|
if (Channels.match('remoteturtle.status', channel) or Channels.match('remoteturtle.response', channel)) then
|
||||||
|
if type(message) == "table" then
|
||||||
|
forwardToServer(message)
|
||||||
|
end
|
||||||
|
elseif Channels.match('remoteturtle.pocket', channel) then
|
||||||
|
if type(message) == "table" then
|
||||||
|
handlePocketMessage(message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- HTTP fallback polling (only active when WS is down)
|
||||||
|
function()
|
||||||
|
while true do
|
||||||
|
if not wsConnected then
|
||||||
|
for turtleID, _ in pairs(turtles) do
|
||||||
|
local result = httpGet("/api/turtle/" .. turtleID .. "/commands")
|
||||||
|
if result and result.commands and #result.commands > 0 then
|
||||||
|
addLog("HTTP poll: " .. #result.commands .. " cmd(s) for T#" .. turtleID, colors.cyan)
|
||||||
|
for _, cmd in ipairs(result.commands) do
|
||||||
|
handleServerCommand({ type = "command", turtleID = turtleID, command = cmd })
|
||||||
|
end
|
||||||
|
httpPost("/api/turtle/" .. turtleID .. "/commands/ack", {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sleep(wsConnected and 10 or 1)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- Dashboard refresh
|
||||||
|
function()
|
||||||
|
while true do
|
||||||
|
sleep(2)
|
||||||
|
if hasMonitor then
|
||||||
|
drawDashboard()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user