Compare commits
330 Commits
b0d070bad7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a66cad13a | ||
|
|
f008a9e665 | ||
|
|
ed612f3e38 | ||
|
|
dcd9e22b6f | ||
|
|
f1c8f08272 | ||
|
|
ffb6d679c0 | ||
|
|
ea90a860e9 | ||
|
|
bdf7a51675 | ||
|
|
c4b9509b5c | ||
|
|
92ea13a680 | ||
|
|
05cf7e98d9 | ||
|
|
9291b063d0 | ||
|
|
6f462c97e0 | ||
|
|
9ff2ce7ff2 | ||
|
|
fc1b23470e | ||
|
|
cb44dd8d0f | ||
|
|
1f41e1fa51 | ||
|
|
fa085339b8 | ||
|
|
5ad01dfd1d | ||
|
|
b72826bc46 | ||
|
|
459664825c | ||
|
|
b13905dade | ||
|
|
5a4af6c986 | ||
|
|
633d162d81 | ||
|
|
aa3b166453 | ||
|
|
56fc79f5f2 | ||
|
|
b6ab6f94f6 | ||
|
|
4d5d2162e6 | ||
|
|
24570d0fc0 | ||
|
|
6312e45bf1 | ||
|
|
34725d7d71 | ||
|
|
811e2a6e18 | ||
|
|
ad0754113d | ||
|
|
3e55d77592 | ||
|
|
9984dc0760 | ||
|
|
88163be0dd | ||
|
|
679a249f8b | ||
|
|
69041244a2 | ||
|
|
9a56e6b736 | ||
|
|
79b50071ee | ||
|
|
9b09a59eba | ||
|
|
6d8ec7b013 | ||
|
|
90ec195497 | ||
|
|
23515728e0 | ||
|
|
a809bddd46 | ||
|
|
5ff1f3e7f0 | ||
|
|
00d31698a1 | ||
|
|
af2c978185 | ||
|
|
8f23aa5caa | ||
|
|
720c6c20fb | ||
|
|
f61e7ca185 | ||
|
|
ddc1b03506 | ||
|
|
460352ec26 | ||
|
|
3ce0e4c530 | ||
|
|
38ff06eb04 | ||
|
|
cfd127dfab | ||
|
|
d2718b3287 | ||
|
|
8f4eeabee9 | ||
|
|
3b2e00b2b4 | ||
|
|
cb666a6a45 | ||
|
|
c424662c18 | ||
|
|
05519dc17e | ||
|
|
681b4e1fa9 | ||
|
|
465a8bacf4 | ||
|
|
bfae87287a | ||
|
|
12fc109a30 | ||
|
|
973e4be6a3 | ||
|
|
fb84b5a554 | ||
|
|
2e3d5b4b6b | ||
|
|
9a34f72178 | ||
|
|
88fdd1c46d | ||
|
|
e3abdb612c | ||
|
|
e84ca4cfb9 | ||
|
|
b8cd239597 | ||
|
|
5aec3df3b3 | ||
|
|
989b6f9118 | ||
|
|
ec5f048d49 | ||
|
|
60c5b3aaba | ||
|
|
2c806bf994 | ||
|
|
b34cc8cec0 | ||
|
|
cef3cdf03d | ||
|
|
b8a1b7c0b3 | ||
|
|
8fcd3f44c7 | ||
|
|
7385c258d5 | ||
|
|
2686ca4697 | ||
|
|
53ae92a184 | ||
|
|
7da9c1d0d8 | ||
|
|
2549adc49d | ||
|
|
bad3b5bf13 | ||
|
|
de58ec6b08 | ||
|
|
f6b39808aa | ||
|
|
c02bb7db68 | ||
|
|
885ebf698d | ||
|
|
1522523f22 | ||
|
|
bca3cb4508 | ||
|
|
ebe4f10df5 | ||
|
|
586b161da9 | ||
|
|
2316e14b9c | ||
|
|
45a4b4e7ae | ||
|
|
9735fd8776 | ||
|
|
c6ff9094ed | ||
|
|
cb2353785f | ||
|
|
46c0817270 | ||
|
|
f0281ddaa5 | ||
|
|
fc5cbae73e | ||
|
|
9cb2939224 | ||
|
|
1a2de77ae2 | ||
|
|
420456c04b | ||
|
|
b7221327c2 | ||
|
|
997201b139 | ||
|
|
daaf969662 | ||
|
|
0b61d8b2dd | ||
|
|
586d231720 | ||
|
|
3c6ee280ba | ||
|
|
5966d4de2b | ||
|
|
1d99ba534a | ||
|
|
b06f878ca0 | ||
|
|
943cc73163 | ||
|
|
2263fbb1de | ||
|
|
481be70940 | ||
|
|
60ef3b81f7 | ||
|
|
997c64c40b | ||
|
|
a68ddd843f | ||
|
|
fe44978f6e | ||
|
|
c862b2816c | ||
|
|
73f8a21a81 | ||
|
|
617310eade | ||
|
|
7dae800eed | ||
|
|
86a6db04ac | ||
|
|
8e1d1f67fc | ||
|
|
fa8b45b74a | ||
|
|
32677ecac5 | ||
|
|
5f6dbce277 | ||
|
|
75ca027ab4 | ||
|
|
cfc891d164 | ||
|
|
5b89e0432e | ||
|
|
37bd17f26a | ||
|
|
c0865d5196 | ||
|
|
a0eaeb6712 | ||
|
|
d8f3d5d13c | ||
|
|
2571865917 | ||
|
|
0e6d4acfdd | ||
|
|
67b2d7eb2e | ||
|
|
7b63c92434 | ||
|
|
2a0a90892c | ||
|
|
11c289a3ed | ||
|
|
ac6dd5da95 | ||
|
|
58cc909de8 | ||
|
|
b9023758a6 | ||
|
|
6e05efa2f0 | ||
|
|
b632e14932 | ||
|
|
5208c38738 | ||
|
|
a64e9a1a51 | ||
|
|
be0064aae5 | ||
|
|
dd2a877192 | ||
|
|
1ef975cbae | ||
|
|
e62e83cf33 | ||
|
|
eff33dfe09 | ||
|
|
f38a219ad0 | ||
|
|
0bf5590343 | ||
|
|
bf7db42384 | ||
|
|
8731da04f9 | ||
|
|
ff58778f3f | ||
|
|
668d4a3685 | ||
|
|
e401c39bb3 | ||
|
|
d4e6b469df | ||
|
|
150cc4b41a | ||
|
|
220f2a90d4 | ||
|
|
ba11d2c9d2 | ||
|
|
dca14643c6 | ||
|
|
67498b93bf | ||
|
|
df00a6be70 | ||
|
|
f23ebcb05a | ||
|
|
08281d88fe | ||
|
|
882dc75762 | ||
|
|
4c774ac306 | ||
|
|
e38551dfc2 | ||
|
|
87c103ea9e | ||
|
|
836e7b61c7 | ||
|
|
f8baadce3a | ||
|
|
a5aec5800e | ||
|
|
0e41ee12c5 | ||
|
|
bdc4dade9f | ||
|
|
e8ef1d669b | ||
|
|
3dfc4237c6 | ||
|
|
1492f59de7 | ||
|
|
d2185ac493 | ||
|
|
fa98e86055 | ||
|
|
1b08777f88 | ||
|
|
68d4ee52c9 | ||
|
|
6f73fdd0ed | ||
|
|
0f22c8e49b | ||
|
|
9796f73e64 | ||
|
|
6da90fc560 | ||
|
|
afc4c1f97a | ||
|
|
9a294f0c98 | ||
|
|
1f803fbde6 | ||
|
|
207f6901d6 | ||
|
|
d2e0deb945 | ||
|
|
9fd9180087 | ||
|
|
558fb92c41 | ||
|
|
bd68a79cd5 | ||
|
|
f0afbca74b | ||
|
|
35c76cbdca | ||
|
|
7a3b30bbbf | ||
|
|
d7433b8bcc | ||
|
|
3c0f72acf1 | ||
|
|
75b2088b4f | ||
|
|
0eac9497de | ||
|
|
855874576d | ||
|
|
0c925036d9 | ||
|
|
68e21d9c82 | ||
|
|
1cccfb5baa | ||
|
|
4bd999d394 | ||
|
|
8d66ef637e | ||
|
|
5a4fd000fe | ||
|
|
86250deba3 | ||
|
|
b1d68565cd | ||
|
|
922e6ab25d | ||
|
|
5b23ab1a14 | ||
|
|
5fb8ddf68e | ||
|
|
91918bd124 | ||
|
|
8d43c0dc99 | ||
|
|
6b45682fac | ||
|
|
af2d120baa | ||
|
|
544c5be954 | ||
|
|
baa807ed2a | ||
|
|
9202094de9 | ||
|
|
835bfde1f5 | ||
|
|
bc4f87f178 | ||
|
|
a1d2b62d5f | ||
|
|
b08ff805b4 | ||
|
|
5ceef8ba1c | ||
|
|
5ef8977fad | ||
|
|
e800d53c38 | ||
|
|
6da6a06295 | ||
|
|
43bba2ced7 | ||
|
|
856cf88a19 | ||
|
|
74152951ae | ||
|
|
c2968060fe | ||
|
|
127a80813e | ||
|
|
31b2c8f61a | ||
|
|
6b48ddc8b3 | ||
|
|
a3ca1aae1c | ||
|
|
c0573a62aa | ||
|
|
5bfaf46a8b | ||
|
|
4bda9c536b | ||
|
|
6e50a109dc | ||
|
|
61f817ba18 | ||
|
|
90b1e9fdec | ||
|
|
8bcee0b16c | ||
|
|
7b3ae34cfa | ||
|
|
546e17e6d2 | ||
|
|
73d098ca33 | ||
|
|
a419374cb2 | ||
|
|
f402f3665c | ||
|
|
6600ba9ea4 | ||
|
|
a9bf76bd57 | ||
|
|
05d331efe2 | ||
|
|
7ca59e7197 | ||
|
|
42a626807b | ||
|
|
c7315aa3de | ||
|
|
c5e980f5ec | ||
|
|
57ef89f52c | ||
|
|
dd58093c40 | ||
|
|
38b7846607 | ||
|
|
a3fe5c7471 | ||
|
|
a68db21f9c | ||
|
|
a74802afee | ||
|
|
fb53056e85 | ||
|
|
23259a5410 | ||
|
|
aa0884f1d8 | ||
|
|
ac4311a2cd | ||
|
|
7003620ef7 | ||
|
|
d43887ed41 | ||
|
|
3170ca3491 | ||
|
|
399d2b693a | ||
|
|
0c69b555df | ||
|
|
0d2fe5b8b1 | ||
|
|
1435a7ac55 | ||
|
|
4bdbc006f8 | ||
|
|
9048d197a8 | ||
|
|
bd758e4c7b | ||
|
|
e87ee43822 | ||
|
|
e76477f38d | ||
|
|
c492529c1d | ||
|
|
0296253149 | ||
|
|
9abd0f1f45 | ||
|
|
3589813dc6 | ||
|
|
bd96333d22 | ||
|
|
81bfdd75d5 | ||
|
|
b036218fff | ||
|
|
6534af4516 | ||
|
|
e0495eeb9c | ||
|
|
3b3ed33b33 | ||
|
|
ac841dc1c5 | ||
|
|
81e0dc4959 | ||
|
|
cf0b66e2fe | ||
|
|
bffacbf8c8 | ||
|
|
1610ade7b7 | ||
|
|
611609d404 | ||
|
|
175c6add10 | ||
|
|
5ec385c1f2 | ||
|
|
836d734b1f | ||
|
|
9a02a8d27f | ||
|
|
1a415beac0 | ||
|
|
000d1bd625 | ||
|
|
cc7c90799e | ||
|
|
e3a4788440 | ||
|
|
96003014da | ||
|
|
f70dd16cff | ||
|
|
a91ad1d4ca | ||
|
|
4dacfb53aa | ||
|
|
8172431e44 | ||
|
|
a2d43d0cfb | ||
|
|
94713539da | ||
|
|
9f9abb45ec | ||
|
|
a8a99f4f53 | ||
|
|
dc3ed12b14 | ||
|
|
172cdac94b | ||
|
|
4626ab80ff | ||
|
|
b0b242a07b | ||
|
|
d2f2e85bf2 | ||
|
|
efa5afd79a | ||
|
|
fd9caf44e9 | ||
|
|
30647fca50 | ||
|
|
a6edc0934c | ||
|
|
db3e07689a | ||
|
|
e19abdb9fa | ||
|
|
ba91de54d8 |
46
.dockerignore
Normal file
46
.dockerignore
Normal file
@@ -0,0 +1,46 @@
|
||||
# Docker ignore patterns
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation (not needed in container)
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Test files
|
||||
*.test.js
|
||||
*.spec.js
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
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
|
||||
]],
|
||||
}
|
||||
291
ARCHITECTURE.md
Normal file
291
ARCHITECTURE.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# System Architecture Diagram
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
```
|
||||
╔═══════════════════════════╗
|
||||
║ YOUR COMPUTER ║
|
||||
║ ║
|
||||
┌────────────────────║──────────────────────────║────────────────┐
|
||||
│ ║ ║ │
|
||||
│ ┏━━━━━━━━━━━━━━┓ ║ ┏━━━━━━━━━━━━━━━━━━━┓ ║ ┏━━━━━━━━━━┓ │
|
||||
│ ┃ Browser ┃ ║ ┃ Node.js Server ┃ ║ ┃ Minecraft┃ │
|
||||
│ ┃ ┃ ║ ┃ ┃ ║ ┃ ┃ │
|
||||
│ ┃ React App ┃◄─╫─►┃ Express + WS ┃◄─╫─►┃ Bridge ┃ │
|
||||
│ ┃ ┃ ║ ┃ ┃ ║ ┃ Computer ┃ │
|
||||
│ ┃ :3000 ┃ ║ ┃ :3001 / :3002 ┃ ║ ┃ ┃ │
|
||||
│ ┗━━━━━━━━━━━━━━┛ ║ ┗━━━━━━━━━━━━━━━━━━━┛ ║ ┗━━━━━━━━━━┛ │
|
||||
│ ║ ║ │ │
|
||||
└────────────────────║──────────────────────────║───────┼────────┘
|
||||
╚═══════════════════════════╝ │
|
||||
│ Modem
|
||||
│ Wireless
|
||||
↓
|
||||
┏━━━━━━━━━━━━━━┓
|
||||
┃ Turtle 1 ┃
|
||||
┃ Turtle 2 ┃
|
||||
┃ Turtle N ┃
|
||||
┗━━━━━━━━━━━━━━┛
|
||||
```
|
||||
|
||||
## Communication Flow
|
||||
|
||||
### Status Updates (Turtle → Web)
|
||||
|
||||
```
|
||||
Turtle Bridge Server Web Client
|
||||
│ │ │ │
|
||||
│ Status Broadcast │ │ │
|
||||
├──────modem───────>│ │ │
|
||||
│ (channel 102) │ │ │
|
||||
│ │ HTTP POST │ │
|
||||
│ ├───────────────────>│ │
|
||||
│ │ /api/turtle/update│ │
|
||||
│ │ │ WebSocket Broadcast │
|
||||
│ │ ├─────────────────────>│
|
||||
│ │ │ turtle_update │
|
||||
│ │ │ │
|
||||
│ │ │ ┌───┴───┐
|
||||
│ │ │ │Update │
|
||||
│ │ │ │3D Map │
|
||||
│ │ │ └───────┘
|
||||
```
|
||||
|
||||
### Commands (Web → Turtle)
|
||||
|
||||
```
|
||||
Web Client Server Bridge Turtle
|
||||
│ │ │ │
|
||||
│ Click "Explore" │ │ │
|
||||
├─────WebSocket────>│ │ │
|
||||
│ command msg │ │ │
|
||||
│ │ Queue Command │ │
|
||||
│ ├──(in memory) │ │
|
||||
│ │ │ │
|
||||
│ │ │ Poll for Commands │
|
||||
│ │ HTTP GET │ │
|
||||
│ │<──────────────────┤ │
|
||||
│ │/api/turtle/:id/.. │ │
|
||||
│ │ │ │
|
||||
│ │ Return Commands │ │
|
||||
│ ├──────────────────>│ │
|
||||
│ │ JSON │ │
|
||||
│ │ │ Forward via Modem │
|
||||
│ │ ├──────────────────>│
|
||||
│ │ │ (channel 100) │
|
||||
│ │ │ │
|
||||
│ │ │ ┌───┴────┐
|
||||
│ │ │ │Execute │
|
||||
│ │ │ │Command │
|
||||
│ │ │ └────────┘
|
||||
```
|
||||
|
||||
## Component Details
|
||||
|
||||
### React Frontend (Port 3000)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ App.jsx │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ View Controls (Split/Map/..) │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────┬──────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ Control │ Map3D.jsx │ │
|
||||
│ │ Panel │ │ │
|
||||
│ │ .jsx │ ┌────────────┐ │ │
|
||||
│ │ │ │ Three.js │ │ │
|
||||
│ │ - Turtle │ │ Canvas │ │ │
|
||||
│ │ Cards │ │ │ │ │
|
||||
│ │ - Details │ │ - Turtles │ │ │
|
||||
│ │ - Commands│ │ - Grid │ │ │
|
||||
│ │ - Manual │ │ - Camera │ │ │
|
||||
│ │ Control │ │ │ │ │
|
||||
│ │ │ └────────────┘ │ │
|
||||
│ └────────────┴──────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ turtleStore.js │ │
|
||||
│ │ - WebSocket connection │ │
|
||||
│ │ - State management │ │
|
||||
│ │ - Command sending │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Node.js Server (Ports 3001, 3002)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ server.js │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Express HTTP Server │ │
|
||||
│ │ Port: 3001 │ │
|
||||
│ │ │ │
|
||||
│ │ POST /api/turtle/update │ │
|
||||
│ │ GET /api/turtle/:id/commands│ │
|
||||
│ │ POST /api/turtle/:id/command │ │
|
||||
│ │ GET /api/turtles │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ WebSocket Server │ │
|
||||
│ │ Port: 3002 │ │
|
||||
│ │ │ │
|
||||
│ │ - Broadcast turtle updates │ │
|
||||
│ │ - Receive commands │ │
|
||||
│ │ - Track connected clients │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ In-Memory Data Store │ │
|
||||
│ │ │ │
|
||||
│ │ turtleData Map: │ │
|
||||
│ │ turtleID → state │ │
|
||||
│ │ - position │ │
|
||||
│ │ - fuel │ │
|
||||
│ │ - inventory │ │
|
||||
│ │ - pendingCommands[] │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Minecraft Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ webbridge.lua │
|
||||
│ (Bridge Computer) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Modem Listener │ │
|
||||
│ │ Channels: 101, 102 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ ↕ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ HTTP Client │ │
|
||||
│ │ - POST status to server │ │
|
||||
│ │ - GET commands from server │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
↕ (modem)
|
||||
┌─────────────────────────────────────┐
|
||||
│ turtle.lua │
|
||||
│ (Each Turtle) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Command Processor │ │
|
||||
│ │ Listen: channel 100 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Status Broadcaster │ │
|
||||
│ │ Send: channel 102 (every 5s) │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Autonomous AI │ │
|
||||
│ │ - Exploration │ │
|
||||
│ │ - Mining │ │
|
||||
│ │ - Pathfinding │ │
|
||||
│ │ - Fuel management │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Network Ports
|
||||
|
||||
```
|
||||
Port 3000 → React Dev Server (Vite)
|
||||
Port 3001 → Express HTTP API
|
||||
Port 3002 → WebSocket Server
|
||||
|
||||
Modem Channels:
|
||||
100 → Commands to Turtles
|
||||
101 → Responses from Turtles
|
||||
102 → Status Updates
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Turtle State Object
|
||||
|
||||
```javascript
|
||||
{
|
||||
turtleID: 123,
|
||||
mode: "mining", // idle, exploring, mining, returning, manual
|
||||
position: {
|
||||
x: 100,
|
||||
y: 64,
|
||||
z: 200
|
||||
},
|
||||
homePosition: {
|
||||
x: 95,
|
||||
y: 64,
|
||||
z: 195
|
||||
},
|
||||
fuel: 5000,
|
||||
inventory: [
|
||||
{ slot: 1, name: "minecraft:coal_ore", count: 32 },
|
||||
{ slot: 2, name: "minecraft:iron_ore", count: 16 }
|
||||
],
|
||||
lastUpdate: 1234567890,
|
||||
pendingCommands: [
|
||||
{ command: "explore", param: null, timestamp: 1234567890 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Command Message
|
||||
|
||||
```javascript
|
||||
{
|
||||
command: "explore", // explore, mine, returnHome, stop, forward, etc.
|
||||
param: null, // optional parameter
|
||||
target: 123 // turtle ID (for Lua) or turtleID (for web)
|
||||
}
|
||||
```
|
||||
|
||||
## Technology Stack Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ - React 18 │
|
||||
│ - Three.js / React Three Fiber │
|
||||
│ - CSS3 │
|
||||
└─────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────┐
|
||||
│ State Management │
|
||||
│ - Zustand │
|
||||
│ - WebSocket API │
|
||||
└─────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────┐
|
||||
│ Backend Services │
|
||||
│ - Node.js │
|
||||
│ - Express.js │
|
||||
│ - ws (WebSocket library) │
|
||||
└─────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────┐
|
||||
│ Game Integration │
|
||||
│ - ComputerCraft Lua │
|
||||
│ - HTTP API │
|
||||
│ - Modem Wireless │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This architecture provides:
|
||||
- ✅ Real-time bidirectional communication
|
||||
- ✅ Scalable to multiple turtles
|
||||
- ✅ Web-based interface accessible from any device
|
||||
- ✅ Backward compatible with existing Lua scripts
|
||||
- ✅ Clean separation of concerns
|
||||
- ✅ Easy to extend and modify
|
||||
355
DOCKER.md
Normal file
355
DOCKER.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# 🐳 Docker Deployment Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Production Deployment
|
||||
|
||||
Start everything with one command:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build and start the backend server (ports 3001, 3002)
|
||||
- Build and start the frontend client (port 3000)
|
||||
- Set up networking between containers
|
||||
|
||||
### Development Mode (with hot reload)
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Hot reload for both server and client
|
||||
- Volume mounting for live code changes
|
||||
- Development dependencies
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Zoraxy Reverse Proxy │
|
||||
│ (Your external proxy) │
|
||||
└────────┬────────────────┬───────────┘
|
||||
│ │
|
||||
│ │
|
||||
┌────▼─────┐ ┌────▼─────┐
|
||||
│ Client │ │ Server │
|
||||
│ :3000 │ │ :3001 │
|
||||
│ (Vite) │ │ :3002 │
|
||||
└──────────┘ └──────────┘
|
||||
│ │
|
||||
└────────┬───────┘
|
||||
│
|
||||
turtle-network
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### Backend Server (turtle-server)
|
||||
- **Ports**: 3001 (HTTP), 3002 (WebSocket)
|
||||
- **Image**: Built from `server/Dockerfile`
|
||||
- **Health Check**: HTTP GET /api/turtles
|
||||
|
||||
### Frontend Client (turtle-client)
|
||||
- **Port**: 3000 (Vite preview server)
|
||||
- **Image**: Built from `client/Dockerfile`
|
||||
- **Serves**: Pre-built static React app
|
||||
|
||||
## Docker Commands
|
||||
|
||||
### Start Services
|
||||
```bash
|
||||
# Production (detached)
|
||||
docker-compose up -d
|
||||
|
||||
# Development (with logs)
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
# Build and start
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### Stop Services
|
||||
```bash
|
||||
docker-compose down
|
||||
|
||||
# Remove volumes too
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose logs -f server
|
||||
docker-compose logs -f client
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
```bash
|
||||
# All services
|
||||
docker-compose restart
|
||||
|
||||
# Specific service
|
||||
docker-compose restart server
|
||||
```
|
||||
|
||||
### Shell Access
|
||||
```bash
|
||||
# Server container
|
||||
docker-compose exec server sh
|
||||
|
||||
# Client container
|
||||
docker-compose exec client sh
|
||||
```
|
||||
|
||||
## Zoraxy Configuration
|
||||
|
||||
Configure your Zoraxy reverse proxy to forward:
|
||||
|
||||
### Frontend (Web UI)
|
||||
- **Domain**: `turtles.yourdomain.com` (or your choice)
|
||||
- **Target**: `http://localhost:3000`
|
||||
- **WebSocket**: Enabled
|
||||
|
||||
### Backend API
|
||||
- **Domain**: `turtles-api.yourdomain.com` (or your choice)
|
||||
- **Target**: `http://localhost:3001`
|
||||
- **WebSocket**: Enabled (for port 3002)
|
||||
|
||||
### Alternative: Single Domain Setup
|
||||
Forward all `/api/*` requests to backend:
|
||||
- **Domain**: `turtles.yourdomain.com`
|
||||
- **Path `/`**: → `http://localhost:3000` (Frontend)
|
||||
- **Path `/api`**: → `http://localhost:3001` (Backend)
|
||||
- **WebSocket**: → `ws://localhost:3002`
|
||||
|
||||
### Update Client Configuration
|
||||
|
||||
Edit `client/src/store/turtleStore.js` before building:
|
||||
```javascript
|
||||
const WS_URL = 'wss://turtles.yourdomain.com/ws'; // or your WebSocket endpoint
|
||||
const API_URL = 'https://turtles.yourdomain.com/api'; // or your API endpoint
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env` files if needed:
|
||||
|
||||
### server/.env
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
WS_PORT=3002
|
||||
```
|
||||
|
||||
### client/.env.production
|
||||
```env
|
||||
VITE_API_URL=https://turtles-api.yourdomain.com
|
||||
VITE_WS_URL=wss://turtles-api.yourdomain.com/ws
|
||||
```
|
||||
|
||||
## Building Images
|
||||
|
||||
### Build all images
|
||||
```bash
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
### Build specific service
|
||||
```bash
|
||||
docker-compose build server
|
||||
docker-compose build client
|
||||
```
|
||||
|
||||
### Rebuild without cache
|
||||
```bash
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
## Minecraft Bridge Configuration
|
||||
|
||||
Update `webbridge.lua` to point to your Docker host:
|
||||
|
||||
```lua
|
||||
-- If running on same machine
|
||||
local SERVER_URL = "http://localhost:3001"
|
||||
|
||||
-- If running on different machine
|
||||
local SERVER_URL = "http://YOUR_DOCKER_HOST_IP:3001"
|
||||
|
||||
-- If using Zoraxy reverse proxy
|
||||
local SERVER_URL = "https://turtles-api.yourdomain.com"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :3000
|
||||
sudo lsof -i :3001
|
||||
|
||||
# Stop the containers
|
||||
docker-compose down
|
||||
|
||||
# Remove any conflicting containers
|
||||
docker ps -a
|
||||
docker rm <container_id>
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs server
|
||||
docker-compose logs client
|
||||
|
||||
# Rebuild
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### Can't Connect from Minecraft
|
||||
- Verify firewall allows ports 3001-3002
|
||||
- Check Docker host IP is correct
|
||||
- Ensure HTTP API is enabled in ComputerCraft config
|
||||
- Test: `http.get("http://YOUR_IP:3001/api/turtles")`
|
||||
|
||||
### Health Check Failing
|
||||
```bash
|
||||
# Check container health
|
||||
docker-compose ps
|
||||
|
||||
# Manually test health endpoint
|
||||
curl http://localhost:3001/api/turtles
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Set up Zoraxy reverse proxy
|
||||
- [ ] Configure SSL/TLS certificates
|
||||
- [ ] Update API URLs in client code
|
||||
- [ ] Build images: `docker-compose build`
|
||||
- [ ] Start services: `docker-compose up -d`
|
||||
- [ ] Verify health checks: `docker-compose ps`
|
||||
- [ ] Test web interface
|
||||
- [ ] Update Minecraft bridge script
|
||||
- [ ] Test turtle connection
|
||||
|
||||
## Updating the Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose down
|
||||
docker-compose up --build -d
|
||||
|
||||
# Or use rolling update
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Reduce Image Size
|
||||
- Images use Alpine Linux (minimal)
|
||||
- Production builds exclude dev dependencies
|
||||
- Multi-stage builds where applicable
|
||||
|
||||
### Resource Limits
|
||||
Add to docker-compose.yml:
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### Volume Management
|
||||
```bash
|
||||
# List volumes
|
||||
docker volume ls
|
||||
|
||||
# Clean up unused volumes
|
||||
docker volume prune
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup Container Data
|
||||
```bash
|
||||
# Export container
|
||||
docker export turtle-server > server-backup.tar
|
||||
docker export turtle-client > client-backup.tar
|
||||
```
|
||||
|
||||
### Backup Logs
|
||||
```bash
|
||||
docker-compose logs > logs-backup.txt
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Best Practices
|
||||
1. Use Zoraxy for SSL/TLS termination
|
||||
2. Don't expose Docker ports directly to internet
|
||||
3. Use environment variables for sensitive data
|
||||
4. Keep Docker and images updated
|
||||
5. Use non-root user in containers (already configured)
|
||||
|
||||
### Network Isolation
|
||||
Containers use `turtle-network` bridge network:
|
||||
- Isolated from host network
|
||||
- Can communicate with each other
|
||||
- Exposed ports defined in docker-compose.yml
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Container Stats
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Health Status
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### System Resources
|
||||
```bash
|
||||
docker system df
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
**Problem**: Client can't connect to server
|
||||
**Solution**: Make sure both containers are on same network and server is healthy
|
||||
|
||||
**Problem**: WebSocket connection fails through Zoraxy
|
||||
**Solution**: Enable WebSocket support in Zoraxy proxy rules
|
||||
|
||||
**Problem**: Changes not reflected after rebuild
|
||||
**Solution**: Use `--no-cache` flag: `docker-compose build --no-cache`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Start**: `docker-compose up -d`
|
||||
**Stop**: `docker-compose down`
|
||||
**Logs**: `docker-compose logs -f`
|
||||
**Rebuild**: `docker-compose up --build -d`
|
||||
|
||||
Set up your Zoraxy reverse proxy to forward traffic to:
|
||||
- Frontend: `localhost:3000`
|
||||
- Backend: `localhost:3001` + `localhost:3002` (WebSocket)
|
||||
|
||||
**Easy deployment with Docker + Zoraxy!** 🐳✨
|
||||
511
FEATURES.md
Normal file
511
FEATURES.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# 🎮 Feature Documentation
|
||||
|
||||
## ✅ Implemented Features
|
||||
|
||||
### 1. 🗄️ Database Persistence (SQLite)
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Persistent storage for all turtle data across server restarts.
|
||||
|
||||
**Features:**
|
||||
- Turtle home positions saved to database
|
||||
- World block discoveries persisted
|
||||
- Turtle configurations stored
|
||||
- Path recordings saved
|
||||
- Task queue persistence
|
||||
- Mining area claims tracked
|
||||
|
||||
**Database Tables:**
|
||||
- `turtle_homes` - Home position for each turtle
|
||||
- `turtle_config` - Configuration per turtle
|
||||
- `world_blocks` - All discovered blocks with timestamps
|
||||
- `turtle_paths` - Recorded movement paths
|
||||
- `task_queue` - Shared task system
|
||||
- `mining_areas` - Area claims and zones
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
GET /api/turtle/:id/home - Get turtle home
|
||||
POST /api/turtle/:id/home - Set turtle home
|
||||
GET /api/stats - Server statistics
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ No data loss on server restart
|
||||
- ✅ Historical block discovery tracking
|
||||
- ✅ Persistent configurations
|
||||
- ✅ Scalable to many turtles
|
||||
|
||||
---
|
||||
|
||||
### 2. 📦 Inventory Management UI
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Beautiful 4x4 grid inventory display with real-time updates.
|
||||
|
||||
**Features:**
|
||||
- 16-slot grid layout (matches Minecraft turtle inventory)
|
||||
- Item-specific emoji icons (💎 for diamonds, ⚫ for coal, etc.)
|
||||
- Item counts displayed in corner
|
||||
- Item names shown on hover
|
||||
- Empty slots clearly marked
|
||||
- Smooth hover animations
|
||||
- Touch-friendly on mobile
|
||||
|
||||
**Visual Design:**
|
||||
- Dark theme matching overall UI
|
||||
- Glowing borders for filled slots
|
||||
- Dashed borders for empty slots
|
||||
- Item count badges
|
||||
- Responsive grid layout
|
||||
|
||||
**Item Icons:**
|
||||
- 💎 Diamond ore
|
||||
- 🟡 Gold ore
|
||||
- ⚪ Iron ore
|
||||
- ⚫ Coal
|
||||
- 🟢 Emerald
|
||||
- 🔴 Redstone
|
||||
- 🔵 Lapis
|
||||
- 🗿 Stone
|
||||
- 🟤 Dirt
|
||||
- 🪵 Wood/logs
|
||||
- 🪨 Cobblestone
|
||||
- 📦 Generic items
|
||||
|
||||
---
|
||||
|
||||
### 3. 📱 Mobile-Responsive Design
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Fully responsive interface that works on all devices.
|
||||
|
||||
**Breakpoints:**
|
||||
- **Desktop (1024px+):** Split 50/50 view
|
||||
- **Tablet (768-1024px):** Stacked vertical layout
|
||||
- **Mobile (480-768px):** Tab-based single view
|
||||
- **Small Mobile (<480px):** Optimized compact layout
|
||||
|
||||
**Touch Optimizations:**
|
||||
- Minimum 44px touch targets (iOS standard)
|
||||
- Larger buttons on touch devices
|
||||
- Active states instead of hover
|
||||
- Optimized spacing for fingers
|
||||
- Scroll-friendly layouts
|
||||
|
||||
**Orientation Support:**
|
||||
- **Portrait:** Vertical stacking
|
||||
- **Landscape:** Side-by-side split
|
||||
|
||||
**Accessibility:**
|
||||
- Reduced motion support
|
||||
- High contrast mode
|
||||
- Screen reader friendly
|
||||
- Keyboard navigation
|
||||
|
||||
---
|
||||
|
||||
### 4. 🧠 Intelligent Navigation
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Smart exploration algorithm that learns and adapts.
|
||||
|
||||
**Features:**
|
||||
- Position memory (tracks visited locations)
|
||||
- Stuck detection and auto-recovery
|
||||
- Hole escape algorithm
|
||||
- Depth management (avoids going too deep)
|
||||
- Horizontal-focused exploration (75%)
|
||||
- Prefers unvisited areas
|
||||
- Valuable ore priority mining
|
||||
|
||||
**Navigation Priorities:**
|
||||
1. Mine valuable ores (always)
|
||||
2. Move horizontally (75%)
|
||||
3. Go down if safe (15%, only above y=15)
|
||||
4. Go up if needed (10%, only if stuck or deep)
|
||||
|
||||
**Anti-Stuck Features:**
|
||||
- Detects stationary position
|
||||
- Climbs out after 8 stuck steps
|
||||
- Tries all 4 directions
|
||||
- Digs through obstacles
|
||||
- Never gets permanently stuck
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Backend API Ready (Frontend Pending)
|
||||
|
||||
### 5. 🛤️ Path Recording System
|
||||
**Status:** 🔶 Backend Complete, Frontend Pending
|
||||
|
||||
Record and replay turtle movement paths.
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
POST /api/paths - Save a path
|
||||
GET /api/paths/:turtleId - Get all paths for turtle
|
||||
DELETE /api/paths/:pathId - Delete a path
|
||||
```
|
||||
|
||||
**Database:**
|
||||
- Stores path name, turtle ID, and movement data
|
||||
- Timestamps for creation and updates
|
||||
|
||||
**To Implement (Frontend):**
|
||||
- Record button to start/stop path recording
|
||||
- Path list viewer
|
||||
- Playback controls
|
||||
- Path sharing between turtles
|
||||
|
||||
---
|
||||
|
||||
### 6. 🤝 Multi-Turtle Task Coordination
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Shared task queue for coordinating multiple turtles.
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
POST /api/tasks - Create task
|
||||
GET /api/tasks - Get all tasks
|
||||
GET /api/tasks/next - Get next available task
|
||||
POST /api/tasks/:id/assign - Assign task to turtle
|
||||
POST /api/tasks/:id/complete - Mark task complete
|
||||
```
|
||||
|
||||
**Task System:**
|
||||
- Priority-based queue
|
||||
- Task types: mine, explore, build, gather, transport, clear_area
|
||||
- Status tracking: pending, in_progress, completed, failed
|
||||
- Automatic assignment to available turtles
|
||||
|
||||
**Frontend Features:**
|
||||
- ✅ Task creation UI with 6 task types
|
||||
- ✅ Task queue display with filtering
|
||||
- ✅ Priority management (1-10 with labels)
|
||||
- ✅ Assignment controls
|
||||
- ✅ Coordinate input for area-based tasks
|
||||
- ✅ Status tracking and updates
|
||||
- ✅ Mobile responsive design
|
||||
|
||||
---
|
||||
|
||||
### 7. 🗺️ Mining Area Visualization
|
||||
**Status:** 🔶 Backend Complete, Frontend Pending
|
||||
|
||||
Claim and visualize mining zones on 3D map.
|
||||
|
||||
**API Endpoints:**
|
||||
```
|
||||
POST /api/mining-areas - Claim mining area
|
||||
GET /api/mining-areas - Get all areas
|
||||
POST /api/mining-areas/:id/close - Close area
|
||||
```
|
||||
|
||||
**Database:**
|
||||
- Stores 3D bounding boxes (min/max X, Y, Z)
|
||||
- Tracks which turtle owns the area
|
||||
- Status: active or closed
|
||||
|
||||
**To Implement (Frontend):**
|
||||
- 3D wireframe boxes on map
|
||||
- Color coding per turtle
|
||||
- Area claim UI
|
||||
- Conflict detection
|
||||
|
||||
---
|
||||
|
||||
### 8. ⏰ Task Scheduling
|
||||
**Status:** 🔶 Backend Complete
|
||||
|
||||
Schedule tasks with priorities and time-based execution.
|
||||
|
||||
**Features:**
|
||||
- Priority system (0-10)
|
||||
- Task types and metadata
|
||||
- Queue management
|
||||
- Status tracking
|
||||
|
||||
**To Implement (Frontend):**
|
||||
- Scheduling calendar view
|
||||
- Priority slider
|
||||
- Task templates
|
||||
- Recurring tasks
|
||||
|
||||
---
|
||||
|
||||
## 📋 Not Yet Implemented
|
||||
|
||||
### 9. 🔐 Authentication System
|
||||
**Status:** ❌ Not Started
|
||||
|
||||
Multi-user support with login and permissions.
|
||||
|
||||
**Planned Features:**
|
||||
- User registration and login
|
||||
- JWT token authentication
|
||||
- Role-based permissions (admin, user, viewer)
|
||||
- Per-user turtle assignments
|
||||
- Action logging and audit trail
|
||||
|
||||
**Tech Stack (Proposed):**
|
||||
- bcrypt for password hashing
|
||||
- jsonwebtoken for JWT
|
||||
- Express middleware for auth
|
||||
- Cookie-based sessions
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ✅ Newly Implemented Frontend Features
|
||||
|
||||
### 10. 🎤 Voice Commands
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Natural language control using Web Speech API.
|
||||
|
||||
**Features:**
|
||||
- 20+ voice commands for turtle control
|
||||
- Speech recognition with natural language processing
|
||||
- Speech synthesis for confirmation feedback
|
||||
- Browser compatibility detection (Chrome, Edge, Safari)
|
||||
- Command help with categorized lists
|
||||
- Pulse animation when listening
|
||||
|
||||
**Command Categories:**
|
||||
- Movement: forward, back, turn left/right, up, down
|
||||
- Actions: dig, dig up/down, place, refuel
|
||||
- Autonomous: explore, mine, return home, stop, set home, status
|
||||
|
||||
**UI Features:**
|
||||
- ✅ Voice button with listening state
|
||||
- ✅ Transcript display
|
||||
- ✅ Last command confirmation
|
||||
- ✅ Collapsible command help
|
||||
- ✅ Mobile responsive design
|
||||
|
||||
---
|
||||
|
||||
### 11. 📊 Mining Statistics Dashboard
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Comprehensive mining analytics and leaderboards.
|
||||
|
||||
**Features:**
|
||||
- Per-turtle mining statistics with block type breakdown
|
||||
- Top miners leaderboard with rankings (🥇🥈🥉)
|
||||
- Time filters (24h, 7 days, 30 days, All time)
|
||||
- Item-specific emoji icons for blocks
|
||||
- All turtles overview grid
|
||||
- Empty state handling
|
||||
|
||||
**UI Features:**
|
||||
- ✅ Total blocks mined display
|
||||
- ✅ Block type breakdown with counts
|
||||
- ✅ Leaderboard with medal rankings
|
||||
- ✅ Time-based filtering
|
||||
- ✅ Summary cards for all turtles
|
||||
- ✅ Responsive grid layouts
|
||||
- ✅ Animated transitions
|
||||
|
||||
---
|
||||
|
||||
### 12. 👥 Turtle Groups Management
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Organize turtles into teams with coordinated commands.
|
||||
|
||||
**Features:**
|
||||
- Create and delete turtle groups
|
||||
- 8 color presets for team identification
|
||||
- Add/remove turtles from groups
|
||||
- Send commands to entire group at once
|
||||
- Member list with status indicators
|
||||
- Group member count tracking
|
||||
|
||||
**UI Features:**
|
||||
- ✅ Group creation form with color picker
|
||||
- ✅ Group cards with member lists
|
||||
- ✅ Add/remove member controls
|
||||
- ✅ Group command buttons (explore, mine, return home, stop)
|
||||
- ✅ Status indicators per turtle
|
||||
- ✅ Success/error message feedback
|
||||
- ✅ Mobile responsive design
|
||||
|
||||
---
|
||||
|
||||
### 13. 📋 Task Queue Management
|
||||
**Status:** ✅ Complete
|
||||
|
||||
Schedule and coordinate multi-turtle tasks.
|
||||
|
||||
**Features:**
|
||||
- Create tasks with 6 types (mine_area, explore, gather, build, transport, clear_area)
|
||||
- Priority system (1-10) with visual labels (Critical, High, Medium, Low)
|
||||
- Coordinate input for area-based tasks
|
||||
- Assign tasks to specific turtles or leave unassigned
|
||||
- Filter by status (all, pending, in_progress, completed, failed)
|
||||
- Task lifecycle management (start, complete, fail, delete)
|
||||
- Result tracking and timestamps
|
||||
|
||||
**UI Features:**
|
||||
- ✅ Task creation form with validation
|
||||
- ✅ Priority slider with color coding
|
||||
- ✅ Coordinate input grid
|
||||
- ✅ Turtle assignment dropdown
|
||||
- ✅ Status filter tabs
|
||||
- ✅ Task cards with actions
|
||||
- ✅ Empty state handling
|
||||
- ✅ Mobile responsive design
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current System Statistics
|
||||
|
||||
**Lines of Code:**
|
||||
- Server: ~650 lines (with database integration)
|
||||
- Client: ~3,500 lines (including new components)
|
||||
- Turtle Lua: ~1,000 lines
|
||||
- Database: ~450 lines
|
||||
- **Total: ~5,600 lines**
|
||||
|
||||
**Features Implemented:**
|
||||
- ✅ Real-time WebSocket communication
|
||||
- ✅ 3D block visualization
|
||||
- ✅ GPS tracking
|
||||
- ✅ Autonomous exploration
|
||||
- ✅ Home position management
|
||||
- ✅ Distance limiting
|
||||
- ✅ Intelligent navigation
|
||||
- ✅ Database persistence
|
||||
- ✅ Inventory management UI
|
||||
- ✅ Mobile-responsive design
|
||||
- ✅ Task queue (backend + frontend)
|
||||
- ✅ Path recording backend
|
||||
- ✅ Mining area backend
|
||||
- ✅ Voice commands
|
||||
- ✅ Mining statistics dashboard
|
||||
- ✅ Turtle groups/teams
|
||||
- ✅ Multi-panel tabbed interface
|
||||
|
||||
**Database Tables:** 11
|
||||
**API Endpoints:** 35+
|
||||
**Supported Devices:** Desktop, Tablet, Mobile
|
||||
**Frontend Components:** 12+
|
||||
**Voice Commands:** 20+
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Install Dependencies:**
|
||||
```bash
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
```
|
||||
|
||||
2. **Start Server:**
|
||||
```bash
|
||||
cd server && npm start
|
||||
```
|
||||
|
||||
3. **Start Client:**
|
||||
```bash
|
||||
cd client && npm run dev
|
||||
```
|
||||
|
||||
4. **Deploy Lua Files:**
|
||||
- Copy `turtle.lua` to your Minecraft turtle
|
||||
- Copy `webbridge.lua` to a computer with wireless modem
|
||||
- Configure SERVER_URL in webbridge.lua
|
||||
|
||||
5. **Run Turtle:**
|
||||
```lua
|
||||
turtle.lua
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### High Priority
|
||||
1. ✅ Database persistence - **DONE**
|
||||
2. ✅ Inventory UI - **DONE**
|
||||
3. ✅ Mobile responsive - **DONE**
|
||||
4. ✅ Path recording UI - **DONE**
|
||||
5. ✅ Task coordination UI - **DONE**
|
||||
6. ✅ Mining area visualization - **DONE**
|
||||
|
||||
### Medium Priority
|
||||
7. Task scheduling interface
|
||||
8. Multi-turtle work distribution
|
||||
9. Advanced group coordination features
|
||||
|
||||
### Low Priority
|
||||
10. Authentication system
|
||||
11. User permissions
|
||||
12. Advanced analytics
|
||||
13. Performance monitoring
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips for Use
|
||||
|
||||
**For Best Performance:**
|
||||
- Use GPS hosts for accurate positioning
|
||||
- Keep turtles within 200 blocks of home
|
||||
- Use webbridge computer as dedicated proxy
|
||||
- Run server on local network for best latency
|
||||
|
||||
**For Mobile Use:**
|
||||
- Use landscape mode for split view
|
||||
- Swipe to scroll turtle list
|
||||
- Long-press inventory slots for info
|
||||
- Use tab buttons to switch views
|
||||
|
||||
**For Multiple Turtles:**
|
||||
- Give each turtle a unique ID
|
||||
- Set different home positions
|
||||
- Use task queue for coordination
|
||||
- Monitor from task dashboard
|
||||
|
||||
**For Path Recording:**
|
||||
- Record paths while turtle explores
|
||||
- Save and replay recorded paths
|
||||
- View waypoint details and distances
|
||||
- Use for repetitive mining routes
|
||||
|
||||
**For Mining Areas:**
|
||||
- Define 3D mining areas with coordinates
|
||||
- Assign areas to specific turtles
|
||||
- Track area status (planned/mining/completed)
|
||||
- Visualize areas as 3D wireframes on map
|
||||
- Detect overlapping area conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - Feel free to modify and distribute!
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! Areas needing work:
|
||||
- Task scheduling automation
|
||||
- Multi-turtle work distribution algorithms
|
||||
- Authentication system
|
||||
- Performance optimizations
|
||||
- Additional turtle commands
|
||||
- Advanced analytics dashboard
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** February 19, 2026
|
||||
**Version:** 2.1.0
|
||||
**Status:** Production Ready ✨
|
||||
|
||||
259
IMPLEMENTATION_SUMMARY.md
Normal file
259
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Implementation Summary - Mining Area Visualization
|
||||
|
||||
## ✅ Completed Features
|
||||
|
||||
### 1. 3D Mining Area Visualization (Map3D.jsx)
|
||||
**Added MiningArea Component:**
|
||||
- 3D wireframe boxes for each mining area
|
||||
- Semi-transparent fill for better visibility
|
||||
- Dynamic color coding based on:
|
||||
- Turtle color (when assigned)
|
||||
- Status: Green (completed), Orange (mining), Purple/Blue (planned)
|
||||
- Area labels with name and dimensions (W×H×D)
|
||||
- Status indicators (pulsing sphere for active mining)
|
||||
- Interactive hover effects
|
||||
- Selection support with animated rotation
|
||||
- Real-time updates every 5 seconds
|
||||
|
||||
**Integration in Scene:**
|
||||
- Fetches mining areas from `/api/mining-areas`
|
||||
- Renders all areas with proper positioning
|
||||
- Supports area selection via click
|
||||
- Positioned between blocks and home marker for proper layering
|
||||
|
||||
### 2. Mining Areas Management Panel (MiningAreasPanel.jsx)
|
||||
**Core Features:**
|
||||
- Create new mining areas with form
|
||||
- Assign areas to specific turtles
|
||||
- Define 3D coordinates (start/end positions)
|
||||
- "Use Current Position" button to auto-fill from selected turtle
|
||||
- List all mining areas with cards
|
||||
- Filter by status: All, Planned, Mining, Completed
|
||||
- Update area status (Start Mining, Mark Complete)
|
||||
- Delete mining areas
|
||||
- Real-time updates every 5 seconds
|
||||
|
||||
**Advanced Features:**
|
||||
- **Conflict Detection:** Automatically detects overlapping areas
|
||||
- **Volume Calculation:** Shows total block count for each area
|
||||
- **Visual Warnings:** Red borders and warnings for conflicting areas
|
||||
- **Responsive Design:** Mobile-friendly layout
|
||||
- **Status Management:** Easy status transitions with buttons
|
||||
|
||||
**UI Details:**
|
||||
- Clean card-based layout
|
||||
- Color-coded status badges
|
||||
- Coordinate display in monospace font
|
||||
- Creation date tracking
|
||||
- Turtle assignment display
|
||||
- Action buttons per area
|
||||
|
||||
### 3. Integration (App.jsx)
|
||||
**Changes Made:**
|
||||
- Imported MiningAreasPanel component
|
||||
- Added 'areas' case to panelTab state
|
||||
- Added routing for MiningAreasPanel in renderPanelContent
|
||||
- Created 7th tab: ⛏️ Areas
|
||||
- Passed required props: turtles, selectedTurtle, apiUrl
|
||||
|
||||
**Tab System:**
|
||||
Now 7 tabs total:
|
||||
1. 🎮 Control
|
||||
2. 🎤 Voice
|
||||
3. 📊 Stats
|
||||
4. 👥 Groups
|
||||
5. 📋 Tasks
|
||||
6. 🛤️ Paths
|
||||
7. ⛏️ Areas (NEW)
|
||||
|
||||
## 📁 Files Created/Modified
|
||||
|
||||
### New Files:
|
||||
1. `/client/src/components/MiningAreasPanel.jsx` (430 lines)
|
||||
- Full panel component with CRUD operations
|
||||
- Conflict detection algorithm
|
||||
- Volume calculations
|
||||
- Status management
|
||||
|
||||
2. `/client/src/components/MiningAreasPanel.css` (450 lines)
|
||||
- Complete styling with animations
|
||||
- Responsive breakpoints
|
||||
- Conflict warning styles
|
||||
- Status badge colors
|
||||
- Mobile optimizations
|
||||
|
||||
### Modified Files:
|
||||
1. `/client/src/components/Map3D.jsx`
|
||||
- Added MiningArea component (90 lines)
|
||||
- Added miningAreas state to Scene
|
||||
- Added useEffect to fetch areas
|
||||
- Integrated area rendering in Scene return
|
||||
|
||||
2. `/client/src/App.jsx`
|
||||
- Added MiningAreasPanel import
|
||||
- Updated panelTab state comment
|
||||
- Added 'areas' case in renderPanelContent
|
||||
- Added ⛏️ Areas tab button
|
||||
|
||||
3. `/FEATURES.md`
|
||||
- Updated completion status
|
||||
- Marked Path Recording UI as complete
|
||||
- Marked Mining Area Visualization as complete
|
||||
- Added usage tips for both features
|
||||
- Updated version to 2.1.0
|
||||
|
||||
## 🎨 Visual Features
|
||||
|
||||
### 3D Visualization:
|
||||
- Wireframe boxes with thick lines
|
||||
- Semi-transparent colored fill
|
||||
- Floating labels above areas
|
||||
- Dimension indicators
|
||||
- Status-based coloring
|
||||
- Hover highlighting
|
||||
- Selection animation
|
||||
- Mining status indicator (glowing sphere)
|
||||
|
||||
### UI Panel:
|
||||
- Filter buttons with counts
|
||||
- Create form with validation
|
||||
- Coordinate inputs (X, Y, Z)
|
||||
- Turtle selection dropdown
|
||||
- "Use Current Position" helper
|
||||
- Area cards with info
|
||||
- Conflict warnings (red borders)
|
||||
- Action buttons per status
|
||||
- Volume display
|
||||
- Creation date
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### API Endpoints Used:
|
||||
- `GET /api/mining-areas` - Fetch all areas
|
||||
- `POST /api/mining-areas` - Create new area
|
||||
- `PUT /api/mining-areas/:id` - Update area status
|
||||
- `DELETE /api/mining-areas/:id` - Delete area
|
||||
|
||||
### State Management:
|
||||
- Local state for areas list
|
||||
- Local state for form data
|
||||
- Local state for filters
|
||||
- Local state for selected area (3D)
|
||||
- Real-time polling (5 second intervals)
|
||||
|
||||
### Conflict Detection Algorithm:
|
||||
```javascript
|
||||
// Checks if two 3D boxes overlap
|
||||
const checkOverlap = (area1, area2) => {
|
||||
const overlapX = Math.max(
|
||||
Math.min(area1.endX, area2.endX) - Math.max(area1.startX, area2.startX) + 1,
|
||||
0
|
||||
);
|
||||
const overlapY = Math.max(
|
||||
Math.min(area1.endY, area2.endY) - Math.max(area1.startY, area2.startY) + 1,
|
||||
0
|
||||
);
|
||||
const overlapZ = Math.max(
|
||||
Math.min(area1.endZ, area2.endZ) - Math.max(area1.startZ, area2.startZ) + 1,
|
||||
0
|
||||
);
|
||||
return overlapX > 0 && overlapY > 0 && overlapZ > 0;
|
||||
};
|
||||
```
|
||||
|
||||
### Volume Calculation:
|
||||
```javascript
|
||||
const calculateVolume = (area) => {
|
||||
const width = Math.abs(area.endX - area.startX) + 1;
|
||||
const height = Math.abs(area.endY - area.startY) + 1;
|
||||
const depth = Math.abs(area.endZ - area.startZ) + 1;
|
||||
return width * height * depth;
|
||||
};
|
||||
```
|
||||
|
||||
## ✨ User Workflows
|
||||
|
||||
### Creating a Mining Area:
|
||||
1. Click "+ New Area" button
|
||||
2. Enter area name (e.g., "Diamond Mine #1")
|
||||
3. Select turtle to assign
|
||||
4. Enter start coordinates OR click "Use Current Position"
|
||||
5. Enter end coordinates
|
||||
6. Click "Create Area"
|
||||
7. Area appears in list and on 3D map
|
||||
|
||||
### Managing Area Status:
|
||||
1. View area in list (status badge shows current state)
|
||||
2. Click "▶️ Start Mining" to begin (changes to orange)
|
||||
3. Click "✓ Mark Complete" when done (changes to green)
|
||||
4. Click "🗑️ Delete" to remove area
|
||||
|
||||
### Detecting Conflicts:
|
||||
1. System automatically checks all areas for overlaps
|
||||
2. Conflicting areas show red border
|
||||
3. Warning message lists overlapping areas
|
||||
4. Review and resolve conflicts manually
|
||||
|
||||
### Visualizing on Map:
|
||||
1. All areas render as 3D wireframe boxes
|
||||
2. Colors match turtle assignment or status
|
||||
3. Click area to select (animates with rotation)
|
||||
4. Hover for highlighting effect
|
||||
5. Labels show name and dimensions
|
||||
|
||||
## 📊 Status Indicators
|
||||
|
||||
### Colors:
|
||||
- 🟣 Purple/Blue: Planned (not started)
|
||||
- 🟠 Orange: Mining (in progress) + glowing sphere
|
||||
- 🟢 Green: Completed (finished)
|
||||
- 🔴 Red: Conflict detected (overlapping)
|
||||
|
||||
### Badges:
|
||||
- Small rounded badges in area cards
|
||||
- Uppercase text with color coding
|
||||
- Matches 3D visualization colors
|
||||
|
||||
## 🎯 Achievement
|
||||
|
||||
All requested features from todo list are now **COMPLETE**:
|
||||
|
||||
✅ **Path Recording UI** (Completed Earlier)
|
||||
- Record button with start/stop
|
||||
- Path list viewer with cards
|
||||
- Playback controls
|
||||
- Save/load paths from database
|
||||
- Visual preview in details modal
|
||||
- Waypoint grid and distance calculation
|
||||
|
||||
✅ **Mining Area Visualization** (Just Completed)
|
||||
- 3D wireframe boxes on Map3D
|
||||
- Color coding per turtle and status
|
||||
- Area claim UI in MiningAreasPanel
|
||||
- Conflict detection with warnings
|
||||
- Full CRUD operations
|
||||
- Volume calculations
|
||||
- Status management
|
||||
|
||||
## 📈 System Status
|
||||
|
||||
**Before:** 6 functional tabs
|
||||
**After:** 7 functional tabs
|
||||
|
||||
**Total Components:** 8 major components
|
||||
**Total Lines Added:** ~970 lines (MiningAreasPanel.jsx + CSS + Map3D changes)
|
||||
**Backend APIs:** All 35+ endpoints functional
|
||||
**Database Tables:** All 11 tables integrated
|
||||
|
||||
## 🚀 Ready for Testing
|
||||
|
||||
The system is now feature-complete with:
|
||||
- Voice control
|
||||
- Mining statistics
|
||||
- Turtle groups
|
||||
- Task queue
|
||||
- Path recording
|
||||
- **Mining area visualization (NEW)**
|
||||
- 3D world map with all features integrated
|
||||
|
||||
All features are production-ready and fully integrated into the tabbed interface!
|
||||
156
INTERFACE.md
Normal file
156
INTERFACE.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 🎨 Interface Overview
|
||||
|
||||
## Web Interface Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ [📊 Split View] [🗺️ Map Only] [🎮 Control Only] [🟢 Connected]│
|
||||
├──────────────────┬──────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 🐢 Turtle List │ 3D Map View │
|
||||
│ ───────────── │ │
|
||||
│ │ ┌─────┐ │
|
||||
│ ┌────────────┐ │ │ T-1 │ ← Selected Turtle │
|
||||
│ │ Turtle 1 │◄─┼─────────└─────┘ │
|
||||
│ │ [mining] │ │ │
|
||||
│ │ 100,64,200 │ │ │
|
||||
│ │ F:5000 I:8 │ │ 🏠 HOME │
|
||||
│ └────────────┘ │ │
|
||||
│ │ │
|
||||
│ ┌────────────┐ │ Path Trail ---- │
|
||||
│ │ Turtle 2 │ │ \ │
|
||||
│ │ [idle] │ │ ┌─────┐ │
|
||||
│ │ 105,65,195 │ │ │ T-2 │ │
|
||||
│ │ F:3200 I:3 │ │ └─────┘ │
|
||||
│ └────────────┘ │ │
|
||||
│ │ │
|
||||
│ No turtles... │ Grid Floor (5x5 blocks) │
|
||||
│ │ │
|
||||
├──────────────────┴──────────────────────────────────────────────────┤
|
||||
│ Turtle 1 Control │
|
||||
│ ─────────────── │
|
||||
│ Status: │
|
||||
│ Mode: mining Fuel: 5000 │
|
||||
│ Position: X: 100, Y: 64, Z: 200 │
|
||||
│ Home: X: 95, Y: 64, Z: 195 │
|
||||
│ │
|
||||
│ Commands: │
|
||||
│ [🔍 Explore] [⛏️ Mine] [🏠 Return Home] [⏹️ Stop] │
|
||||
│ │
|
||||
│ Manual Control: │
|
||||
│ [↑] │
|
||||
│ [←] [→] │
|
||||
│ [↓] │
|
||||
│ [⬆ Up] [⬇ Down] │
|
||||
│ │
|
||||
│ Inventory: │
|
||||
│ coal_ore x32 iron_ore x16 diamond_ore x3 │
|
||||
│ cobblestone x64 dirt x12 │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Color Scheme
|
||||
|
||||
### Turtle Status Colors
|
||||
- 🟢 **Green (#4ade80)**: Mining/Active
|
||||
- 🔵 **Blue (#60a5fa)**: Exploring
|
||||
- 🟠 **Orange (#f59e0b)**: Returning Home
|
||||
- ⚪ **Gray (#9ca3af)**: Idle
|
||||
- 🟣 **Purple (#a78bfa)**: Manual Control
|
||||
|
||||
### UI Colors
|
||||
- **Background**: Dark blue (#0a0e1a, #0f172a)
|
||||
- **Panels**: Slate (#1e293b)
|
||||
- **Borders**: Gray-blue (#334155)
|
||||
- **Text**: Light gray (#e2e8f0)
|
||||
- **Accents**: Cyan (#60a5fa)
|
||||
|
||||
## Features Visualization
|
||||
|
||||
### 3D Map Features
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Orbital Camera Control │
|
||||
│ - Rotate: Left Click+Drag │
|
||||
│ - Zoom: Scroll Wheel │
|
||||
│ - Pan: Right Click+Drag │
|
||||
│ │
|
||||
│ Turtle Markers: │
|
||||
│ ┌─┐ │
|
||||
│ │░│ ← 3D Box │
|
||||
│ └─┘ │
|
||||
│ ▲ ← Selection Arrow │
|
||||
│ ⭕ ← Selection Ring │
|
||||
│ T-1 ← ID Label │
|
||||
│ │
|
||||
│ Path Trails: │
|
||||
│ ---- Dashed line to home │
|
||||
│ │
|
||||
│ Grid: │
|
||||
│ □ □ □ 1-block grid │
|
||||
│ ▪ ▪ ▪ 5-block sections │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Control Panel Features
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Turtle Card (Clickable) │
|
||||
├──────────────────────────────┤
|
||||
│ Turtle 1 [mining] ● │
|
||||
│ 100, 64, 200 │
|
||||
│ F: 5000 I: 8 items │
|
||||
│ Home: 15 blocks │
|
||||
└──────────────────────────────┘
|
||||
↓ Click to select
|
||||
┌──────────────────────────────┐
|
||||
│ Detail View │
|
||||
├──────────────────────────────┤
|
||||
│ All stats + commands │
|
||||
│ Manual control arrows │
|
||||
│ Inventory grid │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### View Modes
|
||||
|
||||
**Split View** (Default)
|
||||
- Best for desktop monitors
|
||||
- Side-by-side layout
|
||||
- Full visibility of both map and controls
|
||||
|
||||
**Map Only**
|
||||
- Fullscreen 3D visualization
|
||||
- Great for presentations
|
||||
- Multi-turtle overview
|
||||
|
||||
**Control Only**
|
||||
- Detailed turtle management
|
||||
- Better for laptops/smaller screens
|
||||
- Focus on commands and data
|
||||
|
||||
## Real-time Updates
|
||||
|
||||
```
|
||||
Turtle moves → Status broadcast → Web Bridge → Server → WebSocket → React
|
||||
↓
|
||||
Update 3D Map
|
||||
Update Stats
|
||||
Animate Marker
|
||||
```
|
||||
|
||||
Update frequency: Every 5 seconds or on command execution
|
||||
|
||||
## Interactive Elements
|
||||
|
||||
- **Clickable**: Turtle cards, 3D markers, command buttons
|
||||
- **Hoverable**: Buttons show hover effects
|
||||
- **Animated**: Selected turtle rotates, status dot pulses
|
||||
- **Live**: Real-time position tracking on map
|
||||
- **Responsive**: View mode switches, panel scrolling
|
||||
|
||||
---
|
||||
|
||||
Launch the interface to see it in action! 🚀
|
||||
248
PROJECT_SUMMARY.md
Normal file
248
PROJECT_SUMMARY.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 📦 Project Summary
|
||||
|
||||
## What Was Created
|
||||
|
||||
This project has been expanded from a simple Lua-based turtle control system into a **full-stack web application** with:
|
||||
|
||||
### 🎯 Key Components
|
||||
|
||||
1. **Backend Server** (`server/`)
|
||||
- Express.js HTTP server (port 3001)
|
||||
- WebSocket server for real-time updates (port 3002)
|
||||
- REST API for turtle communication
|
||||
- Automatic turtle state management
|
||||
|
||||
2. **Frontend Application** (`client/`)
|
||||
- React 18 with Vite build system
|
||||
- 3D map visualization using Three.js and React Three Fiber
|
||||
- Real-time WebSocket connection
|
||||
- Zustand state management
|
||||
- Responsive control panel with multiple view modes
|
||||
|
||||
3. **Minecraft Bridge** (`webbridge.lua`)
|
||||
- Forwards turtle status from ComputerCraft to web server
|
||||
- Polls for commands from server
|
||||
- HTTP communication bridge
|
||||
|
||||
4. **Original Turtle Scripts** (Enhanced)
|
||||
- `turtle.lua` - Advanced autonomous mining turtle
|
||||
- `pocketremote.lua` - Pocket computer interface
|
||||
|
||||
### 📂 Complete File Structure
|
||||
|
||||
```
|
||||
remoteturtle/
|
||||
│
|
||||
├── 📄 README.md # Complete documentation
|
||||
├── 📄 QUICKSTART.md # Quick setup guide
|
||||
├── 📄 INTERFACE.md # UI/UX overview
|
||||
├── 📄 package.json # Root package file
|
||||
├── 📄 .gitignore # Git ignore rules
|
||||
│
|
||||
├── 🚀 start.sh # Linux/Mac startup script
|
||||
├── 🚀 start.bat # Windows startup script
|
||||
│
|
||||
├── 🖥️ server/ # Backend Server
|
||||
│ ├── server.js # Main server code
|
||||
│ └── package.json # Dependencies
|
||||
│
|
||||
├── 🌐 client/ # React Frontend
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ ├── package.json
|
||||
│ └── src/
|
||||
│ ├── main.jsx # Entry point
|
||||
│ ├── App.jsx # Main app component
|
||||
│ ├── App.css
|
||||
│ ├── index.css # Global styles
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── Map3D.jsx # 3D map visualization
|
||||
│ │ ├── ControlPanel.jsx # Control interface
|
||||
│ │ └── ControlPanel.css # Panel styles
|
||||
│ │
|
||||
│ └── store/
|
||||
│ └── turtleStore.js # State management
|
||||
│
|
||||
└── 🎮 Lua Scripts/ # Minecraft/ComputerCraft
|
||||
├── webbridge.lua # Web server bridge
|
||||
├── turtle.lua # Advanced turtle AI
|
||||
└── pocketremote.lua # Pocket computer UI
|
||||
```
|
||||
|
||||
## 🎨 Features Implemented
|
||||
|
||||
### Web Interface
|
||||
- ✅ Real-time 3D map with orbital camera controls
|
||||
- ✅ Interactive turtle markers with selection
|
||||
- ✅ Path trails showing routes to home
|
||||
- ✅ Multiple view modes (Split/Map/Control)
|
||||
- ✅ Connection status indicator
|
||||
- ✅ Turtle cards with live stats
|
||||
- ✅ Detailed control panel per turtle
|
||||
- ✅ Command buttons (Explore, Mine, Return, Stop)
|
||||
- ✅ Manual control arrows
|
||||
- ✅ Inventory visualization
|
||||
- ✅ Auto-reconnect on disconnect
|
||||
|
||||
### Backend
|
||||
- ✅ WebSocket real-time communication
|
||||
- ✅ REST API for turtle updates
|
||||
- ✅ Command queuing system
|
||||
- ✅ Automatic stale turtle cleanup
|
||||
- ✅ CORS enabled for local development
|
||||
- ✅ Error handling and logging
|
||||
|
||||
### Minecraft Integration
|
||||
- ✅ Bridge script for HTTP communication
|
||||
- ✅ Command polling system
|
||||
- ✅ Status broadcasting
|
||||
- ✅ Backward compatible with existing scripts
|
||||
|
||||
## 🔧 Technologies Used
|
||||
|
||||
### Frontend
|
||||
- **React 18** - UI framework
|
||||
- **Vite** - Build tool and dev server
|
||||
- **Three.js** - 3D graphics library
|
||||
- **@react-three/fiber** - React renderer for Three.js
|
||||
- **@react-three/drei** - Helper components for R3F
|
||||
- **Zustand** - Lightweight state management
|
||||
- **WebSocket API** - Real-time communication
|
||||
|
||||
### Backend
|
||||
- **Node.js** - Runtime environment
|
||||
- **Express** - Web framework
|
||||
- **ws** - WebSocket server
|
||||
- **cors** - Cross-origin resource sharing
|
||||
|
||||
### Minecraft
|
||||
- **ComputerCraft** - Lua API for Minecraft
|
||||
- **HTTP API** - For web communication
|
||||
- **Modem API** - For wireless communication
|
||||
|
||||
## 🚀 Quick Start Commands
|
||||
|
||||
### Install Everything
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### Start in Development Mode
|
||||
```bash
|
||||
# Using npm (starts both servers)
|
||||
npm run dev
|
||||
|
||||
# Or use the helper scripts
|
||||
./start.sh # Linux/Mac
|
||||
start.bat # Windows
|
||||
```
|
||||
|
||||
### Individual Services
|
||||
```bash
|
||||
npm run server # Start backend only
|
||||
npm run client # Start frontend only
|
||||
```
|
||||
|
||||
## 🌐 Access Points
|
||||
|
||||
Once running:
|
||||
- **Web Interface**: http://localhost:3000
|
||||
- **API Server**: http://localhost:3001
|
||||
- **WebSocket**: ws://localhost:3002
|
||||
- **API Docs**: Check README.md API Reference section
|
||||
|
||||
## 📊 Data Flow
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Turtle │
|
||||
│ (turtle.lua)│
|
||||
└──────┬───────┘
|
||||
│ Status (Modem Channel 102)
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Bridge │
|
||||
│(webbridge.lua)│ ← Forwards via HTTP
|
||||
└──────┬───────┘
|
||||
│ POST /api/turtle/update
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Node Server │ ← Broadcasts via WebSocket
|
||||
│ (server.js) │
|
||||
└──────┬───────┘
|
||||
│ WebSocket message
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ React Client │ ← Updates UI in real-time
|
||||
│ (App.jsx) │
|
||||
└──────────────┘
|
||||
│ Command button click
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Server │ ← Queues command
|
||||
└──────┬───────┘
|
||||
│ GET /api/turtle/:id/commands
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Bridge │ ← Polls for commands
|
||||
└──────┬───────┘
|
||||
│ Modem Channel 100
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ Turtle │ ← Executes command
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Run the startup script** to install dependencies
|
||||
2. **Start Minecraft** and set up the bridge computer
|
||||
3. **Deploy turtles** with the turtle.lua script
|
||||
4. **Open the web interface** and start controlling!
|
||||
|
||||
## 💡 Tips for Use
|
||||
|
||||
- Keep the bridge computer loaded (use chunk loaders)
|
||||
- Set up GPS hosts for accurate positioning
|
||||
- Monitor the 3D map for turtle movements
|
||||
- Use split view on larger screens
|
||||
- Switch to map-only for presentations
|
||||
- Try the manual controls for precise movements
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
**Port already in use?**
|
||||
- Change ports in `server/server.js` and `client/src/store/turtleStore.js`
|
||||
|
||||
**Turtles not connecting?**
|
||||
- Verify `webbridge.lua` SERVER_URL matches your setup
|
||||
- Check all scripts use matching channel numbers
|
||||
- Ensure wireless modems are equipped
|
||||
|
||||
**3D map not rendering?**
|
||||
- Check browser console for errors
|
||||
- Ensure WebGL is supported by your browser
|
||||
- Try a different browser (Chrome/Firefox recommended)
|
||||
|
||||
## 📝 Documentation Files
|
||||
|
||||
- **README.md** - Complete documentation with API reference
|
||||
- **QUICKSTART.md** - Fast setup guide
|
||||
- **INTERFACE.md** - UI/UX overview
|
||||
- This file - Project summary
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
You now have a complete web-based control center for your Minecraft turtles!
|
||||
|
||||
**Features you can use:**
|
||||
- Monitor multiple turtles on a 3D map
|
||||
- Send commands from your browser
|
||||
- Watch real-time position updates
|
||||
- Control turtles from any device on your network
|
||||
- Beautiful, modern UI with dark theme
|
||||
|
||||
**Happy turtle controlling!** 🐢✨
|
||||
@@ -64,6 +64,36 @@ You should see:
|
||||
**GPS not working?**
|
||||
- Set up 4 GPS host computers at high altitude
|
||||
- Run `gps host X Y Z` on each (with their coordinates)
|
||||
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||
|
||||
## Pathfinding
|
||||
|
||||
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||
You can trigger it from the web UI via eval commands:
|
||||
|
||||
```lua
|
||||
-- Navigate to coordinates (avoids obstacles)
|
||||
_pathfind.goto(100, 65, -200)
|
||||
|
||||
-- Navigate with block digging enabled
|
||||
_pathfind.goto(100, 65, -200, { dig = true })
|
||||
|
||||
-- Go home
|
||||
_pathfind.goHome()
|
||||
|
||||
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||
_pathfind.face(3)
|
||||
|
||||
-- Get current heading name
|
||||
_pathfind.headingName() -- "east"
|
||||
```
|
||||
|
||||
The pathfinder:
|
||||
- Uses GPS for initial position, then tracks movement locally
|
||||
- Auto-detects heading on startup via GPS triangulation
|
||||
- Handles obstacles by trying to go over/around them
|
||||
- Supports optional block digging for clearing paths
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
780
README.md
780
README.md
@@ -1,21 +1,35 @@
|
||||
# 🐢 Turtle Control Center - Web Panel
|
||||
# 🐢 Turtle Control Center - Advanced Web Panel
|
||||
|
||||
A full-stack web application for monitoring and controlling ComputerCraft turtles in Minecraft. Features a real-time 3D map, WebSocket communication, and an intuitive control panel.
|
||||
A comprehensive full-stack web application for monitoring and controlling ComputerCraft turtles in Minecraft. Features real-time 3D visualization, voice commands, intelligent navigation, mining statistics, team coordination, and persistent database storage.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 📋 Features
|
||||
## ✨ Features
|
||||
|
||||
- **🗺️ Real-time 3D Map**: Visualize turtle positions and movements in a beautiful 3D environment
|
||||
### Core Features
|
||||
- **🗺️ Real-time 3D Map**: Visualize turtle positions and movements with Three.js
|
||||
- **🎮 Control Panel**: Monitor and control multiple turtles simultaneously
|
||||
- **⚡ WebSocket Communication**: Instant updates and commands
|
||||
- **🔄 Auto-reconnect**: Handles connection drops gracefully
|
||||
- **📊 Live Statistics**: Track fuel, inventory, position, and more
|
||||
- **🎯 Manual Control**: Direct control with arrow keys
|
||||
- **🤖 Autonomous Modes**: Explore, mine, and return home automatically
|
||||
- **⚡ WebSocket Communication**: Instant bidirectional updates
|
||||
- **🔄 Auto-reconnect**: Robust connection handling with automatic recovery
|
||||
- **📊 Live Statistics**: Fuel, inventory, position, mining stats, and more
|
||||
- **🎯 Manual Control**: Direct movement and action controls
|
||||
- **🤖 Autonomous Intelligence**: Smart exploration with position memory and stuck detection
|
||||
|
||||
### Advanced Features
|
||||
- **🎤 Voice Commands**: Natural language control with Web Speech API (20+ commands)
|
||||
- **📦 Enhanced Inventory**: 4x4 grid display with item-specific emojis and counts
|
||||
- **📱 Mobile Responsive**: Optimized for desktop, tablet, and mobile devices
|
||||
- **💾 Database Persistence**: SQLite storage for homes, blocks, paths, tasks, and statistics
|
||||
- **📈 Mining Statistics**: Track blocks mined per turtle with leaderboards
|
||||
- **👥 Turtle Teams**: Organize turtles into groups with coordinated commands
|
||||
- **🗺️ Path Recording**: Record and replay turtle movement paths
|
||||
- **📋 Task Queue**: Schedule and coordinate multi-turtle tasks
|
||||
- **🏗️ Mining Areas**: Define and manage mining zones
|
||||
- **⏱️ Session Tracking**: Time-based analytics and performance metrics
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
@@ -38,87 +52,218 @@ A full-stack web application for monitoring and controlling ComputerCraft turtle
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ installed on your computer
|
||||
- Minecraft with ComputerCraft mod installed
|
||||
- At least one turtle with a wireless modem
|
||||
- A computer in Minecraft to run the bridge script
|
||||
- **Docker and Docker Compose** installed on your computer
|
||||
- **Minecraft** with ComputerCraft mod installed
|
||||
- At least one **turtle with a wireless modem**
|
||||
- A **computer in Minecraft** to run the bridge script
|
||||
|
||||
### Installation
|
||||
### Installation (Docker - Recommended)
|
||||
|
||||
1. **Clone or download this project**
|
||||
|
||||
2. **Install Server Dependencies**
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
git clone <repository-url>
|
||||
cd remoteturtle
|
||||
```
|
||||
|
||||
3. **Install Client Dependencies**
|
||||
2. **Start with Docker Compose**
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This will automatically:
|
||||
- Build and start the backend server on port 3001
|
||||
- Build and start the frontend on port 3000
|
||||
- Create a persistent SQLite database volume
|
||||
- Set up networking between containers
|
||||
|
||||
4. **Start the Backend Server**
|
||||
```bash
|
||||
cd server
|
||||
npm start
|
||||
```
|
||||
Server will run on:
|
||||
- HTTP: http://localhost:3001
|
||||
3. **Access the Web Interface**
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:3001
|
||||
- WebSocket: ws://localhost:3002
|
||||
|
||||
5. **Start the React Frontend**
|
||||
```bash
|
||||
cd client
|
||||
npm run dev
|
||||
4. **Set up Minecraft Bridge Computer**
|
||||
|
||||
In Minecraft, place a computer with a wireless modem and run:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
|
||||
reboot
|
||||
```
|
||||
Frontend will open at: http://localhost:3000
|
||||
|
||||
6. **Set up Minecraft Bridge**
|
||||
- In Minecraft, place a computer with a wireless modem
|
||||
|
||||
This installs an auto-update system that:
|
||||
- Downloads the latest `webbridge.lua` on boot
|
||||
- Falls back to cached version if download fails
|
||||
- Automatically connects to your server
|
||||
|
||||
Or manually:
|
||||
- Copy `webbridge.lua` to the computer
|
||||
- Edit the `SERVER_URL` in webbridge.lua to point to your server
|
||||
(If running locally, use `http://localhost:3001`)
|
||||
- Edit the `SERVER_URL` to point to your Docker host
|
||||
- If Minecraft is on the same machine: `http://host.docker.internal:3001`
|
||||
- If on different machine: `http://<your-ip>:3001`
|
||||
- Run: `webbridge`
|
||||
|
||||
7. **Deploy Turtles**
|
||||
- Copy `turtle.lua` to your turtles
|
||||
- Equip wireless modems on turtles
|
||||
- Run: `turtle`
|
||||
5. **Deploy Auto-Updating Turtles**
|
||||
|
||||
On each turtle with a wireless modem, run:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
|
||||
reboot
|
||||
```
|
||||
|
||||
This installs an auto-update system that:
|
||||
- Downloads the latest `turtle.lua` on boot
|
||||
- Falls back to cached version if download fails
|
||||
- Keeps turtles updated automatically
|
||||
|
||||
6. **Optional: Deploy Pocket Computer Control**
|
||||
|
||||
For wireless pocket computer control:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
|
||||
reboot
|
||||
```
|
||||
|
||||
This provides a wireless control interface for managing turtles from anywhere in-game
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose up -d --build
|
||||
|
||||
# Stop and remove volumes (resets database)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Alternative: Manual Installation (Without Docker)
|
||||
|
||||
If you prefer not to use Docker:
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
```
|
||||
|
||||
2. **Start Backend**
|
||||
```bash
|
||||
cd server && npm start
|
||||
```
|
||||
|
||||
3. **Start Frontend**
|
||||
```bash
|
||||
cd client && npm run dev
|
||||
```
|
||||
|
||||
4. Follow steps 4-6 from the Docker installation above
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
remoteturtle/
|
||||
├── server/ # Node.js backend
|
||||
│ ├── server.js # Express + WebSocket server
|
||||
│ └── package.json
|
||||
├── client/ # React frontend
|
||||
├── server/ # Node.js backend (650+ lines)
|
||||
│ ├── server.js # Express REST API + WebSocket server
|
||||
│ ├── database.js # SQLite database layer (450+ lines)
|
||||
│ ├── turtle_control.db # SQLite database file
|
||||
│ ├── package.json
|
||||
│ └── node_modules/
|
||||
│
|
||||
├── client/ # React frontend (1,500+ lines)
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── Map3D.jsx # 3D map visualization
|
||||
│ │ │ ├── ControlPanel.jsx # Control interface
|
||||
│ │ │ └── ControlPanel.css
|
||||
│ │ │ ├── Map3D.jsx # Three.js 3D visualization
|
||||
│ │ │ ├── ControlPanel.jsx # Main control interface
|
||||
│ │ │ ├── ControlPanel.css # Control panel styles
|
||||
│ │ │ ├── VoiceControl.jsx # Voice command interface (NEW)
|
||||
│ │ │ └── VoiceControl.css # Voice control styles (NEW)
|
||||
│ │ ├── store/
|
||||
│ │ │ └── turtleStore.js # Zustand state management
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── App.css
|
||||
│ │ ├── main.jsx
|
||||
│ │ └── index.css
|
||||
│ │ │ └── turtleStore.js # Zustand state management
|
||||
│ │ ├── App.jsx # Main application component
|
||||
│ │ ├── App.css # Global styles + responsive design
|
||||
│ │ ├── main.jsx # React entry point
|
||||
│ │ └── index.css # Base styles
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ └── package.json
|
||||
├── webbridge.lua # ComputerCraft bridge script
|
||||
├── turtle.lua # Advanced turtle control script
|
||||
├── pocketremote.lua # Pocket computer interface
|
||||
└── README.md
|
||||
│ ├── vite.config.js # Vite configuration
|
||||
│ ├── package.json
|
||||
│ └── node_modules/
|
||||
│
|
||||
├── webbridge.lua # ComputerCraft HTTP bridge (200+ lines)
|
||||
├── turtle.lua # Intelligent turtle controller (400+ lines)
|
||||
├── pocketremote.lua # Pocket computer interface (150+ lines)
|
||||
├── FEATURES.md # Feature documentation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Key Files Overview
|
||||
|
||||
**Backend:**
|
||||
- `server/server.js` - 35+ REST API endpoints, WebSocket server
|
||||
- `server/database.js` - Database abstraction with 10 tables
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/App.jsx` - Layout and routing
|
||||
- `client/src/components/Map3D.jsx` - 3D turtle visualization
|
||||
- `client/src/components/ControlPanel.jsx` - Main control UI
|
||||
- `client/src/components/VoiceControl.jsx` - Voice commands
|
||||
- `client/src/store/turtleStore.js` - Global state management
|
||||
|
||||
**ComputerCraft:**
|
||||
- `webbridge.lua` - Bridge between Minecraft and web server
|
||||
- `turtle.lua` - Autonomous turtle with intelligent navigation
|
||||
- `pocketremote.lua` - Mobile control from pocket computers
|
||||
|
||||
## 🎮 Usage
|
||||
|
||||
### Web Interface
|
||||
|
||||
The interface features a **multi-panel tabbed design** for comprehensive turtle management:
|
||||
|
||||
#### 🎮 Control Panel
|
||||
- View all connected turtles
|
||||
- Manual movement controls (forward, back, up, down, turn)
|
||||
- Autonomous commands (explore, mine, return home)
|
||||
- Real-time inventory display (4x4 grid with emojis)
|
||||
- GPS position and fuel monitoring
|
||||
|
||||
#### 🎤 Voice Commands
|
||||
- Speak naturally to control turtles
|
||||
- 20+ recognized commands
|
||||
- Real-time transcript display
|
||||
- Speech confirmation feedback
|
||||
- Command help reference
|
||||
|
||||
#### 📊 Statistics Dashboard
|
||||
- Mining statistics per turtle
|
||||
- Block type breakdown with emojis
|
||||
- Top miners leaderboard (🥇🥈🥉)
|
||||
- Time filters (24h, 7d, 30d, All)
|
||||
- Total blocks mined tracking
|
||||
|
||||
#### 👥 Groups Management
|
||||
- Create turtle teams with custom colors
|
||||
- 8 color presets available
|
||||
- Add/remove turtles from groups
|
||||
- Send commands to entire team
|
||||
- Member status tracking
|
||||
|
||||
#### 📋 Task Queue
|
||||
- Create tasks with 6 types
|
||||
- Priority system (1-10)
|
||||
- Coordinate input for area tasks
|
||||
- Filter by status
|
||||
- Assign to specific turtles
|
||||
- Task lifecycle management
|
||||
|
||||
### View Modes
|
||||
|
||||
1. **View Modes**:
|
||||
- **Split View**: See both map and control panel
|
||||
- **Map Only**: Full-screen 3D map
|
||||
@@ -139,14 +284,44 @@ remoteturtle/
|
||||
|
||||
### In-Game Commands
|
||||
|
||||
**Turtle Commands** (via pocket computer or web):
|
||||
- `explore` - Start exploration mode
|
||||
- `returnHome` - Return to home position
|
||||
- `stop` - Stop current operation
|
||||
- `forward/back/up/down` - Movement
|
||||
**Turtle Commands** (via pocket computer, web, or voice):
|
||||
|
||||
**Movement:**
|
||||
- `forward/back/up/down` - Directional movement
|
||||
- `turnLeft/turnRight` - Rotation
|
||||
- `dig/digUp/digDown` - Mining
|
||||
|
||||
**Actions:**
|
||||
- `dig/digUp/digDown` - Mining operations
|
||||
- `place/placeUp/placeDown` - Block placement
|
||||
- `refuel` - Consume fuel items
|
||||
|
||||
**Autonomous Operations:**
|
||||
- `explore` - Intelligent exploration with position memory
|
||||
- 75% horizontal movement preference
|
||||
- 15% downward, 10% upward movement
|
||||
- Visited position tracking to avoid loops
|
||||
- Stuck detection with recovery
|
||||
- `mine` - Autonomous mining mode
|
||||
- `returnHome` - Navigate back to home (non-blocking)
|
||||
- `stop` - Stop current operation
|
||||
- `setHome` - Set current position as home
|
||||
- `status` - Report current state
|
||||
|
||||
### Intelligent Navigation Features
|
||||
|
||||
The turtle.lua script includes advanced navigation:
|
||||
|
||||
1. **Position Memory**: Tracks last 50 positions to avoid revisiting
|
||||
2. **Visited Position Tracking**: Maintains history to prevent loops
|
||||
3. **Stuck Detection**: Identifies when turtle can't move and tries alternatives
|
||||
4. **Smart Direction Selection**:
|
||||
- Prefers horizontal movement (75%)
|
||||
- Controlled descent (15%)
|
||||
- Minimal upward movement (10%)
|
||||
5. **Turn Limiting**: Max 3 turn attempts to prevent endless spinning
|
||||
6. **Non-blocking Return Home**: Can return home without timing out
|
||||
7. **Fuel Management**: Auto-refuels when fuel is low
|
||||
8. **GPS Integration**: Uses GPS for accurate positioning
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
@@ -199,45 +374,274 @@ To access from other devices on your network:
|
||||
|
||||
3. Ensure firewall allows connections on ports 3000, 3001, and 3002
|
||||
|
||||
## 📊 Project Statistics
|
||||
|
||||
- **Total Code**: 5,600+ lines
|
||||
- **Database Tables**: 11
|
||||
- **API Endpoints**: 35+
|
||||
- **Supported Commands**: 25+
|
||||
- **Voice Commands**: 20+
|
||||
- **Frontend Components**: 12+
|
||||
- **Task Types**: 6
|
||||
- **Color Presets**: 8
|
||||
|
||||
## 📊 API Reference
|
||||
|
||||
### REST Endpoints
|
||||
### Turtle Management
|
||||
|
||||
**POST /api/turtle/update**
|
||||
- Body: Turtle status object
|
||||
- Updates turtle state
|
||||
#### `POST /api/turtle/update`
|
||||
Update turtle status
|
||||
```json
|
||||
{
|
||||
"turtleID": 123,
|
||||
"name": "Miner-1",
|
||||
"x": 100, "y": 64, "z": 200,
|
||||
"facing": 0,
|
||||
"fuel": 5000,
|
||||
"mode": "exploring",
|
||||
"inventory": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**GET /api/turtle/:id/commands**
|
||||
- Returns pending commands for turtle
|
||||
#### `GET /api/turtles`
|
||||
Get all connected turtles
|
||||
|
||||
**POST /api/turtle/:id/command**
|
||||
- Body: `{ command: string, param: any }`
|
||||
- Queues command for turtle
|
||||
#### `GET /api/turtle/:id`
|
||||
Get specific turtle details
|
||||
|
||||
**GET /api/turtles**
|
||||
- Returns all connected turtles
|
||||
#### `POST /api/turtle/:id/command`
|
||||
Send command to turtle
|
||||
```json
|
||||
{
|
||||
"command": "explore",
|
||||
"param": null
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/turtle/:id/commands`
|
||||
Get pending commands for turtle
|
||||
|
||||
### Home Management
|
||||
|
||||
#### `POST /api/homes`
|
||||
Set turtle home position
|
||||
```json
|
||||
{
|
||||
"turtleID": 123,
|
||||
"x": 100, "y": 64, "z": 200
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/homes/:turtleId`
|
||||
Get turtle's home position
|
||||
|
||||
### Block Tracking
|
||||
|
||||
#### `POST /api/blocks`
|
||||
Record discovered block
|
||||
```json
|
||||
{
|
||||
"x": 100, "y": 64, "z": 200,
|
||||
"blockType": "minecraft:diamond_ore",
|
||||
"discoveredBy": 123
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/blocks`
|
||||
Get all discovered blocks (with filtering)
|
||||
- Query params: `minX`, `maxX`, `minY`, `maxY`, `minZ`, `maxZ`
|
||||
|
||||
### Path Recording
|
||||
|
||||
#### `POST /api/paths`
|
||||
Create new path recording
|
||||
```json
|
||||
{
|
||||
"name": "Mine Route 1",
|
||||
"turtleId": 123,
|
||||
"description": "Efficient mining path"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/paths`
|
||||
Get all recorded paths
|
||||
|
||||
#### `GET /api/paths/:pathId`
|
||||
Get specific path with waypoints
|
||||
|
||||
#### `POST /api/paths/:pathId/waypoints`
|
||||
Add waypoint to path
|
||||
```json
|
||||
{
|
||||
"x": 100, "y": 64, "z": 200,
|
||||
"action": "dig_forward"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/paths/:pathId`
|
||||
Delete path
|
||||
|
||||
### Task Queue
|
||||
|
||||
#### `POST /api/tasks`
|
||||
Create new task
|
||||
```json
|
||||
{
|
||||
"taskType": "mine_area",
|
||||
"priority": 1,
|
||||
"assignedTurtleId": 123,
|
||||
"parameters": {
|
||||
"x1": 100, "y1": 60, "z1": 200,
|
||||
"x2": 120, "y2": 70, "z2": 220
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/tasks`
|
||||
Get all tasks (with optional status filter)
|
||||
- Query params: `status=pending|in_progress|completed|failed`
|
||||
|
||||
#### `GET /api/tasks/:taskId`
|
||||
Get specific task details
|
||||
|
||||
#### `PUT /api/tasks/:taskId`
|
||||
Update task status
|
||||
```json
|
||||
{
|
||||
"status": "completed",
|
||||
"result": "Mined 450 blocks"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/tasks/:taskId`
|
||||
Delete task
|
||||
|
||||
### Mining Areas
|
||||
|
||||
#### `POST /api/mining-areas`
|
||||
Define mining area
|
||||
```json
|
||||
{
|
||||
"name": "Diamond Layer",
|
||||
"x1": 100, "y1": -64, "z1": 200,
|
||||
"x2": 200, "y2": -50, "z2": 300,
|
||||
"priority": 1,
|
||||
"assignedTurtles": [123, 456]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/mining-areas`
|
||||
Get all mining areas
|
||||
|
||||
#### `GET /api/mining-areas/:areaId`
|
||||
Get specific mining area
|
||||
|
||||
#### `PUT /api/mining-areas/:areaId`
|
||||
Update mining area
|
||||
|
||||
#### `DELETE /api/mining-areas/:areaId`
|
||||
Delete mining area
|
||||
|
||||
### Mining Statistics
|
||||
|
||||
#### `POST /api/stats/block-mined`
|
||||
Record block mined
|
||||
```json
|
||||
{
|
||||
"turtleId": 123,
|
||||
"blockType": "minecraft:diamond_ore"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/stats/mining/:turtleId?`
|
||||
Get mining statistics
|
||||
- Query params: `days=7` (filter by recent days)
|
||||
- Returns per-block-type counts
|
||||
|
||||
#### `GET /api/stats/top-miners`
|
||||
Get leaderboard of top mining turtles
|
||||
- Query params: `limit=10`
|
||||
|
||||
### Turtle Groups/Teams
|
||||
|
||||
#### `POST /api/groups`
|
||||
Create turtle group
|
||||
```json
|
||||
{
|
||||
"groupName": "Mining Team Alpha",
|
||||
"color": "#3b82f6"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/groups`
|
||||
Get all groups with member counts
|
||||
|
||||
#### `DELETE /api/groups/:groupId`
|
||||
Delete group
|
||||
|
||||
#### `POST /api/groups/:groupId/members`
|
||||
Add turtle to group
|
||||
```json
|
||||
{
|
||||
"turtleId": 123
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/groups/:groupId/members/:turtleId`
|
||||
Remove turtle from group
|
||||
|
||||
#### `GET /api/turtles/:turtleId/groups`
|
||||
Get turtle's groups
|
||||
|
||||
#### `POST /api/groups/:groupId/command`
|
||||
Send command to all turtles in group
|
||||
```json
|
||||
{
|
||||
"command": "returnHome"
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
**From Server:**
|
||||
**From Server to Client:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "initial_state",
|
||||
"turtles": [...]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "turtle_update",
|
||||
"turtle": {...}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "turtle_disconnected",
|
||||
"turtleID": 123
|
||||
}
|
||||
```
|
||||
|
||||
**From Client:**
|
||||
```json
|
||||
{
|
||||
"type": "block_discovered",
|
||||
"block": {...}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "task_update",
|
||||
"task": {...}
|
||||
}
|
||||
```
|
||||
|
||||
**From Client to Server:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
@@ -247,27 +651,176 @@ To access from other devices on your network:
|
||||
}
|
||||
```
|
||||
|
||||
## 🎤 Voice Commands
|
||||
|
||||
The system supports natural language voice commands using the Web Speech API (Chrome, Edge, Safari):
|
||||
|
||||
### Movement Commands
|
||||
- "forward" / "move forward" / "go forward"
|
||||
- "back" / "backward" / "move back"
|
||||
- "turn left" / "left"
|
||||
- "turn right" / "right"
|
||||
- "up" / "move up" / "go up"
|
||||
- "down" / "move down" / "go down"
|
||||
|
||||
### Action Commands
|
||||
- "dig" / "mine"
|
||||
- "dig up" / "mine up"
|
||||
- "dig down" / "mine down"
|
||||
- "place" / "place block"
|
||||
- "refuel"
|
||||
|
||||
### Autonomous Commands
|
||||
- "explore" / "start exploring"
|
||||
- "mine" / "start mining"
|
||||
- "return home" / "go home" / "come back"
|
||||
- "stop" / "halt"
|
||||
- "set home" / "mark home"
|
||||
- "status" / "report"
|
||||
|
||||
## 💾 Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
1. **turtle_homes** - Home positions for turtles
|
||||
2. **turtle_config** - Turtle configuration settings
|
||||
3. **world_blocks** - Discovered block locations
|
||||
4. **turtle_paths** - Recorded movement paths
|
||||
5. **path_waypoints** - Individual waypoints in paths
|
||||
6. **task_queue** - Task scheduling and coordination
|
||||
7. **mining_areas** - Defined mining zones
|
||||
8. **mining_stats** - Block mining statistics per turtle
|
||||
9. **turtle_groups** - Turtle team/group definitions
|
||||
10. **turtle_group_members** - Group membership
|
||||
11. **turtle_sessions** - Time-based analytics tracking
|
||||
|
||||
## 📱 Frontend Features
|
||||
|
||||
### Enhanced Inventory Display
|
||||
- 4x4 grid matching Minecraft turtle slots
|
||||
- Item-specific emojis (💎 diamond, ⚫ coal, 🟡 gold, etc.)
|
||||
- Item count badges
|
||||
- Empty slot indicators
|
||||
- Hover tooltips with full item names
|
||||
|
||||
### Mobile Responsive Design
|
||||
- **Desktop (>1024px)**: Side-by-side map and controls
|
||||
- **Tablet (768-1024px)**: Vertical stacking
|
||||
- **Mobile (<768px)**: Tab-based navigation, optimized touch targets
|
||||
- Touch-optimized controls (48px minimum)
|
||||
- Landscape orientation support
|
||||
- Accessibility features (reduced motion, high contrast)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Turtles not appearing in web panel
|
||||
|
||||
1. Check that webbridge.lua is running
|
||||
2. Verify SERVER_URL is correct
|
||||
3. Check turtle.lua is running with wireless modem equipped
|
||||
4. Verify all scripts are using same channel numbers
|
||||
1. **Check webbridge is running**
|
||||
- Verify webbridge.lua is active in Minecraft
|
||||
- Check for error messages in the Minecraft console
|
||||
|
||||
2. **Verify SERVER_URL configuration**
|
||||
- Ensure webbridge.lua points to correct server address
|
||||
- Use `http://localhost:3001` for local testing
|
||||
- Use your machine's IP for remote access
|
||||
|
||||
3. **Check turtle setup**
|
||||
- Ensure turtle.lua is running
|
||||
- Verify wireless modem is equipped
|
||||
- Check modem is on correct side (usually top)
|
||||
|
||||
4. **Verify channel configuration**
|
||||
- All scripts must use matching channel numbers
|
||||
- Default: RECEIVE=100, SEND=101, STATUS=102
|
||||
|
||||
### Connection issues
|
||||
|
||||
1. Check server is running: `http://localhost:3001/api/turtles`
|
||||
2. Check firewall settings
|
||||
3. Verify ports 3001 and 3002 are not in use
|
||||
4. Check browser console for WebSocket errors
|
||||
1. **Server not responding**
|
||||
- Check server is running: `http://localhost:3001/api/turtles`
|
||||
- Verify no port conflicts (3001, 3002, 3000)
|
||||
- Check firewall isn't blocking connections
|
||||
|
||||
2. **WebSocket disconnections**
|
||||
- Check browser console for WebSocket errors
|
||||
- Verify WS_PORT (3002) is accessible
|
||||
- Try restarting both client and server
|
||||
|
||||
3. **Database errors**
|
||||
- Check `server/turtle_control.db` file exists
|
||||
- Ensure write permissions on database file
|
||||
- Try deleting database to reinitialize (loses data)
|
||||
|
||||
### Turtle behavior issues
|
||||
|
||||
1. **Turtle gets stuck or spins endlessly**
|
||||
- Check fuel level (need sufficient fuel)
|
||||
- Verify GPS is working (`gps locate` in-game)
|
||||
- Restart turtle.lua to reset state
|
||||
|
||||
2. **Turtle not finding home**
|
||||
- Ensure home position is set (`setHome` command)
|
||||
- Check GPS hosts are functioning
|
||||
- Verify turtle has enough fuel to return
|
||||
|
||||
3. **Turtle timing out**
|
||||
- Fixed in latest version with non-blocking return home
|
||||
- Ensure latest turtle.lua is deployed
|
||||
- Check modem range (max 64 blocks without ender modem)
|
||||
|
||||
### Voice commands not working
|
||||
|
||||
1. **Browser compatibility**
|
||||
- Use Chrome, Edge, or Safari (Firefox not supported)
|
||||
- Check microphone permissions in browser settings
|
||||
- Try HTTPS instead of HTTP (some browsers require it)
|
||||
|
||||
2. **Microphone issues**
|
||||
- Verify microphone is connected and working
|
||||
- Check system permissions allow browser mic access
|
||||
- Test with "Hello Google" or other voice apps
|
||||
|
||||
3. **Commands not recognized**
|
||||
- Speak clearly and at normal pace
|
||||
- Use exact command phrases from the help menu
|
||||
- Check browser console for recognition errors
|
||||
|
||||
### GPS not working
|
||||
|
||||
1. Set up GPS hosts in Minecraft (requires 4 computers)
|
||||
2. Position them in a square pattern at high Y level
|
||||
3. Each must run `gps host X Y Z` with their coordinates
|
||||
1. **Set up GPS hosts**
|
||||
- Requires 4 computers in Minecraft
|
||||
- Position in square pattern at high Y level (Y>100 recommended)
|
||||
- Each runs: `gps host X Y Z` with their exact coordinates
|
||||
|
||||
2. **GPS host placement**
|
||||
```
|
||||
Host positions (example):
|
||||
Computer 1: gps host 100 120 100
|
||||
Computer 2: gps host 200 120 100
|
||||
Computer 3: gps host 100 120 200
|
||||
Computer 4: gps host 200 120 200
|
||||
```
|
||||
|
||||
3. **Troubleshooting GPS**
|
||||
- Ensure all 4 hosts are loaded (chunk loaders)
|
||||
- Keep them at least 6 blocks apart
|
||||
- Must be on same dimension as turtles
|
||||
|
||||
### Performance issues
|
||||
|
||||
1. **Slow web interface**
|
||||
- Reduce number of visible turtles in 3D map
|
||||
- Disable block tracking if not needed
|
||||
- Use production build: `npm run build`
|
||||
|
||||
2. **High database size**
|
||||
- Periodically clean old session data
|
||||
- Archive old paths and tasks
|
||||
- Consider vacuum command: `VACUUM;`
|
||||
|
||||
3. **Memory issues in Minecraft**
|
||||
- Limit number of active turtles
|
||||
- Reduce position memory size in turtle.lua
|
||||
- Use more efficient exploration patterns
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
@@ -308,16 +861,45 @@ Built with:
|
||||
- [Express](https://expressjs.com/) - Backend server
|
||||
- [ws](https://github.com/websockets/ws) - WebSocket server
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
## 🚀 Implementation Status
|
||||
|
||||
- [ ] Path recording and playback
|
||||
- [ ] Multi-turtle task coordination
|
||||
- [ ] Inventory management interface
|
||||
- [ ] Mining area visualization
|
||||
- [ ] Task scheduling
|
||||
- [ ] Database persistence
|
||||
- [ ] Authentication/multi-user support
|
||||
- [ ] Mobile-responsive design
|
||||
### ✅ Completed Features
|
||||
|
||||
- [x] Real-time 3D visualization with Three.js
|
||||
- [x] WebSocket bidirectional communication
|
||||
- [x] Manual turtle control interface
|
||||
- [x] Autonomous exploration with intelligent navigation
|
||||
- [x] Database persistence (SQLite with 11 tables)
|
||||
- [x] Path recording backend (API complete)
|
||||
- [x] Task queue system (backend + frontend complete)
|
||||
- [x] Mining area management backend (API complete)
|
||||
- [x] Mining statistics tracking (backend + frontend complete)
|
||||
- [x] Turtle groups/teams coordination (backend + frontend complete)
|
||||
- [x] Voice command interface (Web Speech API, 20+ commands)
|
||||
- [x] Enhanced inventory display (4x4 grid with emojis)
|
||||
- [x] Mobile-responsive design (desktop/tablet/mobile)
|
||||
- [x] Position memory and visited tracking
|
||||
- [x] Stuck detection and recovery
|
||||
- [x] Session-based analytics
|
||||
- [x] Multi-panel tabbed interface (Control, Voice, Stats, Groups, Tasks)
|
||||
|
||||
### 🚧 Frontend Pending (Backend Complete)
|
||||
|
||||
- [ ] Path recording UI (playback controls, visualization)
|
||||
- [ ] Mining area visualization (3D overlays on map)
|
||||
|
||||
### 📋 Future Enhancements
|
||||
|
||||
- [ ] User authentication system
|
||||
- [ ] Multi-user support with permissions
|
||||
- [ ] Real-time collaboration features
|
||||
- [ ] Advanced pathfinding algorithms (A*)
|
||||
- [ ] Quarry mode with automatic area mining
|
||||
- [ ] Ore detection and priority mining
|
||||
- [ ] Automated smelting coordination
|
||||
- [ ] Chest management and auto-storage
|
||||
- [ ] Fuel depot system
|
||||
- [ ] Emergency recovery protocols
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
|
||||
65
STARTUP_README.md
Normal file
65
STARTUP_README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Auto-Update Startup Scripts
|
||||
|
||||
These scripts automatically download and run the latest versions of your turtle control programs when computers start up.
|
||||
|
||||
## Installation
|
||||
|
||||
### For Turtles:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
|
||||
```
|
||||
|
||||
### For Webbridge Computer:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
|
||||
```
|
||||
|
||||
### For Pocket Computer:
|
||||
```lua
|
||||
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **On boot**, the startup script runs automatically
|
||||
2. **Removes** the old version of the main script
|
||||
3. **Downloads** the latest version from git
|
||||
4. **Executes** the downloaded script
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
If the download fails (no internet, wrong URL, etc.):
|
||||
- Shows an error message
|
||||
- Attempts to run the existing version if available
|
||||
- Shows manual download instructions
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ Always run the latest version
|
||||
✅ No manual script management
|
||||
✅ Just reboot to update
|
||||
✅ Fallback to existing version if offline
|
||||
✅ Clear status messages
|
||||
|
||||
## Manual Update
|
||||
|
||||
To force an update on a running computer:
|
||||
```lua
|
||||
reboot
|
||||
```
|
||||
|
||||
## Changing the URL
|
||||
|
||||
If you need to change the git URL, edit the `SCRIPT_URL` variable in the startup script:
|
||||
```lua
|
||||
local SCRIPT_URL = "https://your-git-url-here/script.lua"
|
||||
```
|
||||
|
||||
## Disabling Auto-Update
|
||||
|
||||
To disable auto-update and use manual management:
|
||||
```lua
|
||||
rm startup
|
||||
```
|
||||
|
||||
Then download scripts manually as needed.
|
||||
347
START_HERE.md
Normal file
347
START_HERE.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# 🎉 Project Complete!
|
||||
|
||||
## What You Now Have
|
||||
|
||||
A **complete full-stack web application** for controlling ComputerCraft mining turtles in Minecraft!
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🐢 TURTLE CONTROL CENTER │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Turtle List │ │ Live 3D Map │ │
|
||||
│ │ ───────────── │ │ │ │
|
||||
│ │ │ │ 🏠 │ │
|
||||
│ │ 🐢 Turtle 1 │ │ \ │ │
|
||||
│ │ [Mining] │ │ \----🐢 Selected │ │
|
||||
│ │ Fuel: 5000 │ │ │ │
|
||||
│ │ Inventory: 8 │ │ │ │
|
||||
│ │ │ │ 🐢 │ │
|
||||
│ │ 🐢 Turtle 2 │ │ │ │
|
||||
│ │ [Exploring] │ │ Rotate • Zoom • Pan │ │
|
||||
│ │ Fuel: 3200 │ │ │ │
|
||||
│ │ Inventory: 3 │ └─────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ + Add More... │ ┌─────────────────────────┐ │
|
||||
│ └──────────────────┘ │ Turtle 1 Control │ │
|
||||
│ │ ───────────────────── │ │
|
||||
│ ✅ Real-time Updates │ [🔍Explore] [⛏️Mine] │ │
|
||||
│ ✅ WebSocket Connected │ [🏠Return] [⏹️Stop] │ │
|
||||
│ ✅ Auto-reconnect │ │ │
|
||||
│ │ Manual: ↑ ← → ↓ │ │
|
||||
│ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📁 Complete File Structure
|
||||
|
||||
```
|
||||
remoteturtle/
|
||||
│
|
||||
├── 📚 Documentation (7 files)
|
||||
│ ├── README.md ⭐ Main documentation
|
||||
│ ├── QUICKSTART.md 🚀 Fast setup guide
|
||||
│ ├── PROJECT_SUMMARY.md 📊 What was built
|
||||
│ ├── ARCHITECTURE.md 🏗️ System design
|
||||
│ ├── INTERFACE.md 🎨 UI overview
|
||||
│ ├── TROUBLESHOOTING.md 🔧 Problem solving
|
||||
│ └── THIS_FILE.md ✅ You are here!
|
||||
│
|
||||
├── 🎮 Setup Scripts (3 files)
|
||||
│ ├── start.sh Linux/Mac launcher
|
||||
│ ├── start.bat Windows launcher
|
||||
│ └── package.json Root config
|
||||
│
|
||||
├── 🖥️ Backend (server/)
|
||||
│ ├── server.js Express + WebSocket server
|
||||
│ └── package.json Node.js dependencies
|
||||
│
|
||||
├── 🌐 Frontend (client/)
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ ├── package.json
|
||||
│ └── src/
|
||||
│ ├── main.jsx
|
||||
│ ├── App.jsx Main React component
|
||||
│ ├── App.css
|
||||
│ ├── index.css
|
||||
│ ├── components/
|
||||
│ │ ├── Map3D.jsx 3D visualization
|
||||
│ │ ├── ControlPanel.jsx UI controls
|
||||
│ │ └── ControlPanel.css
|
||||
│ └── store/
|
||||
│ └── turtleStore.js State management
|
||||
│
|
||||
└── 🎮 Minecraft/Lua (3 files)
|
||||
├── webbridge.lua HTTP bridge to server
|
||||
├── turtle.lua Advanced turtle AI
|
||||
└── pocketremote.lua Original pocket UI
|
||||
|
||||
Total: 23+ files created/enhanced!
|
||||
```
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
### Web Interface
|
||||
- ✅ **3D Interactive Map** - Orbital camera, click to select
|
||||
- ✅ **Real-time Updates** - See turtle movements live
|
||||
- ✅ **Multiple Views** - Split, map-only, control-only
|
||||
- ✅ **Turtle Cards** - Quick overview of all turtles
|
||||
- ✅ **Detailed Control** - Per-turtle commands and stats
|
||||
- ✅ **Manual Controls** - Arrow keys for direct movement
|
||||
- ✅ **Inventory Display** - See what turtles collected
|
||||
- ✅ **Connection Status** - Visual indicator
|
||||
- ✅ **Dark Theme** - Easy on the eyes
|
||||
|
||||
### Technical
|
||||
- ✅ **WebSocket** - Real-time bidirectional communication
|
||||
- ✅ **REST API** - HTTP endpoints for turtle updates
|
||||
- ✅ **State Management** - Zustand for React state
|
||||
- ✅ **3D Graphics** - Three.js rendering
|
||||
- ✅ **Auto-reconnect** - Handles disconnections
|
||||
- ✅ **Command Queue** - Reliable command delivery
|
||||
- ✅ **Stale Cleanup** - Removes offline turtles
|
||||
|
||||
### Minecraft Integration
|
||||
- ✅ **HTTP Bridge** - Connects game to web
|
||||
- ✅ **Wireless Modem** - In-game communication
|
||||
- ✅ **GPS Support** - Position tracking
|
||||
- ✅ **Autonomous Modes** - Explore, mine, return
|
||||
- ✅ **Fuel Management** - Auto-refuel logic
|
||||
- ✅ **Pathfinding** - Navigate to targets
|
||||
- ✅ **Status Broadcasting** - Regular updates
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
**Option A: One Command**
|
||||
```bash
|
||||
./start.sh # Linux/Mac
|
||||
start.bat # Windows
|
||||
```
|
||||
|
||||
**Option B: Manual**
|
||||
```bash
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
```
|
||||
|
||||
### 2. Start Servers
|
||||
|
||||
The startup script automatically starts both:
|
||||
- Backend server (http://localhost:3001)
|
||||
- Frontend app (http://localhost:3000)
|
||||
|
||||
### 3. Configure Minecraft
|
||||
|
||||
**Bridge Computer:**
|
||||
1. Place computer with wireless modem
|
||||
2. Copy `webbridge.lua`
|
||||
3. Edit SERVER_URL if needed
|
||||
4. Run: `webbridge`
|
||||
|
||||
**Turtles:**
|
||||
1. Place turtles with wireless modems
|
||||
2. Copy `turtle.lua`
|
||||
3. Run: `turtle`
|
||||
|
||||
### 4. Open Web Interface
|
||||
|
||||
Navigate to: **http://localhost:3000**
|
||||
|
||||
You should see:
|
||||
- Connection status: 🟢 Connected
|
||||
- Waiting for turtles message
|
||||
- Empty 3D map with grid
|
||||
|
||||
### 5. Control Turtles!
|
||||
|
||||
- Click turtle cards or 3D markers to select
|
||||
- Use command buttons: Explore, Mine, Return, Stop
|
||||
- Try manual controls: ↑ ↓ ← → ⬆ ⬇
|
||||
- Watch the 3D map update in real-time!
|
||||
|
||||
## 📚 Documentation Guide
|
||||
|
||||
**New to the project?** Start here:
|
||||
1. **QUICKSTART.md** - Get running in 5 minutes
|
||||
2. **README.md** - Full documentation
|
||||
|
||||
**Want to understand the system?**
|
||||
- **ARCHITECTURE.md** - How everything connects
|
||||
- **INTERFACE.md** - UI/UX design
|
||||
|
||||
**Having problems?**
|
||||
- **TROUBLESHOOTING.md** - Common issues & solutions
|
||||
|
||||
**Curious what was built?**
|
||||
- **PROJECT_SUMMARY.md** - Complete overview
|
||||
- **THIS_FILE.md** - Quick reference
|
||||
|
||||
## 🎨 Color Guide
|
||||
|
||||
Turtle Status Colors:
|
||||
- 🟢 Green: Mining/Active
|
||||
- 🔵 Blue: Exploring
|
||||
- 🟠 Orange: Returning Home
|
||||
- ⚪ Gray: Idle
|
||||
- 🟣 Purple: Manual Control
|
||||
|
||||
UI Theme:
|
||||
- Background: Dark blue (#0a0e1a)
|
||||
- Panels: Slate (#1e293b)
|
||||
- Text: Light gray (#e2e8f0)
|
||||
- Accents: Cyan (#60a5fa)
|
||||
|
||||
## 🔧 Quick Commands
|
||||
|
||||
```bash
|
||||
# Start everything
|
||||
./start.sh
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Start only backend
|
||||
npm run server
|
||||
|
||||
# Start only frontend
|
||||
npm run client
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🌐 Network Ports
|
||||
|
||||
```
|
||||
3000 - React Frontend (Vite dev server)
|
||||
3001 - Express HTTP API
|
||||
3002 - WebSocket Server
|
||||
|
||||
Minecraft Modem Channels:
|
||||
100 - Commands TO turtles
|
||||
101 - Responses FROM turtles
|
||||
102 - Status updates
|
||||
```
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **GPS Setup**: Place 4 computers at Y=200+ in square pattern
|
||||
2. **Fuel Management**: Keep coal in turtle slot 16
|
||||
3. **Chunk Loading**: Use chunk loader for bridge computer
|
||||
4. **Multiple Turtles**: Each needs unique ID (automatic)
|
||||
5. **View Modes**: Split for monitoring, Map for presentations
|
||||
6. **Browser**: Chrome or Firefox work best
|
||||
7. **Network**: Use IP address for remote access
|
||||
8. **Backup**: Save turtle programs to disk
|
||||
|
||||
## 🔍 Testing Checklist
|
||||
|
||||
- [ ] Node.js installed (v18+)
|
||||
- [ ] Dependencies installed (npm install)
|
||||
- [ ] Server starts without errors
|
||||
- [ ] Browser opens to http://localhost:3000
|
||||
- [ ] Connection status shows "Connected"
|
||||
- [ ] Bridge computer running in Minecraft
|
||||
- [ ] Turtles have wireless modems
|
||||
- [ ] GPS hosts configured (optional but recommended)
|
||||
- [ ] Turtle appears in web interface
|
||||
- [ ] Commands work (try "Explore")
|
||||
- [ ] 3D map updates when turtle moves
|
||||
|
||||
## 📊 What Happens When You Run It
|
||||
|
||||
```
|
||||
1. Start scripts → Install dependencies → Launch servers
|
||||
↓
|
||||
2. Node.js Server starts → Opens HTTP (3001) & WebSocket (3002)
|
||||
↓
|
||||
3. React App starts → Vite dev server (3000) → Opens browser
|
||||
↓
|
||||
4. Browser connects → WebSocket established → Shows "Connected"
|
||||
↓
|
||||
5. In Minecraft → Run webbridge.lua → Listens for turtles
|
||||
↓
|
||||
6. Run turtle.lua → Broadcasts status → Bridge forwards to server
|
||||
↓
|
||||
7. Server → WebSocket → React → Updates UI → Turtle appears!
|
||||
↓
|
||||
8. Click "Explore" → Command queued → Bridge polls → Turtle moves
|
||||
↓
|
||||
9. Real-time updates → 3D map animates → You see turtle exploring!
|
||||
```
|
||||
|
||||
## 🎉 Success Looks Like
|
||||
|
||||
When everything works:
|
||||
- ✅ Terminal shows "Server ready!" and "Vite dev server running"
|
||||
- ✅ Browser shows turtle control interface
|
||||
- ✅ Green "Connected" indicator in top-right
|
||||
- ✅ Minecraft bridge shows "Sent to server" messages
|
||||
- ✅ Turtles appear in left panel
|
||||
- ✅ 3D map shows turtle markers
|
||||
- ✅ Commands execute when clicked
|
||||
- ✅ Map updates as turtles move
|
||||
|
||||
## 🚨 If Something's Wrong
|
||||
|
||||
1. Check server terminal for errors
|
||||
2. Check browser console (F12)
|
||||
3. Check Minecraft computer console
|
||||
4. Read TROUBLESHOOTING.md
|
||||
5. Verify all files exist
|
||||
6. Try restarting everything
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
**React**: https://react.dev/
|
||||
**Three.js**: https://threejs.org/
|
||||
**Node.js**: https://nodejs.org/
|
||||
**ComputerCraft**: https://tweaked.cc/
|
||||
**WebSocket**: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
|
||||
|
||||
## 🌟 Next Steps
|
||||
|
||||
Once you have it running:
|
||||
1. Try controlling multiple turtles
|
||||
2. Set up GPS for accurate tracking
|
||||
3. Watch autonomous mining in action
|
||||
4. Customize the UI colors/layout
|
||||
5. Add new commands
|
||||
6. Share with friends on your network!
|
||||
|
||||
## 📈 Possible Enhancements
|
||||
|
||||
Future ideas:
|
||||
- [ ] Path recording and playback
|
||||
- [ ] Task scheduling
|
||||
- [ ] Multi-turtle coordination
|
||||
- [ ] Database persistence
|
||||
- [ ] User authentication
|
||||
- [ ] Mobile app
|
||||
- [ ] Voice commands
|
||||
- [ ] Mining statistics
|
||||
- [ ] Area visualization
|
||||
- [ ] Turtle groups/teams
|
||||
|
||||
## 🎊 Congratulations!
|
||||
|
||||
You've successfully set up a complete full-stack web application for Minecraft turtle control!
|
||||
|
||||
**You now have:**
|
||||
- Modern React web interface
|
||||
- Real-time 3D visualization
|
||||
- WebSocket communication
|
||||
- Node.js backend server
|
||||
- Minecraft integration
|
||||
- Complete documentation
|
||||
|
||||
**Enjoy your new turtle command center!** 🐢✨
|
||||
|
||||
---
|
||||
|
||||
Need help? Check the documentation files or troubleshooting guide!
|
||||
|
||||
**Happy mining!** ⛏️
|
||||
408
TROUBLESHOOTING.md
Normal file
408
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# 🔧 Troubleshooting Guide
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### Node.js Not Found
|
||||
|
||||
**Problem:** `command not found: node` or `'node' is not recognized`
|
||||
|
||||
**Solution:**
|
||||
1. Install Node.js from https://nodejs.org/ (LTS version recommended)
|
||||
2. Restart your terminal after installation
|
||||
3. Verify: `node --version` should show v18.0.0 or higher
|
||||
|
||||
### npm Install Fails
|
||||
|
||||
**Problem:** Errors during `npm install`
|
||||
|
||||
**Solutions:**
|
||||
- Clear npm cache: `npm cache clean --force`
|
||||
- Delete `node_modules` folders and try again
|
||||
- Update npm: `npm install -g npm@latest`
|
||||
- Check your internet connection
|
||||
- Try: `npm install --legacy-peer-deps`
|
||||
|
||||
## Server Issues
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
**Problem:** `Error: listen EADDRINUSE: address already in use :::3001`
|
||||
|
||||
**Solution 1 - Change Ports:**
|
||||
1. Edit `server/server.js`:
|
||||
```javascript
|
||||
const PORT = 3005; // Change from 3001
|
||||
const WS_PORT = 3006; // Change from 3002
|
||||
```
|
||||
|
||||
2. Edit `client/src/store/turtleStore.js`:
|
||||
```javascript
|
||||
const WS_URL = 'ws://localhost:3006';
|
||||
const API_URL = 'http://localhost:3005';
|
||||
```
|
||||
|
||||
**Solution 2 - Kill Existing Process:**
|
||||
|
||||
Linux/Mac:
|
||||
```bash
|
||||
lsof -ti:3001 | xargs kill -9
|
||||
lsof -ti:3002 | xargs kill -9
|
||||
```
|
||||
|
||||
Windows:
|
||||
```cmd
|
||||
netstat -ano | findstr :3001
|
||||
taskkill /PID <PID_NUMBER> /F
|
||||
```
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
**Problem:** Server crashes on start
|
||||
|
||||
**Checklist:**
|
||||
- [ ] Dependencies installed? Run `cd server && npm install`
|
||||
- [ ] Node.js version 18+? Check with `node --version`
|
||||
- [ ] Firewall blocking ports? Add exception for ports 3001-3002
|
||||
- [ ] Check server logs for specific error messages
|
||||
|
||||
## Client Issues
|
||||
|
||||
### White Screen / Nothing Renders
|
||||
|
||||
**Problem:** Browser shows blank page
|
||||
|
||||
**Solutions:**
|
||||
1. Open browser console (F12) and check for errors
|
||||
2. Verify server is running: http://localhost:3001/api/turtles
|
||||
3. Clear browser cache (Ctrl+Shift+Delete)
|
||||
4. Try incognito/private mode
|
||||
5. Try different browser (Chrome/Firefox recommended)
|
||||
|
||||
### WebSocket Connection Failed
|
||||
|
||||
**Problem:** Red "Disconnected" status in top-right
|
||||
|
||||
**Solutions:**
|
||||
1. Check server console for errors
|
||||
2. Verify WebSocket port (3002) is open
|
||||
3. Check browser console for WebSocket errors
|
||||
4. Ensure server URL is correct in `turtleStore.js`
|
||||
5. Firewall may be blocking WebSocket - add exception
|
||||
|
||||
### 3D Map Not Showing
|
||||
|
||||
**Problem:** Map area is black or shows errors
|
||||
|
||||
**Solutions:**
|
||||
- WebGL not supported: Update graphics drivers
|
||||
- Browser compatibility: Use Chrome 90+ or Firefox 88+
|
||||
- Hardware acceleration disabled: Enable in browser settings
|
||||
- Chrome: Settings → System → Use hardware acceleration
|
||||
- Firefox: Settings → Performance → Use hardware acceleration
|
||||
- GPU issues: Try different browser
|
||||
|
||||
## Minecraft/ComputerCraft Issues
|
||||
|
||||
### Bridge Computer Not Connecting
|
||||
|
||||
**Problem:** `webbridge.lua` runs but no connection to server
|
||||
|
||||
**Checklist:**
|
||||
1. Verify SERVER_URL is correct:
|
||||
```lua
|
||||
local SERVER_URL = "http://localhost:3001"
|
||||
```
|
||||
- Use computer's IP if server is remote
|
||||
- Don't use `https://` (use `http://`)
|
||||
|
||||
2. Check HTTP API is enabled:
|
||||
- Edit `config/computercraft-server.toml` or `.minecraft/config/computercraft.cfg`
|
||||
- Find: `enable_http = true`
|
||||
- Ensure your server URL is not in `blocked_domains`
|
||||
|
||||
3. Test HTTP manually:
|
||||
```lua
|
||||
local response = http.get("http://localhost:3001/api/turtles")
|
||||
print(response ~= nil) -- Should print true
|
||||
```
|
||||
|
||||
### Turtles Not Appearing in Web
|
||||
|
||||
**Problem:** Bridge runs, but no turtles show up
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check bridge computer console for "Status from Turtle X" messages
|
||||
2. Check server console for "Status update from Turtle X" messages
|
||||
3. Check web browser console for WebSocket messages
|
||||
|
||||
**Common Causes:**
|
||||
- Channel mismatch - Verify all scripts use same channels:
|
||||
- `turtle.lua`: STATUS_CHANNEL = 102
|
||||
- `webbridge.lua`: STATUS_CHANNEL = 102
|
||||
|
||||
- Modem not equipped on turtle
|
||||
- Turtles out of wireless range
|
||||
- Turtles not running `turtle.lua` script
|
||||
|
||||
### GPS Not Working
|
||||
|
||||
**Problem:** Turtles show "No GPS"
|
||||
|
||||
**Solution - Set Up GPS Hosts:**
|
||||
|
||||
1. Place 4 computers at high altitude (Y=200+)
|
||||
2. Arrange in square pattern (20+ blocks apart)
|
||||
3. Give each a wireless modem
|
||||
4. Run on each:
|
||||
```lua
|
||||
gps host X Y Z
|
||||
```
|
||||
Replace X Y Z with that computer's actual coordinates
|
||||
|
||||
5. Test from turtle:
|
||||
```lua
|
||||
x, y, z = gps.locate(5)
|
||||
print(x, y, z) -- Should print coordinates
|
||||
```
|
||||
|
||||
### Modem Not Found
|
||||
|
||||
**Problem:** `No wireless modem found!`
|
||||
|
||||
**Solutions:**
|
||||
- Craft wireless modem (Stone + Ender Pearl)
|
||||
- Equip to turtle: Right-click turtle with modem
|
||||
- For computer: Place modem adjacent and right-click
|
||||
- Verify: `peripheral.find("modem")` should not be nil
|
||||
|
||||
### Commands Not Reaching Turtles
|
||||
|
||||
**Problem:** Click commands in web but turtle doesn't respond
|
||||
|
||||
**Debug Steps:**
|
||||
|
||||
1. Check server receives command:
|
||||
- Server console should show: `Command queued for turtle X`
|
||||
|
||||
2. Check bridge polls for commands:
|
||||
- Bridge console should show: `Command for Turtle X: <command>`
|
||||
|
||||
3. Check turtle receives command:
|
||||
- Add debug to `turtle.lua`:
|
||||
```lua
|
||||
print("Received message:", textutils.serialize(message))
|
||||
```
|
||||
|
||||
4. Verify channels match:
|
||||
- Web → Server → Bridge → Modem Channel 100 → Turtle
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Laggy 3D Map
|
||||
|
||||
**Solutions:**
|
||||
- Reduce number of visible turtles
|
||||
- Lower browser zoom level
|
||||
- Close other browser tabs
|
||||
- Update graphics drivers
|
||||
- Try lower graphics settings in browser
|
||||
|
||||
### Server Using Too Much Memory
|
||||
|
||||
**Solutions:**
|
||||
- Restart server periodically
|
||||
- Reduce `statusUpdateInterval` in turtle.lua (less frequent updates)
|
||||
- Clear old turtle data: restart server
|
||||
|
||||
### High Network Usage
|
||||
|
||||
**Solutions:**
|
||||
- Increase `statusUpdateInterval` in turtle.lua (default: 5 seconds)
|
||||
- Reduce number of active turtles
|
||||
- Use local server instead of remote
|
||||
|
||||
## Browser-Specific Issues
|
||||
|
||||
### Chrome
|
||||
|
||||
**WebSocket closes immediately:**
|
||||
- Disable browser extensions (especially ad blockers)
|
||||
- Clear site data: F12 → Application → Clear storage
|
||||
|
||||
### Firefox
|
||||
|
||||
**3D map performance poor:**
|
||||
- Enable WebRender: about:config → gfx.webrender.all → true
|
||||
|
||||
### Safari
|
||||
|
||||
**Not recommended** - Limited WebGL support. Use Chrome or Firefox.
|
||||
|
||||
## Network Issues
|
||||
|
||||
### Can't Access from Other Devices
|
||||
|
||||
**Problem:** Works on localhost but not from other computers
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Get your computer's IP address:
|
||||
```bash
|
||||
# Linux/Mac
|
||||
ifconfig | grep "inet "
|
||||
|
||||
# Windows
|
||||
ipconfig
|
||||
```
|
||||
Example: 192.168.1.100
|
||||
|
||||
2. Update configs to use IP instead of localhost:
|
||||
|
||||
**webbridge.lua:**
|
||||
```lua
|
||||
local SERVER_URL = "http://192.168.1.100:3001"
|
||||
```
|
||||
|
||||
**client/src/store/turtleStore.js:**
|
||||
```javascript
|
||||
const WS_URL = 'ws://192.168.1.100:3002';
|
||||
const API_URL = 'http://192.168.1.100:3001';
|
||||
```
|
||||
|
||||
3. Configure firewall:
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
sudo ufw allow 3000:3002/tcp
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
- Windows Defender Firewall → Advanced Settings
|
||||
- Inbound Rules → New Rule
|
||||
- Port: 3000-3002
|
||||
- Allow the connection
|
||||
|
||||
4. Access from other device: `http://192.168.1.100:3000`
|
||||
|
||||
### Minecraft Server and Web Server on Different Machines
|
||||
|
||||
**Setup:**
|
||||
- Computer A: Minecraft + Bridge
|
||||
- Computer B: Node.js Server + Web Client
|
||||
|
||||
**Configuration:**
|
||||
|
||||
1. On Computer A (Minecraft), edit `webbridge.lua`:
|
||||
```lua
|
||||
local SERVER_URL = "http://<Computer_B_IP>:3001"
|
||||
```
|
||||
|
||||
2. On Computer B, server.js needs no changes
|
||||
|
||||
3. Access web interface from anywhere: `http://<Computer_B_IP>:3000`
|
||||
|
||||
## Common Error Messages
|
||||
|
||||
### "Failed to send to server"
|
||||
|
||||
**In webbridge.lua console**
|
||||
|
||||
**Cause:** Bridge can't reach Node.js server
|
||||
|
||||
**Fix:**
|
||||
- Verify server is running
|
||||
- Check SERVER_URL is correct
|
||||
- Test: `http.get(SERVER_URL .. "/api/turtles")`
|
||||
|
||||
### "Turtle not found"
|
||||
|
||||
**In server logs**
|
||||
|
||||
**Cause:** Turtle ID not in server's turtle map
|
||||
|
||||
**Fix:**
|
||||
- Turtle hasn't sent status update yet - wait 5 seconds
|
||||
- Restart turtle script
|
||||
- Check turtle is broadcasting on correct channel
|
||||
|
||||
### "WebSocket connection failed"
|
||||
|
||||
**In browser console**
|
||||
|
||||
**Cause:** Can't connect to WebSocket server
|
||||
|
||||
**Fix:**
|
||||
- Verify server is running
|
||||
- Check port 3002 is open
|
||||
- Update WS_URL in turtleStore.js
|
||||
- Disable VPN/proxy
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
### Collect Debug Information
|
||||
|
||||
1. **Server logs**: Copy output from server terminal
|
||||
2. **Browser console**: F12 → Console tab → Copy errors
|
||||
3. **Minecraft logs**: Check bridge computer output
|
||||
4. **Network test**: Can you access http://localhost:3001/api/turtles?
|
||||
|
||||
### Reset Everything
|
||||
|
||||
Sometimes easiest to start fresh:
|
||||
|
||||
```bash
|
||||
# Stop all servers (Ctrl+C)
|
||||
|
||||
# Clean and reinstall
|
||||
rm -rf server/node_modules client/node_modules
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
|
||||
# Restart
|
||||
./start.sh # or start.bat on Windows
|
||||
```
|
||||
|
||||
### Check Versions
|
||||
|
||||
```bash
|
||||
node --version # Should be v18.0.0+
|
||||
npm --version # Should be v9.0.0+
|
||||
```
|
||||
|
||||
### Test Connectivity
|
||||
|
||||
**Test 1 - Server Health:**
|
||||
```bash
|
||||
curl http://localhost:3001/api/turtles
|
||||
```
|
||||
Should return: `{"turtles":[]}`
|
||||
|
||||
**Test 2 - WebSocket:**
|
||||
Open browser console and run:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3002');
|
||||
ws.onopen = () => console.log('Connected!');
|
||||
ws.onerror = (e) => console.log('Error:', e);
|
||||
```
|
||||
|
||||
**Test 3 - Minecraft HTTP:**
|
||||
In ComputerCraft:
|
||||
```lua
|
||||
local r = http.get("http://localhost:3001/api/turtles")
|
||||
print(r ~= nil)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**If all else fails:**
|
||||
- Check the full README.md
|
||||
- Review ARCHITECTURE.md for system design
|
||||
- Ensure all dependencies are installed
|
||||
- Try example commands manually
|
||||
- Double-check all configuration files match
|
||||
|
||||
**Still stuck?** Create an issue with:
|
||||
- Error messages
|
||||
- Server/client logs
|
||||
- Steps to reproduce
|
||||
- Your configuration
|
||||
304
UI_IMPROVEMENTS.md
Normal file
304
UI_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 🎨 UI/UX Improvements Applied
|
||||
|
||||
## ✅ Completed Changes
|
||||
|
||||
### 1. **3D Map Enhancements**
|
||||
|
||||
#### Turtle Model
|
||||
- ✅ Replaced spinning cube with **proper turtle model**
|
||||
- Shell, head, legs, tail, eyes with yellow glow
|
||||
- Bobbing animation when selected
|
||||
- Faces correct direction based on `facing` value
|
||||
- Color-coded by mode (exploring=blue, returning=orange, idle=gray)
|
||||
|
||||
#### World Block Visualization
|
||||
- ✅ **Blocks are now detected and displayed**
|
||||
- Turtle scans forward, up, and down blocks
|
||||
- Blocks stored in server's `worldBlocks` Map
|
||||
- Rendered as semi-transparent colored cubes
|
||||
- Color-coded by block type:
|
||||
- 💎 Diamond = Cyan
|
||||
- 💚 Emerald = Green
|
||||
- 🟡 Gold = Gold
|
||||
- ⚪ Iron = Light gray
|
||||
- ⚫ Coal = Black
|
||||
- 🔴 Redstone = Red
|
||||
- 🔵 Lapis = Blue
|
||||
- 🟠 Copper = Orange
|
||||
- 🪨 Stone/Dirt/etc = Gray/Brown
|
||||
|
||||
#### Persistence
|
||||
- ✅ **Blocks stored long-term in server**
|
||||
- `worldBlocks` Map persists during server runtime
|
||||
- Each block includes: position, name, metadata, discoveredBy, timestamp
|
||||
- Sent to new clients on connection
|
||||
- Updated as turtles discover new blocks
|
||||
|
||||
### 2. **Server Enhancements**
|
||||
|
||||
```javascript
|
||||
// Added world block storage
|
||||
const worldBlocks = new Map(); // "x,y,z" -> block data
|
||||
|
||||
// Helper functions
|
||||
- storeBlock(x, y, z, blockData, turtleID)
|
||||
- getBlockPosition(turtlePos, facing, direction)
|
||||
|
||||
// New endpoint
|
||||
GET /api/world/blocks - Returns all discovered blocks
|
||||
|
||||
// Enhanced turtle updates
|
||||
- Processes surroundings data from turtles
|
||||
- Calculates absolute block positions
|
||||
- Stores in worldBlocks Map
|
||||
```
|
||||
|
||||
### 3. **Turtle Script Enhancements**
|
||||
|
||||
```lua
|
||||
-- broadcastStatus() now includes:
|
||||
surroundings = {
|
||||
forward = {name = "minecraft:stone", metadata = 0},
|
||||
up = {name = "minecraft:dirt", metadata = 0},
|
||||
down = {name = "minecraft:bedrock", metadata = 0}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Client Store Updates**
|
||||
|
||||
```javascript
|
||||
// New state
|
||||
worldBlocks: []
|
||||
|
||||
// New function
|
||||
updateBlocksFromSurroundings(turtle)
|
||||
- Calculates block positions from turtle data
|
||||
- Adds to worldBlocks array
|
||||
- Prevents duplicates
|
||||
```
|
||||
|
||||
## 📋 Remaining UI Improvements
|
||||
|
||||
### Priority 1: Responsive Layout
|
||||
|
||||
```css
|
||||
/* Add to ControlPanel.css */
|
||||
@media (max-width: 1024px) {
|
||||
.app-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-view {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.turtle-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Priority 2: Item Icons in Inventory
|
||||
|
||||
Create `client/src/components/InventoryItem.jsx`:
|
||||
|
||||
```jsx
|
||||
import React from 'react';
|
||||
|
||||
const ITEM_ICONS = {
|
||||
'minecraft:diamond': '💎',
|
||||
'minecraft:emerald': '💚',
|
||||
'minecraft:gold_ore': '🟡',
|
||||
'minecraft:iron_ore': '⚪',
|
||||
'minecraft:coal': '⚫',
|
||||
'minecraft:redstone': '🔴',
|
||||
'minecraft:dirt': '🟤',
|
||||
'minecraft:stone': '🪨',
|
||||
'minecraft:cobblestone': '⬜',
|
||||
// Add more...
|
||||
};
|
||||
|
||||
export function InventoryItem({ item }) {
|
||||
const icon = ITEM_ICONS[item.name] || '📦';
|
||||
const shortName = item.name.replace('minecraft:', '').replace('_', ' ');
|
||||
|
||||
return (
|
||||
<div className="inventory-item">
|
||||
<span className="item-icon">{icon}</span>
|
||||
<span className="item-name">{shortName}</span>
|
||||
<span className="item-count">×{item.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Priority 3: Better Inventory Display
|
||||
|
||||
Update `ControlPanel.jsx`:
|
||||
|
||||
```jsx
|
||||
import { InventoryItem } from './InventoryItem';
|
||||
|
||||
// In TurtleDetails component:
|
||||
<div className="detail-section">
|
||||
<h3>Inventory ({turtle.inventoryCount}/16)</h3>
|
||||
<div className="inventory-grid">
|
||||
{turtle.inventory && turtle.inventory.map((item, idx) => (
|
||||
<InventoryItem key={idx} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
/* Add to ControlPanel.css */
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inventory-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #1e293b;
|
||||
border: 2px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.inventory-item:hover {
|
||||
border-color: #60a5fa;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-size: 0.875rem;
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
```
|
||||
|
||||
### Priority 4: Intuitive Controls
|
||||
|
||||
```jsx
|
||||
// Add tooltips to buttons
|
||||
<button
|
||||
title="Start autonomous exploration - turtle will mine valuable ores"
|
||||
onClick={() => handleCommand('explore')}
|
||||
>
|
||||
🔍 Explore
|
||||
</button>
|
||||
|
||||
// Add keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e) => {
|
||||
if (!turtle) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'w': handleCommand('forward'); break;
|
||||
case 's': handleCommand('back'); break;
|
||||
case 'a': handleCommand('turnLeft'); break;
|
||||
case 'd': handleCommand('turnRight'); break;
|
||||
case ' ': handleCommand('up'); break;
|
||||
case 'Shift': handleCommand('down'); break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [turtle]);
|
||||
```
|
||||
|
||||
### Priority 5: Map Controls UI
|
||||
|
||||
```jsx
|
||||
// Add map control panel
|
||||
<div className="map-controls">
|
||||
<button onClick={() => resetCamera()}>🎯 Center</button>
|
||||
<button onClick={() => toggleGrid()}>📏 Grid</button>
|
||||
<button onClick={() => toggleBlocks()}>🧱 Blocks</button>
|
||||
<label>
|
||||
Opacity:
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={blockOpacity}
|
||||
onChange={(e) => setBlockOpacity(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 🚀 How to Apply
|
||||
|
||||
1. **Server changes** - Already applied ✅
|
||||
2. **Turtle changes** - Already applied ✅
|
||||
3. **Client Map3D** - Already applied ✅
|
||||
4. **Client Store** - Already applied ✅
|
||||
|
||||
### To activate:
|
||||
```bash
|
||||
# Restart server
|
||||
cd server && npm start
|
||||
|
||||
# Rebuild client
|
||||
cd client && npm run build
|
||||
|
||||
# Or for development
|
||||
npm run dev
|
||||
|
||||
# In Minecraft
|
||||
# Upload updated turtle.lua and restart
|
||||
# Restart webbridge
|
||||
```
|
||||
|
||||
## 🎯 Expected Results
|
||||
|
||||
1. **Map shows actual discovered blocks** with colors
|
||||
2. **Turtle looks like a turtle** with proper facing direction
|
||||
3. **Blocks persist** across page refreshes (while server runs)
|
||||
4. **Real-time block discovery** as turtle explores
|
||||
5. **Performance optimized** - blocks grouped by color for efficient rendering
|
||||
|
||||
## 📊 Performance Notes
|
||||
|
||||
- Blocks are grouped by color in Map3D for efficient rendering
|
||||
- Semi-transparent (60% opacity) to see through
|
||||
- No textures needed - uses colored cubes
|
||||
- Could add thousands of blocks without performance issues
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
1. **Block textures** - Load Minecraft texture atlas
|
||||
2. **Persistent storage** - Save to database
|
||||
3. **Export/Import** - Download/upload world data
|
||||
4. **Minimap** - 2D top-down view
|
||||
5. **Block filtering** - Show only certain block types
|
||||
6. **Heatmap** - Visualize ore density
|
||||
290
WEBBRIDGE_FIX.md
Normal file
290
WEBBRIDGE_FIX.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# WebBridge Communication Fix
|
||||
|
||||
## Problem
|
||||
The webbridge communication was unreliable, requiring frequent restarts. Commands would get lost between the server → webbridge → turtle chain.
|
||||
|
||||
## Root Causes Identified
|
||||
|
||||
### 1. **Race Condition on Command Clearing**
|
||||
- Server would immediately clear commands when polled
|
||||
- If turtle wasn't listening at that exact moment, commands were lost forever
|
||||
- No retry mechanism
|
||||
|
||||
### 2. **Too Frequent Polling**
|
||||
- Polling every 1 second was too aggressive
|
||||
- Created more opportunities for timing issues
|
||||
- Increased network overhead
|
||||
|
||||
### 3. **Single Transmission**
|
||||
- Commands were transmitted only once over wireless modem
|
||||
- If turtle was busy or signal was weak, command was lost
|
||||
- No acknowledgment system
|
||||
|
||||
## Solutions Implemented
|
||||
|
||||
### 1. **Server-Side: Smart Command Management** (`server/server.js`)
|
||||
|
||||
**Added timing-based command retention:**
|
||||
```javascript
|
||||
// Don't clear commands immediately
|
||||
if (!turtle.lastCommandPollTime || (Date.now() - turtle.lastCommandPollTime) > 5000) {
|
||||
// First poll or > 5 seconds since last poll - send commands
|
||||
turtle.lastCommandPollTime = Date.now();
|
||||
res.json({ commands });
|
||||
} else {
|
||||
// Recent poll - assume previous commands were received, clear them
|
||||
turtle.pendingCommands = [];
|
||||
res.json({ commands: [] });
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Commands stay available for multiple poll cycles
|
||||
- Automatic clearing after 5 seconds (prevents stale commands)
|
||||
- Webbridge can retry if first attempt fails
|
||||
|
||||
**Added explicit acknowledgment endpoint:**
|
||||
```javascript
|
||||
POST /api/turtle/:id/commands/ack
|
||||
```
|
||||
- Webbridge can explicitly confirm commands were sent
|
||||
- Server clears commands only after confirmation
|
||||
- Better tracking and logging
|
||||
|
||||
### 2. **Webbridge: Improved Reliability** (`webbridge.lua`)
|
||||
|
||||
**Reduced polling frequency:**
|
||||
```lua
|
||||
local POLL_INTERVAL = 2 -- Changed from 1 to 2 seconds
|
||||
```
|
||||
- Less aggressive polling reduces race conditions
|
||||
- Gives turtle more time to process
|
||||
- Reduces server load
|
||||
|
||||
**Multiple transmissions per command:**
|
||||
```lua
|
||||
-- Send command 3 times for reliability
|
||||
for i = 1, 3 do
|
||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, commandPacket)
|
||||
os.sleep(0.05) -- Small delay between retransmissions
|
||||
end
|
||||
```
|
||||
- Increases chance of turtle receiving command
|
||||
- 50ms delay prevents message collision
|
||||
- Triple redundancy
|
||||
|
||||
**Explicit acknowledgment:**
|
||||
```lua
|
||||
-- After sending all commands
|
||||
os.sleep(0.5) -- Give turtle time to receive
|
||||
if acknowledgeCommands(turtleID) then
|
||||
addLog(" ACK: Commands acknowledged", colors.lime)
|
||||
end
|
||||
```
|
||||
- Waits 500ms for turtle to receive
|
||||
- Sends acknowledgment to server
|
||||
- Server can safely clear commands
|
||||
|
||||
**Better logging:**
|
||||
```lua
|
||||
addLog("Received " .. #commands .. " command(s) for Turtle #" .. turtleID, colors.cyan)
|
||||
addLog(" CMD: " .. cmd.command .. " -> Turtle #" .. turtleID, colors.yellow)
|
||||
addLog(" ACK: Commands acknowledged", colors.lime)
|
||||
```
|
||||
- Clearer status tracking
|
||||
- Easier debugging
|
||||
- Visual feedback on monitor
|
||||
|
||||
### 3. **Docker Fix** (`server/Dockerfile`)
|
||||
|
||||
**Added missing database.js:**
|
||||
```dockerfile
|
||||
COPY server.js ./
|
||||
COPY database.js ./ # <-- ADDED
|
||||
```
|
||||
- Server was crashing in Docker because database.js wasn't copied
|
||||
- This was causing the "module not found" errors
|
||||
|
||||
## Communication Flow (New)
|
||||
|
||||
### Before Fix:
|
||||
```
|
||||
1. Web UI sends command → Server adds to queue
|
||||
2. Webbridge polls → Server sends commands → Server CLEARS immediately
|
||||
3. Webbridge transmits ONCE to turtle
|
||||
4. If turtle missed it → COMMAND LOST FOREVER
|
||||
```
|
||||
|
||||
### After Fix:
|
||||
```
|
||||
1. Web UI sends command → Server adds to queue
|
||||
2. Webbridge polls → Server sends commands → Server KEEPS for 5s
|
||||
3. Webbridge transmits 3 TIMES to turtle (redundancy)
|
||||
4. Wait 500ms for turtle to receive
|
||||
5. Webbridge sends ACK → Server clears commands
|
||||
6. If ACK fails → Commands still in queue for next poll
|
||||
```
|
||||
|
||||
## Expected Improvements
|
||||
|
||||
### ✅ **No More Lost Commands**
|
||||
- Commands are retried automatically
|
||||
- Multiple transmissions increase success rate
|
||||
- 5-second window allows for retries
|
||||
|
||||
### ✅ **Better Reliability**
|
||||
- Explicit acknowledgment system
|
||||
- Commands don't disappear prematurely
|
||||
- Graceful handling of network issues
|
||||
|
||||
### ✅ **Less Restart Required**
|
||||
- System self-heals from temporary issues
|
||||
- No need to restart webbridge after missed commands
|
||||
- More robust against timing problems
|
||||
|
||||
### ✅ **Better Observability**
|
||||
- Enhanced logging shows command flow
|
||||
- Monitor displays acknowledgment status
|
||||
- Easier to debug issues
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Send Multiple Commands Rapidly**
|
||||
- Commands should all arrive
|
||||
- No commands should be lost
|
||||
- Check webbridge monitor for ACK messages
|
||||
|
||||
2. **Test With Busy Turtle**
|
||||
- Send command while turtle is exploring
|
||||
- Command should still arrive
|
||||
- Multiple transmissions help
|
||||
|
||||
3. **Test Network Issues**
|
||||
- Move turtle far away (weak signal)
|
||||
- Commands should still arrive (3 tries)
|
||||
- If all fail, they retry on next poll
|
||||
|
||||
4. **Monitor Logs**
|
||||
- Server shows command sends and ACKs
|
||||
- Webbridge shows transmission and ACK
|
||||
- Turtle shows command receipt
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. **Update Docker Container:**
|
||||
```bash
|
||||
cd /home/mayatheshy/remoteturtle
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Update Webbridge (In Minecraft):**
|
||||
- Stop current webbridge (Ctrl+T)
|
||||
- Upload new webbridge.lua
|
||||
- Restart: `webbridge`
|
||||
|
||||
3. **Turtle Code (No Changes Needed)**
|
||||
- Existing turtle.lua works with new system
|
||||
- No updates required
|
||||
|
||||
## Configuration
|
||||
|
||||
### Tunable Parameters
|
||||
|
||||
**Server (server.js):**
|
||||
- `lastCommandPollTime` threshold: `5000ms` (5 seconds)
|
||||
- Increase for slower networks
|
||||
- Decrease for faster response
|
||||
|
||||
**Webbridge (webbridge.lua):**
|
||||
- `POLL_INTERVAL`: `2` seconds
|
||||
- Increase for slower networks
|
||||
- Decrease for faster response (but more overhead)
|
||||
|
||||
- Transmission retries: `3` times
|
||||
- Increase for very weak signals
|
||||
- Decrease to reduce spam
|
||||
|
||||
- ACK delay: `0.5` seconds
|
||||
- Increase if turtles are very busy
|
||||
- Decrease for faster acknowledgment
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Server Console Output:
|
||||
```
|
||||
📤 Sending 1 command(s) to turtle 42
|
||||
- forward
|
||||
✅ Turtle 42 acknowledged 1 command(s)
|
||||
```
|
||||
|
||||
### Webbridge Monitor:
|
||||
```
|
||||
[10:30:15] Received 1 command(s) for Turtle #42
|
||||
[10:30:15] CMD: forward -> Turtle #42
|
||||
[10:30:16] ACK: Commands acknowledged
|
||||
```
|
||||
|
||||
### Turtle Output:
|
||||
```
|
||||
Modem message on channel 100
|
||||
Target: 42
|
||||
My ID: 42
|
||||
Command: forward
|
||||
Executing command...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Commands Still Not Arriving?
|
||||
|
||||
1. **Check Server Logs:**
|
||||
- Is server sending commands?
|
||||
- Are ACKs being received?
|
||||
|
||||
2. **Check Webbridge Monitor:**
|
||||
- Is it polling?
|
||||
- Is it transmitting?
|
||||
- Are ACKs succeeding?
|
||||
|
||||
3. **Check Turtle:**
|
||||
- Is modem open on channel 100?
|
||||
- Is command processing loop running?
|
||||
- Check for error messages
|
||||
|
||||
4. **Check Network:**
|
||||
- Are turtles within wireless modem range?
|
||||
- Is webbridge computer within range?
|
||||
- Try moving closer
|
||||
|
||||
### High Failure Rate?
|
||||
|
||||
1. **Increase Transmissions:**
|
||||
- Change `for i = 1, 3` to `for i = 1, 5`
|
||||
- More redundancy
|
||||
|
||||
2. **Increase Poll Interval:**
|
||||
- Change `POLL_INTERVAL = 2` to `POLL_INTERVAL = 3`
|
||||
- More time between attempts
|
||||
|
||||
3. **Check Signal Strength:**
|
||||
- Use ender modems for unlimited range
|
||||
- Add more webbridge relays
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Server:** Minimal (one extra endpoint)
|
||||
- **Webbridge:** Slightly higher (3x transmissions, but 2s polling)
|
||||
- **Turtle:** No change (same command processing)
|
||||
- **Network:** Higher wireless traffic (3x per command), but more reliable
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0** - Original implementation (1s polling, single transmission)
|
||||
- **v2.0** - Current fix (2s polling, 3x transmission, acknowledgment)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** February 20, 2026
|
||||
**Status:** ✅ Ready for Testing
|
||||
25
client/Dockerfile
Normal file
25
client/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# Production build with simple HTTP server
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Install serve globally for serving static files
|
||||
RUN npm install -g serve
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Serve built files with serve (no host restrictions)
|
||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||
19
client/Dockerfile.dev
Normal file
19
client/Dockerfile.dev
Normal file
@@ -0,0 +1,19 @@
|
||||
# Development Dockerfile with hot reload
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Vite dev server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"serve": "vite preview --host 0.0.0.0 --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -4,37 +4,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #1e293b;
|
||||
border-bottom: 2px solid #334155;
|
||||
}
|
||||
|
||||
.view-controls button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-controls button:hover {
|
||||
background: #475569;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.view-controls button.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
@@ -49,28 +19,93 @@
|
||||
|
||||
.app-content.split .map-container {
|
||||
flex: 1;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.app-content.split .panel-container {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.app-content.map .panel-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-content.panel .map-container {
|
||||
display: none;
|
||||
flex: 1;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
background: #0a0e1a;
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.panel-container {
|
||||
background: #0f172a;
|
||||
background: #2c2c2c;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 3px solid #1a1a1a;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
background: #5a5a5a;
|
||||
color: #b0b0b0;
|
||||
border-radius: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #777;
|
||||
}
|
||||
|
||||
.panel-tabs button:hover {
|
||||
background: #6b6b6b;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-tabs button.active {
|
||||
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;
|
||||
}
|
||||
|
||||
.panel-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -80,14 +115,123 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
background: #6b6b6b;
|
||||
}
|
||||
|
||||
/* Mobile-Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
/* Tablet: Stack vertically */
|
||||
.app-content.split {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-content.split .map-container,
|
||||
.app-content.split .panel-container {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-content.split {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-content.split .map-container {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.app-content.split .panel-container {
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Optimize for touch */
|
||||
button, .turtle-card, .inventory-slot {
|
||||
min-height: 44px; /* iOS minimum touch target */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-content.split .map-container {
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
.app-content.split .panel-container {
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Increase touch targets */
|
||||
button {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.turtle-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.inventory-slot {
|
||||
min-height: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Remove hover effects on touch devices */
|
||||
button:hover,
|
||||
.turtle-card:hover,
|
||||
.inventory-slot:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Use active state instead */
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape mobile orientation */
|
||||
@media (max-width: 896px) and (orientation: landscape) {
|
||||
.app-content.split {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-content.split .map-container,
|
||||
.app-content.split .panel-container {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support (respects system preference) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Already optimized for dark mode */
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.turtle-card {
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,122 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Map3D from './components/Map3D';
|
||||
import ControlPanel from './components/ControlPanel';
|
||||
import VoiceControl from './components/VoiceControl';
|
||||
import StatsPanel from './components/StatsPanel';
|
||||
import GroupsPanel from './components/GroupsPanel';
|
||||
import TaskPanel from './components/TaskPanel';
|
||||
import PathRecorder from './components/PathRecorder';
|
||||
import MiningAreasPanel from './components/MiningAreasPanel';
|
||||
import { useTurtleStore } from './store/turtleStore';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const connect = useTurtleStore((state) => state.connect);
|
||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
|
||||
const inventoryDashboardUrl = import.meta.env.VITE_INVENTORY_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}`;
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const renderPanelContent = () => {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`;
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
|
||||
switch (panelTab) {
|
||||
case 'control':
|
||||
return <ControlPanel />;
|
||||
case 'voice':
|
||||
return <VoiceControl turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||
case 'stats':
|
||||
return <StatsPanel selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||
case 'groups':
|
||||
return <GroupsPanel turtles={turtles} apiUrl={apiUrl} wsUrl={wsUrl} />;
|
||||
case 'tasks':
|
||||
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
||||
case 'paths':
|
||||
return <PathRecorder turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||
case 'areas':
|
||||
return <MiningAreasPanel turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||
default:
|
||||
return <ControlPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="view-controls">
|
||||
<button
|
||||
className={view === 'split' ? 'active' : ''}
|
||||
onClick={() => setView('split')}
|
||||
>
|
||||
📊 Split View
|
||||
</button>
|
||||
<button
|
||||
className={view === 'map' ? 'active' : ''}
|
||||
onClick={() => setView('map')}
|
||||
>
|
||||
🗺️ Map Only
|
||||
</button>
|
||||
<button
|
||||
className={view === 'panel' ? 'active' : ''}
|
||||
onClick={() => setView('panel')}
|
||||
>
|
||||
🎮 Control Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`app-content ${view}`}>
|
||||
{(view === 'split' || view === 'map') && (
|
||||
<div className="map-container">
|
||||
<Map3D />
|
||||
<div className="app-content split">
|
||||
<div className="map-container">
|
||||
<Map3D />
|
||||
</div>
|
||||
<div className="panel-container">
|
||||
<div className="panel-tabs">
|
||||
<a
|
||||
href={inventoryDashboardUrl}
|
||||
className="cross-link-btn"
|
||||
title="Open Inventory Manager Dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
📦 Inventory
|
||||
</a>
|
||||
<button
|
||||
className={panelTab === 'control' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('control')}
|
||||
title="Turtle Control"
|
||||
>
|
||||
🎮 Control
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'voice' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('voice')}
|
||||
title="Voice Commands"
|
||||
>
|
||||
🎤 Voice
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'stats' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('stats')}
|
||||
title="Mining Statistics"
|
||||
>
|
||||
📊 Stats
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'groups' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('groups')}
|
||||
title="Turtle Groups"
|
||||
>
|
||||
👥 Groups
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'tasks' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('tasks')}
|
||||
title="Task Queue"
|
||||
>
|
||||
📋 Tasks
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'paths' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('paths')}
|
||||
title="Path Recording"
|
||||
>
|
||||
🛤️ Paths
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'areas' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('areas')}
|
||||
title="Mining Areas"
|
||||
>
|
||||
⛏️ Areas
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-content-wrapper">
|
||||
{renderPanelContent()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(view === 'split' || view === 'panel') && (
|
||||
<div className="panel-container">
|
||||
<ControlPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,44 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTurtleStore } from '../store/turtleStore';
|
||||
import './ControlPanel.css';
|
||||
|
||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
const mode = turtle.mode || 'unknown';
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||
const inventoryCount = turtle.inventory?.length || 0;
|
||||
const inventoryCount = Array.isArray(turtle.inventory)
|
||||
? turtle.inventory.length
|
||||
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||
|
||||
const modeColors = {
|
||||
mining: '#4ade80',
|
||||
exploring: '#60a5fa',
|
||||
returning: '#f59e0b',
|
||||
idle: '#9ca3af',
|
||||
manual: '#a78bfa',
|
||||
unknown: '#6b7280'
|
||||
mining: '#55ff55',
|
||||
exploring: '#55ffff',
|
||||
returning: '#ffaa00',
|
||||
goHome: '#ffaa00',
|
||||
idle: '#aaaaaa',
|
||||
manual: '#ff55ff',
|
||||
refueling: '#ff5555',
|
||||
farming: '#55ff55',
|
||||
dumpInventory: '#aa00aa',
|
||||
dumping: '#aa00aa',
|
||||
moving: '#55ffff',
|
||||
scan: '#5555ff',
|
||||
extraction: '#ffaa00',
|
||||
building: '#00aaaa',
|
||||
autocraft: '#ff55ff',
|
||||
unknown: '#555555'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onSelect}
|
||||
style={{ borderColor: modeColors[mode] }}
|
||||
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
||||
>
|
||||
<div className="turtle-header">
|
||||
<h3>Turtle {turtle.turtleID}</h3>
|
||||
<span className="mode-badge" style={{ background: modeColors[mode] }}>
|
||||
{mode}
|
||||
<h3>{displayName}</h3>
|
||||
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||
{activeState}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||
}
|
||||
|
||||
function TurtleDetails({ turtle }) {
|
||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
|
||||
const equipLeft = useTurtleStore((state) => state.equipLeft);
|
||||
const equipRight = useTurtleStore((state) => state.equipRight);
|
||||
const sortInventory = useTurtleStore((state) => state.sortInventory);
|
||||
const selectSlot = useTurtleStore((state) => state.selectSlot);
|
||||
const dropItems = useTurtleStore((state) => state.dropItems);
|
||||
const suckItems = useTurtleStore((state) => state.suckItems);
|
||||
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
|
||||
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
|
||||
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
|
||||
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
|
||||
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
|
||||
|
||||
if (!turtle) {
|
||||
return (
|
||||
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleCommand = (command, param = null) => {
|
||||
sendCommand(turtle.turtleID, command, param);
|
||||
const handleStateChange = (stateName, data = {}) => {
|
||||
setTurtleState(turtle.turtleID, stateName, data);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (renameValue.trim()) {
|
||||
await renameTurtle(turtle.turtleID, renameValue.trim());
|
||||
setRenameValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotClick = async (slotIndex) => {
|
||||
await selectSlot(turtle.turtleID, slotIndex + 1);
|
||||
};
|
||||
|
||||
const handleConfigSave = async () => {
|
||||
await updateTurtleConfig(turtle.turtleID, configValues);
|
||||
setShowConfig(false);
|
||||
};
|
||||
|
||||
const activeState = turtle.state || turtle.mode || 'idle';
|
||||
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||
|
||||
return (
|
||||
<div className="turtle-details">
|
||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
||||
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
|
||||
|
||||
{/* Rename + Config bar */}
|
||||
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
placeholder="Rename turtle..."
|
||||
className="rename-input"
|
||||
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
|
||||
/>
|
||||
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
|
||||
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}>⚙️ Config</button>
|
||||
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
|
||||
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
|
||||
</div>
|
||||
|
||||
{/* Config Modal */}
|
||||
{showConfig && (
|
||||
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
|
||||
<h3>⚙️ Configuration</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Max Distance:</span>
|
||||
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Auto Refuel:</span>
|
||||
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
|
||||
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Status</h3>
|
||||
<div className="status-grid">
|
||||
<div className="status-item">
|
||||
<span className="label">Mode:</span>
|
||||
<span className="value">{turtle.mode || 'unknown'}</span>
|
||||
<span className="label">State:</span>
|
||||
<span className="value">{activeState}</span>
|
||||
</div>
|
||||
{turtle.stateDescription && (
|
||||
<div className="status-item">
|
||||
<span className="label">Activity:</span>
|
||||
<span className="value">{turtle.stateDescription}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Fuel:</span>
|
||||
<span className="value">
|
||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||
</span>
|
||||
</div>
|
||||
{turtle.totalSteps > 0 && (
|
||||
<div className="status-item">
|
||||
<span className="label">Steps:</span>
|
||||
<span className="value">
|
||||
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="label">Position:</span>
|
||||
<span className="value">
|
||||
@@ -115,67 +224,333 @@ function TurtleDetails({ turtle }) {
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{turtle.error && (
|
||||
<div className="status-item" style={{ color: '#ff5555' }}>
|
||||
<span className="label">Error:</span>
|
||||
<span className="value">{turtle.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{turtle.warning && (
|
||||
<div className="status-item" style={{ color: '#ffaa00' }}>
|
||||
<span className="label">Warning:</span>
|
||||
<span className="value">{turtle.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peripherals section */}
|
||||
{turtle.peripherals && Object.keys(turtle.peripherals).length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h3>Peripherals</h3>
|
||||
<div className="status-grid">
|
||||
{Object.entries(turtle.peripherals).map(([side, info]) => (
|
||||
<div key={side} className="status-item">
|
||||
<span className="label" style={{ textTransform: 'capitalize' }}>{side}:</span>
|
||||
<span className="value" style={{ color: '#ff55ff' }}>
|
||||
{typeof info === 'string' ? info : (info?.types?.join(', ') || 'unknown')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>State Machine</h3>
|
||||
<div className="command-grid">
|
||||
<button
|
||||
className={`command-btn ${activeState === 'idle' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('idle')}
|
||||
title="Stop and go idle"
|
||||
style={activeState === 'idle' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⏸️ Idle
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn explore ${activeState === 'exploring' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('exploring')}
|
||||
title="Autonomous exploration"
|
||||
style={activeState === 'exploring' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🔍 Explore
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn mine ${activeState === 'mining' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('mining')}
|
||||
title="Mining with ore priority"
|
||||
style={activeState === 'mining' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛏️ Mine
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'farming' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('farming')}
|
||||
title="Automated farming"
|
||||
style={activeState === 'farming' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🌾 Farm
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn return ${activeState === 'goHome' || activeState === 'returning' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('goHome')}
|
||||
title="Navigate home"
|
||||
style={activeState === 'goHome' || activeState === 'returning' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🏠 Go Home
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn refuel ${activeState === 'refueling' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('refueling')}
|
||||
title="Auto-refuel from inventory"
|
||||
style={activeState === 'refueling' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
⛽ Refuel
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'dumpInventory' || activeState === 'dumping' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('dumpInventory')}
|
||||
title="Dump inventory into nearby container"
|
||||
style={activeState === 'dumpInventory' || activeState === 'dumping' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
📦 Dump
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'moving' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('moving')}
|
||||
title="Navigate to target"
|
||||
style={activeState === 'moving' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🧭 Move To
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'scan' ? 'active' : ''}`}
|
||||
onClick={() => handleStateChange('scan')}
|
||||
title="Scan surroundings with peripheral scanner"
|
||||
style={activeState === 'scan' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
📡 Scan
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'extraction' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const area = prompt('Enter extraction area (startX,startY,startZ,endX,endY,endZ):');
|
||||
if (area) {
|
||||
const [sx,sy,sz,ex,ey,ez] = area.split(',').map(Number);
|
||||
if (!isNaN(sx) && !isNaN(sy) && !isNaN(sz) && !isNaN(ex) && !isNaN(ey) && !isNaN(ez)) {
|
||||
const points = [];
|
||||
for (let x = sx; x <= ex; x++) for (let y = sy; y <= ey; y++) for (let z = sz; z <= ez; z++) {
|
||||
points.push({x, y, z});
|
||||
}
|
||||
handleStateChange('extraction', { area: points });
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Smart ore extraction in an area"
|
||||
style={activeState === 'extraction' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
💎 Extract
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'building' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const input = prompt('Enter blueprint JSON (array of {x,y,z,name} blocks):');
|
||||
if (input) {
|
||||
try {
|
||||
const blocks = JSON.parse(input);
|
||||
handleStateChange('building', { blocks });
|
||||
} catch (e) {
|
||||
alert('Invalid JSON blueprint');
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Build from blueprint"
|
||||
style={activeState === 'building' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🏗️ Build
|
||||
</button>
|
||||
<button
|
||||
className={`command-btn ${activeState === 'autocraft' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const input = prompt('Enter recipe JSON (array of 16 slot items, null for empty):');
|
||||
if (input) {
|
||||
try {
|
||||
const recipe = JSON.parse(input);
|
||||
handleStateChange('autocraft', { recipe });
|
||||
} catch (e) {
|
||||
alert('Invalid JSON recipe');
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Automated crafting with workbench"
|
||||
style={activeState === 'autocraft' ? { outline: '2px solid #fff' } : {}}
|
||||
>
|
||||
🔨 Autocraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Commands</h3>
|
||||
<div className="command-grid">
|
||||
<h3>Movement</h3>
|
||||
<div className="movement-controls">
|
||||
<div className="movement-row">
|
||||
<button onClick={() => moveForward(turtle.turtleID)} title="Move forward">↑</button>
|
||||
</div>
|
||||
<div className="movement-row">
|
||||
<button onClick={() => turnLeft(turtle.turtleID)} title="Turn left">←</button>
|
||||
<button onClick={() => moveBack(turtle.turtleID)} title="Move backward">↓</button>
|
||||
<button onClick={() => turnRight(turtle.turtleID)} title="Turn right">→</button>
|
||||
</div>
|
||||
<div className="movement-row vertical">
|
||||
<button onClick={() => moveUp(turtle.turtleID)} title="Move up">⬆ Up</button>
|
||||
<button onClick={() => moveDown(turtle.turtleID)} title="Move down">⬇ Down</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Actions</h3>
|
||||
<div className="action-grid">
|
||||
<button
|
||||
className="command-btn explore"
|
||||
onClick={() => handleCommand('explore')}
|
||||
className="action-btn dig"
|
||||
onClick={() => digBlock(turtle.turtleID)}
|
||||
title="Dig block in front"
|
||||
>
|
||||
🔍 Explore
|
||||
⛏️ Dig
|
||||
</button>
|
||||
<button
|
||||
className="command-btn mine"
|
||||
onClick={() => handleCommand('mine')}
|
||||
className="action-btn digup"
|
||||
onClick={() => digBlockUp(turtle.turtleID)}
|
||||
title="Dig block above"
|
||||
>
|
||||
⛏️ Mine
|
||||
⬆️ Dig Up
|
||||
</button>
|
||||
<button
|
||||
className="command-btn return"
|
||||
onClick={() => handleCommand('returnHome')}
|
||||
className="action-btn digdown"
|
||||
onClick={() => digBlockDown(turtle.turtleID)}
|
||||
title="Dig block below"
|
||||
>
|
||||
🏠 Return Home
|
||||
⬇️ Dig Down
|
||||
</button>
|
||||
<button
|
||||
className="command-btn stop"
|
||||
onClick={() => handleCommand('stop')}
|
||||
className="action-btn place"
|
||||
onClick={() => placeBlock(turtle.turtleID)}
|
||||
title="Place block from inventory"
|
||||
>
|
||||
⏹️ Stop
|
||||
🧱 Place
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="manual-controls">
|
||||
<h4>Manual Control</h4>
|
||||
<div className="direction-pad">
|
||||
<button onClick={() => handleCommand('forward')}>↑</button>
|
||||
<div className="horizontal-controls">
|
||||
<button onClick={() => handleCommand('turnLeft')}>←</button>
|
||||
<button onClick={() => handleCommand('turnRight')}>→</button>
|
||||
</div>
|
||||
<button onClick={() => handleCommand('back')}>↓</button>
|
||||
</div>
|
||||
<div className="vertical-controls">
|
||||
<button onClick={() => handleCommand('up')}>⬆ Up</button>
|
||||
<button onClick={() => handleCommand('down')}>⬇ Down</button>
|
||||
</div>
|
||||
{/* Equipment & Inventory Actions */}
|
||||
<div className="detail-section">
|
||||
<h3>Equipment & Inventory</h3>
|
||||
<div className="action-grid">
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => equipLeft(turtle.turtleID)}
|
||||
title="Equip selected item on left side"
|
||||
>
|
||||
🛡️ Equip L
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => equipRight(turtle.turtleID)}
|
||||
title="Equip selected item on right side"
|
||||
>
|
||||
🗡️ Equip R
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => sortInventory(turtle.turtleID)}
|
||||
title="Sort and compact inventory"
|
||||
>
|
||||
📋 Sort
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'front')}
|
||||
title="Drop items forward"
|
||||
>
|
||||
📤 Drop
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'up')}
|
||||
title="Drop items upward"
|
||||
>
|
||||
⬆️ Drop Up
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => dropItems(turtle.turtleID, 'down')}
|
||||
title="Drop items downward"
|
||||
>
|
||||
⬇️ Drop Down
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => suckItems(turtle.turtleID, 'front')}
|
||||
title="Suck items from front"
|
||||
>
|
||||
📥 Suck
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => {
|
||||
const side = prompt('Enter peripheral side (front/top/bottom/left/right):');
|
||||
if (side) connectToInventory(turtle.turtleID, side);
|
||||
}}
|
||||
title="Read adjacent inventory peripheral contents"
|
||||
>
|
||||
📦 Read Inv
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turtle.inventory && turtle.inventory.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h3>Inventory</h3>
|
||||
<div className="inventory-list">
|
||||
{turtle.inventory.map((item, index) => (
|
||||
<div key={index} className="inventory-item">
|
||||
<span className="item-name">
|
||||
{item.name.replace('minecraft:', '')}
|
||||
</span>
|
||||
<span className="item-count">x{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) — Slot: {turtle.selectedSlot || 1}</h3>
|
||||
<div className="inventory-grid">
|
||||
{Array.from({ length: 16 }, (_, slotIndex) => {
|
||||
const item = turtle.inventory[slotIndex];
|
||||
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
|
||||
return (
|
||||
<div
|
||||
key={slotIndex}
|
||||
className={`inventory-slot ${item ? 'filled' : 'empty'} ${isSelected ? 'selected-slot' : ''}`}
|
||||
title={item ? `${item.name.replace('minecraft:', '').replace(/_/g, ' ')} (${item.count}) — Click to select` : `Slot ${slotIndex + 1} — Click to select`}
|
||||
onClick={() => handleSlotClick(slotIndex)}
|
||||
style={isSelected ? { outline: '2px solid #55ffff', outlineOffset: '-2px' } : {}}
|
||||
>
|
||||
{item ? (
|
||||
<>
|
||||
<div className="slot-item">
|
||||
<span className="item-icon">
|
||||
{item.name.includes('diamond') ? '💎' :
|
||||
item.name.includes('gold') ? '<27>' :
|
||||
item.name.includes('iron') ? '⚪' :
|
||||
item.name.includes('coal') ? '⚫' :
|
||||
item.name.includes('emerald') ? '🟢' :
|
||||
item.name.includes('redstone') ? '🔴' :
|
||||
item.name.includes('lapis') ? '🔵' :
|
||||
item.name.includes('stone') ? '🗿' :
|
||||
item.name.includes('dirt') ? '🟤' :
|
||||
item.name.includes('wood') || item.name.includes('log') ? '🪵' :
|
||||
item.name.includes('cobble') ? '🪨' : '<27>📦'}
|
||||
</span>
|
||||
<span className="item-count">{item.count}</span>
|
||||
</div>
|
||||
<div className="slot-name">
|
||||
{item.name.replace('minecraft:', '').replace(/_/g, ' ').split(' ').slice(0, 2).join(' ')}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="slot-number">{slotIndex + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
431
client/src/components/GroupsPanel.css
Normal file
431
client/src/components/GroupsPanel.css
Normal file
@@ -0,0 +1,431 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Groups Panel
|
||||
============================================ */
|
||||
|
||||
.groups-panel {
|
||||
padding: 1.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.groups-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.groups-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Create Group Section */
|
||||
.create-group-section {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.create-group-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-group-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-group-form input {
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.create-group-form input:focus {
|
||||
outline: none;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.create-group-form input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
position: relative;
|
||||
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.3), inset 0 2px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-option.active {
|
||||
border-color: #ffff55;
|
||||
box-shadow: 0 0 0 2px #1a1a1a, 0 0 0 4px #ffff55;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"] {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"]:hover:not(:disabled) {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.create-group-form button[type="submit"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Groups List */
|
||||
.groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
overflow: hidden;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.group-card:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.group-color-indicator {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.group-title h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.delete-group-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.delete-group-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Group Members */
|
||||
.group-members {
|
||||
padding: 0 1.5rem 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.group-members h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.member-status {
|
||||
font-size: 1rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.remove-member-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ff5555;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.remove-member-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.no-members {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.add-member-section {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.add-member-section select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.add-member-section select:hover {
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.add-member-section select:focus {
|
||||
outline: none;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
/* Group Commands */
|
||||
.group-commands {
|
||||
padding: 1rem 1.5rem 1.5rem 1.5rem;
|
||||
border-top: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.group-commands h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.command-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.command-buttons button {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.command-buttons button:hover {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.groups-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.group-members,
|
||||
.group-commands {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.command-buttons {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.groups-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.command-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
327
client/src/components/GroupsPanel.jsx
Normal file
327
client/src/components/GroupsPanel.jsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './GroupsPanel.css';
|
||||
|
||||
const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupColor, setGroupColor] = useState('#3b82f6');
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const colorPresets = [
|
||||
{ name: 'Blue', value: '#345ec3' },
|
||||
{ name: 'Green', value: '#4a8c2a' },
|
||||
{ name: 'Red', value: '#aa0000' },
|
||||
{ name: 'Yellow', value: '#ffaa00' },
|
||||
{ name: 'Purple', value: '#7b2fbe' },
|
||||
{ name: 'Pink', value: '#d4658a' },
|
||||
{ name: 'Cyan', value: '#55ffff' },
|
||||
{ name: 'Orange', value: '#c97a2a' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
|
||||
// Refresh groups every 10 seconds
|
||||
const interval = setInterval(loadGroups, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGroups(Array.isArray(data) ? data : (data.groups || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGroup = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!groupName.trim()) {
|
||||
showMessage('Please enter a group name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
groupName: groupName.trim(),
|
||||
color: groupColor
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Group created successfully!', 'success');
|
||||
setGroupName('');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to create group', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error creating group', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (groupId) => {
|
||||
if (!confirm('Are you sure you want to delete this group?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Group deleted', 'success');
|
||||
if (selectedGroup?.groupId === groupId) {
|
||||
setSelectedGroup(null);
|
||||
}
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to delete group', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error deleting group', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const addTurtleToGroup = async (groupId, turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turtleId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Turtle added to group', 'success');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to add turtle', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error adding turtle', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTurtleFromGroup = async (groupId, turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/members/${turtleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Turtle removed from group', 'success');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to remove turtle', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error removing turtle', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const sendGroupCommand = async (groupId, command) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showMessage(`Command sent to ${data.sentTo} turtles`, 'success');
|
||||
} else {
|
||||
showMessage('Failed to send command', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error sending command', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const getTurtleById = (turtleId) => {
|
||||
return turtles.find(t => t.turtleID === turtleId);
|
||||
};
|
||||
|
||||
const getAvailableTurtles = (groupMembers) => {
|
||||
const memberIds = groupMembers.map(m => m.turtleId);
|
||||
return turtles.filter(t => !memberIds.includes(t.turtleID));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="groups-panel">
|
||||
<div className="groups-header">
|
||||
<h2>👥 Turtle Groups</h2>
|
||||
<div className="group-count">{groups.length} teams</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Group Form */}
|
||||
<div className="create-group-section">
|
||||
<h3>Create New Group</h3>
|
||||
<form onSubmit={createGroup} className="create-group-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
maxLength={50}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="color-picker">
|
||||
{colorPresets.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
className={`color-option ${groupColor === preset.value ? 'active' : ''}`}
|
||||
style={{ backgroundColor: preset.value }}
|
||||
onClick={() => setGroupColor(preset.value)}
|
||||
title={preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !groupName.trim()}>
|
||||
{loading ? 'Creating...' : '➕ Create Group'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="groups-list">
|
||||
{groups.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">👥</div>
|
||||
<div className="empty-title">No Groups Yet</div>
|
||||
<div className="empty-text">Create a group to organize your turtles into teams</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map(group => (
|
||||
<div key={group.groupId} className="group-card">
|
||||
<div className="group-header" style={{ borderLeftColor: group.color }}>
|
||||
<div className="group-title">
|
||||
<div
|
||||
className="group-color-indicator"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<h3>{group.groupName}</h3>
|
||||
<span className="member-count">{group.memberCount} members</span>
|
||||
</div>
|
||||
<button
|
||||
className="delete-group-btn"
|
||||
onClick={() => deleteGroup(group.groupId)}
|
||||
title="Delete group"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Group Members */}
|
||||
<div className="group-members">
|
||||
<h4>Members:</h4>
|
||||
{group.members && group.members.length > 0 ? (
|
||||
<div className="members-list">
|
||||
{group.members.map(member => {
|
||||
const turtle = getTurtleById(member.turtleId);
|
||||
return (
|
||||
<div key={member.turtleId} className="member-item">
|
||||
<div className="member-info">
|
||||
<span className="member-icon">🐢</span>
|
||||
<span className="member-name">
|
||||
{turtle?.name || `Turtle ${member.turtleId}`}
|
||||
</span>
|
||||
{turtle && (
|
||||
<span className="member-status" title={turtle.mode}>
|
||||
{turtle.mode === 'exploring' && '🔍'}
|
||||
{turtle.mode === 'mining' && '⛏️'}
|
||||
{turtle.mode === 'returning' && '🏠'}
|
||||
{turtle.mode === 'idle' && '💤'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="remove-member-btn"
|
||||
onClick={() => removeTurtleFromGroup(group.groupId, member.turtleId)}
|
||||
title="Remove from group"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-members">No members yet</div>
|
||||
)}
|
||||
|
||||
{/* Add Member Dropdown */}
|
||||
{getAvailableTurtles(group.members || []).length > 0 && (
|
||||
<div className="add-member-section">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addTurtleToGroup(group.groupId, parseInt(e.target.value));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">➕ Add turtle...</option>
|
||||
{getAvailableTurtles(group.members || []).map(turtle => (
|
||||
<option key={turtle.turtleID} value={turtle.turtleID}>
|
||||
🐢 {turtle.name || `Turtle ${turtle.turtleID}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Commands */}
|
||||
{group.members && group.members.length > 0 && (
|
||||
<div className="group-commands">
|
||||
<h4>Group Commands:</h4>
|
||||
<div className="command-buttons">
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'explore')}>
|
||||
🔍 Explore
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'mine')}>
|
||||
⛏️ Mine
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'returnHome')}>
|
||||
🏠 Return Home
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'stop')}>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
507
client/src/components/MiningAreasPanel.css
Normal file
507
client/src/components/MiningAreasPanel.css
Normal file
@@ -0,0 +1,507 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Mining Areas Panel
|
||||
============================================ */
|
||||
|
||||
.mining-areas-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #6b4e28;
|
||||
border-bottom: 3px solid #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #ffff55;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Filter buttons */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6b6b6b;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* Create form */
|
||||
.create-form {
|
||||
padding: 1.5rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.create-form h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #ffaa00;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.coordinates-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.coordinates-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinates-header label {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-use-position {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-use-position:hover:not(:disabled) {
|
||||
background: #5a9c3a;
|
||||
}
|
||||
|
||||
.btn-use-position:disabled {
|
||||
background: #4b4b4b;
|
||||
color: #7b7b7b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.coordinate-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coordinate-inputs input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
margin-top: 1rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Areas list */
|
||||
.areas-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #7b7b7b;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Area card */
|
||||
.area-card {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.area-card:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.area-card.has-conflict {
|
||||
border-color: #ff5555;
|
||||
background: linear-gradient(135deg, #3b3b3b 0%, #4a2020 100%);
|
||||
}
|
||||
|
||||
.area-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.area-header h3 {
|
||||
margin: 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: white;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* Area info */
|
||||
.area-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.coordinate-value {
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
color: #55ffff;
|
||||
}
|
||||
|
||||
/* Conflict warning */
|
||||
.conflict-warning {
|
||||
background: #6b1a1a44;
|
||||
border: 2px solid #ff5555;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ff5555;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.conflict-warning ul {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.conflict-warning li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Area actions */
|
||||
.area-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.btn-start:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
background: #345ec3;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: #4a6ed3;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #aa0000;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.areas-list::-webkit-scrollbar,
|
||||
.create-form::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.areas-list::-webkit-scrollbar-track,
|
||||
.create-form::-webkit-scrollbar-track {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
|
||||
.areas-list::-webkit-scrollbar-thumb,
|
||||
.create-form::-webkit-scrollbar-thumb {
|
||||
background: #5a5a5a;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.areas-list::-webkit-scrollbar-thumb:hover,
|
||||
.create-form::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b6b6b;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.panel-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.areas-list {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.area-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.coordinate-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.area-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
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;
|
||||
}
|
||||
471
client/src/components/MiningAreasPanel.jsx
Normal file
471
client/src/components/MiningAreasPanel.jsx
Normal file
@@ -0,0 +1,471 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './MiningAreasPanel.css';
|
||||
|
||||
export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newArea, setNewArea] = useState({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
endX: '',
|
||||
endY: '',
|
||||
endZ: '',
|
||||
turtleID: ''
|
||||
});
|
||||
const [filterStatus, setFilterStatus] = useState('all'); // all, planned, mining, completed
|
||||
|
||||
// Load mining areas
|
||||
useEffect(() => {
|
||||
loadAreas();
|
||||
const interval = setInterval(loadAreas, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [apiUrl]);
|
||||
|
||||
const loadAreas = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/mining-areas`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Server returns a flat array of formatted areas
|
||||
setAreas(Array.isArray(data) ? data : (data.areas || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load mining areas:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create new mining area
|
||||
const handleCreateArea = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newArea.areaName || !newArea.turtleID) {
|
||||
alert('Please provide area name and assign a turtle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
const coords = ['startX', 'startY', 'startZ', 'endX', 'endY', 'endZ'];
|
||||
for (const coord of coords) {
|
||||
if (newArea[coord] === '' || isNaN(Number(newArea[coord]))) {
|
||||
alert(`Please provide valid ${coord} coordinate`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/mining-areas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...newArea,
|
||||
startX: Number(newArea.startX),
|
||||
startY: Number(newArea.startY),
|
||||
startZ: Number(newArea.startZ),
|
||||
endX: Number(newArea.endX),
|
||||
endY: Number(newArea.endY),
|
||||
endZ: Number(newArea.endZ),
|
||||
color: newArea.color,
|
||||
status: 'planned'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowCreateForm(false);
|
||||
setNewArea({
|
||||
areaName: '',
|
||||
color: '#4a8c2a',
|
||||
startX: '',
|
||||
startY: '',
|
||||
startZ: '',
|
||||
endX: '',
|
||||
endY: '',
|
||||
endZ: '',
|
||||
turtleID: ''
|
||||
});
|
||||
loadAreas();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create area: ${error.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create mining area:', error);
|
||||
alert('Failed to create mining area');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete mining area
|
||||
const handleDeleteArea = async (areaID) => {
|
||||
if (!confirm('Are you sure you want to delete this mining area?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadAreas();
|
||||
} else {
|
||||
alert('Failed to delete area');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete area:', error);
|
||||
alert('Failed to delete area');
|
||||
}
|
||||
};
|
||||
|
||||
// Update area status
|
||||
const handleUpdateStatus = async (areaID, newStatus) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadAreas();
|
||||
} else {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-fill from selected turtle position
|
||||
const handleUseCurrentPosition = () => {
|
||||
if (!selectedTurtle || !selectedTurtle.position) {
|
||||
alert('No turtle selected or turtle has no position');
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = selectedTurtle.position;
|
||||
setNewArea(prev => ({
|
||||
...prev,
|
||||
startX: pos.x.toString(),
|
||||
startY: pos.y.toString(),
|
||||
startZ: pos.z.toString(),
|
||||
turtleID: selectedTurtle.turtleID.toString()
|
||||
}));
|
||||
};
|
||||
|
||||
// Calculate volume
|
||||
const calculateVolume = (area) => {
|
||||
const width = Math.abs(area.endX - area.startX) + 1;
|
||||
const height = Math.abs(area.endY - area.startY) + 1;
|
||||
const depth = Math.abs(area.endZ - area.startZ) + 1;
|
||||
return width * height * depth;
|
||||
};
|
||||
|
||||
// Check for overlapping areas
|
||||
const checkOverlap = (area1, area2) => {
|
||||
const overlapX = Math.max(
|
||||
Math.min(area1.endX, area2.endX) - Math.max(area1.startX, area2.startX) + 1,
|
||||
0
|
||||
);
|
||||
const overlapY = Math.max(
|
||||
Math.min(area1.endY, area2.endY) - Math.max(area1.startY, area2.startY) + 1,
|
||||
0
|
||||
);
|
||||
const overlapZ = Math.max(
|
||||
Math.min(area1.endZ, area2.endZ) - Math.max(area1.startZ, area2.startZ) + 1,
|
||||
0
|
||||
);
|
||||
return overlapX > 0 && overlapY > 0 && overlapZ > 0;
|
||||
};
|
||||
|
||||
// Find conflicts
|
||||
const findConflicts = (area) => {
|
||||
return areas.filter(other =>
|
||||
other.areaID !== area.areaID &&
|
||||
checkOverlap(area, other)
|
||||
);
|
||||
};
|
||||
|
||||
// Filter areas
|
||||
const filteredAreas = areas.filter(area => {
|
||||
if (filterStatus === 'all') return true;
|
||||
return area.status === filterStatus;
|
||||
});
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
planned: '#345ec3',
|
||||
mining: '#ffaa00',
|
||||
completed: '#4a8c2a'
|
||||
};
|
||||
return (
|
||||
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b6b6b' }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mining-areas-panel">
|
||||
<div className="panel-header">
|
||||
<h2>⛏️ Mining Areas</h2>
|
||||
<button
|
||||
className="btn-create"
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
>
|
||||
{showCreateForm ? '✕ Cancel' : '+ New Area'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className={`filter-btn ${filterStatus === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
All ({areas.length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filterStatus === 'planned' ? 'active' : ''}`}
|
||||
onClick={() => setFilterStatus('planned')}
|
||||
>
|
||||
📋 Planned ({areas.filter(a => a.status === 'planned').length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filterStatus === 'mining' ? 'active' : ''}`}
|
||||
onClick={() => setFilterStatus('mining')}
|
||||
>
|
||||
⛏️ Mining ({areas.filter(a => a.status === 'mining').length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filterStatus === 'completed' ? 'active' : ''}`}
|
||||
onClick={() => setFilterStatus('completed')}
|
||||
>
|
||||
✓ Done ({areas.filter(a => a.status === 'completed').length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreateForm && (
|
||||
<div className="create-form">
|
||||
<h3>Create Mining Area</h3>
|
||||
<form onSubmit={handleCreateArea}>
|
||||
<div className="form-group">
|
||||
<label>Area Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newArea.areaName}
|
||||
onChange={(e) => setNewArea({ ...newArea, areaName: e.target.value })}
|
||||
placeholder="e.g., Diamond Mine #1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Area Color:</label>
|
||||
<div className="color-picker-row">
|
||||
<input
|
||||
type="color"
|
||||
value={newArea.color}
|
||||
onChange={(e) => setNewArea({ ...newArea, color: e.target.value })}
|
||||
className="color-input"
|
||||
/>
|
||||
{['#4a8c2a', '#345ec3', '#c9a000', '#cc3333', '#8833cc', '#33aacc', '#cc6633'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`color-swatch ${newArea.color === c ? 'active' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewArea({ ...newArea, color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Assign Turtle:</label>
|
||||
<select
|
||||
value={newArea.turtleID}
|
||||
onChange={(e) => setNewArea({ ...newArea, turtleID: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Select turtle...</option>
|
||||
{turtles.map(turtle => (
|
||||
<option key={turtle.turtleID} value={turtle.turtleID}>
|
||||
🐢 {turtle.turtleID} {turtle.label ? `- ${turtle.label}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="coordinates-section">
|
||||
<div className="coordinates-header">
|
||||
<label>Start Position:</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-use-position"
|
||||
onClick={handleUseCurrentPosition}
|
||||
disabled={!selectedTurtle}
|
||||
>
|
||||
Use Current Position
|
||||
</button>
|
||||
</div>
|
||||
<div className="coordinate-inputs">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="X"
|
||||
value={newArea.startX}
|
||||
onChange={(e) => setNewArea({ ...newArea, startX: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Y"
|
||||
value={newArea.startY}
|
||||
onChange={(e) => setNewArea({ ...newArea, startY: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Z"
|
||||
value={newArea.startZ}
|
||||
onChange={(e) => setNewArea({ ...newArea, startZ: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="coordinates-section">
|
||||
<label>End Position:</label>
|
||||
<div className="coordinate-inputs">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="X"
|
||||
value={newArea.endX}
|
||||
onChange={(e) => setNewArea({ ...newArea, endX: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Y"
|
||||
value={newArea.endY}
|
||||
onChange={(e) => setNewArea({ ...newArea, endY: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Z"
|
||||
value={newArea.endZ}
|
||||
onChange={(e) => setNewArea({ ...newArea, endZ: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-submit">
|
||||
Create Area
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Areas list */}
|
||||
<div className="areas-list">
|
||||
{filteredAreas.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No mining areas {filterStatus !== 'all' ? `with status "${filterStatus}"` : ''}</p>
|
||||
{filterStatus === 'all' && (
|
||||
<p>Create a new area to get started</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredAreas.map(area => {
|
||||
const turtle = turtles.find(t => t.turtleID === area.turtleID);
|
||||
const conflicts = findConflicts(area);
|
||||
const volume = calculateVolume(area);
|
||||
|
||||
return (
|
||||
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||
<div className="area-header">
|
||||
<div className="area-title-row">
|
||||
<span className="area-color-dot" style={{ backgroundColor: area.color || '#4a8c2a' }} />
|
||||
<h3>{area.areaName}</h3>
|
||||
</div>
|
||||
<StatusBadge status={area.status} />
|
||||
</div>
|
||||
|
||||
<div className="area-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Turtle:</span>
|
||||
<span className="info-value">
|
||||
🐢 {turtle ? `${turtle.turtleID} ${turtle.label || ''}` : area.turtleID}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-label">Coordinates:</span>
|
||||
<span className="info-value coordinate-value">
|
||||
({area.startX}, {area.startY}, {area.startZ}) →
|
||||
({area.endX}, {area.endY}, {area.endZ})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-label">Volume:</span>
|
||||
<span className="info-value">{volume.toLocaleString()} blocks</span>
|
||||
</div>
|
||||
|
||||
{area.createdAt && (
|
||||
<div className="info-row">
|
||||
<span className="info-label">Created:</span>
|
||||
<span className="info-value">
|
||||
{new Date(area.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<div className="conflict-warning">
|
||||
⚠️ Overlaps with {conflicts.length} other area(s):
|
||||
<ul>
|
||||
{conflicts.map(c => (
|
||||
<li key={c.areaID}>{c.areaName}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="area-actions">
|
||||
{area.status === 'planned' && (
|
||||
<button
|
||||
className="btn-action btn-start"
|
||||
onClick={() => handleUpdateStatus(area.areaID, 'mining')}
|
||||
>
|
||||
▶️ Start Mining
|
||||
</button>
|
||||
)}
|
||||
{area.status === 'mining' && (
|
||||
<button
|
||||
className="btn-action btn-complete"
|
||||
onClick={() => handleUpdateStatus(area.areaID, 'completed')}
|
||||
>
|
||||
✓ Mark Complete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-action btn-delete"
|
||||
onClick={() => handleDeleteArea(area.areaID)}
|
||||
>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
549
client/src/components/PathRecorder.css
Normal file
549
client/src/components/PathRecorder.css
Normal file
@@ -0,0 +1,549 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Path Recorder
|
||||
============================================ */
|
||||
|
||||
.path-recorder {
|
||||
padding: 1.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recorder-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.selected-turtle-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2d6b1a33;
|
||||
border: 2px solid #55ff55;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background: #1a4a6b33;
|
||||
color: #55ffff;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Recording Section */
|
||||
.recording-section {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.recording-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.record-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.record-form input,
|
||||
.record-form textarea {
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.record-form input:focus,
|
||||
.record-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #aa0000;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.record-btn:hover:not(:disabled) {
|
||||
background: #cc0000;
|
||||
box-shadow: inset 0 2px 0 #ff4444, inset 0 -2px 0 #880000;
|
||||
}
|
||||
|
||||
.record-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.waypoint-counter {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ff5555;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-info p {
|
||||
margin: 0.25rem 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recording-info strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
padding: 0.875rem 2rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.stop-btn:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Paths Section */
|
||||
.paths-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #55ffff;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.paths-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.path-card {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.path-card:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.path-card-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.path-info h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.path-description {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
margin: 0.5rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.path-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.75rem;
|
||||
color: #7b7b7b;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.path-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.path-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.path-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: #345ec3;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -2px 0 #223399;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: #4a6ed3;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: #5a9c3a;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #aa0000;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Path Details Modal */
|
||||
.path-details-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #3b3b3b;
|
||||
border: 3px solid #1a1a1a;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
background: #6b4e28;
|
||||
box-shadow: inset 0 2px 0 #8b6d3c, inset 0 -2px 0 #4a3520;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #aa0000;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -2px 0 #770000;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #cc0000;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #a0a0a0;
|
||||
font-style: italic;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.waypoints-list h4,
|
||||
.path-visualization h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.waypoints-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.waypoint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.waypoint-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.waypoint-coords {
|
||||
color: #e0e0e0;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.waypoint-action {
|
||||
margin-left: auto;
|
||||
color: #55ff55;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.path-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.stat .stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat .stat-value {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #55ffff;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.path-recorder {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recorder-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.path-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.waypoints-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.path-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.recorder-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.path-actions button {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
409
client/src/components/PathRecorder.jsx
Normal file
409
client/src/components/PathRecorder.jsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTurtleStore } from '../store/turtleStore';
|
||||
import './PathRecorder.css';
|
||||
|
||||
const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
|
||||
const [paths, setPaths] = useState([]);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState(null);
|
||||
const [pathName, setPathName] = useState('');
|
||||
const [pathDescription, setPathDescription] = useState('');
|
||||
const [selectedPath, setSelectedPath] = useState(null);
|
||||
const [message, setMessage] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playingBack, setPlayingBack] = useState(false);
|
||||
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
|
||||
useEffect(() => {
|
||||
loadPaths();
|
||||
}, []);
|
||||
|
||||
const loadPaths = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPaths(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load paths:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = () => {
|
||||
if (!selectedTurtle) {
|
||||
showMessage('Please select a turtle first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!pathName.trim()) {
|
||||
showMessage('Please enter a path name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRecording(true);
|
||||
setCurrentPath({
|
||||
name: pathName.trim(),
|
||||
description: pathDescription.trim(),
|
||||
turtleId: selectedTurtle.turtleID,
|
||||
waypoints: []
|
||||
});
|
||||
showMessage('Recording started! Move the turtle to record path.', 'success');
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!currentPath || currentPath.waypoints.length === 0) {
|
||||
showMessage('No waypoints recorded', 'error');
|
||||
setRecording(false);
|
||||
setCurrentPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
turtleId: currentPath.turtleId,
|
||||
pathName: currentPath.name,
|
||||
pathData: currentPath.waypoints
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save path');
|
||||
}
|
||||
|
||||
showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
|
||||
setRecording(false);
|
||||
setCurrentPath(null);
|
||||
setPathName('');
|
||||
setPathDescription('');
|
||||
loadPaths();
|
||||
} catch (error) {
|
||||
showMessage('Failed to save path', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePath = async (pathId) => {
|
||||
if (!confirm('Are you sure you want to delete this path?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${pathId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Path deleted', 'success');
|
||||
loadPaths();
|
||||
if (selectedPath?.pathId === pathId) {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
} else {
|
||||
showMessage('Failed to delete path', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error deleting path', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const viewPathDetails = async (path) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSelectedPath(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load path details:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const playbackPath = async (path) => {
|
||||
if (!selectedTurtle) {
|
||||
showMessage('Please select a turtle to play back this path', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load full path data if we don't have waypoints
|
||||
let waypoints = path.pathData || path.waypoints;
|
||||
if (!waypoints || waypoints.length === 0) {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
waypoints = data.waypoints;
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Failed to load path data', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
showMessage('Path has insufficient waypoints for playback', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayingBack(true);
|
||||
showMessage(`Playing back ${waypoints.length} waypoints via server pathfinding...`, 'info');
|
||||
|
||||
const turtleId = selectedTurtle.turtleID;
|
||||
|
||||
// Use server-side pathfinding to navigate to each waypoint sequentially
|
||||
for (let i = 0; i < waypoints.length; i++) {
|
||||
const wp = waypoints[i];
|
||||
|
||||
// Navigate to each waypoint using the server's moving state
|
||||
await setTurtleState(turtleId, 'moving', { target: { x: wp.x, y: wp.y, z: wp.z } });
|
||||
|
||||
// Wait for turtle to arrive (poll position or just add delay)
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
setPlayingBack(false);
|
||||
showMessage('Playback complete! Turtle navigating via server.', 'success');
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
// Simulate recording waypoints when turtle moves
|
||||
useEffect(() => {
|
||||
if (recording && selectedTurtle && currentPath) {
|
||||
const newWaypoint = {
|
||||
x: selectedTurtle.position?.x || 0,
|
||||
y: selectedTurtle.position?.y || 0,
|
||||
z: selectedTurtle.position?.z || 0,
|
||||
action: selectedTurtle.lastAction || 'move'
|
||||
};
|
||||
|
||||
// Only add if position changed
|
||||
const lastWaypoint = currentPath.waypoints[currentPath.waypoints.length - 1];
|
||||
if (!lastWaypoint ||
|
||||
lastWaypoint.x !== newWaypoint.x ||
|
||||
lastWaypoint.y !== newWaypoint.y ||
|
||||
lastWaypoint.z !== newWaypoint.z) {
|
||||
setCurrentPath(prev => ({
|
||||
...prev,
|
||||
waypoints: [...prev.waypoints, newWaypoint]
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [selectedTurtle?.position, recording]);
|
||||
|
||||
const getTurtleById = (turtleId) => {
|
||||
return turtles.find(t => t.turtleID === turtleId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="path-recorder">
|
||||
<div className="recorder-header">
|
||||
<h2>🛤️ Path Recording</h2>
|
||||
{selectedTurtle && (
|
||||
<div className="selected-turtle-badge">
|
||||
🐢 {selectedTurtle.name || `Turtle ${selectedTurtle.turtleID}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recording Controls */}
|
||||
<div className="recording-section">
|
||||
<h3>{recording ? '🔴 Recording...' : '⚪ Ready to Record'}</h3>
|
||||
|
||||
{!recording ? (
|
||||
<div className="record-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Path name..."
|
||||
value={pathName}
|
||||
onChange={(e) => setPathName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description (optional)..."
|
||||
value={pathDescription}
|
||||
onChange={(e) => setPathDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!selectedTurtle || !pathName.trim()}
|
||||
className="record-btn"
|
||||
>
|
||||
🔴 Start Recording
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="recording-controls">
|
||||
<div className="waypoint-counter">
|
||||
📍 {currentPath?.waypoints.length || 0} waypoints recorded
|
||||
</div>
|
||||
<div className="recording-info">
|
||||
<p>Path: <strong>{currentPath?.name}</strong></p>
|
||||
<p>Turtle: <strong>🐢 {selectedTurtle?.name || `Turtle ${selectedTurtle?.turtleID}`}</strong></p>
|
||||
</div>
|
||||
<button onClick={stopRecording} className="stop-btn">
|
||||
⏹️ Stop & Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths List */}
|
||||
<div className="paths-section">
|
||||
<h3>📋 Saved Paths ({paths.length})</h3>
|
||||
|
||||
{loading && <div className="loading">Loading paths...</div>}
|
||||
|
||||
{!loading && paths.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🛤️</div>
|
||||
<div className="empty-title">No Paths Recorded</div>
|
||||
<div className="empty-text">Record a path by selecting a turtle and clicking "Start Recording"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="paths-list">
|
||||
{paths.map(path => {
|
||||
const turtle = getTurtleById(path.turtleId);
|
||||
return (
|
||||
<div key={path.pathId} className="path-card">
|
||||
<div className="path-card-header">
|
||||
<div className="path-info">
|
||||
<h4>{path.name}</h4>
|
||||
{path.description && (
|
||||
<p className="path-description">{path.description}</p>
|
||||
)}
|
||||
<div className="path-meta">
|
||||
<span>🐢 {turtle?.name || `Turtle ${path.turtleId}`}</span>
|
||||
<span>📍 {path.waypointCount || 0} waypoints</span>
|
||||
<span>📅 {new Date(path.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="path-actions">
|
||||
<button
|
||||
onClick={() => viewPathDetails(path)}
|
||||
className="view-btn"
|
||||
title="View details"
|
||||
>
|
||||
👁️ View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => playbackPath(path)}
|
||||
className="play-btn"
|
||||
title="Playback path"
|
||||
disabled={playingBack}
|
||||
>
|
||||
{playingBack ? '⏳ Playing...' : '▶️ Play'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePath(path.pathId)}
|
||||
className="delete-btn"
|
||||
title="Delete path"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path Details Modal */}
|
||||
{selectedPath && (
|
||||
<div className="path-details-modal" onClick={() => setSelectedPath(null)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>📍 {selectedPath.name}</h3>
|
||||
<button onClick={() => setSelectedPath(null)} className="close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{selectedPath.description && (
|
||||
<p className="modal-description">{selectedPath.description}</p>
|
||||
)}
|
||||
|
||||
<div className="waypoints-list">
|
||||
<h4>Waypoints ({selectedPath.waypoints?.length || 0})</h4>
|
||||
<div className="waypoints-grid">
|
||||
{selectedPath.waypoints?.map((waypoint, idx) => (
|
||||
<div key={idx} className="waypoint-item">
|
||||
<span className="waypoint-number">{idx + 1}</span>
|
||||
<span className="waypoint-coords">
|
||||
({waypoint.x}, {waypoint.y}, {waypoint.z})
|
||||
</span>
|
||||
{waypoint.action && (
|
||||
<span className="waypoint-action">{waypoint.action}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="path-visualization">
|
||||
<h4>Path Preview</h4>
|
||||
<div className="path-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Total Distance:</span>
|
||||
<span className="stat-value">
|
||||
{selectedPath.waypoints?.length > 1
|
||||
? Math.floor(
|
||||
selectedPath.waypoints.reduce((total, waypoint, i) => {
|
||||
if (i === 0) return 0;
|
||||
const prev = selectedPath.waypoints[i - 1];
|
||||
return total + Math.sqrt(
|
||||
Math.pow(waypoint.x - prev.x, 2) +
|
||||
Math.pow(waypoint.y - prev.y, 2) +
|
||||
Math.pow(waypoint.z - prev.z, 2)
|
||||
);
|
||||
}, 0)
|
||||
)
|
||||
: 0} blocks
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Start:</span>
|
||||
<span className="stat-value">
|
||||
({selectedPath.waypoints?.[0]?.x}, {selectedPath.waypoints?.[0]?.y}, {selectedPath.waypoints?.[0]?.z})
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">End:</span>
|
||||
<span className="stat-value">
|
||||
{selectedPath.waypoints?.length > 0 && (
|
||||
<>
|
||||
({selectedPath.waypoints[selectedPath.waypoints.length - 1].x}, {' '}
|
||||
{selectedPath.waypoints[selectedPath.waypoints.length - 1].y}, {' '}
|
||||
{selectedPath.waypoints[selectedPath.waypoints.length - 1].z})
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PathRecorder;
|
||||
393
client/src/components/StatsPanel.css
Normal file
393
client/src/components/StatsPanel.css
Normal file
@@ -0,0 +1,393 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Stats Panel
|
||||
============================================ */
|
||||
|
||||
.stats-panel {
|
||||
padding: 1.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.time-filter {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: #3b3b3b;
|
||||
padding: 0.25rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.time-filter button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.time-filter button.active {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Turtle Stats */
|
||||
.turtle-stats {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.turtle-stats h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #55ff55;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.stat-section {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.total-mined {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: #55ffff;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
margin-top: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.blocks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.block-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.block-stat:hover {
|
||||
background: #4b4b4b;
|
||||
box-shadow: inset 0 -1px 0 #333, inset 0 1px 0 #666;
|
||||
}
|
||||
|
||||
.block-emoji {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.block-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.block-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.block-count {
|
||||
font-size: 0.75rem;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Leaderboard */
|
||||
.leaderboard {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.leaderboard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -1px 0 #222, inset 0 1px 0 #444;
|
||||
}
|
||||
|
||||
.leaderboard-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.leaderboard-item:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-1 {
|
||||
border-left: 4px solid #ffaa00;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-2 {
|
||||
border-left: 4px solid #a0a0a0;
|
||||
}
|
||||
|
||||
.leaderboard-item.rank-3 {
|
||||
border-left: 4px solid #cd7f32;
|
||||
}
|
||||
|
||||
.rank {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
color: #ffff55;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.miner-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.miner-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.miner-stats {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.miner-score {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #55ffff;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
/* All Turtles Overview */
|
||||
.all-turtles-stats h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #55ffff;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.turtles-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.turtle-summary-card {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1rem;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.turtle-summary-card:hover {
|
||||
background: #4b4b4b;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.turtle-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #4b4b4b;
|
||||
}
|
||||
|
||||
.turtle-id {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.turtle-total {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #55ffff;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.turtle-top-blocks {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mini-block-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mini-emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mini-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #55ff55;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.time-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-filter button {
|
||||
flex: 1;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.blocks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.turtles-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.leaderboard-item {
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rank {
|
||||
font-size: 1.25rem;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
}
|
||||
218
client/src/components/StatsPanel.jsx
Normal file
218
client/src/components/StatsPanel.jsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './StatsPanel.css';
|
||||
|
||||
const StatsPanel = ({ selectedTurtle, apiUrl }) => {
|
||||
const [miningStats, setMiningStats] = useState([]);
|
||||
const [topMiners, setTopMiners] = useState([]);
|
||||
const [timeFilter, setTimeFilter] = useState(7); // days
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMiningStats();
|
||||
loadTopMiners();
|
||||
|
||||
// Refresh stats every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMiningStats();
|
||||
loadTopMiners();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedTurtle, timeFilter]);
|
||||
|
||||
const loadMiningStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = selectedTurtle
|
||||
? `${apiUrl}/api/stats/mining/${selectedTurtle.turtleID}?days=${timeFilter}`
|
||||
: `${apiUrl}/api/stats/mining?days=${timeFilter}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Response is either a single object (per turtle) or an array (all turtles)
|
||||
setMiningStats(Array.isArray(data) ? data : [data]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load mining stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTopMiners = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/stats/top-miners?limit=10`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Response is now a flat array of {turtleId, totalBlocks, uniqueTypes}
|
||||
setTopMiners(Array.isArray(data) ? data : (data.topMiners || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load top miners:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalBlocks = (stats) => {
|
||||
if (!stats || !stats.blocks) return 0;
|
||||
return stats.blocks.reduce((sum, block) => sum + block.count, 0);
|
||||
};
|
||||
|
||||
const getBlockEmoji = (blockType) => {
|
||||
if (!blockType) return '📦';
|
||||
const lower = blockType.toLowerCase();
|
||||
if (lower.includes('diamond')) return '💎';
|
||||
if (lower.includes('gold')) return '🟡';
|
||||
if (lower.includes('iron')) return '⚪';
|
||||
if (lower.includes('coal')) return '⚫';
|
||||
if (lower.includes('emerald')) return '🟢';
|
||||
if (lower.includes('redstone')) return '🔴';
|
||||
if (lower.includes('lapis')) return '🔵';
|
||||
if (lower.includes('copper')) return '🟠';
|
||||
if (lower.includes('stone')) return '🗿';
|
||||
if (lower.includes('dirt')) return '🟤';
|
||||
if (lower.includes('cobble')) return '🪨';
|
||||
if (lower.includes('wood') || lower.includes('log')) return '🪵';
|
||||
return '📦';
|
||||
};
|
||||
|
||||
const formatBlockType = (blockType) => {
|
||||
if (!blockType) return 'Unknown';
|
||||
return blockType.replace('minecraft:', '').replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stats-panel">
|
||||
<div className="stats-header">
|
||||
<h2>📊 Mining Statistics</h2>
|
||||
<div className="time-filter">
|
||||
<button
|
||||
className={timeFilter === 1 ? 'active' : ''}
|
||||
onClick={() => setTimeFilter(1)}
|
||||
>
|
||||
24h
|
||||
</button>
|
||||
<button
|
||||
className={timeFilter === 7 ? 'active' : ''}
|
||||
onClick={() => setTimeFilter(7)}
|
||||
>
|
||||
7d
|
||||
</button>
|
||||
<button
|
||||
className={timeFilter === 30 ? 'active' : ''}
|
||||
onClick={() => setTimeFilter(30)}
|
||||
>
|
||||
30d
|
||||
</button>
|
||||
<button
|
||||
className={timeFilter === 0 ? 'active' : ''}
|
||||
onClick={() => setTimeFilter(0)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="loading">Loading statistics...</div>}
|
||||
|
||||
{/* Individual Turtle Stats */}
|
||||
{selectedTurtle && miningStats.length > 0 && (
|
||||
<div className="turtle-stats">
|
||||
<h3>🐢 {selectedTurtle.name || `Turtle ${selectedTurtle.turtleID}`}</h3>
|
||||
{miningStats.map((stat) => (
|
||||
<div key={stat.turtleId} className="stat-section">
|
||||
<div className="total-mined">
|
||||
<div className="stat-value">{getTotalBlocks(stat)}</div>
|
||||
<div className="stat-label">Total Blocks Mined</div>
|
||||
</div>
|
||||
|
||||
{stat.blocks && stat.blocks.length > 0 && (
|
||||
<div className="blocks-grid">
|
||||
{stat.blocks.map((block, idx) => (
|
||||
<div key={idx} className="block-stat">
|
||||
<div className="block-emoji">{getBlockEmoji(block.blockType)}</div>
|
||||
<div className="block-info">
|
||||
<div className="block-name">{formatBlockType(block.blockType)}</div>
|
||||
<div className="block-count">{block.count} blocks</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Miners Leaderboard */}
|
||||
<div className="leaderboard">
|
||||
<h3>🏆 Top Miners</h3>
|
||||
<div className="leaderboard-list">
|
||||
{topMiners.length === 0 && (
|
||||
<div className="no-data">No mining data yet. Start mining!</div>
|
||||
)}
|
||||
{topMiners.map((miner, idx) => (
|
||||
<div key={miner.turtleId} className={`leaderboard-item rank-${idx + 1}`}>
|
||||
<div className="rank">
|
||||
{idx === 0 && '🥇'}
|
||||
{idx === 1 && '🥈'}
|
||||
{idx === 2 && '🥉'}
|
||||
{idx > 2 && `#${idx + 1}`}
|
||||
</div>
|
||||
<div className="miner-info">
|
||||
<div className="miner-name">Turtle {miner.turtleId}</div>
|
||||
<div className="miner-stats">
|
||||
{miner.totalBlocks} blocks • {miner.uniqueTypes} types
|
||||
</div>
|
||||
</div>
|
||||
<div className="miner-score">{miner.totalBlocks}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Turtles Summary */}
|
||||
{!selectedTurtle && miningStats.length > 0 && (
|
||||
<div className="all-turtles-stats">
|
||||
<h3>📈 All Turtles Overview</h3>
|
||||
<div className="turtles-summary-grid">
|
||||
{miningStats.map((stat) => (
|
||||
<div key={stat.turtleId} className="turtle-summary-card">
|
||||
<div className="turtle-summary-header">
|
||||
<span className="turtle-id">🐢 Turtle {stat.turtleId}</span>
|
||||
<span className="turtle-total">{getTotalBlocks(stat)}</span>
|
||||
</div>
|
||||
{stat.blocks && stat.blocks.length > 0 && (
|
||||
<div className="turtle-top-blocks">
|
||||
{stat.blocks.slice(0, 3).map((block, idx) => (
|
||||
<div key={idx} className="mini-block-stat">
|
||||
<span className="mini-emoji">{getBlockEmoji(block.blockType)}</span>
|
||||
<span className="mini-count">{block.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && miningStats.length === 0 && topMiners.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">⛏️</div>
|
||||
<div className="empty-title">No Mining Data Yet</div>
|
||||
<div className="empty-text">
|
||||
Start mining with your turtles to see statistics here!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsPanel;
|
||||
445
client/src/components/TaskPanel.css
Normal file
445
client/src/components/TaskPanel.css
Normal file
@@ -0,0 +1,445 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Task Panel
|
||||
============================================ */
|
||||
|
||||
.task-panel {
|
||||
padding: 1.5rem;
|
||||
background: #2c2c2c;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.task-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-task-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.create-task-btn:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #2d6b1a33;
|
||||
color: #55ff55;
|
||||
border-color: #55ff55;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #6b1a1a33;
|
||||
color: #ff5555;
|
||||
border-color: #ff5555;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Create Task Form */
|
||||
.create-task-form {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: slideIn 0.3s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.create-task-form h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #ffaa00;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.create-task-form form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-task-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.create-task-form select,
|
||||
.create-task-form input[type="text"],
|
||||
.create-task-form input[type="number"] {
|
||||
padding: 0.75rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.create-task-form select:focus,
|
||||
.create-task-form input:focus {
|
||||
outline: none;
|
||||
border-color: #55ffff;
|
||||
}
|
||||
|
||||
.create-task-form input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #4a8c2a;
|
||||
}
|
||||
|
||||
.priority-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coordinates-section h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #a0a0a0;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.coordinates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.coord-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coord-group span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #a0a0a0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.coord-group input {
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #4b4b4b;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #4a8c2a;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #5a9c3a;
|
||||
box-shadow: inset 0 2px 0 #7bc05c, inset 0 -2px 0 #3d7b2a;
|
||||
}
|
||||
|
||||
/* Task Filters */
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-filters button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.task-filters button.active {
|
||||
background: #4a8c2a;
|
||||
color: white;
|
||||
box-shadow: inset 0 2px 0 #6ab04c, inset 0 -2px 0 #2d6b1a;
|
||||
}
|
||||
|
||||
/* Tasks List */
|
||||
.tasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.1s;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
background: #4b4b4b;
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.task-type {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.task-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.priority-badge,
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.task-assignment {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.task-parameters {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.task-result {
|
||||
font-size: 0.875rem;
|
||||
color: #55ff55;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #2d6b1a20;
|
||||
border: 2px solid #55ff55;
|
||||
border-left: 4px solid #55ff55;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.task-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6b6b6b;
|
||||
border: 2px solid #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 -2px 0 #444, inset 0 2px 0 #888;
|
||||
}
|
||||
|
||||
.task-actions button:hover {
|
||||
background: #7b7b7b;
|
||||
box-shadow: inset 0 -2px 0 #555, inset 0 2px 0 #999;
|
||||
}
|
||||
|
||||
.task-timestamp {
|
||||
font-size: 0.75rem;
|
||||
color: #7b7b7b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.task-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-task-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.coordinates-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.task-filters button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-actions button {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.task-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-actions button {
|
||||
min-width: 100px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
429
client/src/components/TaskPanel.jsx
Normal file
429
client/src/components/TaskPanel.jsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './TaskPanel.css';
|
||||
|
||||
const TaskPanel = ({ turtles, apiUrl }) => {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [filter, setFilter] = useState('all'); // all, pending, in_progress, completed, failed
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
// Form state
|
||||
const [taskType, setTaskType] = useState('mine_area');
|
||||
const [priority, setPriority] = useState(5);
|
||||
const [assignedTurtleId, setAssignedTurtleId] = useState('');
|
||||
const [parameters, setParameters] = useState({
|
||||
x1: '', y1: '', z1: '',
|
||||
x2: '', y2: '', z2: ''
|
||||
});
|
||||
|
||||
const taskTypes = [
|
||||
{ value: 'mine_area', label: '⛏️ Mine Area', icon: '⛏️' },
|
||||
{ value: 'explore', label: '🔍 Explore', icon: '🔍' },
|
||||
{ value: 'gather', label: '📦 Gather Resources', icon: '📦' },
|
||||
{ value: 'build', label: '🏗️ Build Structure', icon: '🏗️' },
|
||||
{ value: 'transport', label: '🚚 Transport Items', icon: '🚚' },
|
||||
{ value: 'clear_area', label: '🧹 Clear Area', icon: '🧹' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
|
||||
// Refresh tasks every 10 seconds
|
||||
const interval = setInterval(loadTasks, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [filter]);
|
||||
|
||||
const loadTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = filter === 'all'
|
||||
? `${apiUrl}/api/tasks`
|
||||
: `${apiUrl}/api/tasks?status=${filter}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Server returns flat array of formatted tasks
|
||||
setTasks(Array.isArray(data) ? data : (data.tasks || []));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createTask = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate coordinates
|
||||
const coords = ['x1', 'y1', 'z1', 'x2', 'y2', 'z2'];
|
||||
const hasCoords = coords.some(key => parameters[key] !== '');
|
||||
|
||||
if (hasCoords && !coords.every(key => parameters[key] !== '')) {
|
||||
showMessage('Please fill all coordinates or leave them empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
taskType,
|
||||
priority: parseInt(priority),
|
||||
assignedTurtleId: assignedTurtleId ? parseInt(assignedTurtleId) : null,
|
||||
parameters: hasCoords ? {
|
||||
x1: parseInt(parameters.x1),
|
||||
y1: parseInt(parameters.y1),
|
||||
z1: parseInt(parameters.z1),
|
||||
x2: parseInt(parameters.x2),
|
||||
y2: parseInt(parameters.y2),
|
||||
z2: parseInt(parameters.z2)
|
||||
} : {}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Task created successfully!', 'success');
|
||||
setShowCreateForm(false);
|
||||
resetForm();
|
||||
loadTasks();
|
||||
} else {
|
||||
showMessage('Failed to create task', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error creating task', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updateTaskStatus = async (taskId, status, result = null) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, result })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Task updated', 'success');
|
||||
loadTasks();
|
||||
} else {
|
||||
showMessage('Failed to update task', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error updating task', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
if (!confirm('Are you sure you want to delete this task?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Task deleted', 'success');
|
||||
loadTasks();
|
||||
} else {
|
||||
showMessage('Failed to delete task', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error deleting task', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setTaskType('mine_area');
|
||||
setPriority(5);
|
||||
setAssignedTurtleId('');
|
||||
setParameters({ x1: '', y1: '', z1: '', x2: '', y2: '', z2: '' });
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const getTaskIcon = (type) => {
|
||||
const taskType = taskTypes.find(t => t.value === type);
|
||||
return taskType ? taskType.icon : '📋';
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return '#a0a0a0';
|
||||
case 'in_progress': return '#345ec3';
|
||||
case 'completed': return '#4a8c2a';
|
||||
case 'failed': return '#aa0000';
|
||||
default: return '#a0a0a0';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
if (priority >= 8) return { label: 'Critical', color: '#ff5555' };
|
||||
if (priority >= 6) return { label: 'High', color: '#ffaa00' };
|
||||
if (priority >= 4) return { label: 'Medium', color: '#345ec3' };
|
||||
return { label: 'Low', color: '#a0a0a0' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="task-panel">
|
||||
<div className="task-header">
|
||||
<h2>📋 Task Queue</h2>
|
||||
<button
|
||||
className="create-task-btn"
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
>
|
||||
{showCreateForm ? '✕ Cancel' : '➕ New Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Task Form */}
|
||||
{showCreateForm && (
|
||||
<div className="create-task-form">
|
||||
<h3>Create New Task</h3>
|
||||
<form onSubmit={createTask}>
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Task Type:
|
||||
<select value={taskType} onChange={(e) => setTaskType(e.target.value)}>
|
||||
{taskTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Priority (1-10):
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<span className="priority-value" style={{ color: getPriorityLabel(priority).color }}>
|
||||
{priority} - {getPriorityLabel(priority).label}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Assign to Turtle (optional):
|
||||
<select value={assignedTurtleId} onChange={(e) => setAssignedTurtleId(e.target.value)}>
|
||||
<option value="">Any available turtle</option>
|
||||
{turtles.map(turtle => (
|
||||
<option key={turtle.turtleID} value={turtle.turtleID}>
|
||||
🐢 {turtle.name || `Turtle ${turtle.turtleID}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="coordinates-section">
|
||||
<h4>Coordinates (optional):</h4>
|
||||
<div className="coordinates-grid">
|
||||
<div className="coord-group">
|
||||
<span>Start:</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="X1"
|
||||
value={parameters.x1}
|
||||
onChange={(e) => setParameters({...parameters, x1: e.target.value})}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Y1"
|
||||
value={parameters.y1}
|
||||
onChange={(e) => setParameters({...parameters, y1: e.target.value})}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Z1"
|
||||
value={parameters.z1}
|
||||
onChange={(e) => setParameters({...parameters, z1: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="coord-group">
|
||||
<span>End:</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="X2"
|
||||
value={parameters.x2}
|
||||
onChange={(e) => setParameters({...parameters, x2: e.target.value})}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Y2"
|
||||
value={parameters.y2}
|
||||
onChange={(e) => setParameters({...parameters, y2: e.target.value})}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Z2"
|
||||
value={parameters.z2}
|
||||
onChange={(e) => setParameters({...parameters, z2: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-btn">
|
||||
Create Task
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="task-filters">
|
||||
<button
|
||||
className={filter === 'all' ? 'active' : ''}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All ({tasks.length})
|
||||
</button>
|
||||
<button
|
||||
className={filter === 'pending' ? 'active' : ''}
|
||||
onClick={() => setFilter('pending')}
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
<button
|
||||
className={filter === 'in_progress' ? 'active' : ''}
|
||||
onClick={() => setFilter('in_progress')}
|
||||
>
|
||||
In Progress
|
||||
</button>
|
||||
<button
|
||||
className={filter === 'completed' ? 'active' : ''}
|
||||
onClick={() => setFilter('completed')}
|
||||
>
|
||||
Completed
|
||||
</button>
|
||||
<button
|
||||
className={filter === 'failed' ? 'active' : ''}
|
||||
onClick={() => setFilter('failed')}
|
||||
>
|
||||
Failed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tasks List */}
|
||||
<div className="tasks-list">
|
||||
{loading && <div className="loading">Loading tasks...</div>}
|
||||
|
||||
{!loading && tasks.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📋</div>
|
||||
<div className="empty-title">No Tasks Found</div>
|
||||
<div className="empty-text">
|
||||
{filter === 'all'
|
||||
? 'Create a task to get started!'
|
||||
: `No ${filter.replace('_', ' ')} tasks`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.map(task => {
|
||||
const priorityInfo = getPriorityLabel(task.priority);
|
||||
const turtle = turtles.find(t => t.turtleID === task.assignedTurtleId);
|
||||
|
||||
return (
|
||||
<div key={task.taskId} className="task-card">
|
||||
<div className="task-card-header">
|
||||
<div className="task-title">
|
||||
<span className="task-icon">{getTaskIcon(task.taskType)}</span>
|
||||
<span className="task-type">
|
||||
{taskTypes.find(t => t.value === task.taskType)?.label || task.taskType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="task-badges">
|
||||
<span
|
||||
className="priority-badge"
|
||||
style={{ backgroundColor: priorityInfo.color }}
|
||||
>
|
||||
{priorityInfo.label}
|
||||
</span>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(task.status) }}
|
||||
>
|
||||
{task.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.assignedTurtleId && (
|
||||
<div className="task-assignment">
|
||||
🐢 {turtle?.name || `Turtle ${task.assignedTurtleId}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.parameters && Object.keys(task.parameters).length > 0 && (
|
||||
<div className="task-parameters">
|
||||
<strong>Area:</strong> ({task.parameters.x1}, {task.parameters.y1}, {task.parameters.z1}) →
|
||||
({task.parameters.x2}, {task.parameters.y2}, {task.parameters.z2})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.result && (
|
||||
<div className="task-result">
|
||||
<strong>Result:</strong> {task.result}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="task-actions">
|
||||
{task.status === 'pending' && (
|
||||
<>
|
||||
<button onClick={() => updateTaskStatus(task.taskId, 'in_progress')}>
|
||||
▶️ Start
|
||||
</button>
|
||||
<button onClick={() => deleteTask(task.taskId)}>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{task.status === 'in_progress' && (
|
||||
<>
|
||||
<button onClick={() => updateTaskStatus(task.taskId, 'completed', 'Task completed')}>
|
||||
✅ Complete
|
||||
</button>
|
||||
<button onClick={() => updateTaskStatus(task.taskId, 'failed', 'Task failed')}>
|
||||
❌ Fail
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(task.status === 'completed' || task.status === 'failed') && (
|
||||
<button onClick={() => deleteTask(task.taskId)}>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="task-timestamp">
|
||||
Created: {new Date(task.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPanel;
|
||||
217
client/src/components/VoiceControl.css
Normal file
217
client/src/components/VoiceControl.css
Normal file
@@ -0,0 +1,217 @@
|
||||
/* ============================================
|
||||
Minecraft-Themed Voice Control
|
||||
============================================ */
|
||||
|
||||
.voice-control {
|
||||
padding: 1rem;
|
||||
background: #3b3b3b;
|
||||
border: 2px solid #1a1a1a;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
box-shadow: inset 0 -2px 0 #2a2a2a, inset 0 2px 0 #555;
|
||||
}
|
||||
|
||||
.voice-control.unsupported {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.voice-control.unsupported small {
|
||||
color: #a0a0a0;
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.voice-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #ffff55;
|
||||
margin: 0;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.selected-turtle {
|
||||
font-size: 0.875rem;
|
||||
color: #55ff55;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
font-size: 0.875rem;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
background: #345ec3;
|
||||
border: 3px solid #1a1a1a;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.1s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
box-shadow: inset 0 2px 0 #5577dd, inset 0 -3px 0 #223399;
|
||||
}
|
||||
|
||||
.voice-button:hover:not(:disabled) {
|
||||
background: #4a6ed3;
|
||||
box-shadow: inset 0 2px 0 #6688ee, inset 0 -3px 0 #3344aa;
|
||||
}
|
||||
|
||||
.voice-button:disabled {
|
||||
background: #4b4b4b;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
box-shadow: inset 0 -2px 0 #333, inset 0 2px 0 #666;
|
||||
}
|
||||
|
||||
.voice-button.listening {
|
||||
background: #aa0000;
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000;
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 20px rgba(255, 85, 85, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 2px 0 #dd3333, inset 0 -3px 0 #770000, 0 0 40px rgba(255, 85, 85, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-button .icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.6);
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-feedback {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.transcript strong {
|
||||
color: #55ffff;
|
||||
}
|
||||
|
||||
.last-command {
|
||||
color: #55ff55;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.last-command strong {
|
||||
color: #55ff55;
|
||||
}
|
||||
|
||||
.voice-commands-help {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.voice-commands-help details {
|
||||
background: #2c2c2c;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.voice-commands-help summary {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #a0a0a0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.voice-commands-help summary:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.commands-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.command-category h4 {
|
||||
font-size: 0.75rem;
|
||||
color: #ffaa00;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-shadow: 1px 1px 0 #1a1a1a;
|
||||
}
|
||||
|
||||
.command-category ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.command-category li {
|
||||
font-size: 0.75rem;
|
||||
color: #a0a0a0;
|
||||
padding: 0.25rem 0;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.command-category li::before {
|
||||
content: "▸ ";
|
||||
color: #55ff55;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.voice-button {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.commands-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
247
client/src/components/VoiceControl.jsx
Normal file
247
client/src/components/VoiceControl.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTurtleStore } from '../store/turtleStore';
|
||||
import './VoiceControl.css';
|
||||
|
||||
export default function VoiceControl() {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [lastCommand, setLastCommand] = useState('');
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [recognition, setRecognition] = useState(null);
|
||||
|
||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||
const refuelTurtle = useTurtleStore((state) => state.refuelTurtle);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if speech recognition is supported
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
if (SpeechRecognition) {
|
||||
setSupported(true);
|
||||
const recognitionInstance = new SpeechRecognition();
|
||||
recognitionInstance.continuous = false;
|
||||
recognitionInstance.interimResults = false;
|
||||
recognitionInstance.lang = 'en-US';
|
||||
|
||||
recognitionInstance.onresult = (event) => {
|
||||
const speechResult = event.results[0][0].transcript.toLowerCase();
|
||||
setTranscript(speechResult);
|
||||
processVoiceCommand(speechResult);
|
||||
};
|
||||
|
||||
recognitionInstance.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionInstance.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
setRecognition(recognitionInstance);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const processVoiceCommand = (command) => {
|
||||
if (!selectedTurtle) {
|
||||
speak('Please select a turtle first');
|
||||
return;
|
||||
}
|
||||
|
||||
const turtleId = selectedTurtle.turtleID;
|
||||
let actionName = null;
|
||||
|
||||
// Movement commands (server-side)
|
||||
if (command.includes('forward') || command.includes('go ahead')) {
|
||||
moveForward(turtleId);
|
||||
actionName = 'forward';
|
||||
} else if (command.includes('back') || command.includes('backward')) {
|
||||
moveBack(turtleId);
|
||||
actionName = 'back';
|
||||
} else if (command.includes('turn left') || command.includes('left')) {
|
||||
turnLeft(turtleId);
|
||||
actionName = 'turn left';
|
||||
} else if (command.includes('turn right') || command.includes('right')) {
|
||||
turnRight(turtleId);
|
||||
actionName = 'turn right';
|
||||
} else if (command.includes('go up') || command.includes('move up')) {
|
||||
moveUp(turtleId);
|
||||
actionName = 'up';
|
||||
} else if (command.includes('go down') || command.includes('move down')) {
|
||||
moveDown(turtleId);
|
||||
actionName = 'down';
|
||||
} else if (command.includes('dig')) {
|
||||
if (command.includes('up')) {
|
||||
digBlockUp(turtleId);
|
||||
actionName = 'dig up';
|
||||
} else if (command.includes('down')) {
|
||||
digBlockDown(turtleId);
|
||||
actionName = 'dig down';
|
||||
} else {
|
||||
digBlock(turtleId);
|
||||
actionName = 'dig';
|
||||
}
|
||||
} else if (command.includes('place') || command.includes('build')) {
|
||||
placeBlock(turtleId);
|
||||
actionName = 'place';
|
||||
// State machine commands (server-side)
|
||||
} else if (command.includes('explore') || command.includes('start exploring')) {
|
||||
setTurtleState(turtleId, 'exploring');
|
||||
actionName = 'explore';
|
||||
} else if (command.includes('mine') || command.includes('start mining')) {
|
||||
setTurtleState(turtleId, 'mining');
|
||||
actionName = 'mine';
|
||||
} else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) {
|
||||
setTurtleState(turtleId, 'goHome');
|
||||
actionName = 'go home';
|
||||
} else if (command.includes('stop')) {
|
||||
setTurtleState(turtleId, 'idle');
|
||||
actionName = 'idle';
|
||||
} else if (command.includes('refuel')) {
|
||||
setTurtleState(turtleId, 'refueling');
|
||||
actionName = 'refuel';
|
||||
} else if (command.includes('farm')) {
|
||||
setTurtleState(turtleId, 'farming');
|
||||
actionName = 'farm';
|
||||
} else if (command.includes('dump')) {
|
||||
setTurtleState(turtleId, 'dumpInventory');
|
||||
actionName = 'dump inventory';
|
||||
}
|
||||
|
||||
if (actionName) {
|
||||
setLastCommand(actionName);
|
||||
speak(`Sending ${actionName} command`);
|
||||
} else {
|
||||
speak('Command not recognized');
|
||||
}
|
||||
};
|
||||
|
||||
const speak = (text) => {
|
||||
if ('speechSynthesis' in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.rate = 1.1;
|
||||
utterance.pitch = 1.0;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = () => {
|
||||
if (recognition && !isListening) {
|
||||
setTranscript('');
|
||||
setIsListening(true);
|
||||
recognition.start();
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (recognition && isListening) {
|
||||
recognition.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="voice-control unsupported">
|
||||
<p>⚠️ Voice commands not supported in this browser</p>
|
||||
<small>Try Chrome, Edge, or Safari</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-control">
|
||||
<div className="voice-header">
|
||||
<h3>🎤 Voice Control</h3>
|
||||
{selectedTurtle ? (
|
||||
<span className="selected-turtle">Turtle {selectedTurtle.turtleID}</span>
|
||||
) : (
|
||||
<span className="no-selection">Select a turtle first</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`voice-button ${isListening ? 'listening' : ''}`}
|
||||
onClick={isListening ? stopListening : startListening}
|
||||
disabled={!selectedTurtle}
|
||||
>
|
||||
{isListening ? (
|
||||
<>
|
||||
<span className="pulse-ring"></span>
|
||||
<span className="icon">🎙️</span>
|
||||
<span>Listening...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="icon">🎤</span>
|
||||
<span>Press to Speak</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{transcript && (
|
||||
<div className="voice-feedback">
|
||||
<div className="transcript">
|
||||
<strong>You said:</strong> "{transcript}"
|
||||
</div>
|
||||
{lastCommand && (
|
||||
<div className="last-command">
|
||||
<strong>Command:</strong> {lastCommand}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voice-commands-help">
|
||||
<details>
|
||||
<summary>Available Commands</summary>
|
||||
<div className="commands-grid">
|
||||
<div className="command-category">
|
||||
<h4>Movement</h4>
|
||||
<ul>
|
||||
<li>"forward" / "go ahead"</li>
|
||||
<li>"back" / "backward"</li>
|
||||
<li>"turn left" / "left"</li>
|
||||
<li>"turn right" / "right"</li>
|
||||
<li>"go up" / "move up"</li>
|
||||
<li>"go down" / "move down"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="command-category">
|
||||
<h4>Actions</h4>
|
||||
<ul>
|
||||
<li>"dig"</li>
|
||||
<li>"dig up"</li>
|
||||
<li>"dig down"</li>
|
||||
<li>"place" / "build"</li>
|
||||
<li>"refuel"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="command-category">
|
||||
<h4>Autonomous</h4>
|
||||
<ul>
|
||||
<li>"explore" / "start exploring"</li>
|
||||
<li>"mine" / "start mining"</li>
|
||||
<li>"return home" / "go home"</li>
|
||||
<li>"stop"</li>
|
||||
<li>"set home"</li>
|
||||
<li>"status" / "report"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -5,13 +7,12 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0a0e1a;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: unset;
|
||||
image-rendering: pixelated;
|
||||
background: #2c2c2c;
|
||||
color: #d4d4d4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -21,5 +22,5 @@ body {
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const WS_URL = 'ws://localhost:3002';
|
||||
const API_URL = 'http://localhost:3001';
|
||||
// Use environment variables or fallback to relative URLs for proxy
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
|
||||
const API_URL = import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}/api`;
|
||||
|
||||
console.log('🔌 WebSocket URL:', WS_URL);
|
||||
console.log('📡 API URL:', API_URL);
|
||||
|
||||
export const useTurtleStore = create((set, get) => ({
|
||||
// State
|
||||
turtles: {},
|
||||
players: {},
|
||||
worldBlocks: [],
|
||||
chunkAnalyses: {},
|
||||
selectedTurtleId: null,
|
||||
connected: false,
|
||||
ws: null,
|
||||
@@ -28,7 +35,17 @@ export const useTurtleStore = create((set, get) => ({
|
||||
data.turtles.forEach(turtle => {
|
||||
turtlesMap[turtle.turtleID] = turtle;
|
||||
});
|
||||
set({ turtles: turtlesMap });
|
||||
const playersMap = {};
|
||||
if (data.players && Array.isArray(data.players)) {
|
||||
data.players.forEach(player => {
|
||||
playersMap[player.playerID] = player;
|
||||
});
|
||||
}
|
||||
set({
|
||||
turtles: turtlesMap,
|
||||
players: playersMap,
|
||||
worldBlocks: data.blocks || []
|
||||
});
|
||||
} else if (data.type === 'turtle_update') {
|
||||
set(state => ({
|
||||
turtles: {
|
||||
@@ -36,12 +53,105 @@ export const useTurtleStore = create((set, get) => ({
|
||||
[data.turtle.turtleID]: data.turtle
|
||||
}
|
||||
}));
|
||||
} else if (data.type === 'turtle_disconnected') {
|
||||
|
||||
// Update world blocks from turtle surroundings
|
||||
if (data.turtle.surroundings && data.turtle.position && data.turtle.facing !== undefined) {
|
||||
get().updateBlocksFromSurroundings(data.turtle);
|
||||
}
|
||||
} else if (data.type === 'turtle_removed' || data.type === 'turtle_disconnected') {
|
||||
console.log(`🔌 Turtle ${data.turtleID} removed`);
|
||||
set(state => {
|
||||
const newTurtles = { ...state.turtles };
|
||||
delete newTurtles[data.turtleID];
|
||||
return { turtles: newTurtles };
|
||||
|
||||
// If the removed turtle was selected, deselect it
|
||||
const newSelectedId = state.selectedTurtleId === data.turtleID
|
||||
? null
|
||||
: state.selectedTurtleId;
|
||||
|
||||
return {
|
||||
turtles: newTurtles,
|
||||
selectedTurtleId: newSelectedId
|
||||
};
|
||||
});
|
||||
} else if (data.type === 'player_update') {
|
||||
set(state => ({
|
||||
players: {
|
||||
...state.players,
|
||||
[data.playerID]: {
|
||||
playerID: data.playerID,
|
||||
position: data.position,
|
||||
label: data.label || null,
|
||||
timestamp: data.timestamp || Date.now()
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else if (data.type === 'block_discovered') {
|
||||
if (data.block) {
|
||||
set(state => {
|
||||
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
const key = `${data.block.x},${data.block.y},${data.block.z}`;
|
||||
blockMap.set(key, data.block);
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'blocks_discovered') {
|
||||
if (data.blocks && Array.isArray(data.blocks)) {
|
||||
set(state => {
|
||||
const blockMap = new Map(state.worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
data.blocks.forEach(block => {
|
||||
const key = `${block.x},${block.y},${block.z}`;
|
||||
blockMap.set(key, block);
|
||||
});
|
||||
return { worldBlocks: Array.from(blockMap.values()) };
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'turtle_event') {
|
||||
// Handle live turtle events (inventory, peripherals)
|
||||
const turtleId = data.turtleID;
|
||||
if (turtleId && data.eventType === 'inventory_update' && data.inventory) {
|
||||
set(state => {
|
||||
const turtle = state.turtles[turtleId];
|
||||
if (!turtle) return state;
|
||||
return {
|
||||
turtles: {
|
||||
...state.turtles,
|
||||
[turtleId]: { ...turtle, inventory: data.inventory }
|
||||
}
|
||||
};
|
||||
});
|
||||
} else if (turtleId && (data.eventType === 'peripheral_attached' || data.eventType === 'peripheral_detached')) {
|
||||
set(state => {
|
||||
const turtle = state.turtles[turtleId];
|
||||
if (!turtle) return state;
|
||||
return {
|
||||
turtles: {
|
||||
...state.turtles,
|
||||
[turtleId]: { ...turtle, peripherals: data.peripherals || turtle.peripherals }
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'chunk_analysis') {
|
||||
// Store chunk analysis results
|
||||
if (data.chunk) {
|
||||
set(state => ({
|
||||
chunkAnalyses: {
|
||||
...state.chunkAnalyses,
|
||||
[`${data.chunk.x},${data.chunk.z}`]: data.chunk
|
||||
}
|
||||
}));
|
||||
}
|
||||
} else if (data.type === 'block_deleted') {
|
||||
// Remove a block from the world map (turtle moved into it)
|
||||
if (data.x !== undefined && data.y !== undefined && data.z !== undefined) {
|
||||
set(state => {
|
||||
const key = `${data.x},${data.y},${data.z}`;
|
||||
return {
|
||||
worldBlocks: state.worldBlocks.filter(b => `${b.x},${b.y},${b.z}` !== key)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
@@ -67,28 +177,332 @@ export const useTurtleStore = create((set, get) => ({
|
||||
selectTurtle: (turtleId) => {
|
||||
set({ selectedTurtleId: turtleId });
|
||||
},
|
||||
|
||||
sendCommand: async (turtleId, command, param = null) => {
|
||||
const { ws } = get();
|
||||
|
||||
updateBlocksFromSurroundings: (turtle) => {
|
||||
const { surroundings, position, facing } = turtle;
|
||||
if (!surroundings || !position || facing === undefined) return;
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
turtleID: turtleId,
|
||||
command,
|
||||
param
|
||||
}));
|
||||
} else {
|
||||
// Fallback to REST API
|
||||
try {
|
||||
await fetch(`${API_URL}/api/turtle/${turtleId}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command, param })
|
||||
const newBlocks = [];
|
||||
const blockMap = new Map(get().worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||
|
||||
// Calculate block positions based on turtle position and facing
|
||||
const directions = {
|
||||
forward: { x: 0, y: 0, z: 0 },
|
||||
up: { x: 0, y: 1, z: 0 },
|
||||
down: { x: 0, y: -1, z: 0 }
|
||||
};
|
||||
|
||||
// Facing: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X)
|
||||
if (surroundings.forward) {
|
||||
if (facing === 0) directions.forward = { x: 0, y: 0, z: -1 };
|
||||
else if (facing === 1) directions.forward = { x: 1, y: 0, z: 0 };
|
||||
else if (facing === 2) directions.forward = { x: 0, y: 0, z: 1 };
|
||||
else if (facing === 3) directions.forward = { x: -1, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
Object.entries(surroundings).forEach(([direction, blockData]) => {
|
||||
const offset = directions[direction];
|
||||
if (!offset) return;
|
||||
|
||||
const blockPos = {
|
||||
x: position.x + offset.x,
|
||||
y: position.y + offset.y,
|
||||
z: position.z + offset.z
|
||||
};
|
||||
|
||||
const key = `${blockPos.x},${blockPos.y},${blockPos.z}`;
|
||||
if (!blockMap.has(key)) {
|
||||
blockMap.set(key, {
|
||||
...blockPos,
|
||||
...blockData,
|
||||
discoveredBy: turtle.turtleID,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error);
|
||||
}
|
||||
});
|
||||
|
||||
set({ worldBlocks: Array.from(blockMap.values()) });
|
||||
},
|
||||
|
||||
// Set turtle state machine state via REST API
|
||||
setTurtleState: async (turtleId, stateName, stateData = {}) => {
|
||||
console.log(`🔄 Setting state for turtle ${turtleId}: ${stateName}`, stateData);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: stateName, data: stateData })
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log(' ✅ State set:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error setting state:', error);
|
||||
// Fallback: send via WebSocket as a command
|
||||
const { ws } = get();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
turtleID: turtleId,
|
||||
command: 'set_state',
|
||||
param: { state: stateName, data: stateData }
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Execute arbitrary Lua code on a turtle
|
||||
execOnTurtle: async (turtleId, code) => {
|
||||
console.log(`💻 Exec on turtle ${turtleId}:`, code);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error executing:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Server-Side Movement & Actions ==========
|
||||
// All movement/actions are routed through the server's Turtle.js exec() pipeline
|
||||
|
||||
_turtleAction: async (turtleId, action, body = {}) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error ${action}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
moveForward: async (turtleId) => get()._turtleAction(turtleId, 'forward'),
|
||||
moveBack: async (turtleId) => get()._turtleAction(turtleId, 'back'),
|
||||
moveUp: async (turtleId) => get()._turtleAction(turtleId, 'up'),
|
||||
moveDown: async (turtleId) => get()._turtleAction(turtleId, 'down'),
|
||||
turnLeft: async (turtleId) => get()._turtleAction(turtleId, 'turnLeft'),
|
||||
turnRight: async (turtleId) => get()._turtleAction(turtleId, 'turnRight'),
|
||||
digBlock: async (turtleId) => get()._turtleAction(turtleId, 'dig'),
|
||||
digBlockUp: async (turtleId) => get()._turtleAction(turtleId, 'digUp'),
|
||||
digBlockDown: async (turtleId) => get()._turtleAction(turtleId, 'digDown'),
|
||||
placeBlock: async (turtleId, text) => get()._turtleAction(turtleId, 'place', { text }),
|
||||
placeBlockUp: async (turtleId, text) => get()._turtleAction(turtleId, 'placeUp', { text }),
|
||||
placeBlockDown: async (turtleId, text) => get()._turtleAction(turtleId, 'placeDown', { text }),
|
||||
refuelTurtle: async (turtleId, count) => get()._turtleAction(turtleId, 'refuel-action', { count }),
|
||||
|
||||
// Fetch chunk analyses from server
|
||||
fetchChunkAnalyses: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/chunks`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const analyses = {};
|
||||
data.forEach(c => { analyses[`${c.x},${c.z}`] = c; });
|
||||
set({ chunkAnalyses: analyses });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error fetching chunks:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Request chunk analysis for a specific chunk
|
||||
analyzeChunk: async (chunkX, chunkZ) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/chunks/${chunkX}/${chunkZ}/analyze`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set(state => ({
|
||||
chunkAnalyses: {
|
||||
...state.chunkAnalyses,
|
||||
[`${chunkX},${chunkZ}`]: data
|
||||
}
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error analyzing chunk:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Search blocks by name pattern
|
||||
searchBlocks: async (namePattern) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/world/blocks/search?name=${encodeURIComponent(namePattern)}`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error searching blocks:', error);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
// Rename a turtle
|
||||
renameTurtle: async (turtleId, name) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(' ❌ Error renaming turtle:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Equip left
|
||||
equipLeft: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-left`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Equip right
|
||||
equipRight: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/equip-right`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Select inventory slot
|
||||
selectSlot: async (turtleId, slot) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/select`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Transfer items between slots
|
||||
transferItems: async (turtleId, fromSlot, toSlot, count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/transfer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fromSlot, toSlot, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Sort/compact inventory
|
||||
sortInventory: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/sort`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Connect to adjacent inventory
|
||||
connectToInventory: async (turtleId, side) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/connect-inventory`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ side })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Drop items
|
||||
dropItems: async (turtleId, direction = 'front', count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/drop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Suck items
|
||||
suckItems: async (turtleId, direction = 'front', count) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/suck`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction, count })
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Update turtle config
|
||||
updateTurtleConfig: async (turtleId, config) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Get turtle config
|
||||
getTurtleConfig: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/config`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Explore (batched 3-direction inspect)
|
||||
exploreTurtle: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/explore`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// GPS locate
|
||||
gpsLocateTurtle: async (turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/turtle/${turtleId}/gps`, { method: 'POST' });
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -4,6 +4,19 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
hmr: {
|
||||
clientPort: 3000
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
cors: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
43
docker-compose.dev.yml
Normal file
43
docker-compose.dev.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend server (development with hot reload)
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: turtle-server-dev
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "3002:3002"
|
||||
volumes:
|
||||
- ./server:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: npm run dev
|
||||
networks:
|
||||
- turtle-network
|
||||
|
||||
# Frontend client (development with hot reload)
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: turtle-client-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./client:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- server
|
||||
command: npm run dev
|
||||
networks:
|
||||
- turtle-network
|
||||
|
||||
networks:
|
||||
turtle-network:
|
||||
driver: bridge
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
# Backend server
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: turtle-server
|
||||
ports:
|
||||
- "4200:3001" # HTTP API + WebSocket (unified)
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- INVENTORY_SERVER_URL=${INVENTORY_SERVER_URL:-}
|
||||
- API_KEY=${API_KEY:-}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- turtle-network
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/turtles', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Frontend client
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
container_name: turtle-client
|
||||
ports:
|
||||
- "4444:3000" # Vite preview server
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- turtle-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
turtle-network:
|
||||
driver: bridge
|
||||
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",
|
||||
},
|
||||
}
|
||||
10
gpshost.lua
Normal file
10
gpshost.lua
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Runs a GPS host at boot with fixed coordinates.
|
||||
|
||||
local x, y, z = 0, 64, 0 -- TODO: set these to this computer's GPS host coordinates
|
||||
|
||||
-- If your modem isn't on a default side, open rednet explicitly (optional).
|
||||
-- Example:
|
||||
-- local side = "top"
|
||||
-- if peripheral.getType(side) == "modem" and not rednet.isOpen(side) then rednet.open(side) end
|
||||
|
||||
shell.run("gps", "host", tostring(x), tostring(y), tostring(z))
|
||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "turtle-control-center",
|
||||
"version": "1.0.0",
|
||||
"description": "Web-based control center for ComputerCraft mining turtles",
|
||||
"scripts": {
|
||||
"install:all": "cd server && npm install && cd ../client && npm install",
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
"server": "cd server && npm start",
|
||||
"client": "cd client && npm run dev",
|
||||
"build": "cd client && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
"computercraft",
|
||||
"turtle",
|
||||
"control",
|
||||
"web"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
646
pocketcontrol.lua
Normal file
646
pocketcontrol.lua
Normal file
@@ -0,0 +1,646 @@
|
||||
-- Unified Pocket Control Center
|
||||
-- Combines turtle control, GPS tracking, server management, and webbridge control
|
||||
-- Communicates wirelessly with webbridge - NO direct HTTP calls
|
||||
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
local POCKET_CHANNEL = Channels.get('remoteturtle.pocket')
|
||||
|
||||
-- Find modem
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
|
||||
-- Equip modem if this is a pocket computer
|
||||
if pocket then
|
||||
pocket.equipBack()
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
local WebBridge = require('platform.webbridge')
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
-- State
|
||||
local turtles = {}
|
||||
local selectedTurtle = nil
|
||||
local mode = "overview" -- overview, control, gps, webbridge, server
|
||||
local myPosition = nil
|
||||
local webbridgeStatus = nil
|
||||
local serverStats = nil
|
||||
local logMessages = {}
|
||||
local maxLogs = 50
|
||||
|
||||
-- Button system
|
||||
local buttons = {}
|
||||
|
||||
local function addLog(message, color)
|
||||
table.insert(logMessages, 1, {
|
||||
text = message,
|
||||
time = os.epoch("utc"),
|
||||
color = color or colors.white
|
||||
})
|
||||
if #logMessages > maxLogs then
|
||||
table.remove(logMessages)
|
||||
end
|
||||
end
|
||||
|
||||
local function clearButtons()
|
||||
buttons = {}
|
||||
end
|
||||
|
||||
local function addButton(x, y, width, height, label, action, color)
|
||||
table.insert(buttons, {
|
||||
x = x, y = y, width = width, height = height,
|
||||
label = label, action = action, color = color or colors.gray
|
||||
})
|
||||
end
|
||||
|
||||
local function drawButton(btn, highlight)
|
||||
term.setCursorPos(btn.x, btn.y)
|
||||
local bg = highlight and colors.white or btn.color
|
||||
local fg = highlight and colors.black or colors.white
|
||||
term.setBackgroundColor(bg)
|
||||
term.setTextColor(fg)
|
||||
|
||||
local text = btn.label
|
||||
local padding = math.floor((btn.width - #text) / 2)
|
||||
|
||||
for dy = 0, btn.height - 1 do
|
||||
term.setCursorPos(btn.x, btn.y + dy)
|
||||
if dy == math.floor(btn.height / 2) then
|
||||
term.write(string.rep(" ", padding) .. text .. string.rep(" ", btn.width - padding - #text))
|
||||
else
|
||||
term.write(string.rep(" ", btn.width))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function checkButton(x, y)
|
||||
for _, btn in ipairs(buttons) do
|
||||
if x >= btn.x and x < btn.x + btn.width and
|
||||
y >= btn.y and y < btn.y + btn.height then
|
||||
return btn
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update my GPS position and send to webbridge
|
||||
local function updateMyPosition()
|
||||
local x, y, z = gps.locate(2)
|
||||
if x then
|
||||
myPosition = {x = math.floor(x), y = math.floor(y), z = math.floor(z)}
|
||||
-- Send to webbridge (which will forward to server)
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "player_position",
|
||||
playerID = os.getComputerID(),
|
||||
label = os.getComputerLabel() or ("Pocket #" .. os.getComputerID()),
|
||||
position = myPosition,
|
||||
timestamp = os.epoch("utc")
|
||||
})
|
||||
return true
|
||||
else
|
||||
addLog("GPS: Failed to locate", colors.red)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Send command to turtle via webbridge
|
||||
local function sendCommand(turtleID, command, param)
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "turtle_command",
|
||||
turtleID = turtleID,
|
||||
command = command,
|
||||
param = param,
|
||||
from = os.getComputerID()
|
||||
})
|
||||
addLog("Sent: " .. command .. " -> Turtle #" .. turtleID, colors.yellow)
|
||||
end
|
||||
|
||||
-- Send webbridge control command
|
||||
local function sendWebbridgeCommand(command)
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "webbridge_control",
|
||||
command = command,
|
||||
from = os.getComputerID()
|
||||
})
|
||||
addLog("Webbridge: " .. command, colors.cyan)
|
||||
end
|
||||
|
||||
-- Request server stats via webbridge
|
||||
local function fetchServerStats()
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "server_stats_request",
|
||||
from = os.getComputerID()
|
||||
})
|
||||
end
|
||||
|
||||
-- Draw header
|
||||
local function drawHeader()
|
||||
term.setBackgroundColor(colors.blue)
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(1, 1)
|
||||
term.clearLine()
|
||||
|
||||
local title = "POCKET CTRL"
|
||||
term.setCursorPos(2, 1)
|
||||
term.write(title)
|
||||
|
||||
-- Show quick stats
|
||||
term.setCursorPos(w - 10, 1)
|
||||
term.write("T:" .. #turtles .. " ")
|
||||
if myPosition then
|
||||
term.write("GPS")
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw mode tabs
|
||||
local function drawTabs()
|
||||
clearButtons()
|
||||
local tabWidth = math.floor(w / 5)
|
||||
local y = 2
|
||||
|
||||
-- Draw tab highlights
|
||||
term.setCursorPos(1, y)
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clearLine()
|
||||
|
||||
local tabs = {
|
||||
{name = "TRT", mode = "overview"},
|
||||
{name = "CTL", mode = "control"},
|
||||
{name = "GPS", mode = "gps"},
|
||||
{name = "WEB", mode = "webbridge"},
|
||||
{name = "SRV", mode = "server"}
|
||||
}
|
||||
|
||||
for i, tab in ipairs(tabs) do
|
||||
local x = (i - 1) * tabWidth + 1
|
||||
addButton(x, y, tabWidth, 1, tab.name, function() mode = tab.mode end,
|
||||
mode == tab.mode and colors.green or colors.gray)
|
||||
|
||||
term.setCursorPos(x, y)
|
||||
if mode == tab.mode then
|
||||
term.setBackgroundColor(colors.green)
|
||||
term.setTextColor(colors.white)
|
||||
else
|
||||
term.setBackgroundColor(colors.gray)
|
||||
term.setTextColor(colors.lightGray)
|
||||
end
|
||||
local padding = math.floor((tabWidth - #tab.name) / 2)
|
||||
term.write(string.rep(" ", padding) .. tab.name .. string.rep(" ", tabWidth - padding - #tab.name))
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw overview mode
|
||||
local function drawOverview()
|
||||
term.setBackgroundColor(colors.black)
|
||||
local y = 4
|
||||
|
||||
term.setTextColor(colors.lime)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Online: " .. #turtles)
|
||||
y = y + 1
|
||||
|
||||
if #turtles == 0 then
|
||||
term.setTextColor(colors.gray)
|
||||
term.setCursorPos(2, y + 1)
|
||||
term.write("No turtles")
|
||||
else
|
||||
for i, turtle in ipairs(turtles) do
|
||||
if y >= h - 3 then break end
|
||||
|
||||
term.setCursorPos(2, y)
|
||||
term.setTextColor(colors.yellow)
|
||||
term.write("#" .. turtle.turtleID)
|
||||
|
||||
term.setTextColor(colors.lightGray)
|
||||
term.setCursorPos(8, y)
|
||||
local stateText = (turtle.mode or "idle"):sub(1, 8)
|
||||
term.write(stateText)
|
||||
|
||||
if turtle.position then
|
||||
term.setCursorPos(17, y)
|
||||
term.setTextColor(colors.gray)
|
||||
term.write(string.format("%d,%d,%d", turtle.position.x, turtle.position.y, turtle.position.z))
|
||||
end
|
||||
|
||||
y = y + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Add select button
|
||||
local btnY = h - 1
|
||||
addButton(2, btnY, 10, 1, "SELECT", function()
|
||||
if #turtles > 0 then
|
||||
selectedTurtle = (selectedTurtle or 0) % #turtles + 1
|
||||
addLog("Selected #" .. turtles[selectedTurtle].turtleID, colors.lime)
|
||||
end
|
||||
end, colors.green)
|
||||
|
||||
drawButton(buttons[#buttons], false)
|
||||
end
|
||||
|
||||
-- Draw control mode
|
||||
local function drawControl()
|
||||
term.setBackgroundColor(colors.black)
|
||||
local y = 4
|
||||
|
||||
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||
term.setTextColor(colors.red)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("No turtle selected")
|
||||
term.setCursorPos(2, y + 1)
|
||||
term.setTextColor(colors.gray)
|
||||
term.write("SELECT from TURTLES")
|
||||
return
|
||||
end
|
||||
|
||||
local turtle = turtles[selectedTurtle]
|
||||
term.setTextColor(colors.yellow)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Turtle #" .. turtle.turtleID)
|
||||
y = y + 1
|
||||
|
||||
-- Movement buttons (compact layout)
|
||||
local btnW = 4
|
||||
local btnH = 1
|
||||
local centerX = math.floor(w / 2) - 2
|
||||
local centerY = 8
|
||||
|
||||
-- Movement controls
|
||||
addButton(centerX, centerY - 2, btnW, btnH, "FWD", function()
|
||||
sendCommand(turtle.turtleID, "forward")
|
||||
end, colors.blue)
|
||||
|
||||
addButton(centerX, centerY + 2, btnW, btnH, "BCK", function()
|
||||
sendCommand(turtle.turtleID, "back")
|
||||
end, colors.blue)
|
||||
|
||||
addButton(centerX - btnW - 1, centerY, btnW, btnH, "LFT", function()
|
||||
sendCommand(turtle.turtleID, "turnLeft")
|
||||
end, colors.blue)
|
||||
|
||||
addButton(centerX + btnW + 1, centerY, btnW, btnH, "RGT", function()
|
||||
sendCommand(turtle.turtleID, "turnRight")
|
||||
end, colors.blue)
|
||||
|
||||
addButton(centerX, centerY, btnW, btnH, "UP", function()
|
||||
sendCommand(turtle.turtleID, "up")
|
||||
end, colors.green)
|
||||
|
||||
-- Action buttons (bottom rows)
|
||||
local btnY = h - 5
|
||||
addButton(2, btnY, 6, 1, "EXPLR", function()
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(9, btnY, 6, 1, "MINE", function()
|
||||
sendCommand(turtle.turtleID, "mine")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(16, btnY, 6, 1, "HOME", function()
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
addButton(23, btnY, 6, 1, "STOP", function()
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.red)
|
||||
|
||||
btnY = h - 3
|
||||
addButton(2, btnY, 6, 1, "DOWN", function()
|
||||
sendCommand(turtle.turtleID, "down")
|
||||
end, colors.green)
|
||||
|
||||
addButton(9, btnY, 6, 1, "DIG", function()
|
||||
sendCommand(turtle.turtleID, "dig")
|
||||
end, colors.red)
|
||||
|
||||
for i = #buttons - 8, #buttons do
|
||||
if buttons[i] then
|
||||
drawButton(buttons[i], false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw GPS mode
|
||||
local function drawGPS()
|
||||
term.setBackgroundColor(colors.black)
|
||||
local y = 4
|
||||
|
||||
term.setTextColor(colors.lime)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("GPS Tracking")
|
||||
y = y + 1
|
||||
|
||||
-- My position (compact)
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, y)
|
||||
if myPosition then
|
||||
term.write(string.format("You: %d,%d,%d", myPosition.x, myPosition.y, myPosition.z))
|
||||
else
|
||||
term.setTextColor(colors.red)
|
||||
term.write("GPS: Not available")
|
||||
end
|
||||
y = y + 2
|
||||
|
||||
-- Distance to selected turtle
|
||||
if selectedTurtle and turtles[selectedTurtle] and myPosition then
|
||||
local turtle = turtles[selectedTurtle]
|
||||
if turtle.position then
|
||||
local dx = turtle.position.x - myPosition.x
|
||||
local dy = turtle.position.y - myPosition.y
|
||||
local dz = turtle.position.z - myPosition.z
|
||||
local dist = math.sqrt(dx*dx + dy*dy + dz*dz)
|
||||
|
||||
term.setTextColor(colors.cyan)
|
||||
term.setCursorPos(2, y)
|
||||
term.write(string.format("Turtle #%d:", turtle.turtleID))
|
||||
y = y + 1
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, y)
|
||||
term.write(string.format("Dist: %.1f blocks", dist))
|
||||
y = y + 1
|
||||
term.setTextColor(colors.gray)
|
||||
term.setCursorPos(2, y)
|
||||
term.write(string.format("Delta: %d,%d,%d", dx, dy, dz))
|
||||
end
|
||||
end
|
||||
|
||||
y = h - 2
|
||||
addButton(2, y, 13, 1, "UPDATE GPS", function()
|
||||
if updateMyPosition() then
|
||||
addLog("GPS updated", colors.lime)
|
||||
else
|
||||
addLog("GPS failed", colors.red)
|
||||
end
|
||||
end, colors.green)
|
||||
|
||||
addButton(16, y, 13, 1, "GOTO TURTLE", function()
|
||||
if selectedTurtle and turtles[selectedTurtle] and turtles[selectedTurtle].position then
|
||||
local pos = turtles[selectedTurtle].position
|
||||
addLog(string.format("At: %d,%d,%d", pos.x, pos.y, pos.z), colors.cyan)
|
||||
end
|
||||
end, colors.blue)
|
||||
|
||||
drawButton(buttons[#buttons-1], false)
|
||||
drawButton(buttons[#buttons], false)
|
||||
end
|
||||
|
||||
-- Draw webbridge mode
|
||||
local function drawWebbridge()
|
||||
term.setBackgroundColor(colors.black)
|
||||
local y = 4
|
||||
|
||||
term.setTextColor(colors.lime)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Webbridge Control")
|
||||
y = y + 1
|
||||
|
||||
if webbridgeStatus then
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Status: ONLINE")
|
||||
y = y + 1
|
||||
term.setCursorPos(2, y)
|
||||
term.write("MSG:" .. (webbridgeStatus.messages or 0) ..
|
||||
" CMD:" .. (webbridgeStatus.commands or 0))
|
||||
else
|
||||
term.setTextColor(colors.gray)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Status: Unknown")
|
||||
end
|
||||
|
||||
y = y + 2
|
||||
|
||||
-- Compact button layout
|
||||
addButton(2, y, 13, 1, "PING", function()
|
||||
sendWebbridgeCommand("ping")
|
||||
end, colors.blue)
|
||||
|
||||
addButton(16, y, 13, 1, "RESTART", function()
|
||||
sendWebbridgeCommand("restart")
|
||||
end, colors.orange)
|
||||
|
||||
y = y + 2
|
||||
addButton(2, y, 13, 1, "STATUS", function()
|
||||
sendWebbridgeCommand("status")
|
||||
end, colors.green)
|
||||
|
||||
addButton(16, y, 13, 1, "LOGS", function()
|
||||
sendWebbridgeCommand("logs")
|
||||
end, colors.cyan)
|
||||
|
||||
for i = #buttons - 3, #buttons do
|
||||
if buttons[i] then
|
||||
drawButton(buttons[i], false)
|
||||
end
|
||||
end
|
||||
|
||||
-- Show recent logs (compact)
|
||||
y = y + 3
|
||||
term.setTextColor(colors.gray)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Activity:")
|
||||
y = y + 1
|
||||
|
||||
for i = 1, math.min(3, #logMessages) do
|
||||
if y >= h - 1 then break end
|
||||
local log = logMessages[i]
|
||||
term.setTextColor(log.color)
|
||||
term.setCursorPos(2, y)
|
||||
local text = log.text
|
||||
if #text > w - 3 then
|
||||
text = text:sub(1, w - 6) .. "..."
|
||||
end
|
||||
term.write(text)
|
||||
y = y + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw server mode
|
||||
local function drawServer()
|
||||
term.setBackgroundColor(colors.black)
|
||||
local y = 4
|
||||
|
||||
term.setTextColor(colors.lime)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Server Interface")
|
||||
y = y + 1
|
||||
|
||||
if serverStats then
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Status: ONLINE")
|
||||
y = y + 1
|
||||
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Turtles: " .. (serverStats.turtles or 0))
|
||||
y = y + 1
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Blocks: " .. (serverStats.blocks or 0))
|
||||
else
|
||||
term.setTextColor(colors.red)
|
||||
term.setCursorPos(2, y)
|
||||
term.write("Status: OFFLINE")
|
||||
end
|
||||
|
||||
y = h - 2
|
||||
|
||||
addButton(2, y, 13, 1, "REFRESH", function()
|
||||
fetchServerStats()
|
||||
addLog("Stats refreshed", colors.lime)
|
||||
end, colors.green)
|
||||
|
||||
addButton(16, y, 13, 1, "WEB INFO", function()
|
||||
modem.transmit(POCKET_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "web_interface_request",
|
||||
from = os.getComputerID()
|
||||
})
|
||||
addLog("Requesting info", colors.cyan)
|
||||
end, colors.blue)
|
||||
|
||||
drawButton(buttons[#buttons-1], false)
|
||||
drawButton(buttons[#buttons], false)
|
||||
end
|
||||
|
||||
-- Main draw function
|
||||
local function draw()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
|
||||
drawHeader()
|
||||
drawTabs()
|
||||
|
||||
if mode == "overview" then
|
||||
drawOverview()
|
||||
elseif mode == "control" then
|
||||
drawControl()
|
||||
elseif mode == "gps" then
|
||||
drawGPS()
|
||||
elseif mode == "webbridge" then
|
||||
drawWebbridge()
|
||||
elseif mode == "server" then
|
||||
drawServer()
|
||||
end
|
||||
|
||||
-- Status bar (compact)
|
||||
term.setBackgroundColor(colors.gray)
|
||||
term.setCursorPos(1, h)
|
||||
term.clearLine()
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, h)
|
||||
term.write(string.format("T:%d", #turtles))
|
||||
if selectedTurtle and turtles[selectedTurtle] then
|
||||
term.setCursorPos(8, h)
|
||||
term.write("#" .. turtles[selectedTurtle].turtleID)
|
||||
end
|
||||
if myPosition then
|
||||
term.setCursorPos(w - 5, h)
|
||||
term.write("GPS")
|
||||
end
|
||||
end
|
||||
|
||||
-- Main loop
|
||||
print("Pocket Control Center")
|
||||
print("Initializing...")
|
||||
|
||||
-- Initial GPS update
|
||||
updateMyPosition()
|
||||
|
||||
-- Start background tasks
|
||||
parallel.waitForAny(
|
||||
function()
|
||||
-- GPS update loop
|
||||
while true do
|
||||
sleep(2)
|
||||
updateMyPosition()
|
||||
end
|
||||
end,
|
||||
|
||||
function()
|
||||
-- Server stats update loop
|
||||
while true do
|
||||
sleep(10)
|
||||
fetchServerStats()
|
||||
end
|
||||
end,
|
||||
|
||||
function()
|
||||
-- Message listener
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102/103) and target (4212/4213) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
if message.type == "status" then
|
||||
-- Update turtle list
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
turtles[i] = message
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(turtles, message)
|
||||
addLog("Turtle #" .. message.turtleID .. " connected", colors.lime)
|
||||
end
|
||||
end
|
||||
elseif Channels.match('remoteturtle.pocket', channel) and type(message) == "table" then
|
||||
-- Handle responses from webbridge
|
||||
if message.type == "webbridge_status" then
|
||||
webbridgeStatus = message.data
|
||||
addLog("Webbridge status received", colors.cyan)
|
||||
elseif message.type == "server_stats" then
|
||||
serverStats = message.data
|
||||
addLog("Server stats received", colors.lime)
|
||||
elseif message.type == "webbridge_log" then
|
||||
addLog("[WB] " .. (message.text or ""), colors.lightGray)
|
||||
elseif message.type == "command_ack" then
|
||||
addLog("Command acknowledged", colors.green)
|
||||
elseif message.type == "error" then
|
||||
addLog("Error: " .. (message.error or "unknown"), colors.red)
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
|
||||
function()
|
||||
-- UI loop
|
||||
draw()
|
||||
while true do
|
||||
local event, p1, p2, p3 = os.pullEvent()
|
||||
|
||||
if event == "mouse_click" or event == "mouse_up" then
|
||||
local btn = checkButton(p2, p3)
|
||||
if btn and btn.action then
|
||||
btn.action()
|
||||
end
|
||||
draw()
|
||||
elseif event == "char" then
|
||||
if p1 == "q" then
|
||||
break
|
||||
elseif p1 == "r" then
|
||||
draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Pocket Control Center closed")
|
||||
275
pocketgps.lua
Normal file
275
pocketgps.lua
Normal file
@@ -0,0 +1,275 @@
|
||||
-- Live GPS Tracker for Pocket Computer
|
||||
-- Shows your current location in real-time
|
||||
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
-- Setup modem
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
|
||||
-- Equip modem if on pocket computer
|
||||
if pocket then
|
||||
pocket.equipBack()
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
WebBridge.openChannels(modem, { 'remoteturtle.status' })
|
||||
|
||||
local w, h = term.getSize()
|
||||
local myID = os.getComputerID()
|
||||
local currentPos = {x = 0, y = 0, z = 0}
|
||||
local lastUpdate = 0
|
||||
local turtles = {}
|
||||
|
||||
-- Get GPS position
|
||||
local function updateGPS()
|
||||
local x, y, z = gps.locate(5)
|
||||
if x then
|
||||
currentPos = {x = math.floor(x), y = math.floor(y), z = math.floor(z)}
|
||||
lastUpdate = os.clock()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Draw header
|
||||
local function drawHeader()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setBackgroundColor(colors.blue)
|
||||
term.setTextColor(colors.white)
|
||||
term.clearLine()
|
||||
term.setCursorPos(math.floor((w - 14) / 2), 1)
|
||||
term.write(" GPS TRACKER ")
|
||||
end
|
||||
|
||||
-- Draw position info
|
||||
local function drawPosition()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
|
||||
-- Computer ID
|
||||
term.setCursorPos(2, 3)
|
||||
term.write("Computer ID: ")
|
||||
term.setTextColor(colors.yellow)
|
||||
term.write(tostring(myID))
|
||||
|
||||
-- Current coordinates
|
||||
term.setTextColor(colors.white)
|
||||
term.setCursorPos(2, 5)
|
||||
term.write("Current Position:")
|
||||
|
||||
term.setCursorPos(4, 6)
|
||||
term.setTextColor(colors.red)
|
||||
term.write("X: ")
|
||||
term.setTextColor(colors.white)
|
||||
term.write(string.format("%d", currentPos.x))
|
||||
|
||||
term.setCursorPos(4, 7)
|
||||
term.setTextColor(colors.green)
|
||||
term.write("Y: ")
|
||||
term.setTextColor(colors.white)
|
||||
term.write(string.format("%d", currentPos.y))
|
||||
|
||||
term.setCursorPos(4, 8)
|
||||
term.setTextColor(colors.blue)
|
||||
term.write("Z: ")
|
||||
term.setTextColor(colors.white)
|
||||
term.write(string.format("%d", currentPos.z))
|
||||
|
||||
-- Time since last update
|
||||
local timeSince = os.clock() - lastUpdate
|
||||
term.setCursorPos(2, 10)
|
||||
term.setTextColor(colors.gray)
|
||||
if lastUpdate > 0 then
|
||||
term.write(string.format("Updated %.1fs ago", timeSince))
|
||||
else
|
||||
term.write("No GPS fix yet...")
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw nearby turtles
|
||||
local function drawTurtles()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
|
||||
term.setCursorPos(2, 12)
|
||||
term.write("Nearby Turtles:")
|
||||
|
||||
local line = 13
|
||||
local count = 0
|
||||
|
||||
-- Sort turtles by distance
|
||||
local sortedTurtles = {}
|
||||
for id, data in pairs(turtles) do
|
||||
if data.position then
|
||||
local dx = data.position.x - currentPos.x
|
||||
local dy = data.position.y - currentPos.y
|
||||
local dz = data.position.z - currentPos.z
|
||||
local distance = math.sqrt(dx*dx + dy*dy + dz*dz)
|
||||
table.insert(sortedTurtles, {id = id, data = data, distance = distance})
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(sortedTurtles, function(a, b) return a.distance < b.distance end)
|
||||
|
||||
-- Display up to 5 closest turtles
|
||||
for i = 1, math.min(5, #sortedTurtles) do
|
||||
local turtle = sortedTurtles[i]
|
||||
term.setCursorPos(4, line)
|
||||
term.setTextColor(colors.yellow)
|
||||
term.write(string.format("#%d", turtle.id))
|
||||
term.setTextColor(colors.white)
|
||||
term.write(string.format(" [%.1fm]", turtle.distance))
|
||||
|
||||
term.setCursorPos(6, line + 1)
|
||||
term.setTextColor(colors.gray)
|
||||
term.write(string.format("(%d, %d, %d)",
|
||||
turtle.data.position.x,
|
||||
turtle.data.position.y,
|
||||
turtle.data.position.z))
|
||||
|
||||
line = line + 2
|
||||
count = count + 1
|
||||
if line >= h - 2 then break end
|
||||
end
|
||||
|
||||
if count == 0 then
|
||||
term.setCursorPos(4, line)
|
||||
term.setTextColor(colors.gray)
|
||||
term.write("None detected")
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw footer
|
||||
local function drawFooter()
|
||||
term.setCursorPos(1, h)
|
||||
term.setBackgroundColor(colors.gray)
|
||||
term.setTextColor(colors.white)
|
||||
term.clearLine()
|
||||
term.setCursorPos(2, h)
|
||||
term.write("[Q] Quit [R] Refresh")
|
||||
end
|
||||
|
||||
-- Full screen refresh
|
||||
local function draw()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
drawHeader()
|
||||
drawPosition()
|
||||
drawTurtles()
|
||||
drawFooter()
|
||||
end
|
||||
|
||||
-- Handle turtle status broadcasts
|
||||
local function handleStatus(message)
|
||||
if message.id and message.id ~= myID then
|
||||
if not turtles[message.id] then
|
||||
turtles[message.id] = {}
|
||||
end
|
||||
|
||||
-- Update turtle data
|
||||
if message.position then
|
||||
turtles[message.id].position = message.position
|
||||
end
|
||||
if message.status then
|
||||
turtles[message.id].status = message.status
|
||||
end
|
||||
if message.fuel then
|
||||
turtles[message.id].fuel = message.fuel
|
||||
end
|
||||
|
||||
turtles[message.id].lastSeen = os.clock()
|
||||
end
|
||||
end
|
||||
|
||||
-- Clean up old turtles (not seen in 30 seconds)
|
||||
local function cleanupTurtles()
|
||||
local now = os.clock()
|
||||
for id, data in pairs(turtles) do
|
||||
if data.lastSeen and (now - data.lastSeen) > 30 then
|
||||
turtles[id] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Main loop
|
||||
local function main()
|
||||
print("GPS Tracker starting...")
|
||||
print("Getting initial GPS fix...")
|
||||
|
||||
-- Get initial GPS position
|
||||
updateGPS()
|
||||
|
||||
draw()
|
||||
|
||||
local gpsTimer = os.startTimer(2) -- Update GPS every 2 seconds
|
||||
local cleanupTimer = os.startTimer(10) -- Cleanup every 10 seconds
|
||||
local drawTimer = os.startTimer(0.5) -- Redraw every 0.5 seconds
|
||||
|
||||
while true do
|
||||
local event, param1, param2, param3, param4, param5 = os.pullEvent()
|
||||
|
||||
if event == "timer" then
|
||||
if param1 == gpsTimer then
|
||||
updateGPS()
|
||||
gpsTimer = os.startTimer(2)
|
||||
elseif param1 == cleanupTimer then
|
||||
cleanupTurtles()
|
||||
cleanupTimer = os.startTimer(10)
|
||||
elseif param1 == drawTimer then
|
||||
draw()
|
||||
drawTimer = os.startTimer(0.5)
|
||||
end
|
||||
|
||||
elseif event == "modem_message" then
|
||||
local channel = param2
|
||||
local replyChannel = param3
|
||||
local message = param4
|
||||
|
||||
-- Uses Channels.match() for dual-mode safety: accepts messages on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" then
|
||||
handleStatus(message)
|
||||
end
|
||||
|
||||
elseif event == "char" then
|
||||
if param1 == "q" or param1 == "Q" then
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("GPS Tracker stopped.")
|
||||
return
|
||||
elseif param1 == "r" or param1 == "R" then
|
||||
updateGPS()
|
||||
draw()
|
||||
end
|
||||
|
||||
elseif event == "mouse_click" then
|
||||
-- Check if quit button clicked (bottom bar)
|
||||
if param3 == h then
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("GPS Tracker stopped.")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Run the tracker
|
||||
local success, err = pcall(main)
|
||||
if not success then
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.setTextColor(colors.white)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("Error: " .. tostring(err))
|
||||
end
|
||||
236
pocketremote.lua
236
pocketremote.lua
@@ -1,9 +1,12 @@
|
||||
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||
-- Monitor and control autonomous mining turtles
|
||||
|
||||
local CHANNEL_SEND = 100
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local Channels = require('platform.channels')
|
||||
local WebBridge = require('platform.webbridge')
|
||||
|
||||
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||
|
||||
local modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
@@ -15,15 +18,17 @@ if pocket then
|
||||
modem = peripheral.find("modem")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
})
|
||||
|
||||
local w, h = term.getSize()
|
||||
|
||||
-- Tracked turtles
|
||||
local turtles = {}
|
||||
local selectedTurtle = nil
|
||||
local viewMode = "overview" -- overview, detail, manual
|
||||
local viewMode = "overview" -- overview, detail, manual, modes
|
||||
|
||||
-- Button system
|
||||
local buttons = {}
|
||||
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
|
||||
})
|
||||
end
|
||||
|
||||
-- Send state change command to turtle (new protocol)
|
||||
local function sendStateCommand(turtleID, stateName, stateData)
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "state_change",
|
||||
state = stateName,
|
||||
data = stateData or {},
|
||||
target = turtleID
|
||||
})
|
||||
end
|
||||
|
||||
-- Helper function to format fuel display
|
||||
local function formatFuel(fuel)
|
||||
if not fuel then
|
||||
@@ -127,7 +142,7 @@ local function drawOverview()
|
||||
-- Turtle info box
|
||||
term.setCursorPos(1, y)
|
||||
term.setTextColor(selected and colors.lime or colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
if turtle.position then
|
||||
print(string.format(" %d,%d,%d",
|
||||
@@ -197,7 +212,7 @@ local function drawDetail()
|
||||
term.setTextColor(colors.white)
|
||||
|
||||
print("")
|
||||
print("Mode: " .. (turtle.mode or "unknown"))
|
||||
print("State: " .. (turtle.state or turtle.mode or "idle"))
|
||||
print("Fuel: " .. formatFuel(turtle.fuel))
|
||||
|
||||
if turtle.position then
|
||||
@@ -234,8 +249,8 @@ local function drawDetail()
|
||||
print(" Empty")
|
||||
end
|
||||
|
||||
-- Action buttons
|
||||
local btnY = h - 7
|
||||
-- Action buttons (row 1: explore/home/stop)
|
||||
local btnY = h - 10
|
||||
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.green)
|
||||
@@ -248,16 +263,60 @@ local function drawDetail()
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.red)
|
||||
|
||||
btnY = h - 4
|
||||
addButton(1, btnY, 12, 2, "MANUAL", function()
|
||||
-- Row 2: manual/modes/setHome
|
||||
btnY = h - 7
|
||||
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||
viewMode = "manual"
|
||||
sendCommand(turtle.turtleID, "manual")
|
||||
end, colors.purple)
|
||||
|
||||
addButton(14, btnY, 12, 2, "SET HOME", function()
|
||||
addButton(10, btnY, 8, 2, "MODES", function()
|
||||
viewMode = "modes"
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(19, btnY, 7, 2, "HOME*", function()
|
||||
sendCommand(turtle.turtleID, "setHome")
|
||||
end, colors.blue)
|
||||
|
||||
-- Row 3: equip/rename
|
||||
btnY = h - 4
|
||||
addButton(1, btnY, 8, 2, "EQUIP L", function()
|
||||
-- Send eval to equip left
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = "return turtle.equipLeft()",
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.purple)
|
||||
|
||||
addButton(10, btnY, 8, 2, "EQUIP R", function()
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = "return turtle.equipRight()",
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.purple)
|
||||
|
||||
addButton(19, btnY, 7, 2, "RENAME", function()
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setTextColor(colors.yellow)
|
||||
print("Enter new name:")
|
||||
term.setTextColor(colors.white)
|
||||
local name = read()
|
||||
if name and #name > 0 then
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "rename",
|
||||
name = name,
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end
|
||||
draw()
|
||||
end, colors.lightBlue)
|
||||
|
||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||
viewMode = "overview"
|
||||
end, colors.gray)
|
||||
@@ -349,9 +408,37 @@ local function drawManual()
|
||||
sendCommand(turtle.turtleID, "refuel")
|
||||
end, colors.lime)
|
||||
|
||||
addButton(19, h - 3, 7, 2, "INFO", function()
|
||||
sendCommand(turtle.turtleID, "status")
|
||||
end, colors.lightBlue)
|
||||
addButton(19, h - 3, 7, 2, "SORT", function()
|
||||
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||
type = "eval",
|
||||
uuid = tostring(math.random(100000, 999999)),
|
||||
code = [[
|
||||
local moved = 0
|
||||
for slot = 1, 16 do
|
||||
local item = turtle.getItemDetail(slot)
|
||||
if item then
|
||||
for target = 1, slot - 1 do
|
||||
local ti = turtle.getItemDetail(target)
|
||||
if not ti then
|
||||
turtle.select(slot)
|
||||
turtle.transferTo(target)
|
||||
moved = moved + 1
|
||||
break
|
||||
elseif ti.name == item.name and ti.count < 64 then
|
||||
turtle.select(slot)
|
||||
turtle.transferTo(target)
|
||||
moved = moved + 1
|
||||
if turtle.getItemCount(slot) == 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
turtle.select(1)
|
||||
return moved
|
||||
]],
|
||||
target = turtle.turtleID
|
||||
})
|
||||
end, colors.cyan)
|
||||
|
||||
addButton(1, h, 12, 1, "< BACK", function()
|
||||
viewMode = "detail"
|
||||
@@ -369,6 +456,98 @@ local function drawManual()
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function drawModes()
|
||||
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||
viewMode = "overview"
|
||||
return
|
||||
end
|
||||
|
||||
clearButtons()
|
||||
local turtle = turtles[selectedTurtle]
|
||||
|
||||
term.setBackgroundColor(colors.black)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
term.setTextColor(colors.cyan)
|
||||
print("=STATE MACHINE=")
|
||||
term.setTextColor(colors.white)
|
||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||
|
||||
-- State buttons
|
||||
local btnY = 4
|
||||
local btnW = 12
|
||||
|
||||
addButton(1, btnY, btnW, 2, "IDLE", function()
|
||||
sendStateCommand(turtle.turtleID, "idle")
|
||||
sendCommand(turtle.turtleID, "stop")
|
||||
end, colors.gray)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "EXPLORE", function()
|
||||
sendStateCommand(turtle.turtleID, "exploring")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.green)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "MINE", function()
|
||||
sendStateCommand(turtle.turtleID, "mining")
|
||||
sendCommand(turtle.turtleID, "explore")
|
||||
end, colors.orange)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "FARM", function()
|
||||
sendStateCommand(turtle.turtleID, "farming")
|
||||
end, colors.lime)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "GO HOME", function()
|
||||
sendStateCommand(turtle.turtleID, "goHome")
|
||||
sendCommand(turtle.turtleID, "returnHome")
|
||||
end, colors.yellow)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "REFUEL", function()
|
||||
sendStateCommand(turtle.turtleID, "refueling")
|
||||
sendCommand(turtle.turtleID, "refuel")
|
||||
end, colors.red)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "DUMP INV", function()
|
||||
sendStateCommand(turtle.turtleID, "dumpInventory")
|
||||
end, colors.brown)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "MOVE TO", function()
|
||||
-- Could prompt for coordinates, for now just sends moving state
|
||||
sendStateCommand(turtle.turtleID, "moving")
|
||||
end, colors.lightBlue)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "SCAN", function()
|
||||
sendStateCommand(turtle.turtleID, "scan")
|
||||
end, colors.purple)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "EXTRACT", function()
|
||||
sendStateCommand(turtle.turtleID, "extraction")
|
||||
end, colors.magenta)
|
||||
|
||||
btnY = btnY + 3
|
||||
addButton(1, btnY, btnW, 2, "BUILD", function()
|
||||
sendStateCommand(turtle.turtleID, "building")
|
||||
end, colors.lightBlue)
|
||||
|
||||
addButton(14, btnY, btnW, 2, "AUTOCRAFT", function()
|
||||
sendStateCommand(turtle.turtleID, "autocraft")
|
||||
end, colors.pink)
|
||||
|
||||
-- Back button
|
||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||
viewMode = "detail"
|
||||
end, colors.gray)
|
||||
|
||||
for _, btn in ipairs(buttons) do
|
||||
drawButton(btn)
|
||||
end
|
||||
|
||||
term.setTextColor(colors.white)
|
||||
end
|
||||
|
||||
local function draw()
|
||||
if viewMode == "overview" then
|
||||
drawOverview()
|
||||
@@ -376,6 +555,8 @@ local function draw()
|
||||
drawDetail()
|
||||
elseif viewMode == "manual" then
|
||||
drawManual()
|
||||
elseif viewMode == "modes" then
|
||||
drawModes()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -450,14 +631,20 @@ parallel.waitForAny(
|
||||
end,
|
||||
function()
|
||||
-- Status receiver
|
||||
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||
-- both legacy (102) and target (4212) channels during migration.
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
||||
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||
-- Update or add turtle
|
||||
local found = false
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
-- Preserve state if not in message
|
||||
if not message.state then
|
||||
message.state = t.state or message.mode or "idle"
|
||||
end
|
||||
turtles[i] = message
|
||||
found = true
|
||||
break
|
||||
@@ -465,6 +652,9 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
if not found then
|
||||
if not message.state then
|
||||
message.state = message.mode or "idle"
|
||||
end
|
||||
table.insert(turtles, message)
|
||||
if not selectedTurtle then
|
||||
selectedTurtle = 1
|
||||
@@ -472,8 +662,16 @@ parallel.waitForAny(
|
||||
end
|
||||
|
||||
draw()
|
||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
|
||||
-- Response from turtle
|
||||
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||
-- State change confirmation or other response
|
||||
if message.type == "state_changed" and message.turtleID then
|
||||
for i, t in ipairs(turtles) do
|
||||
if t.turtleID == message.turtleID then
|
||||
turtles[i].state = message.state
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
draw()
|
||||
end
|
||||
end
|
||||
|
||||
38
server/Dockerfile
Normal file
38
server/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Node.js backend
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy all server code
|
||||
COPY . .
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3001 3002
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/turtles', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start server
|
||||
CMD ["node", "server.js"]
|
||||
37
server/Dockerfile.dev
Normal file
37
server/Dockerfile.dev
Normal file
@@ -0,0 +1,37 @@
|
||||
# Stage 1: Fetch platform server package from git
|
||||
FROM alpine:3.20 AS platform
|
||||
RUN apk add --no-cache git
|
||||
ARG PLATFORM_REPO=https://git.spatulaa.com/MayaTheShy/cc-platform-core.git
|
||||
ARG PLATFORM_BRANCH=master
|
||||
RUN git clone --depth 1 --branch "$PLATFORM_BRANCH" "$PLATFORM_REPO" /src \
|
||||
&& rm -rf /src/server/node_modules /src/.git
|
||||
|
||||
# Stage 2: Development with hot reload
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy platform server package from the git-clone stage
|
||||
COPY --from=platform /src/server /app/platform-server/
|
||||
|
||||
# Install nodemon for hot reload
|
||||
RUN npm install -g nodemon
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Rewrite file: dependency to use the local copy inside the container
|
||||
RUN sed -i 's|file:../../cc-platform-core/server|file:./platform-server|' package.json \
|
||||
&& rm -f package-lock.json
|
||||
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3001 3002
|
||||
|
||||
# Start with nodemon for hot reload
|
||||
CMD ["nodemon", "server.js"]
|
||||
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' });
|
||||
});
|
||||
});
|
||||
779
server/database.js
Normal file
779
server/database.js
Normal file
@@ -0,0 +1,779 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const db = new Database(path.join(__dirname, 'turtle_control.db'));
|
||||
|
||||
// Enable WAL journal mode for better concurrent read/write performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize database schema
|
||||
export function initializeDatabase() {
|
||||
// Turtle homes table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_homes (
|
||||
turtle_id INTEGER PRIMARY KEY,
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Turtle configuration table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_config (
|
||||
turtle_id INTEGER PRIMARY KEY,
|
||||
max_distance INTEGER DEFAULT 200,
|
||||
facing INTEGER DEFAULT 0,
|
||||
config_json TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// World blocks table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS world_blocks (
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
block_name TEXT NOT NULL,
|
||||
metadata INTEGER DEFAULT 0,
|
||||
discovered_by INTEGER NOT NULL,
|
||||
discovered_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (x, y, z)
|
||||
)
|
||||
`);
|
||||
|
||||
// Turtle paths table (for path recording)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
turtle_id INTEGER NOT NULL,
|
||||
path_name TEXT NOT NULL,
|
||||
path_data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Task queue table (for multi-turtle coordination)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_type TEXT NOT NULL,
|
||||
task_data TEXT NOT NULL,
|
||||
assigned_turtle_id INTEGER,
|
||||
priority INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Mining areas table (for area visualization)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mining_areas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
turtle_id INTEGER NOT NULL,
|
||||
min_x INTEGER NOT NULL,
|
||||
min_y INTEGER NOT NULL,
|
||||
min_z INTEGER NOT NULL,
|
||||
max_x INTEGER NOT NULL,
|
||||
max_y INTEGER NOT NULL,
|
||||
max_z INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
color TEXT DEFAULT '#4a8c2a',
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate existing mining_areas table to add name/color columns if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(mining_areas)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('name')) {
|
||||
db.exec('ALTER TABLE mining_areas ADD COLUMN name TEXT');
|
||||
}
|
||||
if (!columns.includes('color')) {
|
||||
db.exec("ALTER TABLE mining_areas ADD COLUMN color TEXT DEFAULT '#4a8c2a'");
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Mining statistics table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mining_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
turtle_id INTEGER NOT NULL,
|
||||
block_type TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
session_start INTEGER NOT NULL,
|
||||
last_mined INTEGER NOT NULL,
|
||||
UNIQUE(turtle_id, block_type, session_start)
|
||||
)
|
||||
`);
|
||||
|
||||
// Turtle groups/teams table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_name TEXT NOT NULL UNIQUE,
|
||||
color TEXT DEFAULT '#3b82f6',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Turtle group membership table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_group_members (
|
||||
turtle_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (turtle_id, group_id),
|
||||
FOREIGN KEY (group_id) REFERENCES turtle_groups(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Session tracking table (for time-based statistics)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS turtle_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
turtle_id INTEGER NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
blocks_mined INTEGER DEFAULT 0,
|
||||
distance_traveled INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
// Player positions table (for tracking pocket computer users)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS player_positions (
|
||||
player_id INTEGER PRIMARY KEY,
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrate player_positions table to add label column if missing
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(player_positions)").all();
|
||||
const columns = tableInfo.map(c => c.name);
|
||||
if (!columns.includes('label')) {
|
||||
db.exec('ALTER TABLE player_positions ADD COLUMN label TEXT');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore migration errors
|
||||
}
|
||||
|
||||
// Chunk analysis table (ore density per chunk)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
x INTEGER NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
analysis TEXT DEFAULT '{}',
|
||||
scanned_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (x, z)
|
||||
)
|
||||
`);
|
||||
|
||||
// Add block_state and block_tags columns to world_blocks if not present
|
||||
try {
|
||||
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_state TEXT DEFAULT '{}'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
try {
|
||||
db.exec(`ALTER TABLE world_blocks ADD COLUMN block_tags TEXT DEFAULT '{}'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
|
||||
// Create indexes for better performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_world_blocks_discovered
|
||||
ON world_blocks(discovered_by);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_task_queue_status
|
||||
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');
|
||||
}
|
||||
|
||||
// Turtle Homes
|
||||
export function saveTurtleHome(turtleId, position) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO turtle_homes (turtle_id, x, y, z, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(turtleId, position.x, position.y, position.z, Date.now());
|
||||
}
|
||||
|
||||
export function getTurtleHome(turtleId) {
|
||||
const stmt = db.prepare('SELECT x, y, z FROM turtle_homes WHERE turtle_id = ?');
|
||||
return stmt.get(turtleId);
|
||||
}
|
||||
|
||||
export function getAllTurtleHomes() {
|
||||
const stmt = db.prepare('SELECT turtle_id, x, y, z FROM turtle_homes');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
// Turtle Configuration
|
||||
export function saveTurtleConfig(turtleId, config) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO turtle_config (turtle_id, max_distance, facing, config_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(
|
||||
turtleId,
|
||||
config.maxDistance || 200,
|
||||
config.facing || 0,
|
||||
JSON.stringify(config),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
export function getTurtleConfig(turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_config WHERE turtle_id = ?');
|
||||
const row = stmt.get(turtleId);
|
||||
if (row && row.config_json) {
|
||||
return JSON.parse(row.config_json);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// World Blocks
|
||||
export function saveWorldBlock(x, y, z, blockName, metadata, discoveredBy, blockState = null, blockTags = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO world_blocks (x, y, z, block_name, metadata, discovered_by, discovered_at, block_state, block_tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(x, y, z, blockName, metadata || 0, discoveredBy, Date.now(),
|
||||
blockState ? JSON.stringify(blockState) : '{}',
|
||||
blockTags ? JSON.stringify(blockTags) : '{}');
|
||||
}
|
||||
|
||||
export function getWorldBlocks(limit = 10000) {
|
||||
const stmt = db.prepare('SELECT * FROM world_blocks 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) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM world_blocks
|
||||
WHERE x BETWEEN ? AND ?
|
||||
AND y BETWEEN ? AND ?
|
||||
AND z BETWEEN ? AND ?
|
||||
`);
|
||||
return stmt.all(minX, maxX, minY, maxY, minZ, maxZ);
|
||||
}
|
||||
|
||||
export function clearOldBlocks(daysOld = 7) {
|
||||
const cutoffTime = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
|
||||
const stmt = db.prepare('DELETE FROM world_blocks WHERE discovered_at < ?');
|
||||
const result = stmt.run(cutoffTime);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// Turtle Paths
|
||||
export function savePath(turtleId, pathName, pathData) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO turtle_paths (turtle_id, path_name, path_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
const result = stmt.run(turtleId, pathName, JSON.stringify(pathData), now, now);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getPaths(turtleId = null) {
|
||||
if (turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE turtle_id = ? ORDER BY created_at DESC');
|
||||
return stmt.all(turtleId).map(row => ({
|
||||
...row,
|
||||
path_data: JSON.parse(row.path_data)
|
||||
}));
|
||||
} else {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths ORDER BY created_at DESC');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
path_data: JSON.parse(row.path_data)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function getPath(pathId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_paths WHERE id = ?');
|
||||
const row = stmt.get(pathId);
|
||||
if (row) {
|
||||
return { ...row, path_data: JSON.parse(row.path_data) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deletePath(pathId) {
|
||||
const stmt = db.prepare('DELETE FROM turtle_paths WHERE id = ?');
|
||||
return stmt.run(pathId);
|
||||
}
|
||||
|
||||
// Task Queue
|
||||
export function createTask(taskType, taskData, priority = 0, assignedTurtleId = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO task_queue (task_type, task_data, assigned_turtle_id, priority, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
const result = stmt.run(taskType, JSON.stringify(taskData), assignedTurtleId, priority, now, now);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getNextTask() {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM task_queue
|
||||
WHERE status = 'pending'
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = stmt.get();
|
||||
if (row) {
|
||||
return {
|
||||
...row,
|
||||
task_data: JSON.parse(row.task_data)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assignTask(taskId, turtleId) {
|
||||
if (turtleId === null || turtleId === undefined) {
|
||||
// Un-assign: clear turtle and revert to pending
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = NULL, status = 'pending', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(Date.now(), taskId);
|
||||
} else {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET assigned_turtle_id = ?, status = 'assigned', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(turtleId, Date.now(), taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTaskStatus(taskId, status, result = null) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET 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) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE task_queue
|
||||
SET status = 'completed', updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(Date.now(), taskId);
|
||||
}
|
||||
|
||||
export function deleteTask(taskId) {
|
||||
const stmt = db.prepare('DELETE FROM task_queue WHERE id = ?');
|
||||
return stmt.run(taskId);
|
||||
}
|
||||
|
||||
export function getAllTasks(status = null) {
|
||||
if (status) {
|
||||
const stmt = db.prepare('SELECT * FROM task_queue WHERE status = ? ORDER BY priority DESC, created_at DESC');
|
||||
return stmt.all(status).map(row => ({
|
||||
...row,
|
||||
task_data: JSON.parse(row.task_data)
|
||||
}));
|
||||
}
|
||||
const stmt = db.prepare('SELECT * FROM task_queue ORDER BY priority DESC, created_at DESC');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
task_data: JSON.parse(row.task_data)
|
||||
}));
|
||||
}
|
||||
|
||||
// Mining Areas
|
||||
export function saveMiningArea(turtleId, bounds, areaName = null, status = 'planned', color = '#4a8c2a') {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO mining_areas (turtle_id, min_x, min_y, min_z, max_x, max_y, max_z, name, color, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
const result = stmt.run(
|
||||
turtleId,
|
||||
bounds.minX, bounds.minY, bounds.minZ,
|
||||
bounds.maxX, bounds.maxY, bounds.maxZ,
|
||||
areaName, color,
|
||||
status, now, now
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getMiningAreas(statusFilter = null) {
|
||||
if (statusFilter) {
|
||||
const stmt = db.prepare('SELECT * FROM mining_areas WHERE status = ? ORDER BY created_at DESC');
|
||||
return stmt.all(statusFilter);
|
||||
}
|
||||
const stmt = db.prepare('SELECT * FROM mining_areas ORDER BY created_at DESC');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
export function updateMiningAreaStatus(areaId, status) {
|
||||
const stmt = db.prepare('UPDATE mining_areas SET status = ?, updated_at = ? WHERE id = ?');
|
||||
stmt.run(status, Date.now(), areaId);
|
||||
}
|
||||
|
||||
export function updateMiningArea(areaId, updates) {
|
||||
const allowedFields = ['name', 'color', 'status', 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', 'turtle_id'];
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
setClauses.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
if (setClauses.length === 0) return;
|
||||
setClauses.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(areaId);
|
||||
const stmt = db.prepare(`UPDATE mining_areas SET ${setClauses.join(', ')} WHERE id = ?`);
|
||||
stmt.run(...values);
|
||||
}
|
||||
|
||||
export function deleteMiningArea(areaId) {
|
||||
const stmt = db.prepare('DELETE FROM mining_areas WHERE id = ?');
|
||||
return stmt.run(areaId);
|
||||
}
|
||||
|
||||
export function closeMiningArea(areaId) {
|
||||
const stmt = db.prepare('UPDATE mining_areas SET status = \'closed\', updated_at = ? WHERE id = ?');
|
||||
stmt.run(Date.now(), areaId);
|
||||
}
|
||||
|
||||
// Mining Statistics
|
||||
export function recordBlockMined(turtleId, blockType) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO mining_stats (turtle_id, block_type, count, session_start, last_mined)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(turtle_id, block_type, session_start)
|
||||
DO UPDATE SET count = count + 1, last_mined = ?
|
||||
`);
|
||||
const now = Date.now();
|
||||
const sessionStart = now - (now % (24 * 60 * 60 * 1000)); // Start of day
|
||||
stmt.run(turtleId, blockType, sessionStart, now, now);
|
||||
}
|
||||
|
||||
export function getMiningStats(turtleId = null, days = 7) {
|
||||
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (turtleId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT block_type, SUM(count) as total_count
|
||||
FROM mining_stats
|
||||
WHERE turtle_id = ? AND session_start >= ?
|
||||
GROUP BY block_type
|
||||
ORDER BY total_count DESC
|
||||
`);
|
||||
return stmt.all(turtleId, cutoff);
|
||||
} else {
|
||||
const stmt = db.prepare(`
|
||||
SELECT turtle_id, block_type, SUM(count) as total_count
|
||||
FROM mining_stats
|
||||
WHERE session_start >= ?
|
||||
GROUP BY turtle_id, block_type
|
||||
ORDER BY turtle_id, total_count DESC
|
||||
`);
|
||||
return stmt.all(cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTopMiners(limit = 10) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT turtle_id, SUM(count) as total_blocks, COUNT(DISTINCT block_type) as unique_types
|
||||
FROM mining_stats
|
||||
GROUP BY turtle_id
|
||||
ORDER BY total_blocks DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit);
|
||||
}
|
||||
|
||||
// Turtle Groups/Teams
|
||||
export function createGroup(groupName, color = '#3b82f6') {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO turtle_groups (group_name, color, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(groupName, color, Date.now());
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function getAllGroups() {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_groups ORDER BY created_at DESC');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
export function deleteGroup(groupId) {
|
||||
const stmt = db.prepare('DELETE FROM turtle_groups WHERE id = ?');
|
||||
stmt.run(groupId);
|
||||
}
|
||||
|
||||
export function addTurtleToGroup(turtleId, groupId) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO turtle_group_members (turtle_id, group_id, joined_at)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
stmt.run(turtleId, groupId, Date.now());
|
||||
}
|
||||
|
||||
export function removeTurtleFromGroup(turtleId, groupId) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM turtle_group_members
|
||||
WHERE turtle_id = ? AND group_id = ?
|
||||
`);
|
||||
stmt.run(turtleId, groupId);
|
||||
}
|
||||
|
||||
export function getGroupMembers(groupId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT turtle_id, joined_at
|
||||
FROM turtle_group_members
|
||||
WHERE group_id = ?
|
||||
`);
|
||||
return stmt.all(groupId);
|
||||
}
|
||||
|
||||
export function getTurtleGroups(turtleId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT g.*, m.joined_at
|
||||
FROM turtle_groups g
|
||||
JOIN turtle_group_members m ON g.id = m.group_id
|
||||
WHERE m.turtle_id = ?
|
||||
`);
|
||||
return stmt.all(turtleId);
|
||||
}
|
||||
|
||||
// Session Tracking
|
||||
export function startSession(turtleId) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO turtle_sessions (turtle_id, started_at)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
const result = stmt.run(turtleId, Date.now());
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function endSession(sessionId, blocksMined, distanceTraveled) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE turtle_sessions
|
||||
SET ended_at = ?, blocks_mined = ?, distance_traveled = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(Date.now(), blocksMined, distanceTraveled, sessionId);
|
||||
}
|
||||
|
||||
export function getSessionStats(turtleId, limit = 10) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM turtle_sessions
|
||||
WHERE turtle_id = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(turtleId, limit);
|
||||
}
|
||||
|
||||
// Player Positions
|
||||
export function savePlayerPosition(playerId, position, label = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO player_positions (player_id, x, y, z, label, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(playerId, position.x, position.y, position.z, label, Date.now());
|
||||
}
|
||||
|
||||
export function getPlayerPosition(playerId) {
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions WHERE player_id = ?');
|
||||
const row = stmt.get(playerId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllPlayerPositions() {
|
||||
const stmt = db.prepare('SELECT player_id, x, y, z, label, updated_at FROM player_positions');
|
||||
return stmt.all().map(row => ({
|
||||
playerID: row.player_id,
|
||||
position: { x: row.x, y: row.y, z: row.z },
|
||||
label: row.label,
|
||||
lastUpdate: row.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
export function closeDatabase() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ========== CHUNK ANALYSIS ==========
|
||||
|
||||
export function saveChunkAnalysis(x, z, analysis) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO chunks (x, z, analysis, scanned_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(x, z, JSON.stringify(analysis), Date.now());
|
||||
}
|
||||
|
||||
export function getChunkAnalysis(x, z) {
|
||||
const stmt = db.prepare('SELECT * FROM chunks WHERE x = ? AND z = ?');
|
||||
const row = stmt.get(x, z);
|
||||
if (row) {
|
||||
return { ...row, analysis: JSON.parse(row.analysis) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAllChunkAnalyses() {
|
||||
const stmt = db.prepare('SELECT * FROM chunks');
|
||||
return stmt.all().map(row => ({
|
||||
...row,
|
||||
analysis: JSON.parse(row.analysis)
|
||||
}));
|
||||
}
|
||||
|
||||
// ========== BLOCK SEARCH ==========
|
||||
|
||||
export function getBlocksWithNameLike(fromX, fromY, fromZ, toX, toY, toZ, namePattern) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT x, y, z, block_name as name, metadata, block_state, block_tags
|
||||
FROM world_blocks
|
||||
WHERE x BETWEEN ? AND ?
|
||||
AND y BETWEEN ? AND ?
|
||||
AND z BETWEEN ? AND ?
|
||||
AND block_name LIKE ?
|
||||
`);
|
||||
return stmt.all(
|
||||
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||
Math.min(fromZ, toZ), Math.max(fromZ, toZ),
|
||||
namePattern
|
||||
).map(row => ({
|
||||
...row,
|
||||
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBlock(x, y, z) {
|
||||
const stmt = db.prepare('SELECT block_name as name, metadata, block_state, block_tags FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||
const row = stmt.get(x, y, z);
|
||||
if (row) {
|
||||
return {
|
||||
...row,
|
||||
state: row.block_state ? JSON.parse(row.block_state) : {},
|
||||
tags: row.block_tags ? JSON.parse(row.block_tags) : {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteBlocksInArea(fromX, fromY, fromZ, toX, toY, toZ) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM world_blocks
|
||||
WHERE x BETWEEN ? AND ?
|
||||
AND y BETWEEN ? AND ?
|
||||
AND z BETWEEN ? AND ?
|
||||
`);
|
||||
return stmt.run(
|
||||
Math.min(fromX, toX), Math.max(fromX, toX),
|
||||
Math.min(fromY, toY), Math.max(fromY, toY),
|
||||
Math.min(fromZ, toZ), Math.max(fromZ, toZ)
|
||||
);
|
||||
}
|
||||
|
||||
// ========== BLOCK DELETION ==========
|
||||
|
||||
export function deleteBlock(x, y, z) {
|
||||
const stmt = db.prepare('DELETE FROM world_blocks WHERE x = ? AND y = ? AND z = ?');
|
||||
return stmt.run(x, y, z);
|
||||
}
|
||||
|
||||
// ========== TURTLE STATE PERSISTENCE ==========
|
||||
|
||||
export function saveTurtleState(turtleId, stateName, stateData = {}) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO turtle_state (turtle_id, state_name, state_data, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(turtleId, stateName, JSON.stringify(stateData), Date.now());
|
||||
}
|
||||
|
||||
export function getTurtleState(turtleId) {
|
||||
const stmt = db.prepare('SELECT * FROM turtle_state WHERE turtle_id = ?');
|
||||
const row = stmt.get(turtleId);
|
||||
if (row) {
|
||||
return {
|
||||
stateName: row.state_name,
|
||||
stateData: JSON.parse(row.state_data),
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteTurtleState(turtleId) {
|
||||
const stmt = db.prepare('DELETE FROM turtle_state WHERE turtle_id = ?');
|
||||
return stmt.run(turtleId);
|
||||
}
|
||||
|
||||
// Export database instance for custom queries if needed
|
||||
export { db };
|
||||
109
server/helpers/levenshtein.js
Normal file
109
server/helpers/levenshtein.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Levenshtein distance utility
|
||||
* Used for fuzzy matching block/item names for inventory operations.
|
||||
* Inspired by runi95/turtle-control-panel utility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the Levenshtein edit distance between two strings.
|
||||
* @param {string} a - First string
|
||||
* @param {string} b - Second string
|
||||
* @returns {number} The edit distance
|
||||
*/
|
||||
export function levenshtein(a, b) {
|
||||
if (a === b) return 0;
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
// Use a single-row approach for memory efficiency
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
const row = new Array(bLen + 1);
|
||||
|
||||
// Initialize the first row (distance from empty string to b[0..j])
|
||||
for (let j = 0; j <= bLen; j++) {
|
||||
row[j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
let prev = i; // row[0] for this iteration = i
|
||||
for (let j = 1; j <= bLen; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
const val = Math.min(
|
||||
row[j] + 1, // deletion
|
||||
prev + 1, // insertion
|
||||
row[j - 1] + cost // substitution
|
||||
);
|
||||
row[j - 1] = prev;
|
||||
prev = val;
|
||||
}
|
||||
row[bLen] = prev;
|
||||
}
|
||||
|
||||
return row[bLen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best match for a query string among candidates using Levenshtein distance.
|
||||
* @param {string} query - The search term
|
||||
* @param {string[]} candidates - Array of candidate strings
|
||||
* @param {number} maxDistance - Maximum acceptable distance (default: Infinity)
|
||||
* @returns {{ match: string|null, distance: number }} Best match and its distance
|
||||
*/
|
||||
export function findBestMatch(query, candidates, maxDistance = Infinity) {
|
||||
let bestMatch = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
|
||||
// Exact substring match is distance 0
|
||||
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||
const dist = Math.abs(lowerCandidate.length - lowerQuery.length);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||
if (dist < bestDistance && dist <= maxDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return { match: bestMatch, distance: bestDistance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all matches within a given distance.
|
||||
* @param {string} query - The search term
|
||||
* @param {string[]} candidates - Array of candidate strings
|
||||
* @param {number} maxDistance - Maximum acceptable distance
|
||||
* @returns {Array<{match: string, distance: number}>} Matches sorted by distance
|
||||
*/
|
||||
export function findAllMatches(query, candidates, maxDistance = 3) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
|
||||
// Exact substring match
|
||||
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
|
||||
matches.push({ match: candidate, distance: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dist = levenshtein(lowerQuery, lowerCandidate);
|
||||
if (dist <= maxDistance) {
|
||||
matches.push({ match: candidate, distance: dist });
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "nodemon server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
@@ -17,11 +19,14 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"cors": "^2.8.5"
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
"nodemon": "^3.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
361
server/pathfinding/DStarLite.js
Normal file
361
server/pathfinding/DStarLite.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* D* Lite Pathfinding Algorithm
|
||||
*
|
||||
* An incremental heuristic search algorithm that efficiently replans
|
||||
* when the environment changes (new blocks discovered, blocks mined, etc.)
|
||||
*
|
||||
* Based on Koenig & Likhachev (2002) and adapted from
|
||||
* runi95/turtle-control-panel implementation.
|
||||
*/
|
||||
import { Point } from './Point.js';
|
||||
import { Node } from './Node.js';
|
||||
import { PriorityQueue } from './PriorityQueue.js';
|
||||
|
||||
// Unbreakable blocks that cannot be mined
|
||||
const UNBREAKABLE_BLOCKS = new Set([
|
||||
'minecraft:bedrock',
|
||||
'minecraft:barrier',
|
||||
'minecraft:command_block',
|
||||
'minecraft:chain_command_block',
|
||||
'minecraft:repeating_command_block',
|
||||
'minecraft:structure_block',
|
||||
'minecraft:jigsaw',
|
||||
'minecraft:end_portal_frame',
|
||||
'minecraft:end_portal',
|
||||
'minecraft:nether_portal',
|
||||
'minecraft:spawner',
|
||||
'minecraft:reinforced_deepslate',
|
||||
]);
|
||||
|
||||
// Blocks that are liquid/hazardous - avoid unless necessary
|
||||
const HAZARDOUS_BLOCKS = new Set([
|
||||
'minecraft:lava',
|
||||
'minecraft:water',
|
||||
'minecraft:flowing_lava',
|
||||
'minecraft:flowing_water',
|
||||
]);
|
||||
|
||||
export class DStarLite {
|
||||
/**
|
||||
* @param {Point} start - Starting position
|
||||
* @param {Point} goal - Goal position
|
||||
* @param {Map} worldBlocks - Map of "x,y,z" -> blockData from server
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(start, goal, worldBlocks, options = {}) {
|
||||
this.start = start;
|
||||
this.goal = goal;
|
||||
this.worldBlocks = worldBlocks;
|
||||
this.km = 0;
|
||||
this.nodes = new Map(); // key -> Node
|
||||
this.queue = new PriorityQueue();
|
||||
|
||||
// Options
|
||||
this.maxSteps = options.maxSteps || 50000;
|
||||
this.canMine = options.canMine !== false; // Default: true - can mine through blocks
|
||||
this.boundaryMin = options.boundaryMin || null; // Point - min boundary
|
||||
this.boundaryMax = options.boundaryMax || null; // Point - max boundary
|
||||
this.avoidHazards = options.avoidHazards !== false; // Default: true
|
||||
|
||||
// Initialize
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a node for a point
|
||||
*/
|
||||
getNode(point) {
|
||||
const key = point.toKey();
|
||||
if (!this.nodes.has(key)) {
|
||||
const node = new Node(point);
|
||||
|
||||
// Check world blocks for this position
|
||||
const blockData = this.worldBlocks.get(key);
|
||||
if (blockData) {
|
||||
node.blockData = blockData;
|
||||
|
||||
if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
} else if (blockData.name && blockData.name !== 'minecraft:air') {
|
||||
// There's a block here - it's mineable
|
||||
node.mineable = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check boundary constraints
|
||||
if (this.boundaryMin && this.boundaryMax) {
|
||||
if (point.x < this.boundaryMin.x || point.x > this.boundaryMax.x ||
|
||||
point.y < this.boundaryMin.y || point.y > this.boundaryMax.y ||
|
||||
point.z < this.boundaryMin.z || point.z > this.boundaryMax.z) {
|
||||
node.blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't go below bedrock
|
||||
if (point.y < -64) node.blocked = true;
|
||||
if (point.y > 320) node.blocked = true;
|
||||
|
||||
this.nodes.set(key, node);
|
||||
}
|
||||
return this.nodes.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the D* Lite algorithm
|
||||
*/
|
||||
_initialize() {
|
||||
const goalNode = this.getNode(this.goal);
|
||||
goalNode.rhs = 0;
|
||||
|
||||
const key = goalNode.calculateKey(this.start, this.km);
|
||||
this.queue.insertOrUpdate(goalNode, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the shortest path
|
||||
* Returns true if a path exists, false otherwise
|
||||
*/
|
||||
computeShortestPath() {
|
||||
const startNode = this.getNode(this.start);
|
||||
let steps = 0;
|
||||
|
||||
while (
|
||||
!this.queue.isEmpty() &&
|
||||
(PriorityQueue.compareKeys(this.queue.topKey(), startNode.calculateKey(this.start, this.km)) < 0 ||
|
||||
startNode.rhs !== startNode.g)
|
||||
) {
|
||||
steps++;
|
||||
if (steps > this.maxSteps) {
|
||||
console.warn(`D* Lite: Max steps (${this.maxSteps}) exceeded`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldKey = this.queue.topKey();
|
||||
const u = this.queue.pop();
|
||||
if (!u) break;
|
||||
|
||||
const newKey = u.calculateKey(this.start, this.km);
|
||||
|
||||
if (PriorityQueue.compareKeys(oldKey, newKey) < 0) {
|
||||
// Key has changed, reinsert with new key
|
||||
this.queue.insertOrUpdate(u, newKey);
|
||||
} else if (u.g > u.rhs) {
|
||||
// Overconsistent - decrease g
|
||||
u.g = u.rhs;
|
||||
|
||||
// Update predecessors
|
||||
const neighbors = u.point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
} else {
|
||||
// Underconsistent - increase g
|
||||
u.g = Infinity;
|
||||
|
||||
// Update u and its predecessors
|
||||
this._updateNode(u);
|
||||
const neighbors = u.point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return startNode.g !== Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a node's rhs value and queue status
|
||||
*/
|
||||
_updateNode(node) {
|
||||
if (!node.point.equals(this.goal)) {
|
||||
// rhs = min over all successors of (cost(node, succ) + succ.g)
|
||||
let minRhs = Infinity;
|
||||
const neighbors = node.point.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(node, neighborNode);
|
||||
const val = cost + neighborNode.g;
|
||||
if (val < minRhs) {
|
||||
minRhs = val;
|
||||
}
|
||||
}
|
||||
|
||||
node.rhs = minRhs;
|
||||
}
|
||||
|
||||
// Remove from queue if present
|
||||
if (this.queue.contains(node)) {
|
||||
this.queue.remove(node);
|
||||
}
|
||||
|
||||
// Re-insert if inconsistent
|
||||
if (node.g !== node.rhs) {
|
||||
const key = node.calculateKey(this.start, this.km);
|
||||
this.queue.insertOrUpdate(node, key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cost to traverse from one node to an adjacent node
|
||||
*/
|
||||
_cost(from, to) {
|
||||
return to.getTraversalCost();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the computed path from start to goal
|
||||
* Returns array of Points, or null if no path exists
|
||||
*/
|
||||
getPath() {
|
||||
if (!this.computeShortestPath()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = [this.start];
|
||||
let current = this.start;
|
||||
let maxPathLength = 10000;
|
||||
|
||||
while (!current.equals(this.goal) && maxPathLength > 0) {
|
||||
maxPathLength--;
|
||||
|
||||
const currentNode = this.getNode(current);
|
||||
if (currentNode.g === Infinity) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
// Find the best neighbor to move to
|
||||
let bestNeighbor = null;
|
||||
let bestCost = Infinity;
|
||||
const neighbors = current.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(currentNode, neighborNode) + neighborNode.g;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestNeighbor = neighborPoint;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestNeighbor || bestCost === Infinity) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
path.push(bestNeighbor);
|
||||
current = bestNeighbor;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the algorithm when the turtle moves to a new position
|
||||
* This is the key D* Lite optimization - it adjusts km to avoid full recomputation
|
||||
*/
|
||||
updateStart(newStart) {
|
||||
this.km += this.start.euclideanDistanceTo(newStart);
|
||||
this.start = newStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the algorithm that a block has changed at a position
|
||||
* This triggers efficient replanning
|
||||
* @param {Point} point - The position that changed
|
||||
* @param {Object|null} blockData - New block data, or null if block was removed
|
||||
*/
|
||||
updateBlock(point, blockData) {
|
||||
const node = this.getNode(point);
|
||||
const oldBlocked = node.blocked;
|
||||
const oldMineable = node.mineable;
|
||||
|
||||
// Update node state
|
||||
node.blockData = blockData;
|
||||
|
||||
if (!blockData || blockData.name === 'minecraft:air') {
|
||||
node.blocked = false;
|
||||
node.mineable = false;
|
||||
} else if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
node.mineable = false;
|
||||
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||
node.blocked = true;
|
||||
node.mineable = false;
|
||||
} else {
|
||||
node.blocked = false;
|
||||
node.mineable = true;
|
||||
}
|
||||
|
||||
// Only replan if the traversability changed
|
||||
if (node.blocked !== oldBlocked || node.mineable !== oldMineable) {
|
||||
// Update this node and all its neighbors
|
||||
this._updateNode(node);
|
||||
const neighbors = point.getNeighbors();
|
||||
for (const neighborPoint of neighbors) {
|
||||
if (this.nodes.has(neighborPoint.toKey())) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
this._updateNode(neighborNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next step the turtle should take from current position
|
||||
* Returns the next Point to move to, or null if at goal or no path
|
||||
*/
|
||||
getNextStep() {
|
||||
if (this.start.equals(this.goal)) {
|
||||
return null; // Already at goal
|
||||
}
|
||||
|
||||
if (!this.computeShortestPath()) {
|
||||
return null; // No path
|
||||
}
|
||||
|
||||
const startNode = this.getNode(this.start);
|
||||
if (startNode.g === Infinity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestNeighbor = null;
|
||||
let bestCost = Infinity;
|
||||
const neighbors = this.start.getNeighbors();
|
||||
|
||||
for (const neighborPoint of neighbors) {
|
||||
const neighborNode = this.getNode(neighborPoint);
|
||||
const cost = this._cost(startNode, neighborNode) + neighborNode.g;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestNeighbor = neighborPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return bestNeighbor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replan the path (called after block updates)
|
||||
*/
|
||||
replan() {
|
||||
return this.computeShortestPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the pathfinding
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
nodesExplored: this.nodes.size,
|
||||
queueSize: this.queue.size,
|
||||
startG: this.getNode(this.start).g,
|
||||
goalRhs: this.getNode(this.goal).rhs,
|
||||
km: this.km,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
server/pathfinding/Node.js
Normal file
61
server/pathfinding/Node.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Node - Represents a pathfinding node in the D* Lite algorithm
|
||||
* Each node wraps a Point and stores pathfinding metadata
|
||||
*/
|
||||
import { Point } from './Point.js';
|
||||
|
||||
export class Node {
|
||||
constructor(point) {
|
||||
this.point = point;
|
||||
this.key = point.toKey();
|
||||
|
||||
// D* Lite values
|
||||
this.g = Infinity; // Cost from start to this node
|
||||
this.rhs = Infinity; // One-step lookahead cost
|
||||
|
||||
// Whether this node is blocked (wall, unbreakable block, etc.)
|
||||
this.blocked = false;
|
||||
|
||||
// Whether this node contains a mineable block (costs more to traverse but possible)
|
||||
this.mineable = false;
|
||||
|
||||
// The block data at this position (if known)
|
||||
this.blockData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the D* Lite key pair for priority queue ordering
|
||||
* @param {Point} start - The current start position
|
||||
* @param {number} km - The key modifier (updated on robot movement)
|
||||
*/
|
||||
calculateKey(start, km = 0) {
|
||||
const minVal = Math.min(this.g, this.rhs);
|
||||
return [
|
||||
minVal + start.euclideanDistanceTo(this.point) + km,
|
||||
minVal
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this node is consistent (g === rhs)
|
||||
*/
|
||||
isConsistent() {
|
||||
return this.g === this.rhs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the traversal cost to move to this node
|
||||
* - Blocked nodes: Infinity
|
||||
* - Mineable blocks: 2 (higher cost to prefer open paths)
|
||||
* - Open space: 1
|
||||
*/
|
||||
getTraversalCost() {
|
||||
if (this.blocked) return Infinity;
|
||||
if (this.mineable) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Node(${this.point}, g=${this.g}, rhs=${this.rhs}, blocked=${this.blocked})`;
|
||||
}
|
||||
}
|
||||
100
server/pathfinding/Point.js
Normal file
100
server/pathfinding/Point.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Point - Represents a 3D coordinate in the Minecraft world
|
||||
* Inspired by runi95/turtle-control-panel Point class
|
||||
*/
|
||||
export class Point {
|
||||
constructor(x, y, z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Manhattan distance to another point
|
||||
*/
|
||||
distanceTo(other) {
|
||||
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y) + Math.abs(this.z - other.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Euclidean distance to another point (used for D* Lite heuristic)
|
||||
*/
|
||||
euclideanDistanceTo(other) {
|
||||
const dx = this.x - other.x;
|
||||
const dy = this.y - other.y;
|
||||
const dz = this.z - other.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check equality with another point
|
||||
*/
|
||||
equals(other) {
|
||||
if (!other) return false;
|
||||
return this.x === other.x && this.y === other.y && this.z === other.z;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 6 neighboring points (up, down, north, south, east, west)
|
||||
*/
|
||||
getNeighbors() {
|
||||
return [
|
||||
new Point(this.x + 1, this.y, this.z), // East
|
||||
new Point(this.x - 1, this.y, this.z), // West
|
||||
new Point(this.x, this.y + 1, this.z), // Up
|
||||
new Point(this.x, this.y - 1, this.z), // Down
|
||||
new Point(this.x, this.y, this.z + 1), // South
|
||||
new Point(this.x, this.y, this.z - 1), // North
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cardinal direction needed to move from this point to another adjacent point
|
||||
* Returns: 'forward', 'back', 'up', 'down', or facing adjustment needed
|
||||
*/
|
||||
directionTo(other) {
|
||||
const dx = other.x - this.x;
|
||||
const dy = other.y - this.y;
|
||||
const dz = other.z - this.z;
|
||||
|
||||
if (dy === 1) return 'up';
|
||||
if (dy === -1) return 'down';
|
||||
if (dx === 1) return { facing: 1 }; // East
|
||||
if (dx === -1) return { facing: 3 }; // West
|
||||
if (dz === 1) return { facing: 2 }; // South
|
||||
if (dz === -1) return { facing: 0 }; // North
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique string key for maps
|
||||
*/
|
||||
toKey() {
|
||||
return `${this.x},${this.y},${this.z}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Point from key string
|
||||
*/
|
||||
static fromKey(key) {
|
||||
const [x, y, z] = key.split(',').map(Number);
|
||||
return new Point(x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Point from object with x,y,z properties
|
||||
*/
|
||||
static from(obj) {
|
||||
if (!obj) return null;
|
||||
return new Point(obj.x, obj.y, obj.z);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `(${this.x}, ${this.y}, ${this.z})`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { x: this.x, y: this.y, z: this.z };
|
||||
}
|
||||
}
|
||||
175
server/pathfinding/PriorityQueue.js
Normal file
175
server/pathfinding/PriorityQueue.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* PriorityQueue - Min-heap priority queue for D* Lite
|
||||
* Uses two-element key pairs [k1, k2] for ordering
|
||||
*/
|
||||
export class PriorityQueue {
|
||||
constructor() {
|
||||
this.heap = [];
|
||||
this.keyMap = new Map(); // key string -> index in heap
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two key pairs
|
||||
* Returns negative if a < b, positive if a > b, 0 if equal
|
||||
*/
|
||||
static compareKeys(a, b) {
|
||||
if (a[0] !== b[0]) return a[0] - b[0];
|
||||
return a[1] - b[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum key without removing
|
||||
*/
|
||||
topKey() {
|
||||
if (this.heap.length === 0) return [Infinity, Infinity];
|
||||
return this.heap[0].priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a node with a priority
|
||||
*/
|
||||
insertOrUpdate(node, priority) {
|
||||
const key = node.key;
|
||||
|
||||
if (this.keyMap.has(key)) {
|
||||
// Update existing entry
|
||||
const index = this.keyMap.get(key);
|
||||
const oldPriority = this.heap[index].priority;
|
||||
this.heap[index].priority = priority;
|
||||
this.heap[index].node = node;
|
||||
|
||||
// Determine whether to bubble up or sift down
|
||||
if (PriorityQueue.compareKeys(priority, oldPriority) < 0) {
|
||||
this._bubbleUp(index);
|
||||
} else {
|
||||
this._siftDown(index);
|
||||
}
|
||||
} else {
|
||||
// Insert new entry
|
||||
const entry = { node, priority };
|
||||
this.heap.push(entry);
|
||||
const index = this.heap.length - 1;
|
||||
this.keyMap.set(key, index);
|
||||
this._bubbleUp(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the minimum node
|
||||
*/
|
||||
pop() {
|
||||
if (this.heap.length === 0) return null;
|
||||
|
||||
const min = this.heap[0];
|
||||
this.keyMap.delete(min.node.key);
|
||||
|
||||
const last = this.heap.pop();
|
||||
if (this.heap.length > 0) {
|
||||
this.heap[0] = last;
|
||||
this.keyMap.set(last.node.key, 0);
|
||||
this._siftDown(0);
|
||||
}
|
||||
|
||||
return min.node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific node by key
|
||||
*/
|
||||
remove(node) {
|
||||
const key = node.key;
|
||||
if (!this.keyMap.has(key)) return;
|
||||
|
||||
const index = this.keyMap.get(key);
|
||||
this.keyMap.delete(key);
|
||||
|
||||
const last = this.heap.pop();
|
||||
if (index < this.heap.length) {
|
||||
this.heap[index] = last;
|
||||
this.keyMap.set(last.node.key, index);
|
||||
this._bubbleUp(index);
|
||||
this._siftDown(index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is in the queue
|
||||
*/
|
||||
contains(node) {
|
||||
return this.keyMap.has(node.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the queue
|
||||
*/
|
||||
clear() {
|
||||
this.heap = [];
|
||||
this.keyMap.clear();
|
||||
}
|
||||
|
||||
// --- Internal heap operations ---
|
||||
|
||||
_parent(i) {
|
||||
return Math.floor((i - 1) / 2);
|
||||
}
|
||||
|
||||
_leftChild(i) {
|
||||
return 2 * i + 1;
|
||||
}
|
||||
|
||||
_rightChild(i) {
|
||||
return 2 * i + 2;
|
||||
}
|
||||
|
||||
_swap(i, j) {
|
||||
const temp = this.heap[i];
|
||||
this.heap[i] = this.heap[j];
|
||||
this.heap[j] = temp;
|
||||
|
||||
this.keyMap.set(this.heap[i].node.key, i);
|
||||
this.keyMap.set(this.heap[j].node.key, j);
|
||||
}
|
||||
|
||||
_bubbleUp(i) {
|
||||
while (i > 0) {
|
||||
const parent = this._parent(i);
|
||||
if (PriorityQueue.compareKeys(this.heap[i].priority, this.heap[parent].priority) < 0) {
|
||||
this._swap(i, parent);
|
||||
i = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_siftDown(i) {
|
||||
const n = this.heap.length;
|
||||
while (true) {
|
||||
let smallest = i;
|
||||
const left = this._leftChild(i);
|
||||
const right = this._rightChild(i);
|
||||
|
||||
if (left < n && PriorityQueue.compareKeys(this.heap[left].priority, this.heap[smallest].priority) < 0) {
|
||||
smallest = left;
|
||||
}
|
||||
if (right < n && PriorityQueue.compareKeys(this.heap[right].priority, this.heap[smallest].priority) < 0) {
|
||||
smallest = right;
|
||||
}
|
||||
|
||||
if (smallest !== i) {
|
||||
this._swap(i, smallest);
|
||||
i = smallest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
server/pathfinding/index.js
Normal file
4
server/pathfinding/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Point } from './Point.js';
|
||||
export { Node } from './Node.js';
|
||||
export { PriorityQueue } from './PriorityQueue.js';
|
||||
export { DStarLite } from './DStarLite.js';
|
||||
1885
server/server.js
1885
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';
|
||||
56
startup_pocket.lua
Normal file
56
startup_pocket.lua
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Pocket Control Startup Script
|
||||
-- Automatically downloads and runs the latest pocketcontrol.lua from git
|
||||
|
||||
local SCRIPT_URL = "https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/pocketcontrol.lua"
|
||||
local SCRIPT_NAME = "pocketcontrol.lua"
|
||||
|
||||
print("========================================")
|
||||
print(" POCKET CONTROL AUTO-UPDATER")
|
||||
print("========================================")
|
||||
print("")
|
||||
|
||||
-- Remove old version
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
print("Removing old version...")
|
||||
fs.delete(SCRIPT_NAME)
|
||||
end
|
||||
|
||||
-- Download latest version
|
||||
print("Downloading latest version...")
|
||||
print("URL: " .. SCRIPT_URL)
|
||||
|
||||
local response = http.get(SCRIPT_URL)
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
|
||||
-- Save to file
|
||||
local file = fs.open(SCRIPT_NAME, "w")
|
||||
file.write(content)
|
||||
file.close()
|
||||
|
||||
print("✓ Download successful!")
|
||||
print("")
|
||||
print("Starting pocket control...")
|
||||
sleep(1)
|
||||
|
||||
-- Run the script
|
||||
shell.run(SCRIPT_NAME)
|
||||
else
|
||||
print("✗ Download failed!")
|
||||
print("Please check:")
|
||||
print(" - Internet connection")
|
||||
print(" - URL is correct")
|
||||
print(" - HTTP API is enabled")
|
||||
print("")
|
||||
print("Falling back to existing script...")
|
||||
|
||||
-- Try to run existing version if download fails
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
shell.run(SCRIPT_NAME)
|
||||
else
|
||||
print("No existing script found!")
|
||||
print("Please download manually:")
|
||||
print(" wget " .. SCRIPT_URL .. " " .. SCRIPT_NAME)
|
||||
end
|
||||
end
|
||||
69
startup_turtle.lua
Normal file
69
startup_turtle.lua
Normal file
@@ -0,0 +1,69 @@
|
||||
-- Turtle Startup Script
|
||||
-- Automatically downloads and runs the latest turtle.lua from git
|
||||
|
||||
local SCRIPT_URL = "https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/turtle.lua"
|
||||
local SCRIPT_NAME = "turtle.lua"
|
||||
|
||||
print("========================================")
|
||||
print(" TURTLE AUTO-UPDATER")
|
||||
print("========================================")
|
||||
print("")
|
||||
|
||||
-- Remove old version
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
print("Removing old version...")
|
||||
fs.delete(SCRIPT_NAME)
|
||||
end
|
||||
|
||||
-- Download latest version
|
||||
print("Downloading latest version...")
|
||||
print("URL: " .. SCRIPT_URL)
|
||||
|
||||
local response = http.get(SCRIPT_URL)
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
|
||||
-- Save to file
|
||||
local file = fs.open(SCRIPT_NAME, "w")
|
||||
file.write(content)
|
||||
file.close()
|
||||
|
||||
print("✓ Download successful!")
|
||||
print("")
|
||||
print("Starting turtle program...")
|
||||
sleep(1)
|
||||
|
||||
-- Run the script with error handling
|
||||
local success, error = pcall(function()
|
||||
shell.run(SCRIPT_NAME)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
print("")
|
||||
print("========================================")
|
||||
print(" ERROR OCCURRED!")
|
||||
print("========================================")
|
||||
print(error)
|
||||
print("")
|
||||
print("Press any key to see error details...")
|
||||
os.pullEvent("key")
|
||||
end
|
||||
else
|
||||
print("✗ Download failed!")
|
||||
print("Please check:")
|
||||
print(" - Internet connection")
|
||||
print(" - URL is correct")
|
||||
print(" - HTTP API is enabled")
|
||||
print("")
|
||||
print("Falling back to existing script...")
|
||||
|
||||
-- Try to run existing version if download fails
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
shell.run(SCRIPT_NAME)
|
||||
else
|
||||
print("No existing script found!")
|
||||
print("Please download manually:")
|
||||
print(" wget " .. SCRIPT_URL .. " " .. SCRIPT_NAME)
|
||||
end
|
||||
end
|
||||
56
startup_webbridge.lua
Normal file
56
startup_webbridge.lua
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Webbridge Startup Script
|
||||
-- Automatically downloads and runs the latest webbridge.lua from git
|
||||
|
||||
local SCRIPT_URL = "https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/webbridge.lua"
|
||||
local SCRIPT_NAME = "webbridge.lua"
|
||||
|
||||
print("========================================")
|
||||
print(" WEBBRIDGE AUTO-UPDATER")
|
||||
print("========================================")
|
||||
print("")
|
||||
|
||||
-- Remove old version
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
print("Removing old version...")
|
||||
fs.delete(SCRIPT_NAME)
|
||||
end
|
||||
|
||||
-- Download latest version
|
||||
print("Downloading latest version...")
|
||||
print("URL: " .. SCRIPT_URL)
|
||||
|
||||
local response = http.get(SCRIPT_URL)
|
||||
if response then
|
||||
local content = response.readAll()
|
||||
response.close()
|
||||
|
||||
-- Save to file
|
||||
local file = fs.open(SCRIPT_NAME, "w")
|
||||
file.write(content)
|
||||
file.close()
|
||||
|
||||
print("✓ Download successful!")
|
||||
print("")
|
||||
print("Starting webbridge program...")
|
||||
sleep(1)
|
||||
|
||||
-- Run the script
|
||||
shell.run(SCRIPT_NAME)
|
||||
else
|
||||
print("✗ Download failed!")
|
||||
print("Please check:")
|
||||
print(" - Internet connection")
|
||||
print(" - URL is correct")
|
||||
print(" - HTTP API is enabled")
|
||||
print("")
|
||||
print("Falling back to existing script...")
|
||||
|
||||
-- Try to run existing version if download fails
|
||||
if fs.exists(SCRIPT_NAME) then
|
||||
shell.run(SCRIPT_NAME)
|
||||
else
|
||||
print("No existing script found!")
|
||||
print("Please download manually:")
|
||||
print(" wget " .. SCRIPT_URL .. " " .. SCRIPT_NAME)
|
||||
end
|
||||
end
|
||||
925
turtle.lua
925
turtle.lua
File diff suppressed because it is too large
Load Diff
599
webbridge.lua
599
webbridge.lua
@@ -1,93 +1,536 @@
|
||||
-- Web Bridge for Turtle System
|
||||
-- This script forwards turtle status updates to the web server
|
||||
-- Place this on a computer connected to the wireless network
|
||||
-- Web Bridge v2 (WebSocket Protocol)
|
||||
-- Connects to server via WebSocket for instant bidirectional communication.
|
||||
-- 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://localhost:3001" -- Change to your server address
|
||||
local CHANNEL_RECEIVE = 101
|
||||
local STATUS_CHANNEL = 102
|
||||
local WebBridge = require('platform.webbridge')
|
||||
local Channels = require('platform.channels')
|
||||
|
||||
-------------------------------------------------
|
||||
-- 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 modem = peripheral.find("modem")
|
||||
if not modem then
|
||||
error("No wireless modem found!")
|
||||
end
|
||||
|
||||
modem.open(CHANNEL_RECEIVE)
|
||||
modem.open(STATUS_CHANNEL)
|
||||
local hasMonitor = monitor ~= nil
|
||||
if hasMonitor then
|
||||
monitor.setTextScale(0.5)
|
||||
end
|
||||
|
||||
print("Web Bridge Started")
|
||||
print("Listening for turtle updates...")
|
||||
print("Server: " .. SERVER_URL)
|
||||
-- Open channels via platform (respects dual-mode migration)
|
||||
WebBridge.openChannels(modem, {
|
||||
'remoteturtle.response',
|
||||
'remoteturtle.status',
|
||||
'remoteturtle.pocket',
|
||||
})
|
||||
|
||||
-- Track turtles to forward updates
|
||||
-- Track turtles and stats
|
||||
local turtles = {}
|
||||
local stats = {
|
||||
messagesReceived = 0,
|
||||
commandsSent = 0,
|
||||
serverUpdates = 0,
|
||||
errors = 0,
|
||||
startTime = os.epoch("utc")
|
||||
}
|
||||
local activityLog = {}
|
||||
local wsHandle = nil
|
||||
local wsConnected = false
|
||||
|
||||
-- Function to send data to web server
|
||||
local function sendToServer(data)
|
||||
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
|
||||
-- ========== Dashboard Drawing ==========
|
||||
|
||||
local function centerText(y, text, fg, bg)
|
||||
if not hasMonitor then return end
|
||||
local w = monitor.getSize()
|
||||
monitor.setCursorPos(math.floor((w - #text) / 2) + 1, y)
|
||||
monitor.setTextColor(fg or colors.white)
|
||||
monitor.setBackgroundColor(bg or colors.black)
|
||||
monitor.write(text)
|
||||
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
|
||||
|
||||
local function formatTime(ms)
|
||||
local seconds = math.floor(ms / 1000)
|
||||
local minutes = math.floor(seconds / 60)
|
||||
local hours = math.floor(minutes / 60)
|
||||
if hours > 0 then return string.format("%dh %dm", hours, minutes % 60)
|
||||
elseif minutes > 0 then return string.format("%dm %ds", minutes, seconds % 60)
|
||||
else return string.format("%ds", seconds) end
|
||||
end
|
||||
|
||||
local function drawDashboard()
|
||||
if not hasMonitor then return end
|
||||
|
||||
local w, h = monitor.getSize()
|
||||
monitor.setBackgroundColor(colors.black)
|
||||
monitor.clear()
|
||||
|
||||
-- Count turtles
|
||||
local turtleCount = 0
|
||||
for _ in pairs(turtles) do turtleCount = turtleCount + 1 end
|
||||
|
||||
-- Header
|
||||
local connIcon = wsConnected and "WS" or "HTTP"
|
||||
monitor.setBackgroundColor(colors.blue)
|
||||
monitor.setCursorPos(1, 1)
|
||||
monitor.clearLine()
|
||||
centerText(1, "TURTLE BRIDGE [" .. connIcon .. "] - " .. turtleCount .. " ONLINE", colors.white, colors.blue)
|
||||
|
||||
-- Status line
|
||||
monitor.setBackgroundColor(colors.gray)
|
||||
monitor.setCursorPos(1, 2)
|
||||
monitor.clearLine()
|
||||
monitor.setTextColor(colors.white)
|
||||
monitor.setCursorPos(2, 2)
|
||||
monitor.write("MSG:" .. stats.messagesReceived .. " CMD:" .. stats.commandsSent .. " ERR:" .. stats.errors)
|
||||
|
||||
-- Turtle list
|
||||
monitor.setBackgroundColor(colors.black)
|
||||
local startY = 4
|
||||
|
||||
if turtleCount == 0 then
|
||||
monitor.setTextColor(colors.gray)
|
||||
monitor.setCursorPos(2, startY)
|
||||
monitor.write("No turtles connected. Waiting...")
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Function to poll for commands from server
|
||||
local function pollCommands(turtleID)
|
||||
local response = http.get(SERVER_URL .. "/api/turtle/" .. turtleID .. "/commands")
|
||||
|
||||
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 {}
|
||||
end
|
||||
|
||||
-- Main loop
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
|
||||
if channel == STATUS_CHANNEL and type(message) == "table" then
|
||||
-- Status update from turtle
|
||||
print("Status from Turtle " .. (message.turtleID or "?"))
|
||||
|
||||
-- Update local cache
|
||||
turtles[message.turtleID] = message
|
||||
|
||||
-- Forward to web server
|
||||
local success = sendToServer(message)
|
||||
if success then
|
||||
print(" -> Sent to server")
|
||||
|
||||
-- Check for commands from server
|
||||
local commands = pollCommands(message.turtleID)
|
||||
|
||||
-- Forward commands back to turtle
|
||||
for _, cmd in ipairs(commands) do
|
||||
print(" -> Command for Turtle " .. message.turtleID .. ": " .. cmd.command)
|
||||
modem.transmit(100, 101, {
|
||||
command = cmd.command,
|
||||
param = cmd.param,
|
||||
target = message.turtleID
|
||||
})
|
||||
local y = startY
|
||||
for id, turtle in pairs(turtles) do
|
||||
if y >= h - 5 then break end
|
||||
local statusColor = getStatusColor(turtle)
|
||||
local timeSince = 0
|
||||
if turtle.lastSeen then
|
||||
timeSince = math.floor((os.epoch("utc") - turtle.lastSeen) / 1000)
|
||||
end
|
||||
else
|
||||
print(" -> Failed to send to server")
|
||||
|
||||
monitor.setCursorPos(2, y)
|
||||
monitor.setTextColor(statusColor)
|
||||
monitor.write("\7")
|
||||
monitor.setTextColor(colors.white)
|
||||
monitor.write(string.format(" T#%-3d", id))
|
||||
monitor.setTextColor(colors.lightGray)
|
||||
local tState = (turtle.mode or "IDLE"):upper()
|
||||
if #tState > 10 then tState = tState:sub(1, 10) end
|
||||
monitor.write(" " .. tState)
|
||||
|
||||
if turtle.position then
|
||||
monitor.setTextColor(colors.gray)
|
||||
monitor.write(string.format(" [%d,%d,%d]", turtle.position.x or 0, turtle.position.y or 0, turtle.position.z or 0))
|
||||
end
|
||||
|
||||
monitor.setTextColor(colors.gray)
|
||||
monitor.write(string.format(" %ds", timeSince))
|
||||
y = y + 1
|
||||
end
|
||||
end
|
||||
|
||||
sleep(0.1) -- Small delay to prevent overwhelming the system
|
||||
|
||||
-- Activity log
|
||||
local logY = h - 4
|
||||
monitor.setBackgroundColor(colors.gray)
|
||||
monitor.setCursorPos(1, logY)
|
||||
monitor.clearLine()
|
||||
monitor.setTextColor(colors.yellow)
|
||||
monitor.setCursorPos(2, logY)
|
||||
monitor.write("ACTIVITY LOG")
|
||||
|
||||
logY = logY + 1
|
||||
monitor.setBackgroundColor(colors.black)
|
||||
for i = 1, math.min(3, #activityLog) do
|
||||
local entry = activityLog[i]
|
||||
monitor.setCursorPos(1, logY)
|
||||
monitor.clearLine()
|
||||
monitor.setTextColor(colors.gray)
|
||||
monitor.write(os.date("%H:%M:%S", (entry.time or 0) / 1000) .. " ")
|
||||
monitor.setTextColor(entry.color or colors.white)
|
||||
local maxWidth = w - 10
|
||||
local text = entry.text
|
||||
if #text > maxWidth then text = text:sub(1, maxWidth - 3) .. "..." end
|
||||
monitor.write(text)
|
||||
logY = logY + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function addLog(text, color)
|
||||
table.insert(activityLog, 1, { text = text, color = color or colors.white, time = os.epoch("utc") })
|
||||
if #activityLog > 35 then table.remove(activityLog) end
|
||||
if not hasMonitor then print(text) end
|
||||
end
|
||||
|
||||
-- ========== WebSocket Send Helper ==========
|
||||
|
||||
local function wsSend(data)
|
||||
if wsHandle and wsConnected then
|
||||
local ok = pcall(function()
|
||||
wsHandle.send(textutils.serializeJSON(data))
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ========== 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
|
||||
|
||||
local function httpGet(path)
|
||||
local result = WebBridge.httpGet(SERVER_URL .. path)
|
||||
if result then
|
||||
local ok, parsed = pcall(textutils.unserialiseJSON, result)
|
||||
if ok then return parsed end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ========== Forward Turtle Modem Message to Server ==========
|
||||
|
||||
local function forwardToServer(message)
|
||||
if not message or type(message) ~= "table" then return end
|
||||
|
||||
local msgType = message.type
|
||||
local turtleID = message.turtleID
|
||||
|
||||
if msgType == "status" then
|
||||
turtles[turtleID] = message
|
||||
turtles[turtleID].lastSeen = os.epoch("utc")
|
||||
addLog("T#" .. turtleID .. " status", colors.lightBlue)
|
||||
|
||||
if wsSend(message) then
|
||||
stats.serverUpdates = stats.serverUpdates + 1
|
||||
else
|
||||
if httpPost("/api/turtle/update", message) then
|
||||
stats.serverUpdates = stats.serverUpdates + 1
|
||||
else
|
||||
stats.errors = stats.errors + 1
|
||||
addLog(" -> Server error", colors.red)
|
||||
end
|
||||
end
|
||||
|
||||
elseif msgType == "eval_response" then
|
||||
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",
|
||||
turtleID = turtleID,
|
||||
homePosition = result and result.homePosition or nil
|
||||
})
|
||||
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
|
||||
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",
|
||||
turtleID = data.turtleID,
|
||||
homePosition = data.homePosition
|
||||
})
|
||||
addLog("Home -> T#" .. data.turtleID, colors.lime)
|
||||
|
||||
elseif data.type == "home_set_confirm" then
|
||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||
type = "home_set_confirm",
|
||||
turtleID = data.turtleID,
|
||||
homePosition = data.homePosition
|
||||
})
|
||||
addLog("Home confirmed T#" .. data.turtleID, colors.lime)
|
||||
|
||||
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
|
||||
|
||||
if message.type == "turtle_command" then
|
||||
addLog("Pocket #" .. pocketID .. " -> T#" .. message.turtleID .. ": " .. message.command, colors.magenta)
|
||||
modem.transmit(COMMAND_CHANNEL, CHANNEL_RECEIVE, {
|
||||
command = message.command,
|
||||
param = message.param,
|
||||
target = message.turtleID
|
||||
})
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "command_ack", to = pocketID })
|
||||
|
||||
elseif message.type == "player_position" then
|
||||
addLog("Pocket #" .. pocketID .. " GPS", 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 })
|
||||
end
|
||||
|
||||
elseif message.type == "server_stats_request" then
|
||||
addLog("Pocket #" .. pocketID .. " stats", colors.yellow)
|
||||
local result = httpGet("/api/stats")
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
||||
type = result and "server_stats" or "error",
|
||||
to = pocketID,
|
||||
data = result,
|
||||
error = not result and "Failed to fetch" or nil
|
||||
})
|
||||
|
||||
elseif message.type == "webbridge_control" then
|
||||
addLog("Pocket #" .. pocketID .. " " .. message.command, colors.orange)
|
||||
if message.command == "ping" then
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Pong! (" .. (wsConnected and "WS" or "HTTP") .. ")" })
|
||||
elseif message.command == "status" then
|
||||
local tc = 0
|
||||
for _ in pairs(turtles) do tc = tc + 1 end
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, {
|
||||
type = "webbridge_status", to = pocketID,
|
||||
data = { messages = stats.messagesReceived, commands = stats.commandsSent, turtles = tc, uptime = os.epoch("utc") - stats.startTime, wsConnected = wsConnected }
|
||||
})
|
||||
elseif message.command == "restart" then
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = "Restarting..." })
|
||||
sleep(1)
|
||||
os.reboot()
|
||||
elseif message.command == "logs" then
|
||||
for i = math.max(1, #activityLog - 5), #activityLog do
|
||||
if activityLog[i] then
|
||||
modem.transmit(POCKET_CHANNEL, POCKET_CHANNEL, { type = "webbridge_log", to = pocketID, text = activityLog[i].text })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ========== Initialization ==========
|
||||
|
||||
print("Web Bridge v2 (WebSocket Protocol)")
|
||||
print("Server: " .. SERVER_URL)
|
||||
print("WS: " .. WS_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
|
||||
|
||||
-- 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,
|
||||
|
||||
-- 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