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?**
|
**GPS not working?**
|
||||||
- Set up 4 GPS host computers at high altitude
|
- Set up 4 GPS host computers at high altitude
|
||||||
- Run `gps host X Y Z` on each (with their coordinates)
|
- Run `gps host X Y Z` on each (with their coordinates)
|
||||||
|
- **Alternative:** If running Opus OS, use its `gpsServer` package — a single
|
||||||
|
turtle can self-build a complete GPS constellation (replaces 4 host computers)
|
||||||
|
|
||||||
|
## Pathfinding
|
||||||
|
|
||||||
|
The turtle now includes a built-in pathfinding module exposed as `_G._pathfind`.
|
||||||
|
You can trigger it from the web UI via eval commands:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Navigate to coordinates (avoids obstacles)
|
||||||
|
_pathfind.goto(100, 65, -200)
|
||||||
|
|
||||||
|
-- Navigate with block digging enabled
|
||||||
|
_pathfind.goto(100, 65, -200, { dig = true })
|
||||||
|
|
||||||
|
-- Go home
|
||||||
|
_pathfind.goHome()
|
||||||
|
|
||||||
|
-- Face a specific heading (0=south, 1=west, 2=north, 3=east)
|
||||||
|
_pathfind.face(3)
|
||||||
|
|
||||||
|
-- Get current heading name
|
||||||
|
_pathfind.headingName() -- "east"
|
||||||
|
```
|
||||||
|
|
||||||
|
The pathfinder:
|
||||||
|
- Uses GPS for initial position, then tracks movement locally
|
||||||
|
- Auto-detects heading on startup via GPS triangulation
|
||||||
|
- Handles obstacles by trying to go over/around them
|
||||||
|
- Supports optional block digging for clearing paths
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|||||||
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
|
- **🎮 Control Panel**: Monitor and control multiple turtles simultaneously
|
||||||
- **⚡ WebSocket Communication**: Instant updates and commands
|
- **⚡ WebSocket Communication**: Instant bidirectional updates
|
||||||
- **🔄 Auto-reconnect**: Handles connection drops gracefully
|
- **🔄 Auto-reconnect**: Robust connection handling with automatic recovery
|
||||||
- **📊 Live Statistics**: Track fuel, inventory, position, and more
|
- **📊 Live Statistics**: Fuel, inventory, position, mining stats, and more
|
||||||
- **🎯 Manual Control**: Direct control with arrow keys
|
- **🎯 Manual Control**: Direct movement and action controls
|
||||||
- **🤖 Autonomous Modes**: Explore, mine, and return home automatically
|
- **🤖 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
|
## 🏗️ Architecture
|
||||||
|
|
||||||
@@ -38,87 +52,218 @@ A full-stack web application for monitoring and controlling ComputerCraft turtle
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+ installed on your computer
|
- **Docker and Docker Compose** installed on your computer
|
||||||
- Minecraft with ComputerCraft mod installed
|
- **Minecraft** with ComputerCraft mod installed
|
||||||
- At least one turtle with a wireless modem
|
- At least one **turtle with a wireless modem**
|
||||||
- A computer in Minecraft to run the bridge script
|
- A **computer in Minecraft** to run the bridge script
|
||||||
|
|
||||||
### Installation
|
### Installation (Docker - Recommended)
|
||||||
|
|
||||||
1. **Clone or download this project**
|
1. **Clone or download this project**
|
||||||
|
|
||||||
2. **Install Server Dependencies**
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
git clone <repository-url>
|
||||||
npm install
|
cd remoteturtle
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install Client Dependencies**
|
2. **Start with Docker Compose**
|
||||||
```bash
|
```bash
|
||||||
cd client
|
docker-compose up -d
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This will automatically:
|
||||||
|
- Build and start the backend server on port 3001
|
||||||
|
- Build and start the frontend on port 3000
|
||||||
|
- Create a persistent SQLite database volume
|
||||||
|
- Set up networking between containers
|
||||||
|
|
||||||
4. **Start the Backend Server**
|
3. **Access the Web Interface**
|
||||||
```bash
|
- Frontend: http://localhost:3000
|
||||||
cd server
|
- Backend API: http://localhost:3001
|
||||||
npm start
|
|
||||||
```
|
|
||||||
Server will run on:
|
|
||||||
- HTTP: http://localhost:3001
|
|
||||||
- WebSocket: ws://localhost:3002
|
- WebSocket: ws://localhost:3002
|
||||||
|
|
||||||
5. **Start the React Frontend**
|
4. **Set up Minecraft Bridge Computer**
|
||||||
```bash
|
|
||||||
cd client
|
In Minecraft, place a computer with a wireless modem and run:
|
||||||
npm run dev
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_webbridge.lua startup
|
||||||
|
reboot
|
||||||
```
|
```
|
||||||
Frontend will open at: http://localhost:3000
|
|
||||||
|
This installs an auto-update system that:
|
||||||
6. **Set up Minecraft Bridge**
|
- Downloads the latest `webbridge.lua` on boot
|
||||||
- In Minecraft, place a computer with a wireless modem
|
- Falls back to cached version if download fails
|
||||||
|
- Automatically connects to your server
|
||||||
|
|
||||||
|
Or manually:
|
||||||
- Copy `webbridge.lua` to the computer
|
- Copy `webbridge.lua` to the computer
|
||||||
- Edit the `SERVER_URL` in webbridge.lua to point to your server
|
- Edit the `SERVER_URL` to point to your Docker host
|
||||||
(If running locally, use `http://localhost:3001`)
|
- If Minecraft is on the same machine: `http://host.docker.internal:3001`
|
||||||
|
- If on different machine: `http://<your-ip>:3001`
|
||||||
- Run: `webbridge`
|
- Run: `webbridge`
|
||||||
|
|
||||||
7. **Deploy Turtles**
|
5. **Deploy Auto-Updating Turtles**
|
||||||
- Copy `turtle.lua` to your turtles
|
|
||||||
- Equip wireless modems on turtles
|
On each turtle with a wireless modem, run:
|
||||||
- Run: `turtle`
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_turtle.lua startup
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs an auto-update system that:
|
||||||
|
- Downloads the latest `turtle.lua` on boot
|
||||||
|
- Falls back to cached version if download fails
|
||||||
|
- Keeps turtles updated automatically
|
||||||
|
|
||||||
|
6. **Optional: Deploy Pocket Computer Control**
|
||||||
|
|
||||||
|
For wireless pocket computer control:
|
||||||
|
```lua
|
||||||
|
wget https://git.spatulaa.com/MayaTheShy/remoteturtle/raw/branch/master/startup_pocket.lua startup
|
||||||
|
reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a wireless control interface for managing turtles from anywhere in-game
|
||||||
|
|
||||||
|
### Docker Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Stop and remove volumes (resets database)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: Manual Installation (Without Docker)
|
||||||
|
|
||||||
|
If you prefer not to use Docker:
|
||||||
|
|
||||||
|
1. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
cd server && npm install
|
||||||
|
cd ../client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start Backend**
|
||||||
|
```bash
|
||||||
|
cd server && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start Frontend**
|
||||||
|
```bash
|
||||||
|
cd client && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Follow steps 4-6 from the Docker installation above
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
remoteturtle/
|
remoteturtle/
|
||||||
├── server/ # Node.js backend
|
├── server/ # Node.js backend (650+ lines)
|
||||||
│ ├── server.js # Express + WebSocket server
|
│ ├── server.js # Express REST API + WebSocket server
|
||||||
│ └── package.json
|
│ ├── database.js # SQLite database layer (450+ lines)
|
||||||
├── client/ # React frontend
|
│ ├── turtle_control.db # SQLite database file
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── node_modules/
|
||||||
|
│
|
||||||
|
├── client/ # React frontend (1,500+ lines)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/
|
│ │ ├── components/
|
||||||
│ │ │ ├── Map3D.jsx # 3D map visualization
|
│ │ │ ├── Map3D.jsx # Three.js 3D visualization
|
||||||
│ │ │ ├── ControlPanel.jsx # Control interface
|
│ │ │ ├── ControlPanel.jsx # Main control interface
|
||||||
│ │ │ └── ControlPanel.css
|
│ │ │ ├── ControlPanel.css # Control panel styles
|
||||||
|
│ │ │ ├── VoiceControl.jsx # Voice command interface (NEW)
|
||||||
|
│ │ │ └── VoiceControl.css # Voice control styles (NEW)
|
||||||
│ │ ├── store/
|
│ │ ├── store/
|
||||||
│ │ │ └── turtleStore.js # Zustand state management
|
│ │ │ └── turtleStore.js # Zustand state management
|
||||||
│ │ ├── App.jsx
|
│ │ ├── App.jsx # Main application component
|
||||||
│ │ ├── App.css
|
│ │ ├── App.css # Global styles + responsive design
|
||||||
│ │ ├── main.jsx
|
│ │ ├── main.jsx # React entry point
|
||||||
│ │ └── index.css
|
│ │ └── index.css # Base styles
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js # Vite configuration
|
||||||
│ └── package.json
|
│ ├── package.json
|
||||||
├── webbridge.lua # ComputerCraft bridge script
|
│ └── node_modules/
|
||||||
├── turtle.lua # Advanced turtle control script
|
│
|
||||||
├── pocketremote.lua # Pocket computer interface
|
├── webbridge.lua # ComputerCraft HTTP bridge (200+ lines)
|
||||||
└── README.md
|
├── 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
|
## 🎮 Usage
|
||||||
|
|
||||||
### Web Interface
|
### 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**:
|
1. **View Modes**:
|
||||||
- **Split View**: See both map and control panel
|
- **Split View**: See both map and control panel
|
||||||
- **Map Only**: Full-screen 3D map
|
- **Map Only**: Full-screen 3D map
|
||||||
@@ -139,14 +284,44 @@ remoteturtle/
|
|||||||
|
|
||||||
### In-Game Commands
|
### In-Game Commands
|
||||||
|
|
||||||
**Turtle Commands** (via pocket computer or web):
|
**Turtle Commands** (via pocket computer, web, or voice):
|
||||||
- `explore` - Start exploration mode
|
|
||||||
- `returnHome` - Return to home position
|
**Movement:**
|
||||||
- `stop` - Stop current operation
|
- `forward/back/up/down` - Directional movement
|
||||||
- `forward/back/up/down` - Movement
|
|
||||||
- `turnLeft/turnRight` - Rotation
|
- `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
|
- `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
|
## 🔧 Configuration
|
||||||
|
|
||||||
@@ -199,45 +374,274 @@ To access from other devices on your network:
|
|||||||
|
|
||||||
3. Ensure firewall allows connections on ports 3000, 3001, and 3002
|
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
|
## 📊 API Reference
|
||||||
|
|
||||||
### REST Endpoints
|
### Turtle Management
|
||||||
|
|
||||||
**POST /api/turtle/update**
|
#### `POST /api/turtle/update`
|
||||||
- Body: Turtle status object
|
Update turtle status
|
||||||
- Updates turtle state
|
```json
|
||||||
|
{
|
||||||
|
"turtleID": 123,
|
||||||
|
"name": "Miner-1",
|
||||||
|
"x": 100, "y": 64, "z": 200,
|
||||||
|
"facing": 0,
|
||||||
|
"fuel": 5000,
|
||||||
|
"mode": "exploring",
|
||||||
|
"inventory": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**GET /api/turtle/:id/commands**
|
#### `GET /api/turtles`
|
||||||
- Returns pending commands for turtle
|
Get all connected turtles
|
||||||
|
|
||||||
**POST /api/turtle/:id/command**
|
#### `GET /api/turtle/:id`
|
||||||
- Body: `{ command: string, param: any }`
|
Get specific turtle details
|
||||||
- Queues command for turtle
|
|
||||||
|
|
||||||
**GET /api/turtles**
|
#### `POST /api/turtle/:id/command`
|
||||||
- Returns all connected turtles
|
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
|
### WebSocket Messages
|
||||||
|
|
||||||
**From Server:**
|
**From Server to Client:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "initial_state",
|
"type": "initial_state",
|
||||||
"turtles": [...]
|
"turtles": [...]
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"type": "turtle_update",
|
"type": "turtle_update",
|
||||||
"turtle": {...}
|
"turtle": {...}
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"type": "turtle_disconnected",
|
"type": "turtle_disconnected",
|
||||||
"turtleID": 123
|
"turtleID": 123
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**From Client:**
|
```json
|
||||||
|
{
|
||||||
|
"type": "block_discovered",
|
||||||
|
"block": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task_update",
|
||||||
|
"task": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**From Client to Server:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "command",
|
"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
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Turtles not appearing in web panel
|
### Turtles not appearing in web panel
|
||||||
|
|
||||||
1. Check that webbridge.lua is running
|
1. **Check webbridge is running**
|
||||||
2. Verify SERVER_URL is correct
|
- Verify webbridge.lua is active in Minecraft
|
||||||
3. Check turtle.lua is running with wireless modem equipped
|
- Check for error messages in the Minecraft console
|
||||||
4. Verify all scripts are using same channel numbers
|
|
||||||
|
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
|
### Connection issues
|
||||||
|
|
||||||
1. Check server is running: `http://localhost:3001/api/turtles`
|
1. **Server not responding**
|
||||||
2. Check firewall settings
|
- Check server is running: `http://localhost:3001/api/turtles`
|
||||||
3. Verify ports 3001 and 3002 are not in use
|
- Verify no port conflicts (3001, 3002, 3000)
|
||||||
4. Check browser console for WebSocket errors
|
- 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
|
### GPS not working
|
||||||
|
|
||||||
1. Set up GPS hosts in Minecraft (requires 4 computers)
|
1. **Set up GPS hosts**
|
||||||
2. Position them in a square pattern at high Y level
|
- Requires 4 computers in Minecraft
|
||||||
3. Each must run `gps host X Y Z` with their coordinates
|
- 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
|
## 🎨 Customization
|
||||||
|
|
||||||
@@ -308,16 +861,45 @@ Built with:
|
|||||||
- [Express](https://expressjs.com/) - Backend server
|
- [Express](https://expressjs.com/) - Backend server
|
||||||
- [ws](https://github.com/websockets/ws) - WebSocket server
|
- [ws](https://github.com/websockets/ws) - WebSocket server
|
||||||
|
|
||||||
## 🚀 Future Enhancements
|
## 🚀 Implementation Status
|
||||||
|
|
||||||
- [ ] Path recording and playback
|
### ✅ Completed Features
|
||||||
- [ ] Multi-turtle task coordination
|
|
||||||
- [ ] Inventory management interface
|
- [x] Real-time 3D visualization with Three.js
|
||||||
- [ ] Mining area visualization
|
- [x] WebSocket bidirectional communication
|
||||||
- [ ] Task scheduling
|
- [x] Manual turtle control interface
|
||||||
- [ ] Database persistence
|
- [x] Autonomous exploration with intelligent navigation
|
||||||
- [ ] Authentication/multi-user support
|
- [x] Database persistence (SQLite with 11 tables)
|
||||||
- [ ] Mobile-responsive design
|
- [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
|
## 💡 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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"serve": "vite preview --host 0.0.0.0 --port 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -4,37 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
background: #2c2c2c;
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-bottom: 2px solid #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: none;
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button:hover {
|
|
||||||
background: #475569;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
@@ -49,28 +19,93 @@
|
|||||||
|
|
||||||
.app-content.split .map-container {
|
.app-content.split .map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content.split .panel-container {
|
.app-content.split .panel-container {
|
||||||
width: 600px;
|
flex: 1;
|
||||||
}
|
width: 50%;
|
||||||
|
|
||||||
.app-content.map .panel-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content.panel .map-container {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-container {
|
.map-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0a0e1a;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-container {
|
.panel-container {
|
||||||
background: #0f172a;
|
background: #2c2c2c;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
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 */
|
/* Scrollbar styling */
|
||||||
@@ -80,14 +115,123 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1e293b;
|
background: #2c2c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #475569;
|
background: #5a5a5a;
|
||||||
border-radius: 4px;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #64748b;
|
background: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-Responsive Design */
|
||||||
|
@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 Map3D from './components/Map3D';
|
||||||
import ControlPanel from './components/ControlPanel';
|
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 { useTurtleStore } from './store/turtleStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const connect = useTurtleStore((state) => state.connect);
|
const connect = useTurtleStore((state) => state.connect);
|
||||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||||
|
const 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(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
}, [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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="view-controls">
|
<div className="app-content split">
|
||||||
<button
|
<div className="map-container">
|
||||||
className={view === 'split' ? 'active' : ''}
|
<Map3D />
|
||||||
onClick={() => setView('split')}
|
</div>
|
||||||
>
|
<div className="panel-container">
|
||||||
📊 Split View
|
<div className="panel-tabs">
|
||||||
</button>
|
<a
|
||||||
<button
|
href={inventoryDashboardUrl}
|
||||||
className={view === 'map' ? 'active' : ''}
|
className="cross-link-btn"
|
||||||
onClick={() => setView('map')}
|
title="Open Inventory Manager Dashboard"
|
||||||
>
|
target="_blank"
|
||||||
🗺️ Map Only
|
rel="noopener noreferrer"
|
||||||
</button>
|
>
|
||||||
<button
|
📦 Inventory
|
||||||
className={view === 'panel' ? 'active' : ''}
|
</a>
|
||||||
onClick={() => setView('panel')}
|
<button
|
||||||
>
|
className={panelTab === 'control' ? 'active' : ''}
|
||||||
🎮 Control Only
|
onClick={() => setPanelTab('control')}
|
||||||
</button>
|
title="Turtle Control"
|
||||||
</div>
|
>
|
||||||
|
🎮 Control
|
||||||
<div className={`app-content ${view}`}>
|
</button>
|
||||||
{(view === 'split' || view === 'map') && (
|
<button
|
||||||
<div className="map-container">
|
className={panelTab === 'voice' ? 'active' : ''}
|
||||||
<Map3D />
|
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>
|
</div>
|
||||||
)}
|
|
||||||
{(view === 'split' || view === 'panel') && (
|
|
||||||
<div className="panel-container">
|
|
||||||
<ControlPanel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,44 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useTurtleStore } from '../store/turtleStore';
|
import { useTurtleStore } from '../store/turtleStore';
|
||||||
import './ControlPanel.css';
|
import './ControlPanel.css';
|
||||||
|
|
||||||
function TurtleCard({ turtle, isSelected, onSelect }) {
|
function TurtleCard({ turtle, isSelected, onSelect }) {
|
||||||
const mode = turtle.mode || 'unknown';
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
const fuel = turtle.fuel === 'unlimited' ? '∞' : (turtle.fuel || '?');
|
||||||
const inventoryCount = turtle.inventory?.length || 0;
|
const inventoryCount = Array.isArray(turtle.inventory)
|
||||||
|
? turtle.inventory.length
|
||||||
|
: (turtle.inventory ? Object.keys(turtle.inventory).length : 0);
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
const modeColors = {
|
const modeColors = {
|
||||||
mining: '#4ade80',
|
mining: '#55ff55',
|
||||||
exploring: '#60a5fa',
|
exploring: '#55ffff',
|
||||||
returning: '#f59e0b',
|
returning: '#ffaa00',
|
||||||
idle: '#9ca3af',
|
goHome: '#ffaa00',
|
||||||
manual: '#a78bfa',
|
idle: '#aaaaaa',
|
||||||
unknown: '#6b7280'
|
manual: '#ff55ff',
|
||||||
|
refueling: '#ff5555',
|
||||||
|
farming: '#55ff55',
|
||||||
|
dumpInventory: '#aa00aa',
|
||||||
|
dumping: '#aa00aa',
|
||||||
|
moving: '#55ffff',
|
||||||
|
scan: '#5555ff',
|
||||||
|
extraction: '#ffaa00',
|
||||||
|
building: '#00aaaa',
|
||||||
|
autocraft: '#ff55ff',
|
||||||
|
unknown: '#555555'
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
className={`turtle-card ${isSelected ? 'selected' : ''}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
style={{ borderColor: modeColors[mode] }}
|
style={{ borderColor: modeColors[activeState] || modeColors.unknown }}
|
||||||
>
|
>
|
||||||
<div className="turtle-header">
|
<div className="turtle-header">
|
||||||
<h3>Turtle {turtle.turtleID}</h3>
|
<h3>{displayName}</h3>
|
||||||
<span className="mode-badge" style={{ background: modeColors[mode] }}>
|
<span className="mode-badge" style={{ background: modeColors[activeState] || modeColors.unknown }}>
|
||||||
{mode}
|
{activeState}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,7 +79,32 @@ function TurtleCard({ turtle, isSelected, onSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TurtleDetails({ turtle }) {
|
function TurtleDetails({ turtle }) {
|
||||||
const sendCommand = useTurtleStore((state) => state.sendCommand);
|
const setTurtleState = useTurtleStore((state) => state.setTurtleState);
|
||||||
|
const renameTurtle = useTurtleStore((state) => state.renameTurtle);
|
||||||
|
const equipLeft = useTurtleStore((state) => state.equipLeft);
|
||||||
|
const equipRight = useTurtleStore((state) => state.equipRight);
|
||||||
|
const sortInventory = useTurtleStore((state) => state.sortInventory);
|
||||||
|
const selectSlot = useTurtleStore((state) => state.selectSlot);
|
||||||
|
const dropItems = useTurtleStore((state) => state.dropItems);
|
||||||
|
const suckItems = useTurtleStore((state) => state.suckItems);
|
||||||
|
const connectToInventory = useTurtleStore((state) => state.connectToInventory);
|
||||||
|
const updateTurtleConfig = useTurtleStore((state) => state.updateTurtleConfig);
|
||||||
|
const exploreTurtle = useTurtleStore((state) => state.exploreTurtle);
|
||||||
|
const gpsLocateTurtle = useTurtleStore((state) => state.gpsLocateTurtle);
|
||||||
|
const moveForward = useTurtleStore((state) => state.moveForward);
|
||||||
|
const moveBack = useTurtleStore((state) => state.moveBack);
|
||||||
|
const moveUp = useTurtleStore((state) => state.moveUp);
|
||||||
|
const moveDown = useTurtleStore((state) => state.moveDown);
|
||||||
|
const turnLeft = useTurtleStore((state) => state.turnLeft);
|
||||||
|
const turnRight = useTurtleStore((state) => state.turnRight);
|
||||||
|
const digBlock = useTurtleStore((state) => state.digBlock);
|
||||||
|
const digBlockUp = useTurtleStore((state) => state.digBlockUp);
|
||||||
|
const digBlockDown = useTurtleStore((state) => state.digBlockDown);
|
||||||
|
const placeBlock = useTurtleStore((state) => state.placeBlock);
|
||||||
|
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [configValues, setConfigValues] = useState({ maxDistance: 200, autoRefuel: true });
|
||||||
|
|
||||||
if (!turtle) {
|
if (!turtle) {
|
||||||
return (
|
return (
|
||||||
@@ -76,27 +114,98 @@ function TurtleDetails({ turtle }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCommand = (command, param = null) => {
|
const handleStateChange = (stateName, data = {}) => {
|
||||||
sendCommand(turtle.turtleID, command, param);
|
setTurtleState(turtle.turtleID, stateName, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (renameValue.trim()) {
|
||||||
|
await renameTurtle(turtle.turtleID, renameValue.trim());
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = async (slotIndex) => {
|
||||||
|
await selectSlot(turtle.turtleID, slotIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigSave = async () => {
|
||||||
|
await updateTurtleConfig(turtle.turtleID, configValues);
|
||||||
|
setShowConfig(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeState = turtle.state || turtle.mode || 'idle';
|
||||||
|
const displayName = turtle.label || `Turtle ${turtle.turtleID}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turtle-details">
|
<div className="turtle-details">
|
||||||
<h2>Turtle {turtle.turtleID} Control</h2>
|
<h2>{displayName} <span style={{color: '#aaaaaa', fontSize: '0.8em'}}>#{turtle.turtleID}</span></h2>
|
||||||
|
|
||||||
|
{/* Rename + Config bar */}
|
||||||
|
<div className="detail-section" style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
|
placeholder="Rename turtle..."
|
||||||
|
className="rename-input"
|
||||||
|
style={{ flex: 1, minWidth: '120px', padding: '4px 8px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleRename} className="command-btn" style={{ padding: '4px 12px' }}>📝 Rename</button>
|
||||||
|
<button onClick={() => setShowConfig(!showConfig)} className="command-btn" style={{ padding: '4px 12px' }}>⚙️ Config</button>
|
||||||
|
<button onClick={() => gpsLocateTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>📡 GPS</button>
|
||||||
|
<button onClick={() => exploreTurtle(turtle.turtleID)} className="command-btn" style={{ padding: '4px 12px' }}>🔎 Inspect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Modal */}
|
||||||
|
{showConfig && (
|
||||||
|
<div className="detail-section" style={{ background: '#2c2c2c', padding: '12px', border: '3px solid #1a1a1a' }}>
|
||||||
|
<h3>⚙️ Configuration</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Max Distance:</span>
|
||||||
|
<input type="number" value={configValues.maxDistance} onChange={(e) => setConfigValues({...configValues, maxDistance: parseInt(e.target.value)})} style={{ width: '80px', padding: '2px 6px', border: '2px solid #333', background: '#1a1a1a', color: '#e0e0e0' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Auto Refuel:</span>
|
||||||
|
<input type="checkbox" checked={configValues.autoRefuel} onChange={(e) => setConfigValues({...configValues, autoRefuel: e.target.checked})} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setShowConfig(false)} className="command-btn" style={{ padding: '4px 12px' }}>Cancel</button>
|
||||||
|
<button onClick={handleConfigSave} className="command-btn explore" style={{ padding: '4px 12px' }}>💾 Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Status</h3>
|
<h3>Status</h3>
|
||||||
<div className="status-grid">
|
<div className="status-grid">
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Mode:</span>
|
<span className="label">State:</span>
|
||||||
<span className="value">{turtle.mode || 'unknown'}</span>
|
<span className="value">{activeState}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.stateDescription && (
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">Activity:</span>
|
||||||
|
<span className="value">{turtle.stateDescription}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Fuel:</span>
|
<span className="label">Fuel:</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
{turtle.fuel === 'unlimited' ? 'Unlimited' : turtle.fuel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.totalSteps > 0 && (
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="label">Steps:</span>
|
||||||
|
<span className="value">
|
||||||
|
{turtle.totalSteps} total ({turtle.stepsSinceLastRefuel || 0} since refuel)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="label">Position:</span>
|
<span className="label">Position:</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
@@ -115,67 +224,333 @@ function TurtleDetails({ turtle }) {
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{turtle.error && (
|
||||||
|
<div className="status-item" style={{ color: '#ff5555' }}>
|
||||||
|
<span className="label">Error:</span>
|
||||||
|
<span className="value">{turtle.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{turtle.warning && (
|
||||||
|
<div className="status-item" style={{ color: '#ffaa00' }}>
|
||||||
|
<span className="label">Warning:</span>
|
||||||
|
<span className="value">{turtle.warning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Commands</h3>
|
<h3>Movement</h3>
|
||||||
<div className="command-grid">
|
<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
|
<button
|
||||||
className="command-btn explore"
|
className="action-btn dig"
|
||||||
onClick={() => handleCommand('explore')}
|
onClick={() => digBlock(turtle.turtleID)}
|
||||||
|
title="Dig block in front"
|
||||||
>
|
>
|
||||||
🔍 Explore
|
⛏️ Dig
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn mine"
|
className="action-btn digup"
|
||||||
onClick={() => handleCommand('mine')}
|
onClick={() => digBlockUp(turtle.turtleID)}
|
||||||
|
title="Dig block above"
|
||||||
>
|
>
|
||||||
⛏️ Mine
|
⬆️ Dig Up
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn return"
|
className="action-btn digdown"
|
||||||
onClick={() => handleCommand('returnHome')}
|
onClick={() => digBlockDown(turtle.turtleID)}
|
||||||
|
title="Dig block below"
|
||||||
>
|
>
|
||||||
🏠 Return Home
|
⬇️ Dig Down
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="command-btn stop"
|
className="action-btn place"
|
||||||
onClick={() => handleCommand('stop')}
|
onClick={() => placeBlock(turtle.turtleID)}
|
||||||
|
title="Place block from inventory"
|
||||||
>
|
>
|
||||||
⏹️ Stop
|
🧱 Place
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="manual-controls">
|
{/* Equipment & Inventory Actions */}
|
||||||
<h4>Manual Control</h4>
|
<div className="detail-section">
|
||||||
<div className="direction-pad">
|
<h3>Equipment & Inventory</h3>
|
||||||
<button onClick={() => handleCommand('forward')}>↑</button>
|
<div className="action-grid">
|
||||||
<div className="horizontal-controls">
|
<button
|
||||||
<button onClick={() => handleCommand('turnLeft')}>←</button>
|
className="action-btn"
|
||||||
<button onClick={() => handleCommand('turnRight')}>→</button>
|
onClick={() => equipLeft(turtle.turtleID)}
|
||||||
</div>
|
title="Equip selected item on left side"
|
||||||
<button onClick={() => handleCommand('back')}>↓</button>
|
>
|
||||||
</div>
|
🛡️ Equip L
|
||||||
<div className="vertical-controls">
|
</button>
|
||||||
<button onClick={() => handleCommand('up')}>⬆ Up</button>
|
<button
|
||||||
<button onClick={() => handleCommand('down')}>⬇ Down</button>
|
className="action-btn"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{turtle.inventory && turtle.inventory.length > 0 && (
|
{turtle.inventory && turtle.inventory.length > 0 && (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h3>Inventory</h3>
|
<h3>Inventory ({turtle.inventoryCount || turtle.inventory.length}/16) — Slot: {turtle.selectedSlot || 1}</h3>
|
||||||
<div className="inventory-list">
|
<div className="inventory-grid">
|
||||||
{turtle.inventory.map((item, index) => (
|
{Array.from({ length: 16 }, (_, slotIndex) => {
|
||||||
<div key={index} className="inventory-item">
|
const item = turtle.inventory[slotIndex];
|
||||||
<span className="item-name">
|
const isSelected = (turtle.selectedSlot || 1) === (slotIndex + 1);
|
||||||
{item.name.replace('minecraft:', '')}
|
return (
|
||||||
</span>
|
<div
|
||||||
<span className="item-count">x{item.count}</span>
|
key={slotIndex}
|
||||||
</div>
|
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>
|
||||||
</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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -5,13 +7,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
-webkit-font-smoothing: none;
|
||||||
sans-serif;
|
-moz-osx-font-smoothing: unset;
|
||||||
-webkit-font-smoothing: antialiased;
|
image-rendering: pixelated;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
background: #2c2c2c;
|
||||||
background: #0a0e1a;
|
color: #d4d4d4;
|
||||||
color: #e0e0e0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,5 +22,5 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
const WS_URL = 'ws://localhost:3002';
|
// Use environment variables or fallback to relative URLs for proxy
|
||||||
const API_URL = 'http://localhost:3001';
|
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) => ({
|
export const useTurtleStore = create((set, get) => ({
|
||||||
// State
|
// State
|
||||||
turtles: {},
|
turtles: {},
|
||||||
|
players: {},
|
||||||
|
worldBlocks: [],
|
||||||
|
chunkAnalyses: {},
|
||||||
selectedTurtleId: null,
|
selectedTurtleId: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
ws: null,
|
ws: null,
|
||||||
@@ -28,7 +35,17 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
data.turtles.forEach(turtle => {
|
data.turtles.forEach(turtle => {
|
||||||
turtlesMap[turtle.turtleID] = 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') {
|
} else if (data.type === 'turtle_update') {
|
||||||
set(state => ({
|
set(state => ({
|
||||||
turtles: {
|
turtles: {
|
||||||
@@ -36,12 +53,105 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
[data.turtle.turtleID]: data.turtle
|
[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 => {
|
set(state => {
|
||||||
const newTurtles = { ...state.turtles };
|
const newTurtles = { ...state.turtles };
|
||||||
delete newTurtles[data.turtleID];
|
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) {
|
} catch (error) {
|
||||||
console.error('Error processing message:', error);
|
console.error('Error processing message:', error);
|
||||||
@@ -67,28 +177,332 @@ export const useTurtleStore = create((set, get) => ({
|
|||||||
selectTurtle: (turtleId) => {
|
selectTurtle: (turtleId) => {
|
||||||
set({ selectedTurtleId: turtleId });
|
set({ selectedTurtleId: turtleId });
|
||||||
},
|
},
|
||||||
|
|
||||||
sendCommand: async (turtleId, command, param = null) => {
|
updateBlocksFromSurroundings: (turtle) => {
|
||||||
const { ws } = get();
|
const { surroundings, position, facing } = turtle;
|
||||||
|
if (!surroundings || !position || facing === undefined) return;
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
const newBlocks = [];
|
||||||
ws.send(JSON.stringify({
|
const blockMap = new Map(get().worldBlocks.map(b => [`${b.x},${b.y},${b.z}`, b]));
|
||||||
type: 'command',
|
|
||||||
turtleID: turtleId,
|
// Calculate block positions based on turtle position and facing
|
||||||
command,
|
const directions = {
|
||||||
param
|
forward: { x: 0, y: 0, z: 0 },
|
||||||
}));
|
up: { x: 0, y: 1, z: 0 },
|
||||||
} else {
|
down: { x: 0, y: -1, z: 0 }
|
||||||
// Fallback to REST API
|
};
|
||||||
try {
|
|
||||||
await fetch(`${API_URL}/api/turtle/${turtleId}/command`, {
|
// Facing: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X)
|
||||||
method: 'POST',
|
if (surroundings.forward) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (facing === 0) directions.forward = { x: 0, y: 0, z: -1 };
|
||||||
body: JSON.stringify({ command, param })
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
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)
|
-- Touch-Enabled Command Center for Pocket Computer (FIXED)
|
||||||
-- Monitor and control autonomous mining turtles
|
-- Monitor and control autonomous mining turtles
|
||||||
|
|
||||||
local CHANNEL_SEND = 100
|
local Channels = require('platform.channels')
|
||||||
local CHANNEL_RECEIVE = 101
|
local WebBridge = require('platform.webbridge')
|
||||||
local STATUS_CHANNEL = 102
|
|
||||||
|
local CHANNEL_SEND = Channels.get('remoteturtle.command')
|
||||||
|
local CHANNEL_RECEIVE = Channels.get('remoteturtle.response')
|
||||||
|
local STATUS_CHANNEL = Channels.get('remoteturtle.status')
|
||||||
|
|
||||||
local modem = peripheral.find("modem")
|
local modem = peripheral.find("modem")
|
||||||
if not modem then
|
if not modem then
|
||||||
@@ -15,15 +18,17 @@ if pocket then
|
|||||||
modem = peripheral.find("modem")
|
modem = peripheral.find("modem")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
WebBridge.openChannels(modem, {
|
||||||
modem.open(STATUS_CHANNEL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
})
|
||||||
|
|
||||||
local w, h = term.getSize()
|
local w, h = term.getSize()
|
||||||
|
|
||||||
-- Tracked turtles
|
-- Tracked turtles
|
||||||
local turtles = {}
|
local turtles = {}
|
||||||
local selectedTurtle = nil
|
local selectedTurtle = nil
|
||||||
local viewMode = "overview" -- overview, detail, manual
|
local viewMode = "overview" -- overview, detail, manual, modes
|
||||||
|
|
||||||
-- Button system
|
-- Button system
|
||||||
local buttons = {}
|
local buttons = {}
|
||||||
@@ -90,6 +95,16 @@ local function sendCommand(turtleID, command, param)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Send state change command to turtle (new protocol)
|
||||||
|
local function sendStateCommand(turtleID, stateName, stateData)
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "state_change",
|
||||||
|
state = stateName,
|
||||||
|
data = stateData or {},
|
||||||
|
target = turtleID
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- Helper function to format fuel display
|
-- Helper function to format fuel display
|
||||||
local function formatFuel(fuel)
|
local function formatFuel(fuel)
|
||||||
if not fuel then
|
if not fuel then
|
||||||
@@ -127,7 +142,7 @@ local function drawOverview()
|
|||||||
-- Turtle info box
|
-- Turtle info box
|
||||||
term.setCursorPos(1, y)
|
term.setCursorPos(1, y)
|
||||||
term.setTextColor(selected and colors.lime or colors.white)
|
term.setTextColor(selected and colors.lime or colors.white)
|
||||||
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.mode or "unknown"))
|
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||||
|
|
||||||
if turtle.position then
|
if turtle.position then
|
||||||
print(string.format(" %d,%d,%d",
|
print(string.format(" %d,%d,%d",
|
||||||
@@ -197,7 +212,7 @@ local function drawDetail()
|
|||||||
term.setTextColor(colors.white)
|
term.setTextColor(colors.white)
|
||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("Mode: " .. (turtle.mode or "unknown"))
|
print("State: " .. (turtle.state or turtle.mode or "idle"))
|
||||||
print("Fuel: " .. formatFuel(turtle.fuel))
|
print("Fuel: " .. formatFuel(turtle.fuel))
|
||||||
|
|
||||||
if turtle.position then
|
if turtle.position then
|
||||||
@@ -234,8 +249,8 @@ local function drawDetail()
|
|||||||
print(" Empty")
|
print(" Empty")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Action buttons
|
-- Action buttons (row 1: explore/home/stop)
|
||||||
local btnY = h - 7
|
local btnY = h - 10
|
||||||
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
addButton(1, btnY, 8, 2, "EXPLORE", function()
|
||||||
sendCommand(turtle.turtleID, "explore")
|
sendCommand(turtle.turtleID, "explore")
|
||||||
end, colors.green)
|
end, colors.green)
|
||||||
@@ -248,16 +263,60 @@ local function drawDetail()
|
|||||||
sendCommand(turtle.turtleID, "stop")
|
sendCommand(turtle.turtleID, "stop")
|
||||||
end, colors.red)
|
end, colors.red)
|
||||||
|
|
||||||
btnY = h - 4
|
-- Row 2: manual/modes/setHome
|
||||||
addButton(1, btnY, 12, 2, "MANUAL", function()
|
btnY = h - 7
|
||||||
|
addButton(1, btnY, 8, 2, "MANUAL", function()
|
||||||
viewMode = "manual"
|
viewMode = "manual"
|
||||||
sendCommand(turtle.turtleID, "manual")
|
sendCommand(turtle.turtleID, "manual")
|
||||||
end, colors.purple)
|
end, colors.purple)
|
||||||
|
|
||||||
addButton(14, btnY, 12, 2, "SET HOME", function()
|
addButton(10, btnY, 8, 2, "MODES", function()
|
||||||
|
viewMode = "modes"
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
|
addButton(19, btnY, 7, 2, "HOME*", function()
|
||||||
sendCommand(turtle.turtleID, "setHome")
|
sendCommand(turtle.turtleID, "setHome")
|
||||||
end, colors.blue)
|
end, colors.blue)
|
||||||
|
|
||||||
|
-- Row 3: equip/rename
|
||||||
|
btnY = h - 4
|
||||||
|
addButton(1, btnY, 8, 2, "EQUIP L", function()
|
||||||
|
-- Send eval to equip left
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipLeft()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(10, btnY, 8, 2, "EQUIP R", function()
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = "return turtle.equipRight()",
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(19, btnY, 7, 2, "RENAME", function()
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
term.setTextColor(colors.yellow)
|
||||||
|
print("Enter new name:")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
local name = read()
|
||||||
|
if name and #name > 0 then
|
||||||
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
|
type = "rename",
|
||||||
|
name = name,
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end
|
||||||
|
draw()
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
addButton(1, h - 1, 12, 2, "< BACK", function()
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
viewMode = "overview"
|
viewMode = "overview"
|
||||||
end, colors.gray)
|
end, colors.gray)
|
||||||
@@ -349,9 +408,37 @@ local function drawManual()
|
|||||||
sendCommand(turtle.turtleID, "refuel")
|
sendCommand(turtle.turtleID, "refuel")
|
||||||
end, colors.lime)
|
end, colors.lime)
|
||||||
|
|
||||||
addButton(19, h - 3, 7, 2, "INFO", function()
|
addButton(19, h - 3, 7, 2, "SORT", function()
|
||||||
sendCommand(turtle.turtleID, "status")
|
modem.transmit(CHANNEL_SEND, CHANNEL_RECEIVE, {
|
||||||
end, colors.lightBlue)
|
type = "eval",
|
||||||
|
uuid = tostring(math.random(100000, 999999)),
|
||||||
|
code = [[
|
||||||
|
local moved = 0
|
||||||
|
for slot = 1, 16 do
|
||||||
|
local item = turtle.getItemDetail(slot)
|
||||||
|
if item then
|
||||||
|
for target = 1, slot - 1 do
|
||||||
|
local ti = turtle.getItemDetail(target)
|
||||||
|
if not ti then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
break
|
||||||
|
elseif ti.name == item.name and ti.count < 64 then
|
||||||
|
turtle.select(slot)
|
||||||
|
turtle.transferTo(target)
|
||||||
|
moved = moved + 1
|
||||||
|
if turtle.getItemCount(slot) == 0 then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
turtle.select(1)
|
||||||
|
return moved
|
||||||
|
]],
|
||||||
|
target = turtle.turtleID
|
||||||
|
})
|
||||||
|
end, colors.cyan)
|
||||||
|
|
||||||
addButton(1, h, 12, 1, "< BACK", function()
|
addButton(1, h, 12, 1, "< BACK", function()
|
||||||
viewMode = "detail"
|
viewMode = "detail"
|
||||||
@@ -369,6 +456,98 @@ local function drawManual()
|
|||||||
term.setTextColor(colors.white)
|
term.setTextColor(colors.white)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function drawModes()
|
||||||
|
if not selectedTurtle or not turtles[selectedTurtle] then
|
||||||
|
viewMode = "overview"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
clearButtons()
|
||||||
|
local turtle = turtles[selectedTurtle]
|
||||||
|
|
||||||
|
term.setBackgroundColor(colors.black)
|
||||||
|
term.clear()
|
||||||
|
term.setCursorPos(1, 1)
|
||||||
|
term.setTextColor(colors.cyan)
|
||||||
|
print("=STATE MACHINE=")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
print(string.format("T-%d [%s]", turtle.turtleID or 0, turtle.state or turtle.mode or "idle"))
|
||||||
|
|
||||||
|
-- State buttons
|
||||||
|
local btnY = 4
|
||||||
|
local btnW = 12
|
||||||
|
|
||||||
|
addButton(1, btnY, btnW, 2, "IDLE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "idle")
|
||||||
|
sendCommand(turtle.turtleID, "stop")
|
||||||
|
end, colors.gray)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "EXPLORE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "exploring")
|
||||||
|
sendCommand(turtle.turtleID, "explore")
|
||||||
|
end, colors.green)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "MINE", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "mining")
|
||||||
|
sendCommand(turtle.turtleID, "explore")
|
||||||
|
end, colors.orange)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "FARM", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "farming")
|
||||||
|
end, colors.lime)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "GO HOME", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "goHome")
|
||||||
|
sendCommand(turtle.turtleID, "returnHome")
|
||||||
|
end, colors.yellow)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "REFUEL", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "refueling")
|
||||||
|
sendCommand(turtle.turtleID, "refuel")
|
||||||
|
end, colors.red)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "DUMP INV", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "dumpInventory")
|
||||||
|
end, colors.brown)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "MOVE TO", function()
|
||||||
|
-- Could prompt for coordinates, for now just sends moving state
|
||||||
|
sendStateCommand(turtle.turtleID, "moving")
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "SCAN", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "scan")
|
||||||
|
end, colors.purple)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "EXTRACT", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "extraction")
|
||||||
|
end, colors.magenta)
|
||||||
|
|
||||||
|
btnY = btnY + 3
|
||||||
|
addButton(1, btnY, btnW, 2, "BUILD", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "building")
|
||||||
|
end, colors.lightBlue)
|
||||||
|
|
||||||
|
addButton(14, btnY, btnW, 2, "AUTOCRAFT", function()
|
||||||
|
sendStateCommand(turtle.turtleID, "autocraft")
|
||||||
|
end, colors.pink)
|
||||||
|
|
||||||
|
-- Back button
|
||||||
|
addButton(1, h - 1, 12, 2, "< BACK", function()
|
||||||
|
viewMode = "detail"
|
||||||
|
end, colors.gray)
|
||||||
|
|
||||||
|
for _, btn in ipairs(buttons) do
|
||||||
|
drawButton(btn)
|
||||||
|
end
|
||||||
|
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
end
|
||||||
|
|
||||||
local function draw()
|
local function draw()
|
||||||
if viewMode == "overview" then
|
if viewMode == "overview" then
|
||||||
drawOverview()
|
drawOverview()
|
||||||
@@ -376,6 +555,8 @@ local function draw()
|
|||||||
drawDetail()
|
drawDetail()
|
||||||
elseif viewMode == "manual" then
|
elseif viewMode == "manual" then
|
||||||
drawManual()
|
drawManual()
|
||||||
|
elseif viewMode == "modes" then
|
||||||
|
drawModes()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -450,14 +631,20 @@ parallel.waitForAny(
|
|||||||
end,
|
end,
|
||||||
function()
|
function()
|
||||||
-- Status receiver
|
-- Status receiver
|
||||||
|
-- Uses Channels.match() for dual-mode safety: accepts status on
|
||||||
|
-- both legacy (102) and target (4212) channels during migration.
|
||||||
while true do
|
while true do
|
||||||
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
local event, side, channel, replyChannel, message = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == STATUS_CHANNEL and type(message) == "table" and message.type == "status" then
|
if Channels.match('remoteturtle.status', channel) and type(message) == "table" and message.type == "status" then
|
||||||
-- Update or add turtle
|
-- Update or add turtle
|
||||||
local found = false
|
local found = false
|
||||||
for i, t in ipairs(turtles) do
|
for i, t in ipairs(turtles) do
|
||||||
if t.turtleID == message.turtleID then
|
if t.turtleID == message.turtleID then
|
||||||
|
-- Preserve state if not in message
|
||||||
|
if not message.state then
|
||||||
|
message.state = t.state or message.mode or "idle"
|
||||||
|
end
|
||||||
turtles[i] = message
|
turtles[i] = message
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
@@ -465,6 +652,9 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
|
|
||||||
if not found then
|
if not found then
|
||||||
|
if not message.state then
|
||||||
|
message.state = message.mode or "idle"
|
||||||
|
end
|
||||||
table.insert(turtles, message)
|
table.insert(turtles, message)
|
||||||
if not selectedTurtle then
|
if not selectedTurtle then
|
||||||
selectedTurtle = 1
|
selectedTurtle = 1
|
||||||
@@ -472,8 +662,16 @@ parallel.waitForAny(
|
|||||||
end
|
end
|
||||||
|
|
||||||
draw()
|
draw()
|
||||||
elseif channel == CHANNEL_RECEIVE and type(message) == "table" and message.status then
|
elseif Channels.match('remoteturtle.response', channel) and type(message) == "table" then
|
||||||
-- Response from turtle
|
-- State change confirmation or other response
|
||||||
|
if message.type == "state_changed" and message.turtleID then
|
||||||
|
for i, t in ipairs(turtles) do
|
||||||
|
if t.turtleID == message.turtleID then
|
||||||
|
turtles[i].state = message.state
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
draw()
|
draw()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"minecraft",
|
"minecraft",
|
||||||
@@ -17,11 +19,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cc-platform/server": "file:../../cc-platform-core/server",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2"
|
||||||
"cors": "^2.8.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
361
server/pathfinding/DStarLite.js
Normal file
361
server/pathfinding/DStarLite.js
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* D* Lite Pathfinding Algorithm
|
||||||
|
*
|
||||||
|
* An incremental heuristic search algorithm that efficiently replans
|
||||||
|
* when the environment changes (new blocks discovered, blocks mined, etc.)
|
||||||
|
*
|
||||||
|
* Based on Koenig & Likhachev (2002) and adapted from
|
||||||
|
* runi95/turtle-control-panel implementation.
|
||||||
|
*/
|
||||||
|
import { Point } from './Point.js';
|
||||||
|
import { Node } from './Node.js';
|
||||||
|
import { PriorityQueue } from './PriorityQueue.js';
|
||||||
|
|
||||||
|
// Unbreakable blocks that cannot be mined
|
||||||
|
const UNBREAKABLE_BLOCKS = new Set([
|
||||||
|
'minecraft:bedrock',
|
||||||
|
'minecraft:barrier',
|
||||||
|
'minecraft:command_block',
|
||||||
|
'minecraft:chain_command_block',
|
||||||
|
'minecraft:repeating_command_block',
|
||||||
|
'minecraft:structure_block',
|
||||||
|
'minecraft:jigsaw',
|
||||||
|
'minecraft:end_portal_frame',
|
||||||
|
'minecraft:end_portal',
|
||||||
|
'minecraft:nether_portal',
|
||||||
|
'minecraft:spawner',
|
||||||
|
'minecraft:reinforced_deepslate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Blocks that are liquid/hazardous - avoid unless necessary
|
||||||
|
const HAZARDOUS_BLOCKS = new Set([
|
||||||
|
'minecraft:lava',
|
||||||
|
'minecraft:water',
|
||||||
|
'minecraft:flowing_lava',
|
||||||
|
'minecraft:flowing_water',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class DStarLite {
|
||||||
|
/**
|
||||||
|
* @param {Point} start - Starting position
|
||||||
|
* @param {Point} goal - Goal position
|
||||||
|
* @param {Map} worldBlocks - Map of "x,y,z" -> blockData from server
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
*/
|
||||||
|
constructor(start, goal, worldBlocks, options = {}) {
|
||||||
|
this.start = start;
|
||||||
|
this.goal = goal;
|
||||||
|
this.worldBlocks = worldBlocks;
|
||||||
|
this.km = 0;
|
||||||
|
this.nodes = new Map(); // key -> Node
|
||||||
|
this.queue = new PriorityQueue();
|
||||||
|
|
||||||
|
// Options
|
||||||
|
this.maxSteps = options.maxSteps || 50000;
|
||||||
|
this.canMine = options.canMine !== false; // Default: true - can mine through blocks
|
||||||
|
this.boundaryMin = options.boundaryMin || null; // Point - min boundary
|
||||||
|
this.boundaryMax = options.boundaryMax || null; // Point - max boundary
|
||||||
|
this.avoidHazards = options.avoidHazards !== false; // Default: true
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this._initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a node for a point
|
||||||
|
*/
|
||||||
|
getNode(point) {
|
||||||
|
const key = point.toKey();
|
||||||
|
if (!this.nodes.has(key)) {
|
||||||
|
const node = new Node(point);
|
||||||
|
|
||||||
|
// Check world blocks for this position
|
||||||
|
const blockData = this.worldBlocks.get(key);
|
||||||
|
if (blockData) {
|
||||||
|
node.blockData = blockData;
|
||||||
|
|
||||||
|
if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||||
|
node.blocked = true;
|
||||||
|
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||||
|
node.blocked = true;
|
||||||
|
} else if (blockData.name && blockData.name !== 'minecraft:air') {
|
||||||
|
// There's a block here - it's mineable
|
||||||
|
node.mineable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check boundary constraints
|
||||||
|
if (this.boundaryMin && this.boundaryMax) {
|
||||||
|
if (point.x < this.boundaryMin.x || point.x > this.boundaryMax.x ||
|
||||||
|
point.y < this.boundaryMin.y || point.y > this.boundaryMax.y ||
|
||||||
|
point.z < this.boundaryMin.z || point.z > this.boundaryMax.z) {
|
||||||
|
node.blocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't go below bedrock
|
||||||
|
if (point.y < -64) node.blocked = true;
|
||||||
|
if (point.y > 320) node.blocked = true;
|
||||||
|
|
||||||
|
this.nodes.set(key, node);
|
||||||
|
}
|
||||||
|
return this.nodes.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the D* Lite algorithm
|
||||||
|
*/
|
||||||
|
_initialize() {
|
||||||
|
const goalNode = this.getNode(this.goal);
|
||||||
|
goalNode.rhs = 0;
|
||||||
|
|
||||||
|
const key = goalNode.calculateKey(this.start, this.km);
|
||||||
|
this.queue.insertOrUpdate(goalNode, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the shortest path
|
||||||
|
* Returns true if a path exists, false otherwise
|
||||||
|
*/
|
||||||
|
computeShortestPath() {
|
||||||
|
const startNode = this.getNode(this.start);
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
|
while (
|
||||||
|
!this.queue.isEmpty() &&
|
||||||
|
(PriorityQueue.compareKeys(this.queue.topKey(), startNode.calculateKey(this.start, this.km)) < 0 ||
|
||||||
|
startNode.rhs !== startNode.g)
|
||||||
|
) {
|
||||||
|
steps++;
|
||||||
|
if (steps > this.maxSteps) {
|
||||||
|
console.warn(`D* Lite: Max steps (${this.maxSteps}) exceeded`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldKey = this.queue.topKey();
|
||||||
|
const u = this.queue.pop();
|
||||||
|
if (!u) break;
|
||||||
|
|
||||||
|
const newKey = u.calculateKey(this.start, this.km);
|
||||||
|
|
||||||
|
if (PriorityQueue.compareKeys(oldKey, newKey) < 0) {
|
||||||
|
// Key has changed, reinsert with new key
|
||||||
|
this.queue.insertOrUpdate(u, newKey);
|
||||||
|
} else if (u.g > u.rhs) {
|
||||||
|
// Overconsistent - decrease g
|
||||||
|
u.g = u.rhs;
|
||||||
|
|
||||||
|
// Update predecessors
|
||||||
|
const neighbors = u.point.getNeighbors();
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
this._updateNode(neighborNode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Underconsistent - increase g
|
||||||
|
u.g = Infinity;
|
||||||
|
|
||||||
|
// Update u and its predecessors
|
||||||
|
this._updateNode(u);
|
||||||
|
const neighbors = u.point.getNeighbors();
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
this._updateNode(neighborNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return startNode.g !== Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a node's rhs value and queue status
|
||||||
|
*/
|
||||||
|
_updateNode(node) {
|
||||||
|
if (!node.point.equals(this.goal)) {
|
||||||
|
// rhs = min over all successors of (cost(node, succ) + succ.g)
|
||||||
|
let minRhs = Infinity;
|
||||||
|
const neighbors = node.point.getNeighbors();
|
||||||
|
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
const cost = this._cost(node, neighborNode);
|
||||||
|
const val = cost + neighborNode.g;
|
||||||
|
if (val < minRhs) {
|
||||||
|
minRhs = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.rhs = minRhs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue if present
|
||||||
|
if (this.queue.contains(node)) {
|
||||||
|
this.queue.remove(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-insert if inconsistent
|
||||||
|
if (node.g !== node.rhs) {
|
||||||
|
const key = node.calculateKey(this.start, this.km);
|
||||||
|
this.queue.insertOrUpdate(node, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cost to traverse from one node to an adjacent node
|
||||||
|
*/
|
||||||
|
_cost(from, to) {
|
||||||
|
return to.getTraversalCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the computed path from start to goal
|
||||||
|
* Returns array of Points, or null if no path exists
|
||||||
|
*/
|
||||||
|
getPath() {
|
||||||
|
if (!this.computeShortestPath()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = [this.start];
|
||||||
|
let current = this.start;
|
||||||
|
let maxPathLength = 10000;
|
||||||
|
|
||||||
|
while (!current.equals(this.goal) && maxPathLength > 0) {
|
||||||
|
maxPathLength--;
|
||||||
|
|
||||||
|
const currentNode = this.getNode(current);
|
||||||
|
if (currentNode.g === Infinity) {
|
||||||
|
return null; // No path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best neighbor to move to
|
||||||
|
let bestNeighbor = null;
|
||||||
|
let bestCost = Infinity;
|
||||||
|
const neighbors = current.getNeighbors();
|
||||||
|
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
const cost = this._cost(currentNode, neighborNode) + neighborNode.g;
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
bestNeighbor = neighborPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestNeighbor || bestCost === Infinity) {
|
||||||
|
return null; // No path
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push(bestNeighbor);
|
||||||
|
current = bestNeighbor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the algorithm when the turtle moves to a new position
|
||||||
|
* This is the key D* Lite optimization - it adjusts km to avoid full recomputation
|
||||||
|
*/
|
||||||
|
updateStart(newStart) {
|
||||||
|
this.km += this.start.euclideanDistanceTo(newStart);
|
||||||
|
this.start = newStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the algorithm that a block has changed at a position
|
||||||
|
* This triggers efficient replanning
|
||||||
|
* @param {Point} point - The position that changed
|
||||||
|
* @param {Object|null} blockData - New block data, or null if block was removed
|
||||||
|
*/
|
||||||
|
updateBlock(point, blockData) {
|
||||||
|
const node = this.getNode(point);
|
||||||
|
const oldBlocked = node.blocked;
|
||||||
|
const oldMineable = node.mineable;
|
||||||
|
|
||||||
|
// Update node state
|
||||||
|
node.blockData = blockData;
|
||||||
|
|
||||||
|
if (!blockData || blockData.name === 'minecraft:air') {
|
||||||
|
node.blocked = false;
|
||||||
|
node.mineable = false;
|
||||||
|
} else if (UNBREAKABLE_BLOCKS.has(blockData.name)) {
|
||||||
|
node.blocked = true;
|
||||||
|
node.mineable = false;
|
||||||
|
} else if (this.avoidHazards && HAZARDOUS_BLOCKS.has(blockData.name)) {
|
||||||
|
node.blocked = true;
|
||||||
|
node.mineable = false;
|
||||||
|
} else {
|
||||||
|
node.blocked = false;
|
||||||
|
node.mineable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only replan if the traversability changed
|
||||||
|
if (node.blocked !== oldBlocked || node.mineable !== oldMineable) {
|
||||||
|
// Update this node and all its neighbors
|
||||||
|
this._updateNode(node);
|
||||||
|
const neighbors = point.getNeighbors();
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
if (this.nodes.has(neighborPoint.toKey())) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
this._updateNode(neighborNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next step the turtle should take from current position
|
||||||
|
* Returns the next Point to move to, or null if at goal or no path
|
||||||
|
*/
|
||||||
|
getNextStep() {
|
||||||
|
if (this.start.equals(this.goal)) {
|
||||||
|
return null; // Already at goal
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.computeShortestPath()) {
|
||||||
|
return null; // No path
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = this.getNode(this.start);
|
||||||
|
if (startNode.g === Infinity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestNeighbor = null;
|
||||||
|
let bestCost = Infinity;
|
||||||
|
const neighbors = this.start.getNeighbors();
|
||||||
|
|
||||||
|
for (const neighborPoint of neighbors) {
|
||||||
|
const neighborNode = this.getNode(neighborPoint);
|
||||||
|
const cost = this._cost(startNode, neighborNode) + neighborNode.g;
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
bestNeighbor = neighborPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestNeighbor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replan the path (called after block updates)
|
||||||
|
*/
|
||||||
|
replan() {
|
||||||
|
return this.computeShortestPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about the pathfinding
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
nodesExplored: this.nodes.size,
|
||||||
|
queueSize: this.queue.size,
|
||||||
|
startG: this.getNode(this.start).g,
|
||||||
|
goalRhs: this.getNode(this.goal).rhs,
|
||||||
|
km: this.km,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
61
server/pathfinding/Node.js
Normal file
61
server/pathfinding/Node.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Node - Represents a pathfinding node in the D* Lite algorithm
|
||||||
|
* Each node wraps a Point and stores pathfinding metadata
|
||||||
|
*/
|
||||||
|
import { Point } from './Point.js';
|
||||||
|
|
||||||
|
export class Node {
|
||||||
|
constructor(point) {
|
||||||
|
this.point = point;
|
||||||
|
this.key = point.toKey();
|
||||||
|
|
||||||
|
// D* Lite values
|
||||||
|
this.g = Infinity; // Cost from start to this node
|
||||||
|
this.rhs = Infinity; // One-step lookahead cost
|
||||||
|
|
||||||
|
// Whether this node is blocked (wall, unbreakable block, etc.)
|
||||||
|
this.blocked = false;
|
||||||
|
|
||||||
|
// Whether this node contains a mineable block (costs more to traverse but possible)
|
||||||
|
this.mineable = false;
|
||||||
|
|
||||||
|
// The block data at this position (if known)
|
||||||
|
this.blockData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the D* Lite key pair for priority queue ordering
|
||||||
|
* @param {Point} start - The current start position
|
||||||
|
* @param {number} km - The key modifier (updated on robot movement)
|
||||||
|
*/
|
||||||
|
calculateKey(start, km = 0) {
|
||||||
|
const minVal = Math.min(this.g, this.rhs);
|
||||||
|
return [
|
||||||
|
minVal + start.euclideanDistanceTo(this.point) + km,
|
||||||
|
minVal
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this node is consistent (g === rhs)
|
||||||
|
*/
|
||||||
|
isConsistent() {
|
||||||
|
return this.g === this.rhs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the traversal cost to move to this node
|
||||||
|
* - Blocked nodes: Infinity
|
||||||
|
* - Mineable blocks: 2 (higher cost to prefer open paths)
|
||||||
|
* - Open space: 1
|
||||||
|
*/
|
||||||
|
getTraversalCost() {
|
||||||
|
if (this.blocked) return Infinity;
|
||||||
|
if (this.mineable) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `Node(${this.point}, g=${this.g}, rhs=${this.rhs}, blocked=${this.blocked})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
server/pathfinding/Point.js
Normal file
100
server/pathfinding/Point.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Point - Represents a 3D coordinate in the Minecraft world
|
||||||
|
* Inspired by runi95/turtle-control-panel Point class
|
||||||
|
*/
|
||||||
|
export class Point {
|
||||||
|
constructor(x, y, z) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Manhattan distance to another point
|
||||||
|
*/
|
||||||
|
distanceTo(other) {
|
||||||
|
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y) + Math.abs(this.z - other.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Euclidean distance to another point (used for D* Lite heuristic)
|
||||||
|
*/
|
||||||
|
euclideanDistanceTo(other) {
|
||||||
|
const dx = this.x - other.x;
|
||||||
|
const dy = this.y - other.y;
|
||||||
|
const dz = this.z - other.z;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check equality with another point
|
||||||
|
*/
|
||||||
|
equals(other) {
|
||||||
|
if (!other) return false;
|
||||||
|
return this.x === other.x && this.y === other.y && this.z === other.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all 6 neighboring points (up, down, north, south, east, west)
|
||||||
|
*/
|
||||||
|
getNeighbors() {
|
||||||
|
return [
|
||||||
|
new Point(this.x + 1, this.y, this.z), // East
|
||||||
|
new Point(this.x - 1, this.y, this.z), // West
|
||||||
|
new Point(this.x, this.y + 1, this.z), // Up
|
||||||
|
new Point(this.x, this.y - 1, this.z), // Down
|
||||||
|
new Point(this.x, this.y, this.z + 1), // South
|
||||||
|
new Point(this.x, this.y, this.z - 1), // North
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cardinal direction needed to move from this point to another adjacent point
|
||||||
|
* Returns: 'forward', 'back', 'up', 'down', or facing adjustment needed
|
||||||
|
*/
|
||||||
|
directionTo(other) {
|
||||||
|
const dx = other.x - this.x;
|
||||||
|
const dy = other.y - this.y;
|
||||||
|
const dz = other.z - this.z;
|
||||||
|
|
||||||
|
if (dy === 1) return 'up';
|
||||||
|
if (dy === -1) return 'down';
|
||||||
|
if (dx === 1) return { facing: 1 }; // East
|
||||||
|
if (dx === -1) return { facing: 3 }; // West
|
||||||
|
if (dz === 1) return { facing: 2 }; // South
|
||||||
|
if (dz === -1) return { facing: 0 }; // North
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique string key for maps
|
||||||
|
*/
|
||||||
|
toKey() {
|
||||||
|
return `${this.x},${this.y},${this.z}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Point from key string
|
||||||
|
*/
|
||||||
|
static fromKey(key) {
|
||||||
|
const [x, y, z] = key.split(',').map(Number);
|
||||||
|
return new Point(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Point from object with x,y,z properties
|
||||||
|
*/
|
||||||
|
static from(obj) {
|
||||||
|
if (!obj) return null;
|
||||||
|
return new Point(obj.x, obj.y, obj.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return `(${this.x}, ${this.y}, ${this.z})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return { x: this.x, y: this.y, z: this.z };
|
||||||
|
}
|
||||||
|
}
|
||||||
175
server/pathfinding/PriorityQueue.js
Normal file
175
server/pathfinding/PriorityQueue.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* PriorityQueue - Min-heap priority queue for D* Lite
|
||||||
|
* Uses two-element key pairs [k1, k2] for ordering
|
||||||
|
*/
|
||||||
|
export class PriorityQueue {
|
||||||
|
constructor() {
|
||||||
|
this.heap = [];
|
||||||
|
this.keyMap = new Map(); // key string -> index in heap
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.heap.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty() {
|
||||||
|
return this.heap.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two key pairs
|
||||||
|
* Returns negative if a < b, positive if a > b, 0 if equal
|
||||||
|
*/
|
||||||
|
static compareKeys(a, b) {
|
||||||
|
if (a[0] !== b[0]) return a[0] - b[0];
|
||||||
|
return a[1] - b[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the minimum key without removing
|
||||||
|
*/
|
||||||
|
topKey() {
|
||||||
|
if (this.heap.length === 0) return [Infinity, Infinity];
|
||||||
|
return this.heap[0].priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a node with a priority
|
||||||
|
*/
|
||||||
|
insertOrUpdate(node, priority) {
|
||||||
|
const key = node.key;
|
||||||
|
|
||||||
|
if (this.keyMap.has(key)) {
|
||||||
|
// Update existing entry
|
||||||
|
const index = this.keyMap.get(key);
|
||||||
|
const oldPriority = this.heap[index].priority;
|
||||||
|
this.heap[index].priority = priority;
|
||||||
|
this.heap[index].node = node;
|
||||||
|
|
||||||
|
// Determine whether to bubble up or sift down
|
||||||
|
if (PriorityQueue.compareKeys(priority, oldPriority) < 0) {
|
||||||
|
this._bubbleUp(index);
|
||||||
|
} else {
|
||||||
|
this._siftDown(index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert new entry
|
||||||
|
const entry = { node, priority };
|
||||||
|
this.heap.push(entry);
|
||||||
|
const index = this.heap.length - 1;
|
||||||
|
this.keyMap.set(key, index);
|
||||||
|
this._bubbleUp(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return the minimum node
|
||||||
|
*/
|
||||||
|
pop() {
|
||||||
|
if (this.heap.length === 0) return null;
|
||||||
|
|
||||||
|
const min = this.heap[0];
|
||||||
|
this.keyMap.delete(min.node.key);
|
||||||
|
|
||||||
|
const last = this.heap.pop();
|
||||||
|
if (this.heap.length > 0) {
|
||||||
|
this.heap[0] = last;
|
||||||
|
this.keyMap.set(last.node.key, 0);
|
||||||
|
this._siftDown(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return min.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific node by key
|
||||||
|
*/
|
||||||
|
remove(node) {
|
||||||
|
const key = node.key;
|
||||||
|
if (!this.keyMap.has(key)) return;
|
||||||
|
|
||||||
|
const index = this.keyMap.get(key);
|
||||||
|
this.keyMap.delete(key);
|
||||||
|
|
||||||
|
const last = this.heap.pop();
|
||||||
|
if (index < this.heap.length) {
|
||||||
|
this.heap[index] = last;
|
||||||
|
this.keyMap.set(last.node.key, index);
|
||||||
|
this._bubbleUp(index);
|
||||||
|
this._siftDown(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node is in the queue
|
||||||
|
*/
|
||||||
|
contains(node) {
|
||||||
|
return this.keyMap.has(node.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the queue
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.heap = [];
|
||||||
|
this.keyMap.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal heap operations ---
|
||||||
|
|
||||||
|
_parent(i) {
|
||||||
|
return Math.floor((i - 1) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_leftChild(i) {
|
||||||
|
return 2 * i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rightChild(i) {
|
||||||
|
return 2 * i + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
_swap(i, j) {
|
||||||
|
const temp = this.heap[i];
|
||||||
|
this.heap[i] = this.heap[j];
|
||||||
|
this.heap[j] = temp;
|
||||||
|
|
||||||
|
this.keyMap.set(this.heap[i].node.key, i);
|
||||||
|
this.keyMap.set(this.heap[j].node.key, j);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bubbleUp(i) {
|
||||||
|
while (i > 0) {
|
||||||
|
const parent = this._parent(i);
|
||||||
|
if (PriorityQueue.compareKeys(this.heap[i].priority, this.heap[parent].priority) < 0) {
|
||||||
|
this._swap(i, parent);
|
||||||
|
i = parent;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_siftDown(i) {
|
||||||
|
const n = this.heap.length;
|
||||||
|
while (true) {
|
||||||
|
let smallest = i;
|
||||||
|
const left = this._leftChild(i);
|
||||||
|
const right = this._rightChild(i);
|
||||||
|
|
||||||
|
if (left < n && PriorityQueue.compareKeys(this.heap[left].priority, this.heap[smallest].priority) < 0) {
|
||||||
|
smallest = left;
|
||||||
|
}
|
||||||
|
if (right < n && PriorityQueue.compareKeys(this.heap[right].priority, this.heap[smallest].priority) < 0) {
|
||||||
|
smallest = right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smallest !== i) {
|
||||||
|
this._swap(i, smallest);
|
||||||
|
i = smallest;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
server/pathfinding/index.js
Normal file
4
server/pathfinding/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Point } from './Point.js';
|
||||||
|
export { Node } from './Node.js';
|
||||||
|
export { PriorityQueue } from './PriorityQueue.js';
|
||||||
|
export { DStarLite } from './DStarLite.js';
|
||||||
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
|
-- Web Bridge v2 (WebSocket Protocol)
|
||||||
-- This script forwards turtle status updates to the web server
|
-- Connects to server via WebSocket for instant bidirectional communication.
|
||||||
-- Place this on a computer connected to the wireless network
|
-- 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 WebBridge = require('platform.webbridge')
|
||||||
local CHANNEL_RECEIVE = 101
|
local Channels = require('platform.channels')
|
||||||
local STATUS_CHANNEL = 102
|
|
||||||
|
-------------------------------------------------
|
||||||
|
-- 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
|
if not modem then
|
||||||
error("No wireless modem found!")
|
error("No wireless modem found!")
|
||||||
end
|
end
|
||||||
|
|
||||||
modem.open(CHANNEL_RECEIVE)
|
local hasMonitor = monitor ~= nil
|
||||||
modem.open(STATUS_CHANNEL)
|
if hasMonitor then
|
||||||
|
monitor.setTextScale(0.5)
|
||||||
|
end
|
||||||
|
|
||||||
print("Web Bridge Started")
|
-- Open channels via platform (respects dual-mode migration)
|
||||||
print("Listening for turtle updates...")
|
WebBridge.openChannels(modem, {
|
||||||
print("Server: " .. SERVER_URL)
|
'remoteturtle.response',
|
||||||
|
'remoteturtle.status',
|
||||||
|
'remoteturtle.pocket',
|
||||||
|
})
|
||||||
|
|
||||||
-- Track turtles to forward updates
|
-- Track turtles and stats
|
||||||
local turtles = {}
|
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
|
-- ========== Dashboard Drawing ==========
|
||||||
local function sendToServer(data)
|
|
||||||
local jsonData = textutils.serializeJSON(data)
|
local function centerText(y, text, fg, bg)
|
||||||
|
if not hasMonitor then return end
|
||||||
local response = http.post(
|
local w = monitor.getSize()
|
||||||
SERVER_URL .. "/api/turtle/update",
|
monitor.setCursorPos(math.floor((w - #text) / 2) + 1, y)
|
||||||
jsonData,
|
monitor.setTextColor(fg or colors.white)
|
||||||
{["Content-Type"] = "application/json"}
|
monitor.setBackgroundColor(bg or colors.black)
|
||||||
)
|
monitor.write(text)
|
||||||
|
end
|
||||||
if response then
|
|
||||||
response.close()
|
local function getStatusColor(turtle)
|
||||||
return true
|
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
|
else
|
||||||
return false
|
local y = startY
|
||||||
end
|
for id, turtle in pairs(turtles) do
|
||||||
end
|
if y >= h - 5 then break end
|
||||||
|
local statusColor = getStatusColor(turtle)
|
||||||
-- Function to poll for commands from server
|
local timeSince = 0
|
||||||
local function pollCommands(turtleID)
|
if turtle.lastSeen then
|
||||||
local response = http.get(SERVER_URL .. "/api/turtle/" .. turtleID .. "/commands")
|
timeSince = math.floor((os.epoch("utc") - turtle.lastSeen) / 1000)
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
end
|
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
|
||||||
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
|
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